Handle wrapped Responses endpoint 404s

Teach the Responses fallback classifier to inspect status codes, wrapped exception types, and response bodies so LiteLLM NotFoundError wrappers that hide the /v1/responses URL still fall back to chat completions.

Keep rate-limit errors non-fallback and add a regression test for the OpenAIException detail-only 404 shape observed with providers that do not expose the Responses API.
This commit is contained in:
Alessandro 2026-06-11 04:12:40 +02:00
parent e7b64a39fd
commit df19e399e8
2 changed files with 102 additions and 0 deletions

View file

@ -1523,6 +1523,8 @@ def _is_responses_not_supported_error(exc: Exception) -> bool:
text = _exception_text(exc).lower()
if any(marker in text for marker in ("429", "too many requests", "rate limit")):
return False
if _is_not_found_error(exc) and _looks_like_responses_endpoint_not_found(text):
return True
if "/v1/responses" in text and any(
marker in text for marker in ("404", "not found")
):
@ -1540,6 +1542,22 @@ def _is_responses_not_supported_error(exc: Exception) -> bool:
)
def _is_not_found_error(exc: Exception) -> bool:
if _exception_status_code(exc) == 404:
return True
return "notfounderror" in _exception_type_chain(exc).lower()
def _looks_like_responses_endpoint_not_found(text: str) -> bool:
if "/v1/responses" in text:
return True
if "not found" not in text:
return False
if "openaiexception" in text:
return True
return "detail" in text and "not found" in text
def _is_responses_state_unsupported_error(exc: Exception) -> bool:
text = _exception_text(exc).lower()
if any(marker in text for marker in ("429", "too many requests", "rate limit")):
@ -1588,6 +1606,18 @@ def _exception_text(exc: Exception | None) -> str:
if exc is None:
return ""
parts = [exc.__class__.__name__, str(exc)]
for attr in ("status_code", "code", "message", "body"):
value = getattr(exc, attr, None)
if value not in (None, ""):
parts.append(f"{attr}={value}")
response = getattr(exc, "response", None)
if response is not None:
response_text = getattr(response, "text", None)
if response_text:
parts.append(str(response_text))
response_url = getattr(response, "url", None)
if response_url:
parts.append(str(response_url))
cause = getattr(exc, "__cause__", None)
context = getattr(exc, "__context__", None)
if cause is not None:
@ -1597,6 +1627,31 @@ def _exception_text(exc: Exception | None) -> str:
return "\n".join(parts)
def _exception_status_code(exc: Exception | None) -> int | None:
if exc is None:
return None
for attr in ("status_code", "code"):
value = getattr(exc, attr, None)
if isinstance(value, int):
return value
if isinstance(value, str) and value.isdigit():
return int(value)
response = getattr(exc, "response", None)
value = getattr(response, "status_code", None)
return value if isinstance(value, int) else None
def _exception_type_chain(exc: Exception | None) -> str:
names: list[str] = []
current = exc
while current is not None:
names.append(current.__class__.__name__)
cause = getattr(current, "__cause__", None)
context = getattr(current, "__context__", None)
current = cause or (context if context is not cause else None)
return "\n".join(names)
def _close_sync_stream(stream: Any) -> None:
for method_name in ("close", "aclose"):
close = getattr(stream, method_name, None)

View file

@ -357,6 +357,53 @@ async def test_unified_call_falls_back_to_chat_when_responses_endpoint_missing(
assert calls == ["responses", "chat", "chat"]
@pytest.mark.asyncio
async def test_unified_call_falls_back_when_litellm_hides_responses_404_url(
monkeypatch,
):
class NotFoundError(Exception):
status_code = 404
calls: list[str] = []
async def fake_aresponses(*args, **kwargs):
calls.append("responses")
raise NotFoundError(
'litellm.NotFoundError: NotFoundError: OpenAIException - {"detail":"Not Found"}'
)
async def fake_acompletion(*args, **kwargs):
calls.append("chat")
assert kwargs["stream"] is True
assert kwargs["drop_params"] is True
return _AsyncChunkStream([_chunk("fallback")])
async def fake_rate_limiter(*args, **kwargs):
return None
monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
wrapper = models.LiteLLMChatWrapper(
model="claude-opus-4.7",
provider="openai",
model_config=None,
)
async def response_callback(chunk: str, full: str):
return None
response, reasoning = await wrapper.unified_call(
messages=[],
response_callback=response_callback,
)
assert response == "fallback"
assert reasoning == ""
assert calls == ["responses", "chat"]
@pytest.mark.asyncio
async def test_unified_call_preserves_cache_control_with_chat_for_non_native_responses(
monkeypatch,