diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 8503a7f90f9..959a55dc0e9 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -2259,6 +2259,15 @@ def run_conversation( # "unknown variant `image_url`, expected `text`". "unknown variant `image_url`, expected `text`", "unknown variant image_url, expected text", + # OpenRouter routes a request to upstream endpoints and, + # when none of the candidate endpoints for the model accept + # image input, returns HTTP 404 "No endpoints found that + # support image input". Without this phrase the agent never + # strips the images, the retry loop re-sends the same + # rejected request until exhaustion, and the gateway leaves + # every subsequent message queued behind the stuck turn — + # the P1 in issue #21160. The 404 passes the 4xx gate below. + "no endpoints found that support image input", ) _err_lower = _err_body.lower() _looks_like_image_rejection = any( diff --git a/tests/run_agent/test_image_rejection_fallback.py b/tests/run_agent/test_image_rejection_fallback.py index d1d6c7ff028..de960096eb0 100644 --- a/tests/run_agent/test_image_rejection_fallback.py +++ b/tests/run_agent/test_image_rejection_fallback.py @@ -195,6 +195,7 @@ class TestImageRejectionPhraseIsolation: "does not support vision", "model does not support image", "image_url'. expected", + "no endpoints found that support image input", ) def _matches(self, body: str) -> bool: @@ -244,10 +245,28 @@ class TestImageRejectionPhraseIsolation: # match the agent cascaded into compression / context-too-large # recovery instead of just stripping the images. "Invalid 'input[56].content[1].image_url'. Expected a valid URL, but got a value with an invalid format.", + # OpenRouter 404 when no upstream endpoint for the model accepts + # image input — issue #21160. The exact wording from the report. + "HTTP 404: No endpoints found that support image input", ] for body in bodies: assert self._matches(body) is True, f"false negative on: {body}" + def test_openrouter_data_policy_no_endpoints_does_not_trip(self): + """OpenRouter has several 'no endpoints ...' 404 bodies. Only the + image-input one is an image rejection — the guardrail / data-policy + variants (agent/error_classifier.py) are about routing restrictions, + not vision, and must route to their own handler, not get their images + stripped. + """ + bodies = [ + "No endpoints available matching your guardrail restrictions", + "No endpoints available matching your data policy", + "No endpoints found matching your data policy", + ] + for body in bodies: + assert self._matches(body) is False, f"false positive on: {body}" + def test_codex_data_url_rejection_does_not_false_match_other_url_errors(self): """The narrow 'image_url'. expected' phrase (keyed on the field-path apostrophe used in the Codex Responses error format)