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,