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:
@@ -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``."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user