From 029d3c4a7e1d4e32cd10c3a3649ebd51e2e4e257 Mon Sep 17 00:00:00 2001 From: mtrefilek Date: Thu, 29 Oct 2026 05:23:54 -0500 Subject: [PATCH] fix(openrouter): accept only PNG and JPEG images for TypeSafe Jev decision models --- litellm/decisions/call.py | 2 +- .../llms/base_llm/decisions/transformation.py | 3 ++ .../openrouter/decisions/transformation.py | 7 ++++ tests/unit/decisions/test_main.py | 39 ++++++++++++++++++- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index 3b73f65c917..ca0acc0aa95 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -612,7 +612,7 @@ def _has_media_type_in(media_types: frozenset[str], image: DecisionsImage) -> bo def _checked_image_types(call: DecisionsCall) -> DecisionsCall: - media_types: Final = call.provider_config.image_media_types + media_types: Final = call.provider_config.image_media_types_for(call.model) if media_types is None: return call found: Final = (image_media_type(image) for image in input_images(call.ir_request.input)) diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index e4050d41075..b6115160ac5 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -649,6 +649,9 @@ class BaseDecisionsConfig(ABC): ) -> bool: return self.supports_images + def image_media_types_for(self, model: str) -> frozenset[str] | None: + return self.image_media_types + def supports_assets(self, headers: Mapping[str, str]) -> bool: return False diff --git a/litellm/llms/openrouter/decisions/transformation.py b/litellm/llms/openrouter/decisions/transformation.py index 016a87df98e..ef336f7c336 100644 --- a/litellm/llms/openrouter/decisions/transformation.py +++ b/litellm/llms/openrouter/decisions/transformation.py @@ -3,6 +3,8 @@ from typing import Final from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig from litellm.types.decisions import DecisionsIRResponse +_TYPESAFE_IMAGE_MEDIA_TYPES: Final = frozenset({"image/png", "image/jpeg", "image/jpg"}) + class OpenRouterDecisionsConfig(BaseDecisionsConfig): path = "/alpha/decisions" @@ -16,6 +18,11 @@ class OpenRouterDecisionsConfig(BaseDecisionsConfig): def get_default_api_base(self) -> str | None: return "https://openrouter.ai/api" + def image_media_types_for(self, model: str) -> frozenset[str] | None: + if model.removeprefix("~").startswith("typesafe/"): + return _TYPESAFE_IMAGE_MEDIA_TYPES + return self.image_media_types + def provider_reported_cost(self, response: DecisionsIRResponse) -> float | None: cost: Final = response.usage.extra.get("cost") if response.usage is not None else None if not isinstance(cost, (int, float)): diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 3f6c5da92c5..a7dd9d90898 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -4065,7 +4065,7 @@ async def test_hosted_vllm_decisions_sends_input_images_to_systemone( _STATE_IMAGE_PROVIDERS: Final = ( - ("openrouter/typesafe/jev-1.14", "https://openrouter.ai/api/alpha/decisions", "typesafe/jev-1.14"), + ("openrouter/cloudflare/clef", "https://openrouter.ai/api/alpha/decisions", "cloudflare/clef"), ("perplexity/pplx-decider-v1.2", "https://api.perplexity.ai/v1/decisions", "pplx-decider-v1.2"), ) _STATE_IMAGE_PROVIDER_IDS: Final = ("openrouter", "perplexity") @@ -4157,6 +4157,43 @@ def test_a_gif_to_a_state_image_provider_is_rejected_before_http( assert not route.called +@pytest.mark.parametrize("model", ("openrouter/typesafe/jev-1.14", "openrouter/~typesafe/jev-latest")) +def test_openrouter_jev_rejects_webp_before_http( + model: str, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=_RESPONSE) + + with pytest.raises(litellm.BadRequestError, match=re.escape("got image/webp")): + litellm.systemone( + model=model, + state=_SCREEN_TEXT, + questions=_SYSTEMONE_PREDICATE, + images=["data:image/webp;base64,BB=="], + api_key="k", + ) + + assert not route.called + + +def test_openrouter_jev_drops_webp_and_keeps_png_under_drop_params(respx_mock: respx.MockRouter) -> None: + route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=_RESPONSE) + + litellm.systemone( + model="openrouter/typesafe/jev-1.14", + state=_SCREEN_TEXT, + questions=_SYSTEMONE_PREDICATE, + images=["data:image/webp;base64,BB==", "data:image/png;base64,AA=="], + api_key="k", + drop_params=True, + ) + + assert json.loads(route.calls.last.request.content)["state"] == [ + _SCREEN_TEXT, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}, + ] + + def test_hosted_vllm_systemone_forwards_images( monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter,