diff --git a/strix/core/execution.py b/strix/core/execution.py index a0099588..a0b81490 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -6,6 +6,7 @@ import asyncio import contextlib import inspect import logging +import re import uuid from collections.abc import Callable from dataclasses import replace @@ -57,6 +58,16 @@ logger = logging.getLogger(__name__) StreamEventSink = Callable[[str, Any], None] _INPUT_REJECTION_CODES = frozenset({400, 404, 422}) +# Replies meaning "this model takes no images", not image errors in general: a +# context overflow that counts "image/vision expansion" must not match. +_IMAGE_REJECTION = re.compile( + r"no endpoints found that support image input" # OpenRouter + r"|image_url is only supported by certain models" # OpenAI + r"|is not a multimodal model|at most 0 image\(s\)" # vLLM + r"|does not support image input" # LiteLLM's Fireworks check + r"|doesn't support the image field", # Bedrock Converse + re.IGNORECASE, +) _MAX_COMPACTIONS_PER_CYCLE = 2 @@ -157,7 +168,9 @@ def _model_error_status_code(exc: BaseException) -> int | None: def _is_image_rejection(exc: BaseException) -> bool: - return _model_error_status_code(exc) in _INPUT_REJECTION_CODES and "image" in str(exc).lower() + return _model_error_status_code(exc) in _INPUT_REJECTION_CODES and bool( + _IMAGE_REJECTION.search(str(exc)) + ) def _is_transient_model_error(exc: BaseException) -> bool: diff --git a/tests/test_execution_transient_retry.py b/tests/test_execution_transient_retry.py index f9008a95..fa9f13c5 100644 --- a/tests/test_execution_transient_retry.py +++ b/tests/test_execution_transient_retry.py @@ -175,6 +175,41 @@ async def test_run_cycle_does_not_retry_permanent_error( await _run_once(monkeypatch, streams) +@pytest.mark.parametrize( + ("status", "message", "expected"), + [ + ( + 404, + "litellm.NotFoundError: NotFoundError: OpenrouterException - " + '{"error":{"message":"No endpoints found that support image input","code":404,' + '"metadata":{"failed_routing_step":"Filter by Image Support"}}}', + True, + ), + (400, "Invalid content type. image_url is only supported by certain models.", True), + (400, "my-model is not a multimodal model", True), + (400, "This model doesn't support the image field for user messages.", True), + ( + 400, + "OpenRouterException: Message: This model's maximum context length is 1048576 " + "tokens, but the request requires 1073484 tokens (942412 input including " + "image/vision expansion + 131072 for the completion). Reduce the input length " + "or max_tokens.", + False, + ), + (400, "At most 5 image(s) may be provided in one prompt.", False), + ( + 400, + "Upstream error from Relace: Generation stopped with an incomplete tool call", + False, + ), + (500, "No endpoints found that support image input", False), + ], +) +def test_is_image_rejection(status: int, message: str, expected: bool) -> None: + exc = APIStatusError(message, response=httpx.Response(status, request=_request()), body=None) + assert execution._is_image_rejection(exc) is expected + + async def _run_with_image_session( monkeypatch: pytest.MonkeyPatch, streams: list[_FakeStream] ) -> tuple[Any, int, int]: @@ -208,20 +243,3 @@ async def test_run_cycle_strips_images_on_image_rejection( assert result is streams[1] assert attempts == 2 assert strips == 1 - - -@pytest.mark.asyncio -async def test_run_cycle_does_not_strip_images_on_unrelated_client_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - bad_request = BadRequestError( - "Upstream error from Relace: Generation stopped with an incomplete tool call", - response=httpx.Response(400, request=_request()), - body=None, - ) - streams = [_FakeStream(exc=bad_request), _FakeStream()] - result, attempts, strips = await _run_with_image_session(monkeypatch, streams) - - assert result is streams[1] - assert attempts == 2 - assert strips == 0