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,