From df19e399e806a0e63511cd83ba4da5ace4b9bb5e Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 11 Jun 2026 04:12:40 +0200 Subject: [PATCH] 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. --- helpers/litellm_transport.py | 55 ++++++++++++++++++++++++++++ tests/test_stream_tool_early_stop.py | 47 ++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/helpers/litellm_transport.py b/helpers/litellm_transport.py index 3d7155296..de5a2b884 100644 --- a/helpers/litellm_transport.py +++ b/helpers/litellm_transport.py @@ -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) diff --git a/tests/test_stream_tool_early_stop.py b/tests/test_stream_tool_early_stop.py index 696ace7c1..8aa7de661 100644 --- a/tests/test_stream_tool_early_stop.py +++ b/tests/test_stream_tool_early_stop.py @@ -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,