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,