Fallback when Responses mock parses SSE
Detect LiteLLM JSONDecodeError failures caused by the Responses mock streaming iterator trying to parse a real SSE stream. Fall back to Chat Completions before any output is emitted, keeping custom OpenAI-compatible proxy prefixes provider-neutral. Add a regression test for SSE-shaped JSON decode failures and update the transport DOX contract. Verified with: PYTHONPATH="/home/eclypso/a0/agent-zero" conda run -n a0 pytest tests/test_stream_tool_early_stop.py tests/test_responses_architecture.py -q; PYTHONPATH="/home/eclypso/a0/agent-zero" conda run -n a0 python -m py_compile helpers/litellm_transport.py; docker exec -i -w /a0 9c436228a4a2 /opt/venv-a0/bin/python -m py_compile helpers/litellm_transport.py; git diff --check.
Alessandro committed
Jul 1, 2026 at 17:23 UTC
038f88848d53515031b0335e0de7ed4d7d18d86b
3 files changed
+73
helpers/litellm_transport.py
+22
@@ -1715,6 +1715,8 @@ def _is_responses_not_supported_error(exc: Exception) -> bool:
1715
text = _exception_text(exc).lower()
1716
if any(marker in text for marker in ("429", "too many requests", "rate limit")):
1717
return False
1718
+ if _is_sse_json_decode_error(exc):
1719
+ return True
1720
if _is_bad_request_error(exc) and _looks_like_responses_request_rejected(text):
1721
return True
1722
if _is_server_error(exc) and _looks_like_responses_endpoint(text):
@@ -1777,6 +1779,26 @@ def _is_server_error(exc: Exception) -> bool:
1779
)
1780
1781
1782
+def _is_sse_json_decode_error(exc: Exception) -> bool:
1783
+ current: BaseException | None = exc
1784
+ while current is not None:
1785
+ if isinstance(current, json.JSONDecodeError) and _looks_like_sse_payload(
1786
+ current.doc
1787
+ ):
1788
+ return True
1789
+ current = current.__cause__ or (
1790
+ current.__context__ if current.__context__ is not current.__cause__ else None
1791
+ )
1792
+ return _looks_like_sse_payload(_exception_text(exc))
1793
+
1794
+
1795
+def _looks_like_sse_payload(text: Any) -> bool:
1796
+ if not isinstance(text, str):
1797
+ return False
1798
+ lowered = text.lstrip().lower()
1799
+ return lowered.startswith("event:") and "\ndata:" in lowered
1800
+
1801
+
1802
def _looks_like_responses_request_rejected(text: str) -> bool:
1803
if "/v1/responses" in text or "responses api" in text:
1804
return True
helpers/litellm_transport.py.dox.md
+1
@@ -29,6 +29,7 @@
29
- Prefer Responses API when configured, but fallback to Chat Completions when the provider does not support Responses.
30
- Fall back to Chat Completions when a Responses request is rejected before any output by an endpoint-specific or shape-specific Bad Request indicating the provider cannot parse Responses payloads.
31
- Fall back to Chat Completions when a Responses endpoint fails before output with an endpoint-specific server error, proxy path-unavailable error, or LiteLLM proxy-extra import error.
32
+- Fall back to Chat Completions when LiteLLM's Responses mock streaming path tries to JSON-decode a real SSE stream before any output.
33
- Preserve Chat Completions tool calls from both non-streaming responses and streaming deltas as canonical `LLMResult` function-call items.
34
- Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported.
35
- Keep prompt-cache markers only for providers that accept them.
tests/test_stream_tool_early_stop.py
+50
@@ -1,3 +1,4 @@
1
+import json
2
import sys
3
from pathlib import Path
4
@@ -643,6 +644,55 @@ async def test_unified_call_falls_back_for_proxy_responses_failures(
644
assert calls == ["responses", "chat"]
645
646
647
+@pytest.mark.asyncio
648
+async def test_unified_call_falls_back_when_responses_mock_reads_sse_as_json(
649
+ monkeypatch,
650
+):
651
+ calls: list[str] = []
652
+ sse_error = json.JSONDecodeError(
653
+ "Expecting value",
654
+ 'event: response.output_text.delta\ndata: {"delta":"hello"}\n\n',
655
+ 0,
656
+ )
657
+ failing_stream = _FailingAsyncChunkStream(sse_error)
658
+
659
+ async def fake_aresponses(*args, **kwargs):
660
+ calls.append("responses")
661
+ return failing_stream
662
+
663
+ async def fake_acompletion(*args, **kwargs):
664
+ calls.append("chat")
665
+ assert kwargs["stream"] is True
666
+ assert kwargs["drop_params"] is True
667
+ return _AsyncChunkStream([_chunk("fallback")])
668
+
669
+ async def fake_rate_limiter(*args, **kwargs):
670
+ return None
671
+
672
+ monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
673
+ monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
674
+ monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
675
+
676
+ wrapper = models.LiteLLMChatWrapper(
677
+ model="omniroute/test-model",
678
+ provider="openai",
679
+ model_config=None,
680
+ )
681
+
682
+ async def response_callback(chunk: str, full: str):
683
+ return None
684
+
685
+ response, reasoning = await wrapper.unified_call(
686
+ messages=[],
687
+ response_callback=response_callback,
688
+ )
689
+
690
+ assert response == "fallback"
691
+ assert reasoning == ""
692
+ assert calls == ["responses", "chat"]
693
+ assert failing_stream.closed is True
694
+
695
+
696
@pytest.mark.asyncio
697
async def test_unified_call_falls_back_when_responses_bad_request_rejects_shape(
698
monkeypatch,