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,