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.

Alessandro committed Jun 11, 2026 at 04:12 UTC df19e399e806a0e63511cd83ba4da5ace4b9bb5e
2 files changed +102
helpers/litellm_transport.py
+55
@@ -1523,6 +1523,8 @@ def _is_responses_not_supported_error(exc: Exception) -> bool:
1523 text = _exception_text(exc).lower()
1524 if any(marker in text for marker in ("429", "too many requests", "rate limit")):
1525 return False
1526 + if _is_not_found_error(exc) and _looks_like_responses_endpoint_not_found(text):
1527 + return True
1528 if "/v1/responses" in text and any(
1529 marker in text for marker in ("404", "not found")
1530 ):
@@ -1540,6 +1542,22 @@ def _is_responses_not_supported_error(exc: Exception) -> bool:
1542 )
1543
1544
1545 +def _is_not_found_error(exc: Exception) -> bool:
1546 + if _exception_status_code(exc) == 404:
1547 + return True
1548 + return "notfounderror" in _exception_type_chain(exc).lower()
1549 +
1550 +
1551 +def _looks_like_responses_endpoint_not_found(text: str) -> bool:
1552 + if "/v1/responses" in text:
1553 + return True
1554 + if "not found" not in text:
1555 + return False
1556 + if "openaiexception" in text:
1557 + return True
1558 + return "detail" in text and "not found" in text
1559 +
1560 +
1561 def _is_responses_state_unsupported_error(exc: Exception) -> bool:
1562 text = _exception_text(exc).lower()
1563 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:
1606 if exc is None:
1607 return ""
1608 parts = [exc.__class__.__name__, str(exc)]
1609 + for attr in ("status_code", "code", "message", "body"):
1610 + value = getattr(exc, attr, None)
1611 + if value not in (None, ""):
1612 + parts.append(f"{attr}={value}")
1613 + response = getattr(exc, "response", None)
1614 + if response is not None:
1615 + response_text = getattr(response, "text", None)
1616 + if response_text:
1617 + parts.append(str(response_text))
1618 + response_url = getattr(response, "url", None)
1619 + if response_url:
1620 + parts.append(str(response_url))
1621 cause = getattr(exc, "__cause__", None)
1622 context = getattr(exc, "__context__", None)
1623 if cause is not None:
@@ -1597,6 +1627,31 @@ def _exception_text(exc: Exception | None) -> str:
1627 return "\n".join(parts)
1628
1629
1630 +def _exception_status_code(exc: Exception | None) -> int | None:
1631 + if exc is None:
1632 + return None
1633 + for attr in ("status_code", "code"):
1634 + value = getattr(exc, attr, None)
1635 + if isinstance(value, int):
1636 + return value
1637 + if isinstance(value, str) and value.isdigit():
1638 + return int(value)
1639 + response = getattr(exc, "response", None)
1640 + value = getattr(response, "status_code", None)
1641 + return value if isinstance(value, int) else None
1642 +
1643 +
1644 +def _exception_type_chain(exc: Exception | None) -> str:
1645 + names: list[str] = []
1646 + current = exc
1647 + while current is not None:
1648 + names.append(current.__class__.__name__)
1649 + cause = getattr(current, "__cause__", None)
1650 + context = getattr(current, "__context__", None)
1651 + current = cause or (context if context is not cause else None)
1652 + return "\n".join(names)
1653 +
1654 +
1655 def _close_sync_stream(stream: Any) -> None:
1656 for method_name in ("close", "aclose"):
1657 close = getattr(stream, method_name, None)
tests/test_stream_tool_early_stop.py
+47
@@ -357,6 +357,53 @@ async def test_unified_call_falls_back_to_chat_when_responses_endpoint_missing(
357 assert calls == ["responses", "chat", "chat"]
358
359
360 +@pytest.mark.asyncio
361 +async def test_unified_call_falls_back_when_litellm_hides_responses_404_url(
362 + monkeypatch,
363 +):
364 + class NotFoundError(Exception):
365 + status_code = 404
366 +
367 + calls: list[str] = []
368 +
369 + async def fake_aresponses(*args, **kwargs):
370 + calls.append("responses")
371 + raise NotFoundError(
372 + 'litellm.NotFoundError: NotFoundError: OpenAIException - {"detail":"Not Found"}'
373 + )
374 +
375 + async def fake_acompletion(*args, **kwargs):
376 + calls.append("chat")
377 + assert kwargs["stream"] is True
378 + assert kwargs["drop_params"] is True
379 + return _AsyncChunkStream([_chunk("fallback")])
380 +
381 + async def fake_rate_limiter(*args, **kwargs):
382 + return None
383 +
384 + monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
385 + monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
386 + monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
387 +
388 + wrapper = models.LiteLLMChatWrapper(
389 + model="claude-opus-4.7",
390 + provider="openai",
391 + model_config=None,
392 + )
393 +
394 + async def response_callback(chunk: str, full: str):
395 + return None
396 +
397 + response, reasoning = await wrapper.unified_call(
398 + messages=[],
399 + response_callback=response_callback,
400 + )
401 +
402 + assert response == "fallback"
403 + assert reasoning == ""
404 + assert calls == ["responses", "chat"]
405 +
406 +
407 @pytest.mark.asyncio
408 async def test_unified_call_preserves_cache_control_with_chat_for_non_native_responses(
409 monkeypatch,