fix(agent): unwrap data/success envelope in auxiliary LLM responses

Some OpenAI-compatible gateways (e.g. api.cline.bot) wrap non-streaming
JSON bodies in {"data": {<chat completion>}, "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.
This commit is contained in:
Amir Ghani
2026-07-12 22:55:44 +02:00
committed by brooklyn!
parent 4a916f7a83
commit bb230bd4b2
2 changed files with 154 additions and 1 deletions

View File

@@ -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": {<chat completion>}, "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``."""

View File

@@ -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."""