mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
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.
This commit is contained in:
parent
e7b64a39fd
commit
df19e399e8
2 changed files with 102 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue