Agent Zero AI framework
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,