From bb230bd4b299fda1762cf8bb7d9ac93c6c8d4c09 Mon Sep 17 00:00:00 2001 From: Amir Ghani Date: Sun, 12 Jul 2026 22:55:44 +0200 Subject: [PATCH] fix(agent): unwrap data/success envelope in auxiliary LLM responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some OpenAI-compatible gateways (e.g. api.cline.bot) wrap non-streaming JSON bodies in {"data": {}, "success": true}. The OpenAI SDK leniently parses this into a ChatCompletion with choices=None and keeps the envelope keys as extra fields, so auxiliary calls (vision, compression, title generation, web extract) failed in _validate_llm_response with a generic "LLM returned invalid response" error. Main chat was unaffected because it streams and SSE chunks pass through unwrapped. Unwrap the envelope at the _validate_llm_response chokepoint: rebuild the real completion via model_validate (SimpleNamespace synthesis as fallback), and surface the provider's actual error message for {"success": false, "data": {"error": ...}} envelopes. Detection is conservative — responses with top-level choices or without a dict "data" field are returned untouched. --- agent/auxiliary_client.py | 54 +++++++++++++- tests/agent/test_auxiliary_client.py | 101 +++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 9e5fcdca72..a140ee5f3c 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -6849,7 +6849,10 @@ def _validate_llm_response( an empty route. See #23270. """ if response is None: - raise RuntimeError(f"Auxiliary {task or 'call'}: LLM returned None response") + raise RuntimeError( + f"Auxiliary {task or 'call'}: LLM returned None response" + ) + response = _unwrap_data_envelope(response, task) from agent.aux_accounting import record_aux_usage record_aux_usage(response, task, provider=provider, base_url=base_url) # Adapter SimpleNamespace responses are fine — they have .choices[0].message. @@ -6929,6 +6932,55 @@ def _fail_relay_auxiliary_call(exc: BaseException) -> None: logger.warning("Relay auxiliary failure finalization failed", exc_info=True) +def _unwrap_data_envelope(response: Any, task: str = None) -> Any: + """Unwrap gateway envelopes like {"data": {}, "success": true}. + + Some OpenAI-compatible gateways (e.g. api.cline.bot) wrap non-streaming + JSON bodies in a data/success envelope. The SDK leniently parses this + into a ChatCompletion with choices=None and keeps the envelope keys as + extra fields, so the real completion is reachable at ``response.data``. + Error envelopes ({"success": false, "data": {"error": ...}}) raise the + provider's actual error instead of a generic invalid-response error. + """ + if _field(response, "choices"): + return response + data = _field(response, "data") + if not isinstance(data, dict): + return response + if _field(response, "success") is False: + err = data.get("error") or data + msg = err.get("message") if isinstance(err, dict) else None + raise RuntimeError( + f"Auxiliary {task or 'call'}: provider returned error envelope: " + f"{msg or err}" + ) + if not data.get("choices"): + return response + try: + return type(response).model_validate(data) + except (AttributeError, TypeError, ValueError): + # No model_validate (SimpleNamespace) or the inner payload fails + # strict validation (pydantic ValidationError is a ValueError). + pass + choices = [] + for ch in data["choices"]: + msg = ch.get("message") if isinstance(ch, dict) else None + if isinstance(msg, dict): + choices.append(SimpleNamespace( + message=SimpleNamespace(**msg), + finish_reason=ch.get("finish_reason") or "stop", + )) + if not choices: + return response + return SimpleNamespace( + id=data.get("id", ""), + model=data.get("model", ""), + object=data.get("object", "chat.completion"), + choices=choices, + usage=data.get("usage"), + ) + + def _recover_aux_response_message(response: Any) -> Optional[Any]: """Synthesize chat-completions shape from Responses-style text (``output_text``, ``output`` items) that some compatible endpoints return outside ``choices``.""" diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 1d2cf179c8..06b2c9b5a3 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -36,6 +36,7 @@ from agent.auxiliary_client import ( _resolve_xai_oauth_for_aux, _CodexCompletionsAdapter, _pool_runtime_base_url, + _unwrap_data_envelope, ) @@ -1829,6 +1830,106 @@ class TestStaleFallbackCandidateSkip: ) +class TestDataEnvelopeUnwrap: + """Gateways like api.cline.bot wrap completions in {"data": ..., "success": ...}.""" + + def _envelope_data(self, content="unwrapped"): + return { + "id": "chatcmpl-env", + "object": "chat.completion", + "created": 1751000000, + "model": "cline-pass/kimi-k2.7-code", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + + def test_unwrap_envelope_via_model_validate(self): + """A leniently-parsed ChatCompletion with the envelope as extras is rebuilt.""" + from openai.types.chat import ChatCompletion + + wrapped = ChatCompletion.model_construct( + data=self._envelope_data(), success=True + ) + + result = _unwrap_data_envelope(wrapped, task="vision") + + assert isinstance(result, ChatCompletion) + assert result.choices[0].message.content == "unwrapped" + assert result.model == "cline-pass/kimi-k2.7-code" + assert result.usage.total_tokens == 15 + + def test_call_llm_unwraps_data_envelope(self): + """End-to-end sync path; SimpleNamespace exercises the synthesis fallback.""" + primary_client = MagicMock() + primary_client.chat.completions.create.return_value = SimpleNamespace( + choices=None, success=True, data=self._envelope_data() + ) + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "cline-pass/kimi-k2.7-code")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("custom", "cline-pass/kimi-k2.7-code", None, None, None)), \ + patch("agent.auxiliary_client._try_configured_fallback_chain") as mock_chain: + result = call_llm( + task="title_generation", + messages=[{"role": "user", "content": "hello"}], + ) + + assert result.choices[0].message.content == "unwrapped" + mock_chain.assert_not_called() + + @pytest.mark.asyncio + async def test_async_call_llm_unwraps_data_envelope(self): + primary_client = MagicMock() + primary_client.chat.completions.create = AsyncMock( + return_value=SimpleNamespace( + choices=None, success=True, data=self._envelope_data() + ) + ) + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "cline-pass/kimi-k2.7-code")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("custom", "cline-pass/kimi-k2.7-code", None, None, None)), \ + patch("agent.auxiliary_client._try_configured_fallback_chain") as mock_chain: + result = await async_call_llm( + task="vision", + messages=[{"role": "user", "content": "hello"}], + ) + + assert result.choices[0].message.content == "unwrapped" + mock_chain.assert_not_called() + + def test_error_envelope_raises_provider_message(self): + wrapped = SimpleNamespace( + choices=None, + success=False, + data={"error": {"message": "upstream provider rejected the request", + "code": "bad_model"}}, + ) + + with pytest.raises(RuntimeError, match="upstream provider rejected the request"): + _unwrap_data_envelope(wrapped, task="vision") + + def test_non_envelope_responses_unchanged(self): + normal = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="hi"))] + ) + assert _unwrap_data_envelope(normal) is normal + + # MagicMock's auto-attribute `data` is not a dict — must stay on the + # existing invalid-response path (guards the fallback tests below). + mock_resp = MagicMock(choices=[]) + assert _unwrap_data_envelope(mock_resp) is mock_resp + + no_choices = SimpleNamespace(choices=None, success=True, data={"no": "choices"}) + assert _unwrap_data_envelope(no_choices) is no_choices + + class TestAuxiliaryFallbackLayering: """Explicit-provider users get layered fallback: configured_chain → main agent → warn."""