Tighten Responses bad request fallback
Keep fallback for endpoint-specific and Venice-style Responses payload rejections, but stop treating generic Bad Request validation text as proof that Responses is unsupported. Add a regression that generic pre-output Responses 400s raise without trying Chat Completions.
Alessandro committed
Jun 25, 2026 at 21:51 UTC
5c914bc49ebd69939232c30757c6d2cf196101ec
3 files changed
+46
-5
helpers/litellm_transport.py
+3
-4
@@ -1574,10 +1574,9 @@ def _looks_like_responses_request_rejected(text: str) -> bool:
1574
"expected object, received string",
1575
"expected string, received array",
1576
"zod",
1577
- "invalid request",
1578
- "invalid type",
1579
- "failed to deserialize",
1580
- "validation error",
1577
+ "failed to deserialize input",
1578
+ "failed to deserialize response",
1579
+ "failed to deserialize responses",
1580
)
1581
)
1582
helpers/litellm_transport.py.dox.md
+1
-1
@@ -26,7 +26,7 @@
26
- Strip Agent Zero internal kwargs before sending requests to LiteLLM.
27
- Do not send orphan tool controls when no tools are present; strict OpenAI-compatible servers can reject empty `tools` arrays.
28
- Prefer Responses API when configured, but fallback to Chat Completions when the provider does not support Responses.
29
-- Fall back to Chat Completions when a Responses request is rejected before any output by a Bad Request/validation error that indicates the provider cannot parse the Responses request shape.
29
+- 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.
30
- Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported.
31
- Keep prompt-cache markers only for providers that accept them.
32
tests/test_stream_tool_early_stop.py
+42
@@ -611,6 +611,48 @@ async def test_unified_call_falls_back_when_responses_bad_request_rejects_shape(
611
assert calls == ["responses", "chat"]
612
613
614
+@pytest.mark.asyncio
615
+async def test_unified_call_raises_generic_responses_bad_request(monkeypatch):
616
+ class BadRequestError(Exception):
617
+ status_code = 400
618
+
619
+ calls: list[str] = []
620
+
621
+ async def fake_aresponses(*args, **kwargs):
622
+ calls.append("responses")
623
+ raise BadRequestError(
624
+ "BadRequestError: validation error: invalid request: max_tokens is too high"
625
+ )
626
+
627
+ async def fake_acompletion(*args, **kwargs):
628
+ calls.append("chat")
629
+ raise AssertionError("generic 400 should not fallback to chat")
630
+
631
+ async def fake_rate_limiter(*args, **kwargs):
632
+ return None
633
+
634
+ monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
635
+ monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
636
+ monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
637
+
638
+ wrapper = models.LiteLLMChatWrapper(
639
+ model="test-model",
640
+ provider="openai",
641
+ model_config=None,
642
+ )
643
+
644
+ async def response_callback(chunk: str, full: str):
645
+ return None
646
+
647
+ with pytest.raises(BadRequestError):
648
+ await wrapper.unified_call(
649
+ messages=[],
650
+ response_callback=response_callback,
651
+ )
652
+
653
+ assert calls == ["responses"]
654
+
655
+
656
@pytest.mark.asyncio
657
async def test_unified_call_preserves_cache_control_with_chat_for_non_native_responses(
658
monkeypatch,