From 70ef9a560523d991868213c517d8d03b28e93c0e Mon Sep 17 00:00:00 2001 From: mtrefilek Date: Wed, 28 Oct 2026 10:28:44 -0500 Subject: [PATCH 01/11] feat(openrouter): send decision images in the state array --- .../openrouter/decisions/transformation.py | 77 ++++++++++++++++++- tests/unit/decisions/test_main.py | 61 +++++++++++++++ 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/litellm/llms/openrouter/decisions/transformation.py b/litellm/llms/openrouter/decisions/transformation.py index 30d33f09efd..99145ad0a5e 100644 --- a/litellm/llms/openrouter/decisions/transformation.py +++ b/litellm/llms/openrouter/decisions/transformation.py @@ -1,13 +1,70 @@ -from typing import Final +from collections.abc import Mapping +from itertools import chain +from typing import Final, TypeAlias -from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig -from litellm.types.decisions import DecisionsIRResponse +from typing_extensions import assert_never + +from litellm.llms.base_llm.decisions.transformation import ( + BaseDecisionsConfig, + decisions_text, + image_media_type, + input_images, +) +from litellm.types.decisions import ( + DecisionsBase64Image, + DecisionsImage, + DecisionsIRMessages, + DecisionsIRRequest, + DecisionsIRResponse, + DecisionsIRState, + OpenAIDecisionInputImage, + OpenAIDecisionInputMessage, + OpenAIDecisionInputText, + UnsupportedDecisionsRequest, +) + +_StateItem: TypeAlias = str | Mapping[str, object] + + +def _data_url(image: DecisionsImage) -> str: + if isinstance(image, DecisionsBase64Image): + return f"data:{image_media_type(image)};base64,{image.base64}" + return image + + +def _image_item(image: DecisionsImage) -> _StateItem: + return {"type": "image_url", "image_url": {"url": _data_url(image)}} + + +def _part_item(part: OpenAIDecisionInputText | OpenAIDecisionInputImage) -> _StateItem: + if isinstance(part, OpenAIDecisionInputText): + return part.text + return _image_item(part.image_url) + + +def _message_items(message: OpenAIDecisionInputMessage) -> tuple[_StateItem, ...]: + if isinstance(message.content, str): + return (message.content,) + return tuple(map(_part_item, message.content)) + + +def _multimodal_state(decision_input: DecisionsIRState | DecisionsIRMessages) -> tuple[_StateItem, ...]: + match decision_input: + case DecisionsIRState(): + return (decisions_text(decision_input.state), *map(_image_item, decision_input.images)) + case DecisionsIRMessages(): + return tuple(chain.from_iterable(map(_message_items, decision_input.messages))) + case _: + assert_never(decision_input) class OpenRouterDecisionsConfig(BaseDecisionsConfig): path = "/alpha/decisions" api_key_env = ("OPENROUTER_API_KEY",) api_base_env = ("OPENROUTER_API_BASE",) + supports_images = True + image_media_types = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp"}) + inlines_remote_images = True def get_default_api_base(self) -> str | None: return "https://openrouter.ai/api" @@ -17,3 +74,17 @@ class OpenRouterDecisionsConfig(BaseDecisionsConfig): if not isinstance(cost, (int, float)): return None return float(cost) + + def transform_decisions_request( + self, + model: str, + request: DecisionsIRRequest, + custom_llm_provider: str, + ) -> Mapping[str, object] | UnsupportedDecisionsRequest: + body: Final = super().transform_decisions_request(model, request, custom_llm_provider) + if isinstance(body, UnsupportedDecisionsRequest) or not input_images(request.input): + return body + return { + **{key: value for key, value in body.items() if key != "images"}, + "state": _multimodal_state(request.input), + } diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index b9813289749..52f1f59508d 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -4064,6 +4064,67 @@ async def test_hosted_vllm_decisions_sends_input_images_to_systemone( assert async_only_image_fetch.fetched == (["https://images.example/input.png"] if remote_image else []) +@pytest.mark.asyncio +@pytest.mark.parametrize("remote_image", (False, True), ids=("inline", "remote")) +async def test_openrouter_decisions_sends_input_images_in_state_order( + respx_mock: respx.MockRouter, + remote_image: bool, + async_only_image_fetch: AsyncOnlyImageFetch, +) -> None: + image: Final[str] = "https://images.example/input.png" if remote_image else "data:image/png;base64,AA==" + expected_image: Final[str] = async_only_image_fetch.data_url if remote_image else image + route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=_RESPONSE) + + response: Final = await litellm.adecisions( + model="openrouter/typesafe/jev-1.14", + input=[ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "Listing photo:"}, + {"type": "input_image", "image_url": image}, + {"type": "input_text", "text": "Is it damaged?"}, + ], + } + ], + questions=[{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}], + api_key="caller-key", + ) + + assert json.loads(route.calls.last.request.content) == { + "model": "typesafe/jev-1.14", + "state": [ + "Listing photo:", + {"type": "image_url", "image_url": {"url": expected_image}}, + "Is it damaged?", + ], + "questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, + } + assert response.answers[0].probability == 0.9 + + +def test_openrouter_systemone_moves_images_into_state(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={"ticket": 1234}, + questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, + images=["data:image/jpeg;base64,AA==", {"content_type": "image/webp", "base64": "BB=="}], + api_key="caller-key", + ) + + assert json.loads(route.calls.last.request.content) == { + "model": "typesafe/jev-1.14", + "state": [ + '{"ticket": 1234}', + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,AA=="}}, + {"type": "image_url", "image_url": {"url": "data:image/webp;base64,BB=="}}, + ], + "questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, + } + + def test_hosted_vllm_systemone_forwards_images( monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, From 225835baeaf54e8c5a43143166b69c3786b9e72d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:53:16 -0700 Subject: [PATCH 02/11] feat(decisions): send Perplexity decision images as image_url parts in state, sharing OpenRouter's state builder --- .../llms/base_llm/decisions/transformation.py | 51 +++++++++++-- .../openrouter/decisions/transformation.py | 75 +------------------ .../perplexity/decisions/transformation.py | 4 + .../test_decisions_openai_format_wire.py | 20 ++++- tests/unit/decisions/test_main.py | 66 +++++++++++----- .../decisions_endpoints/test_endpoints.py | 20 +++-- 6 files changed, 135 insertions(+), 101 deletions(-) diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index e8e4b77e757..7ed621e56b3 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Mapping, Sequence from dataclasses import replace from itertools import chain from types import MappingProxyType -from typing import Final +from typing import Final, TypeAlias import httpx from pydantic import TypeAdapter, ValidationError @@ -66,6 +66,8 @@ _RESERVED_HEADERS: Final[frozenset[str]] = frozenset({"authorization", "content- _REMOTE_URL_PREFIXES: Final = ("http://", "https://") _DATA_URL_PREFIX: Final = "data:" +_StateItem: TypeAlias = str | Mapping[str, object] + def decisions_text(value: DecisionsJSON) -> str: return value if isinstance(value, str) else json.dumps(value) @@ -260,6 +262,38 @@ def _systemone_image(image: DecisionsImage) -> str | Mapping[str, object]: return f"{_DATA_URL_PREFIX}{media_type};base64,{image.partition(',')[2]}" +def _data_url(image: DecisionsImage) -> str: + if isinstance(image, DecisionsBase64Image): + return f"{_DATA_URL_PREFIX}{image_media_type(image)};base64,{image.base64}" + return image + + +def _image_item(image: DecisionsImage) -> _StateItem: + return {"type": "image_url", "image_url": {"url": _data_url(image)}} + + +def _part_item(part: OpenAIDecisionInputText | OpenAIDecisionInputImage) -> _StateItem: + if isinstance(part, OpenAIDecisionInputText): + return part.text + return _image_item(part.image_url) + + +def _message_items(message: OpenAIDecisionInputMessage) -> tuple[_StateItem, ...]: + if isinstance(message.content, str): + return (message.content,) + return tuple(map(_part_item, message.content)) + + +def _multimodal_state(decision_input: DecisionsIRState | DecisionsIRMessages) -> tuple[_StateItem, ...]: + match decision_input: + case DecisionsIRState(): + return (decisions_text(decision_input.state), *map(_image_item, decision_input.images)) + case DecisionsIRMessages(): + return tuple(chain.from_iterable(map(_message_items, decision_input.messages))) + case _: + assert_never(decision_input) + + def _optional(key: str, value: object) -> Mapping[str, object]: return {} if value is None else {key: value} @@ -298,18 +332,23 @@ def _systemone_question(question: DecisionsIRQuestion) -> Mapping[str, object]: def ir_to_systemone_request( - model: str, request: DecisionsIRRequest, *, supports_audio: bool = False, supports_video: bool = False + model: str, + request: DecisionsIRRequest, + *, + supports_audio: bool = False, + supports_video: bool = False, + images_in_state: bool = False, ) -> Mapping[str, object] | UnsupportedDecisionsRequest: if request.audio and not supports_audio: return UnsupportedDecisionsRequest(reason="audio input is not supported by this Decisions model") if request.videos and not supports_video: return UnsupportedDecisionsRequest(reason="video input is not supported by this Decisions model") - state: Final = _systemone_state(request.input) keyed_questions: Final = zip(systemone_keys(request.questions), request.questions, strict=True) images: Final = input_images(request.input) + state_carries_images: Final = images_in_state and bool(images) return { "model": model, - "state": state, + "state": _multimodal_state(request.input) if state_carries_images else _systemone_state(request.input), **( {"audio": [clip if isinstance(clip, str) else clip.model_dump() for clip in request.audio]} if request.audio @@ -321,7 +360,7 @@ def ir_to_systemone_request( else {} ), "questions": {key: _systemone_question(question) for key, question in keyed_questions}, - **({"images": [_systemone_image(image) for image in images]} if images else {}), + **({"images": [_systemone_image(image) for image in images]} if images and not state_carries_images else {}), **( {"assets": {key: asset.model_dump(exclude_none=True) for key, asset in request.assets.items()}} if request.assets @@ -523,6 +562,7 @@ class BaseDecisionsConfig(ABC): max_total_image_bytes: int | None = None image_media_types: frozenset[str] | None = None inlines_remote_images: bool = False + images_in_state: bool = False health_check_questions: Mapping[str, Mapping[str, object]] = MappingProxyType( {"reachable": MappingProxyType({"type": "noul", "instructions": "Is the service reachable?"})} ) @@ -642,6 +682,7 @@ class BaseDecisionsConfig(ABC): request, supports_audio=self.supports_audio_input(model, custom_llm_provider), supports_video=self.supports_video_input(model, custom_llm_provider), + images_in_state=self.images_in_state, ) def unwrap_response(self, payload: object) -> object: diff --git a/litellm/llms/openrouter/decisions/transformation.py b/litellm/llms/openrouter/decisions/transformation.py index 99145ad0a5e..016a87df98e 100644 --- a/litellm/llms/openrouter/decisions/transformation.py +++ b/litellm/llms/openrouter/decisions/transformation.py @@ -1,61 +1,7 @@ -from collections.abc import Mapping -from itertools import chain -from typing import Final, TypeAlias +from typing import Final -from typing_extensions import assert_never - -from litellm.llms.base_llm.decisions.transformation import ( - BaseDecisionsConfig, - decisions_text, - image_media_type, - input_images, -) -from litellm.types.decisions import ( - DecisionsBase64Image, - DecisionsImage, - DecisionsIRMessages, - DecisionsIRRequest, - DecisionsIRResponse, - DecisionsIRState, - OpenAIDecisionInputImage, - OpenAIDecisionInputMessage, - OpenAIDecisionInputText, - UnsupportedDecisionsRequest, -) - -_StateItem: TypeAlias = str | Mapping[str, object] - - -def _data_url(image: DecisionsImage) -> str: - if isinstance(image, DecisionsBase64Image): - return f"data:{image_media_type(image)};base64,{image.base64}" - return image - - -def _image_item(image: DecisionsImage) -> _StateItem: - return {"type": "image_url", "image_url": {"url": _data_url(image)}} - - -def _part_item(part: OpenAIDecisionInputText | OpenAIDecisionInputImage) -> _StateItem: - if isinstance(part, OpenAIDecisionInputText): - return part.text - return _image_item(part.image_url) - - -def _message_items(message: OpenAIDecisionInputMessage) -> tuple[_StateItem, ...]: - if isinstance(message.content, str): - return (message.content,) - return tuple(map(_part_item, message.content)) - - -def _multimodal_state(decision_input: DecisionsIRState | DecisionsIRMessages) -> tuple[_StateItem, ...]: - match decision_input: - case DecisionsIRState(): - return (decisions_text(decision_input.state), *map(_image_item, decision_input.images)) - case DecisionsIRMessages(): - return tuple(chain.from_iterable(map(_message_items, decision_input.messages))) - case _: - assert_never(decision_input) +from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig +from litellm.types.decisions import DecisionsIRResponse class OpenRouterDecisionsConfig(BaseDecisionsConfig): @@ -65,6 +11,7 @@ class OpenRouterDecisionsConfig(BaseDecisionsConfig): supports_images = True image_media_types = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp"}) inlines_remote_images = True + images_in_state = True def get_default_api_base(self) -> str | None: return "https://openrouter.ai/api" @@ -74,17 +21,3 @@ class OpenRouterDecisionsConfig(BaseDecisionsConfig): if not isinstance(cost, (int, float)): return None return float(cost) - - def transform_decisions_request( - self, - model: str, - request: DecisionsIRRequest, - custom_llm_provider: str, - ) -> Mapping[str, object] | UnsupportedDecisionsRequest: - body: Final = super().transform_decisions_request(model, request, custom_llm_provider) - if isinstance(body, UnsupportedDecisionsRequest) or not input_images(request.input): - return body - return { - **{key: value for key, value in body.items() if key != "images"}, - "state": _multimodal_state(request.input), - } diff --git a/litellm/llms/perplexity/decisions/transformation.py b/litellm/llms/perplexity/decisions/transformation.py index 11e2a38f06e..6ffc0cfdb41 100644 --- a/litellm/llms/perplexity/decisions/transformation.py +++ b/litellm/llms/perplexity/decisions/transformation.py @@ -5,6 +5,10 @@ class PerplexityDecisionsConfig(BaseDecisionsConfig): path = "/v1/decisions" api_key_env = ("PERPLEXITYAI_API_KEY", "PERPLEXITY_API_KEY") api_base_env = ("PERPLEXITY_API_BASE",) + supports_images = True + image_media_types = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp"}) + inlines_remote_images = True + images_in_state = True def get_default_api_base(self) -> str | None: return "https://api.perplexity.ai" diff --git a/tests/integration/providers/test_decisions_openai_format_wire.py b/tests/integration/providers/test_decisions_openai_format_wire.py index 2f82561d1b1..c88d26db599 100644 --- a/tests/integration/providers/test_decisions_openai_format_wire.py +++ b/tests/integration/providers/test_decisions_openai_format_wire.py @@ -162,6 +162,7 @@ _PROVIDERS: Final = ( _API_KEY, False, "perplexity/pplx-decider-v1-27b", + takes_images=True, ), _Provider("typesafe", "typesafe/jev-1.13.0", "/v1/systemone", "jev-1.13.0", _API_KEY, False, "typesafe/jev-1.13.0"), _Provider( @@ -172,6 +173,7 @@ _PROVIDERS: Final = ( _API_KEY, False, None, + takes_images=True, provider_reported_cost=1.5834e-5, ), _Provider( @@ -215,9 +217,10 @@ _PROVIDERS: Final = ( _SYSTEM_ONE_PROVIDERS: Final = tuple(provider for provider in _PROVIDERS if not provider.speaks_openai) _TEXT_ONLY_PROVIDERS: Final = tuple(provider for provider in _SYSTEM_ONE_PROVIDERS if not provider.takes_images) _PERPLEXITY: Final = _PROVIDERS[0] +_OPENROUTER: Final = next(provider for provider in _PROVIDERS if provider.name == "openrouter") _TYPESAFE: Final = _PROVIDERS[1] _OPENAI: Final = next(provider for provider in _PROVIDERS if provider.speaks_openai) -_CLOUDFLARE: Final = next(provider for provider in _SYSTEM_ONE_PROVIDERS if provider.takes_images) +_CLOUDFLARE: Final = next(provider for provider in _PROVIDERS if provider.name == "cloudflare") _PREDICATE: Final[dict[str, JsonValue]] = {"type": "predicate", "name": "q", "instructions": "Is it?"} _INVALID_BODIES: Final[tuple[tuple[str, dict[str, JsonValue]], ...]] = ( ("missing questions", {"input": _INPUT}), @@ -510,6 +513,21 @@ def test_cloudflare_receives_images_from_either_format_in_its_images_field(gatew assert [call["body"] for call in _upstream_calls(gateway, handle)] == [expected_body] * 2 +@pytest.mark.parametrize("provider", (_PERPLEXITY, _OPENROUTER), ids=_provider_id) +def test_perplexity_and_openrouter_receive_images_from_either_format_in_their_state( + gateway: Gateway, provider: _Provider +) -> None: + with gateway.scenario() as scenario: + handle: Final = _register(scenario, provider.upstream_reply()) + model: Final = _deployment(scenario, handle, provider) + openai_format, system_one_format = _decide_with_images_in_both_formats(gateway, model) + assert openai_format.status_code == 200, openai_format.text + assert system_one_format.status_code == 200, system_one_format.text + image_item: Final[dict[str, JsonValue]] = {"type": "image_url", "image_url": {"url": _PNG_DATA_URL}} + expected_body: Final = {**provider.upstream_body(), "state": [_INPUT, image_item]} + assert [call["body"] for call in _upstream_calls(gateway, handle)] == [expected_body] * 2 + + def test_openai_forwards_image_input_in_its_own_message_shape(gateway: Gateway) -> None: with gateway.scenario() as scenario: handle: Final = _register(scenario, _OPENAI.upstream_reply()) diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 52f1f59508d..2514ed85bda 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -1903,6 +1903,8 @@ async def test_non_string_safety_identifier_is_dropped_from_the_wire_under_drop_ _CLOUDFLARE_RUN_URL: Final = "https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/cloudflare" _PERPLEXITY_DECISIONS_URL: Final = "https://api.perplexity.ai/v1/decisions" +_TEXT_ONLY_MODEL: Final = "nace/drex-v2.0" +_TEXT_ONLY_URL: Final = "https://console.nace.ai/v1/systemone" _PNG_DATA_URL: Final = "data:image/png;base64,AA==" _REMOTE_IMAGE_URL: Final = "https://images.example/screen.png" _SCREEN_TEXT: Final = "Is the screen cracked?" @@ -2109,10 +2111,10 @@ def test_images_are_refused_before_http_when_the_provider_cannot_take_them( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(litellm, "drop_params", False) - route: Final = respx_mock.post(_PERPLEXITY_DECISIONS_URL).respond(json=_RESPONSE) + route: Final = respx_mock.post(_TEXT_ONLY_URL).respond(json=_RESPONSE) with pytest.raises(litellm.UnsupportedParamsError, match=rf"\['{param}'\].*drop_params") as caught: - getattr(litellm, api)(model="perplexity/pplx-decider-v1-27b", api_key="caller-key", **request_kwargs) + getattr(litellm, api)(model=_TEXT_ONLY_MODEL, api_key="caller-key", **request_kwargs) assert caught.value.status_code == 400 assert not route.called @@ -2124,14 +2126,12 @@ async def test_images_are_dropped_from_the_wire_under_drop_params( api: str, request_kwargs: Mapping[str, object], respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(litellm, "drop_params", False) - route: Final = respx_mock.post(_PERPLEXITY_DECISIONS_URL).respond(json=_RESPONSE) + route: Final = respx_mock.post(_TEXT_ONLY_URL).respond(json=_RESPONSE) - await getattr(litellm, f"a{api}")( - model="perplexity/pplx-decider-v1-27b", api_key="caller-key", drop_params=True, **request_kwargs - ) + await getattr(litellm, f"a{api}")(model=_TEXT_ONLY_MODEL, api_key="caller-key", drop_params=True, **request_kwargs) assert json.loads(route.calls[0].request.content) == { - "model": "pplx-decider-v1-27b", + "model": "drex-v2.0", "state": _SCREEN_TEXT, "questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, } @@ -2324,11 +2324,11 @@ async def test_dropping_the_only_input_images_is_rejected_before_http( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(litellm, "drop_params", False) - route: Final = respx_mock.post(_PERPLEXITY_DECISIONS_URL).respond(json=_RESPONSE) + route: Final = respx_mock.post(_TEXT_ONLY_URL).respond(json=_RESPONSE) with pytest.raises(litellm.BadRequestError, match=r"\['input_image'\].*leaves no input") as caught: await litellm.adecisions( - model="perplexity/pplx-decider-v1-27b", + model=_TEXT_ONLY_MODEL, api_key="caller-key", input=[{"role": "user", "content": [{"type": "input_image", "image_url": _PNG_DATA_URL}]}], questions=_PREDICATE_QUESTIONS, @@ -4064,19 +4064,30 @@ async def test_hosted_vllm_decisions_sends_input_images_to_systemone( assert async_only_image_fetch.fetched == (["https://images.example/input.png"] if remote_image else []) +_STATE_IMAGE_PROVIDERS: Final = ( + ("openrouter/typesafe/jev-1.14", "https://openrouter.ai/api/alpha/decisions", "typesafe/jev-1.14"), + ("perplexity/pplx-decider-v1.2", "https://api.perplexity.ai/v1/decisions", "pplx-decider-v1.2"), +) +_STATE_IMAGE_PROVIDER_IDS: Final = ("openrouter", "perplexity") + + @pytest.mark.asyncio @pytest.mark.parametrize("remote_image", (False, True), ids=("inline", "remote")) -async def test_openrouter_decisions_sends_input_images_in_state_order( +@pytest.mark.parametrize(("model", "url", "upstream_model"), _STATE_IMAGE_PROVIDERS, ids=_STATE_IMAGE_PROVIDER_IDS) +async def test_decisions_sends_input_images_in_state_order( + model: str, + url: str, + upstream_model: str, respx_mock: respx.MockRouter, remote_image: bool, async_only_image_fetch: AsyncOnlyImageFetch, ) -> None: image: Final[str] = "https://images.example/input.png" if remote_image else "data:image/png;base64,AA==" expected_image: Final[str] = async_only_image_fetch.data_url if remote_image else image - route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=_RESPONSE) + route: Final = respx_mock.post(url).respond(json=_RESPONSE) response: Final = await litellm.adecisions( - model="openrouter/typesafe/jev-1.14", + model=model, input=[ { "role": "user", @@ -4092,7 +4103,7 @@ async def test_openrouter_decisions_sends_input_images_in_state_order( ) assert json.loads(route.calls.last.request.content) == { - "model": "typesafe/jev-1.14", + "model": upstream_model, "state": [ "Listing photo:", {"type": "image_url", "image_url": {"url": expected_image}}, @@ -4103,11 +4114,14 @@ async def test_openrouter_decisions_sends_input_images_in_state_order( assert response.answers[0].probability == 0.9 -def test_openrouter_systemone_moves_images_into_state(respx_mock: respx.MockRouter) -> None: - route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=_RESPONSE) +@pytest.mark.parametrize(("model", "url", "upstream_model"), _STATE_IMAGE_PROVIDERS, ids=_STATE_IMAGE_PROVIDER_IDS) +def test_systemone_moves_images_into_state( + model: str, url: str, upstream_model: str, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(url).respond(json=_RESPONSE) litellm.systemone( - model="openrouter/typesafe/jev-1.14", + model=model, state={"ticket": 1234}, questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, images=["data:image/jpeg;base64,AA==", {"content_type": "image/webp", "base64": "BB=="}], @@ -4115,7 +4129,7 @@ def test_openrouter_systemone_moves_images_into_state(respx_mock: respx.MockRout ) assert json.loads(route.calls.last.request.content) == { - "model": "typesafe/jev-1.14", + "model": upstream_model, "state": [ '{"ticket": 1234}', {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,AA=="}}, @@ -4125,6 +4139,24 @@ def test_openrouter_systemone_moves_images_into_state(respx_mock: respx.MockRout } +@pytest.mark.parametrize(("model", "url", "upstream_model"), _STATE_IMAGE_PROVIDERS, ids=_STATE_IMAGE_PROVIDER_IDS) +def test_a_gif_to_a_state_image_provider_is_rejected_before_http( + model: str, url: str, upstream_model: str, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + route: Final = respx_mock.post(url).respond(json=_RESPONSE) + + with pytest.raises( + litellm.BadRequestError, match=re.escape("takes only image/jpeg, image/jpg, image/png, image/webp images") + ) as caught: + litellm.systemone( + model=model, state=_SCREEN_TEXT, questions=_SYSTEMONE_PREDICATE, images=[_GIF_DATA_URL], api_key="k" + ) + + assert caught.value.status_code == 400 + assert not route.called + + def test_hosted_vllm_systemone_forwards_images( monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, diff --git a/tests/unit/proxy/decisions_endpoints/test_endpoints.py b/tests/unit/proxy/decisions_endpoints/test_endpoints.py index a0a79f9b7cf..084ed638182 100644 --- a/tests/unit/proxy/decisions_endpoints/test_endpoints.py +++ b/tests/unit/proxy/decisions_endpoints/test_endpoints.py @@ -962,6 +962,8 @@ _IMAGE_REQUEST_BODIES: Final[tuple[Mapping[str, object], ...]] = ( ) _IMAGE_REQUESTS: Final = tuple(zip(_IMAGE_ENDPOINTS, _IMAGE_REQUEST_BODIES, strict=True)) _IMAGE_REQUEST_IDS: Final = ("openai_format", "systemone_format") +_TEXT_ONLY_DEPLOYMENT: Final[Mapping[str, object]] = {"model": "nace/drex-v2.0", "api_key": "test-key"} +_TEXT_ONLY_URL: Final = "https://console.nace.ai/v1/systemone" def _route_decider_to(monkeypatch: pytest.MonkeyPatch, litellm_params: Mapping[str, object]) -> None: @@ -1017,7 +1019,8 @@ def test_images_to_a_text_only_deployment_are_refused_without_drop_params( param: str, ) -> None: monkeypatch.setattr(litellm, "drop_params", False) - upstream: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE) + _route_decider_to(monkeypatch, _TEXT_ONLY_DEPLOYMENT) + upstream: Final = respx_mock.post(_TEXT_ONLY_URL).respond(json=_RESPONSE) response: Final = client.post(endpoint, json=request_body) @@ -1035,16 +1038,14 @@ def test_images_to_a_text_only_deployment_are_dropped_when_it_drops_params( request_body: Mapping[str, object], ) -> None: monkeypatch.setattr(litellm, "drop_params", False) - _route_decider_to( - monkeypatch, {"model": "perplexity/pplx-decider-v1-27b", "api_key": "test-key", "drop_params": True} - ) - upstream: Final = respx_mock.post("https://api.perplexity.ai/v1/decisions").respond(json=_RESPONSE) + _route_decider_to(monkeypatch, {**_TEXT_ONLY_DEPLOYMENT, "drop_params": True}) + upstream: Final = respx_mock.post(_TEXT_ONLY_URL).respond(json=_RESPONSE) response: Final = client.post(endpoint, json=request_body) assert response.status_code == 200, response.text assert json.loads(upstream.calls[0].request.content) == { - "model": "pplx-decider-v1-27b", + "model": "drex-v2.0", "state": _IMAGE_TEXT, "questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, } @@ -1104,7 +1105,12 @@ def test_malformed_assets_for_a_deployment_without_asset_support_are_dropped_onl response: Final = client.post( "/v1/systemone", - json={**_IMAGE_REQUEST_BODIES[1], "assets": {"photo-1": {"type": "image", "part": "file1"}}}, + json={ + "model": "decider", + "state": _IMAGE_TEXT, + "questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, + "assets": {"photo-1": {"type": "image", "part": "file1"}}, + }, ) if not deployment_drops_params: From 5d1a081334a661022c714c1f9a95485c7098848b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:10:21 -0700 Subject: [PATCH 03/11] fix(decisions): normalize data URL images placed in state the same way as the images field --- litellm/llms/base_llm/decisions/transformation.py | 12 ++++++------ tests/unit/decisions/test_main.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index 7ed621e56b3..e4050d41075 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -256,16 +256,16 @@ def image_media_type(image: DecisionsImage) -> str | None: def _systemone_image(image: DecisionsImage) -> str | Mapping[str, object]: if isinstance(image, DecisionsBase64Image): return {"content_type": image_media_type(image), "base64": image.base64} - media_type: Final = image_media_type(image) - if media_type is None: - return image - return f"{_DATA_URL_PREFIX}{media_type};base64,{image.partition(',')[2]}" + return _data_url(image) def _data_url(image: DecisionsImage) -> str: + media_type: Final = image_media_type(image) if isinstance(image, DecisionsBase64Image): - return f"{_DATA_URL_PREFIX}{image_media_type(image)};base64,{image.base64}" - return image + return f"{_DATA_URL_PREFIX}{media_type};base64,{image.base64}" + if media_type is None: + return image + return f"{_DATA_URL_PREFIX}{media_type};base64,{image.partition(',')[2]}" def _image_item(image: DecisionsImage) -> _StateItem: diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 2514ed85bda..3f6c5da92c5 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -4124,7 +4124,7 @@ def test_systemone_moves_images_into_state( model=model, state={"ticket": 1234}, questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, - images=["data:image/jpeg;base64,AA==", {"content_type": "image/webp", "base64": "BB=="}], + images=["data:IMAGE/JPEG;name=shot.jpg;base64,AA==", {"content_type": "image/webp", "base64": "BB=="}], api_key="caller-key", ) From 029d3c4a7e1d4e32cd10c3a3649ebd51e2e4e257 Mon Sep 17 00:00:00 2001 From: mtrefilek Date: Thu, 29 Oct 2026 05:23:54 -0500 Subject: [PATCH 04/11] 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, From 08c5a183a8cbbd4c30acc000956b862fdac76873 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:57:49 -0700 Subject: [PATCH 05/11] fix(decisions): follow each OpenRouter upstream's image rules, keep caller state items, normalize URL-safe base64 OpenRouter decision models now take the image types, count and size limits of the provider behind them (TypeSafe or Cloudflare), replacing the TypeSafe-only media type override with one rules lookup. Perplexity caps images at 8 before the call. A JSON object state stays one object item and a list state keeps its items ahead of the images, and URL-safe base64 in data URLs is converted to standard base64, which every upstream requires. --- litellm/decisions/call.py | 9 ++- .../llms/base_llm/decisions/transformation.py | 23 ++++-- .../openrouter/decisions/transformation.py | 14 ++-- .../perplexity/decisions/transformation.py | 1 + .../llms/typesafe/decisions/transformation.py | 5 +- tests/unit/decisions/test_main.py | 80 ++++++++++++++++++- 6 files changed, 110 insertions(+), 22 deletions(-) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index ca0acc0aa95..63bd1222d3f 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_for(call.model) + media_types: Final = call.provider_config.image_rules_for(call.model).image_media_types if media_types is None: return call found: Final = (image_media_type(image) for image in input_images(call.ir_request.input)) @@ -635,7 +635,7 @@ def _checked_image_types(call: DecisionsCall) -> DecisionsCall: def _checked_image_count(call: DecisionsCall) -> DecisionsCall: - max_images: Final = call.provider_config.max_images + max_images: Final = call.provider_config.image_rules_for(call.model).max_images image_count: Final = len(input_images(call.ir_request.input)) if max_images is None or image_count <= max_images: return call @@ -660,8 +660,9 @@ def _byte_limit(limit: int) -> str: def _image_size_error(call: DecisionsCall) -> str | None: sizes: Final = tuple(_decoded_image_bytes(image) for image in input_images(call.ir_request.input)) largest: Final = max(sizes, default=0) - per_image: Final = call.provider_config.max_image_bytes - total: Final = call.provider_config.max_total_image_bytes + rules: Final = call.provider_config.image_rules_for(call.model) + per_image: Final = rules.max_image_bytes + total: Final = rules.max_total_image_bytes if per_image is not None and largest > per_image: return f"takes images of at most {_byte_limit(per_image)} each, got one of {largest:,} bytes" if total is not None and sum(sizes) > total: diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index b6115160ac5..50dacadbe07 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -65,6 +65,7 @@ _SYSTEMONE_RESPONSE_ADAPTER: Final[TypeAdapter[DecisionsResponse]] = TypeAdapter _RESERVED_HEADERS: Final[frozenset[str]] = frozenset({"authorization", "content-type"}) _REMOTE_URL_PREFIXES: Final = ("http://", "https://") _DATA_URL_PREFIX: Final = "data:" +_URL_SAFE_TO_STANDARD_BASE64: Final = str.maketrans("-_", "+/") _StateItem: TypeAlias = str | Mapping[str, object] @@ -73,6 +74,10 @@ def decisions_text(value: DecisionsJSON) -> str: return value if isinstance(value, str) else json.dumps(value) +def standard_base64(value: str) -> str: + return value.translate(_URL_SAFE_TO_STANDARD_BASE64) + + def systemone_keys(questions: Sequence[DecisionsIRQuestion]) -> tuple[str, ...]: names: Final = tuple(question.name for question in questions if question.name is not None) if len(frozenset(names)) == len(questions): @@ -262,10 +267,10 @@ def _systemone_image(image: DecisionsImage) -> str | Mapping[str, object]: def _data_url(image: DecisionsImage) -> str: media_type: Final = image_media_type(image) if isinstance(image, DecisionsBase64Image): - return f"{_DATA_URL_PREFIX}{media_type};base64,{image.base64}" + return f"{_DATA_URL_PREFIX}{media_type};base64,{standard_base64(image.base64)}" if media_type is None: return image - return f"{_DATA_URL_PREFIX}{media_type};base64,{image.partition(',')[2]}" + return f"{_DATA_URL_PREFIX}{media_type};base64,{standard_base64(image.partition(',')[2])}" def _image_item(image: DecisionsImage) -> _StateItem: @@ -284,10 +289,16 @@ def _message_items(message: OpenAIDecisionInputMessage) -> tuple[_StateItem, ... return tuple(map(_part_item, message.content)) -def _multimodal_state(decision_input: DecisionsIRState | DecisionsIRMessages) -> tuple[_StateItem, ...]: +def _state_items(state: DecisionsJSON) -> tuple[object, ...]: + if isinstance(state, (str, Mapping)): + return (state,) + return tuple(state) + + +def _multimodal_state(decision_input: DecisionsIRState | DecisionsIRMessages) -> tuple[object, ...]: match decision_input: case DecisionsIRState(): - return (decisions_text(decision_input.state), *map(_image_item, decision_input.images)) + return (*_state_items(decision_input.state), *map(_image_item, decision_input.images)) case DecisionsIRMessages(): return tuple(chain.from_iterable(map(_message_items, decision_input.messages))) case _: @@ -649,8 +660,8 @@ 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 image_rules_for(self, model: str) -> "BaseDecisionsConfig": + return self 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 ef336f7c336..b1af997920b 100644 --- a/litellm/llms/openrouter/decisions/transformation.py +++ b/litellm/llms/openrouter/decisions/transformation.py @@ -1,9 +1,15 @@ +from collections.abc import Mapping +from types import MappingProxyType from typing import Final from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig +from litellm.llms.cloudflare.decisions.transformation import CloudflareDecisionsConfig +from litellm.llms.typesafe.decisions.transformation import TypeSafeDecisionsConfig from litellm.types.decisions import DecisionsIRResponse -_TYPESAFE_IMAGE_MEDIA_TYPES: Final = frozenset({"image/png", "image/jpeg", "image/jpg"}) +_ORG_IMAGE_RULES: Final[Mapping[str, BaseDecisionsConfig]] = MappingProxyType( + {"typesafe": TypeSafeDecisionsConfig(), "cloudflare": CloudflareDecisionsConfig()} +) class OpenRouterDecisionsConfig(BaseDecisionsConfig): @@ -18,10 +24,8 @@ 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 image_rules_for(self, model: str) -> BaseDecisionsConfig: + return _ORG_IMAGE_RULES.get(model.removeprefix("~").partition("/")[0], self) def provider_reported_cost(self, response: DecisionsIRResponse) -> float | None: cost: Final = response.usage.extra.get("cost") if response.usage is not None else None diff --git a/litellm/llms/perplexity/decisions/transformation.py b/litellm/llms/perplexity/decisions/transformation.py index 6ffc0cfdb41..06989ba4134 100644 --- a/litellm/llms/perplexity/decisions/transformation.py +++ b/litellm/llms/perplexity/decisions/transformation.py @@ -6,6 +6,7 @@ class PerplexityDecisionsConfig(BaseDecisionsConfig): api_key_env = ("PERPLEXITYAI_API_KEY", "PERPLEXITY_API_KEY") api_base_env = ("PERPLEXITY_API_BASE",) supports_images = True + max_images = 8 image_media_types = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp"}) inlines_remote_images = True images_in_state = True diff --git a/litellm/llms/typesafe/decisions/transformation.py b/litellm/llms/typesafe/decisions/transformation.py index 952c874bc8e..f77474d2872 100644 --- a/litellm/llms/typesafe/decisions/transformation.py +++ b/litellm/llms/typesafe/decisions/transformation.py @@ -6,7 +6,7 @@ from types import MappingProxyType from typing import Final, Literal from litellm.constants import TYPESAFE_DANGER_ZONE_HEADER_PREFIX -from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig, image_media_type +from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig, image_media_type, standard_base64 from litellm.types.decisions import ( DecisionsAsset, DecisionsIRMessages, @@ -22,7 +22,6 @@ _IMAGES_HEADER: Final = f"{TYPESAFE_DANGER_ZONE_HEADER_PREFIX}images" _ASSET_FORMATS: Final[Mapping[str, Literal["png", "jpg"]]] = MappingProxyType( {"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg"} ) -_URL_SAFE_TO_STANDARD_BASE64: Final = str.maketrans("-_", "+/") def _image_reference(image_url: str) -> str: @@ -36,7 +35,7 @@ def _image_asset(image_url: str) -> DecisionsAsset | None: return DecisionsAsset( type="image", format=asset_format, - data_base64=image_url.partition(",")[2].translate(_URL_SAFE_TO_STANDARD_BASE64), + data_base64=standard_base64(image_url.partition(",")[2]), ) diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index a7dd9d90898..6df93598e80 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -4124,21 +4124,93 @@ def test_systemone_moves_images_into_state( model=model, state={"ticket": 1234}, questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, - images=["data:IMAGE/JPEG;name=shot.jpg;base64,AA==", {"content_type": "image/webp", "base64": "BB=="}], + images=["data:IMAGE/JPEG;name=shot.jpg;base64,A-_A", {"content_type": "image/webp", "base64": "B-_B"}], api_key="caller-key", ) assert json.loads(route.calls.last.request.content) == { "model": upstream_model, "state": [ - '{"ticket": 1234}', - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,AA=="}}, - {"type": "image_url", "image_url": {"url": "data:image/webp;base64,BB=="}}, + {"ticket": 1234}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A+/A"}}, + {"type": "image_url", "image_url": {"url": "data:image/webp;base64,B+/B"}}, ], "questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, } +@pytest.mark.parametrize(("model", "url", "upstream_model"), _STATE_IMAGE_PROVIDERS, ids=_STATE_IMAGE_PROVIDER_IDS) +def test_a_list_state_keeps_its_items_ahead_of_the_images( + model: str, url: str, upstream_model: str, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(url).respond(json=_RESPONSE) + + litellm.systemone( + model=model, + state=["Ticket 1234", {"severity": "high"}], + questions=_SYSTEMONE_PREDICATE, + images=[_PNG_DATA_URL], + api_key="caller-key", + ) + + assert json.loads(route.calls.last.request.content)["state"] == [ + "Ticket 1234", + {"severity": "high"}, + {"type": "image_url", "image_url": {"url": _PNG_DATA_URL}}, + ] + + +@pytest.mark.parametrize( + ("model", "url", "max_images"), + ( + ("perplexity/pplx-decider-v1.2", "https://api.perplexity.ai/v1/decisions", 8), + ("openrouter/cloudflare/clef-flash", "https://openrouter.ai/api/alpha/decisions", 4), + ), + ids=("perplexity", "openrouter-clef"), +) +def test_more_images_than_the_model_takes_are_refused_before_http( + model: str, url: str, max_images: int, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(url).respond(json=_RESPONSE) + + litellm.systemone( + model=model, + state=_SCREEN_TEXT, + questions=_SYSTEMONE_PREDICATE, + images=[_PNG_DATA_URL] * max_images, + api_key="k", + ) + with pytest.raises( + litellm.BadRequestError, + match=re.escape(f"takes at most {max_images} images per request, got {max_images + 1}"), + ): + litellm.systemone( + model=model, + state=_SCREEN_TEXT, + questions=_SYSTEMONE_PREDICATE, + images=[_PNG_DATA_URL] * (max_images + 1), + api_key="k", + ) + + assert route.call_count == 1 + + +def test_an_image_over_the_clef_size_limit_is_refused_before_the_openrouter_call(respx_mock: respx.MockRouter) -> None: + route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=_RESPONSE) + over_four_mib: Final = "data:image/png;base64," + "A" * (4 * 1024 * 1024 // 3 * 4 + 8) + + with pytest.raises(litellm.BadRequestError, match=re.escape("openrouter takes images of at most 4,194,304 bytes")): + litellm.systemone( + model="openrouter/cloudflare/clef-flash", + state=_SCREEN_TEXT, + questions=_SYSTEMONE_PREDICATE, + images=[over_four_mib], + api_key="k", + ) + + assert not route.called + + @pytest.mark.parametrize(("model", "url", "upstream_model"), _STATE_IMAGE_PROVIDERS, ids=_STATE_IMAGE_PROVIDER_IDS) def test_a_gif_to_a_state_image_provider_is_rejected_before_http( model: str, url: str, upstream_model: str, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch From f716db54a39d2541573967ac55be44bed59a0404 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:48:07 -0700 Subject: [PATCH 06/11] fix(decisions): drop images past a model's limits under drop_params, map OpenRouter perplexity models, normalize state images only Images past a count or size limit now drop under drop_params, keeping the longest in-order run that fits, and answer 400 without it. Cloudflare clef follows the same rule. OpenRouter perplexity/ models take Perplexity's 8-image limit. Inside state, URL-safe base64 becomes standard base64 with padding and image/jpg goes out as image/jpeg, while the images field keeps the caller's base64 as on main. --- litellm/decisions/call.py | 64 +++++++---- .../llms/base_llm/decisions/transformation.py | 18 ++- .../openrouter/decisions/transformation.py | 7 +- tests/unit/decisions/test_main.py | 107 ++++++++++++++---- 4 files changed, 147 insertions(+), 49 deletions(-) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index 63bd1222d3f..feebd664246 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -2,6 +2,7 @@ import json from collections.abc import Mapping, Sequence from dataclasses import dataclass, field, replace from functools import partial +from itertools import accumulate from types import MappingProxyType from typing import Final, Literal, TypeAlias @@ -621,14 +622,27 @@ def _checked_image_types(call: DecisionsCall) -> DecisionsCall: ) if not unsupported: return call - supported: Final = with_images_where(call.ir_request.input, partial(_has_media_type_in, media_types)) - if _drops_params(call.litellm_params) and not _leaves_no_input(supported): - return replace(call, ir_request=replace(call.ir_request, input=supported)) + return _dropped_or_refused( + call, + with_images_where(call.ir_request.input, partial(_has_media_type_in, media_types)), + f"takes only {', '.join(sorted(media_types))} images, got {', '.join(unsupported)}", + ) + + +def _with_images_kept( + decision_input: DecisionsIRState | DecisionsIRMessages, kept: Sequence[bool] +) -> DecisionsIRState | DecisionsIRMessages: + flags: Final = iter(kept) + return with_images_where(decision_input, lambda _image: next(flags)) + + +def _dropped_or_refused( + call: DecisionsCall, kept_input: DecisionsIRState | DecisionsIRMessages, refusal: str +) -> DecisionsCall: + if _drops_params(call.litellm_params) and not _leaves_no_input(kept_input): + return replace(call, ir_request=replace(call.ir_request, input=kept_input)) raise litellm.BadRequestError( - message=( - f"{call.custom_llm_provider} takes only {', '.join(sorted(media_types))} images, " - f"got {', '.join(unsupported)}" - ), + message=f"{call.custom_llm_provider} {refusal}", model=call.requested_model, llm_provider=call.custom_llm_provider, ) @@ -639,10 +653,10 @@ def _checked_image_count(call: DecisionsCall) -> DecisionsCall: image_count: Final = len(input_images(call.ir_request.input)) if max_images is None or image_count <= max_images: return call - raise litellm.BadRequestError( - message=f"{call.custom_llm_provider} takes at most {max_images} images per request, got {image_count}", - model=call.requested_model, - llm_provider=call.custom_llm_provider, + return _dropped_or_refused( + call, + _with_images_kept(call.ir_request.input, tuple(index < max_images for index in range(image_count))), + f"takes at most {max_images} images per request, got {image_count}", ) @@ -657,12 +671,8 @@ def _byte_limit(limit: int) -> str: return f"{limit:,} bytes ({limit / (1024 * 1024):g} MiB)" -def _image_size_error(call: DecisionsCall) -> str | None: - sizes: Final = tuple(_decoded_image_bytes(image) for image in input_images(call.ir_request.input)) +def _image_size_error(sizes: Sequence[int], per_image: int | None, total: int | None) -> str | None: largest: Final = max(sizes, default=0) - rules: Final = call.provider_config.image_rules_for(call.model) - per_image: Final = rules.max_image_bytes - total: Final = rules.max_total_image_bytes if per_image is not None and largest > per_image: return f"takes images of at most {_byte_limit(per_image)} each, got one of {largest:,} bytes" if total is not None and sum(sizes) > total: @@ -670,14 +680,26 @@ def _image_size_error(call: DecisionsCall) -> str | None: return None +def _kept_within_sizes(sizes: Sequence[int], per_image: int | None, total: int | None) -> tuple[bool, ...]: + fits: Final = tuple(per_image is None or size <= per_image for size in sizes) + spent: Final = accumulate(size if fit else 0 for size, fit in zip(sizes, fits, strict=True)) + return tuple( + fit and (total is None or bytes_so_far <= total) for fit, bytes_so_far in zip(fits, spent, strict=True) + ) + + def _checked_image_sizes(call: DecisionsCall) -> DecisionsCall: - error: Final = _image_size_error(call) + sizes: Final = tuple(_decoded_image_bytes(image) for image in input_images(call.ir_request.input)) + rules: Final = call.provider_config.image_rules_for(call.model) + error: Final = _image_size_error(sizes, rules.max_image_bytes, rules.max_total_image_bytes) if error is None: return call - raise litellm.BadRequestError( - message=f"{call.custom_llm_provider} {error}", - model=call.requested_model, - llm_provider=call.custom_llm_provider, + return _dropped_or_refused( + call, + _with_images_kept( + call.ir_request.input, _kept_within_sizes(sizes, rules.max_image_bytes, rules.max_total_image_bytes) + ), + error, ) diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index 50dacadbe07..7edc96bf525 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -66,6 +66,7 @@ _RESERVED_HEADERS: Final[frozenset[str]] = frozenset({"authorization", "content- _REMOTE_URL_PREFIXES: Final = ("http://", "https://") _DATA_URL_PREFIX: Final = "data:" _URL_SAFE_TO_STANDARD_BASE64: Final = str.maketrans("-_", "+/") +_MEDIA_TYPE_ALIASES: Final[Mapping[str, str]] = MappingProxyType({"image/jpg": "image/jpeg"}) _StateItem: TypeAlias = str | Mapping[str, object] @@ -261,16 +262,25 @@ def image_media_type(image: DecisionsImage) -> str | None: def _systemone_image(image: DecisionsImage) -> str | Mapping[str, object]: if isinstance(image, DecisionsBase64Image): return {"content_type": image_media_type(image), "base64": image.base64} - return _data_url(image) + media_type: Final = image_media_type(image) + if media_type is None: + return image + return f"{_DATA_URL_PREFIX}{media_type};base64,{image.partition(',')[2]}" + + +def _standard_data_url(media_type: str, payload: str) -> str: + padding: Final = "=" * (-len(payload) % 4) + standard_type: Final = _MEDIA_TYPE_ALIASES.get(media_type, media_type) + return f"{_DATA_URL_PREFIX}{standard_type};base64,{standard_base64(payload)}{padding}" def _data_url(image: DecisionsImage) -> str: - media_type: Final = image_media_type(image) if isinstance(image, DecisionsBase64Image): - return f"{_DATA_URL_PREFIX}{media_type};base64,{standard_base64(image.base64)}" + return _standard_data_url(_media_type(image.content_type), image.base64) + media_type: Final = image_media_type(image) if media_type is None: return image - return f"{_DATA_URL_PREFIX}{media_type};base64,{standard_base64(image.partition(',')[2])}" + return _standard_data_url(media_type, image.partition(",")[2]) def _image_item(image: DecisionsImage) -> _StateItem: diff --git a/litellm/llms/openrouter/decisions/transformation.py b/litellm/llms/openrouter/decisions/transformation.py index b1af997920b..49d42036cd7 100644 --- a/litellm/llms/openrouter/decisions/transformation.py +++ b/litellm/llms/openrouter/decisions/transformation.py @@ -4,11 +4,16 @@ from typing import Final from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig from litellm.llms.cloudflare.decisions.transformation import CloudflareDecisionsConfig +from litellm.llms.perplexity.decisions.transformation import PerplexityDecisionsConfig from litellm.llms.typesafe.decisions.transformation import TypeSafeDecisionsConfig from litellm.types.decisions import DecisionsIRResponse _ORG_IMAGE_RULES: Final[Mapping[str, BaseDecisionsConfig]] = MappingProxyType( - {"typesafe": TypeSafeDecisionsConfig(), "cloudflare": CloudflareDecisionsConfig()} + { + "typesafe": TypeSafeDecisionsConfig(), + "cloudflare": CloudflareDecisionsConfig(), + "perplexity": PerplexityDecisionsConfig(), + } ) diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 6df93598e80..a9f27fe176c 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -2297,7 +2297,7 @@ def _cloudflare_image_requests(count: int) -> tuple[tuple[str, Mapping[str, obje def test_more_images_than_cloudflare_takes_are_refused_before_any_download_or_http( api: str, request_kwargs: Mapping[str, object], respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setattr(litellm, "drop_params", True) + monkeypatch.setattr(litellm, "drop_params", False) route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) with pytest.raises(litellm.BadRequestError, match="at most 4 images per request, got 5") as caught: @@ -2435,6 +2435,10 @@ def _png_object(decoded_bytes: int) -> Mapping[str, object]: return {"content_type": "image/png", "base64": base64.b64encode(bytes(decoded_bytes)).decode()} +def _distinct_png_data_urls(count: int) -> tuple[str, ...]: + return tuple(f"data:image/png;base64,{base64.b64encode(bytes((index,))).decode()}" for index in range(count)) + + _OVERSIZED_IMAGES: Final[tuple[tuple[Sequence[object], str], ...]] = ( ((_png_data_url(4 * _MIB + 1),), "images of at most 4,194,304 bytes (4 MiB) each, got one of 4,194,305 bytes"), ((_png_object(4 * _MIB + 1),), "images of at most 4,194,304 bytes (4 MiB) each, got one of 4,194,305 bytes"), @@ -2445,29 +2449,62 @@ _OVERSIZED_IMAGES: Final[tuple[tuple[Sequence[object], str], ...]] = ( ) +_OVERSIZED_IMAGE_IDS: Final = ("data_url_over_4mib", "object_over_4mib", "total_over_8mib") + + @pytest.mark.usefixtures("cloudflare_account") -@pytest.mark.parametrize("drop_params", (False, True), ids=("strict", "drop_params")) -@pytest.mark.parametrize( - ("images", "refusal"), _OVERSIZED_IMAGES, ids=("data_url_over_4mib", "object_over_4mib", "total_over_8mib") -) +@pytest.mark.parametrize(("images", "refusal"), _OVERSIZED_IMAGES, ids=_OVERSIZED_IMAGE_IDS) def test_images_over_cloudflares_size_limits_are_refused_before_http( - images: Sequence[object], refusal: str, drop_params: bool, respx_mock: respx.MockRouter + images: Sequence[object], refusal: str, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch ) -> None: + monkeypatch.setattr(litellm, "drop_params", False) route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) with pytest.raises(litellm.BadRequestError, match=re.escape(f"cloudflare takes {refusal}")) as caught: - litellm.systemone( - model="cloudflare/clef", - state=_SCREEN_TEXT, - questions=_SYSTEMONE_PREDICATE, - images=images, - drop_params=drop_params, - ) + litellm.systemone(model="cloudflare/clef", state=_SCREEN_TEXT, questions=_SYSTEMONE_PREDICATE, images=images) assert caught.value.status_code == 400 assert not route.called +@pytest.mark.usefixtures("cloudflare_account") +@pytest.mark.parametrize( + ("images", "kept_count"), + tuple(zip((images for images, _ in _OVERSIZED_IMAGES), (0, 0, 2), strict=True)), + ids=_OVERSIZED_IMAGE_IDS, +) +def test_images_over_cloudflares_size_limits_are_dropped_under_drop_params( + images: Sequence[object], kept_count: int, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + + litellm.systemone( + model="cloudflare/clef", + state=_SCREEN_TEXT, + questions=_SYSTEMONE_PREDICATE, + images=images, + drop_params=True, + ) + + assert json.loads(route.calls.last.request.content).get("images", []) == list(images[:kept_count]) + + +@pytest.mark.usefixtures("cloudflare_account") +def test_images_past_cloudflares_limit_are_dropped_under_drop_params(respx_mock: respx.MockRouter) -> None: + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + images: Final = _distinct_png_data_urls(5) + + litellm.systemone( + model="cloudflare/clef", + state=_SCREEN_TEXT, + questions=_SYSTEMONE_PREDICATE, + images=list(images), + drop_params=True, + ) + + assert json.loads(route.calls.last.request.content)["images"] == list(images[:4]) + + @pytest.mark.asyncio @pytest.mark.usefixtures("cloudflare_account") async def test_images_at_cloudflares_size_limits_reach_it(respx_mock: respx.MockRouter) -> None: @@ -4124,7 +4161,7 @@ def test_systemone_moves_images_into_state( model=model, state={"ticket": 1234}, questions={"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, - images=["data:IMAGE/JPEG;name=shot.jpg;base64,A-_A", {"content_type": "image/webp", "base64": "B-_B"}], + images=["data:IMAGE/JPG;name=shot.jpg;base64,A-_AB-", {"content_type": "image/webp", "base64": "B-_B"}], api_key="caller-key", ) @@ -4132,7 +4169,7 @@ def test_systemone_moves_images_into_state( "model": upstream_model, "state": [ {"ticket": 1234}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A+/A"}}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A+/AB+=="}}, {"type": "image_url", "image_url": {"url": "data:image/webp;base64,B+/B"}}, ], "questions": {"is_defect": {"type": "noul", "instructions": "Is this a defect?"}}, @@ -4160,17 +4197,19 @@ def test_a_list_state_keeps_its_items_ahead_of_the_images( ] -@pytest.mark.parametrize( - ("model", "url", "max_images"), - ( - ("perplexity/pplx-decider-v1.2", "https://api.perplexity.ai/v1/decisions", 8), - ("openrouter/cloudflare/clef-flash", "https://openrouter.ai/api/alpha/decisions", 4), - ), - ids=("perplexity", "openrouter-clef"), +_STATE_IMAGE_LIMITS: Final = ( + ("perplexity/pplx-decider-v1.2", "https://api.perplexity.ai/v1/decisions", 8), + ("openrouter/cloudflare/clef-flash", "https://openrouter.ai/api/alpha/decisions", 4), + ("openrouter/perplexity/pplx-decider-v1.2", "https://openrouter.ai/api/alpha/decisions", 8), ) +_STATE_IMAGE_LIMIT_IDS: Final = ("perplexity", "openrouter-clef", "openrouter-perplexity") + + +@pytest.mark.parametrize(("model", "url", "max_images"), _STATE_IMAGE_LIMITS, ids=_STATE_IMAGE_LIMIT_IDS) def test_more_images_than_the_model_takes_are_refused_before_http( - model: str, url: str, max_images: int, respx_mock: respx.MockRouter + model: str, url: str, max_images: int, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch ) -> None: + monkeypatch.setattr(litellm, "drop_params", False) route: Final = respx_mock.post(url).respond(json=_RESPONSE) litellm.systemone( @@ -4195,6 +4234,28 @@ def test_more_images_than_the_model_takes_are_refused_before_http( assert route.call_count == 1 +@pytest.mark.parametrize(("model", "url", "max_images"), _STATE_IMAGE_LIMITS, ids=_STATE_IMAGE_LIMIT_IDS) +def test_images_past_the_models_limit_are_dropped_under_drop_params( + model: str, url: str, max_images: int, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(url).respond(json=_RESPONSE) + images: Final = _distinct_png_data_urls(max_images + 2) + + litellm.systemone( + model=model, + state=_SCREEN_TEXT, + questions=_SYSTEMONE_PREDICATE, + images=list(images), + api_key="k", + drop_params=True, + ) + + assert json.loads(route.calls.last.request.content)["state"] == [ + _SCREEN_TEXT, + *({"type": "image_url", "image_url": {"url": image}} for image in images[:max_images]), + ] + + def test_an_image_over_the_clef_size_limit_is_refused_before_the_openrouter_call(respx_mock: respx.MockRouter) -> None: route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=_RESPONSE) over_four_mib: Final = "data:image/png;base64," + "A" * (4 * 1024 * 1024 // 3 * 4 + 8) From f25509e8e449692db04a0aa3ad489a4e3e23910a Mon Sep 17 00:00:00 2001 From: mtrefilek Date: Thu, 29 Oct 2026 12:14:40 -0500 Subject: [PATCH 07/11] feat(openrouter): allow box questions for Jev 1.14 and jev-latest --- ...odel_prices_and_context_window_backup.json | 12 ++++++ model_prices_and_context_window.json | 12 ++++++ tests/unit/decisions/test_main.py | 41 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 9c3bd9dacbb..979bae225fd 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -44596,6 +44596,18 @@ "supports_system_messages": true, "supports_vision": false }, + "openrouter/typesafe/jev-1.14": { + "litellm_provider": "openrouter", + "mode": "evaluation", + "source": "https://openrouter.ai/docs/guides/community/jev", + "supports_box_questions": true + }, + "openrouter/~typesafe/jev-latest": { + "litellm_provider": "openrouter", + "mode": "evaluation", + "source": "https://openrouter.ai/docs/guides/community/jev", + "supports_box_questions": true + }, "ovhcloud/DeepSeek-R1-Distill-Llama-70B": { "input_cost_per_token": 6.7e-07, "litellm_provider": "ovhcloud", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 9c3bd9dacbb..979bae225fd 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -44596,6 +44596,18 @@ "supports_system_messages": true, "supports_vision": false }, + "openrouter/typesafe/jev-1.14": { + "litellm_provider": "openrouter", + "mode": "evaluation", + "source": "https://openrouter.ai/docs/guides/community/jev", + "supports_box_questions": true + }, + "openrouter/~typesafe/jev-latest": { + "litellm_provider": "openrouter", + "mode": "evaluation", + "source": "https://openrouter.ai/docs/guides/community/jev", + "supports_box_questions": true + }, "ovhcloud/DeepSeek-R1-Distill-Llama-70B": { "input_cost_per_token": 6.7e-07, "litellm_provider": "ovhcloud", diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index a9f27fe176c..2be9d89b092 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -3615,6 +3615,47 @@ async def test_a_deployment_that_advertises_box_questions_gets_box_answers_from_ assert [answer.model_dump(mode="json") for answer in response.answers] == [dict(_CRACK_BOX_ANSWER)] +@pytest.mark.asyncio +@pytest.mark.parametrize("model", ("openrouter/typesafe/jev-1.14", "openrouter/~typesafe/jev-latest")) +async def test_openrouter_jev_sends_box_questions_and_returns_box_answers( + model: str, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond( + json={**_RESPONSE, "answers": {"crack": {k: v for k, v in _CRACK_BOX_ANSWER.items() if k != "name"}}} + ) + + response: Final = await litellm.adecisions( + model=model, + input=[ + { + "role": "user", + "content": [{"type": "input_image", "image_url": "data:image/png;base64,AA=="}], + } + ], + questions=[_CRACK_BOX_QUESTION], + api_key="caller-key", + ) + + assert json.loads(route.calls.last.request.content)["questions"] == { + "crack": {"type": "box", "instructions": "the crack on the screen"} + } + assert [answer.model_dump(mode="json") for answer in response.answers] == [dict(_CRACK_BOX_ANSWER)] + + +def test_openrouter_jev_1_13_still_refuses_box_questions( + 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.UnsupportedParamsError, match=r"\['box questions'\]"): + litellm.decisions( + model="openrouter/typesafe/jev-1.13", input="review", questions=[_CRACK_BOX_QUESTION], api_key="k" + ) + + assert not route.called + + @pytest.mark.asyncio async def test_a_deployment_that_turns_box_questions_off_is_refused_even_when_the_cost_map_allows_them( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch From 2137d34545d2723fdea9a92f6fed9ab7d94bf55f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:10:36 -0700 Subject: [PATCH 08/11] fix(decisions): send images only to OpenRouter upstreams that take them, answer Jev 1.14 box questions OpenRouter decision models other than the TypeSafe, Cloudflare and Perplexity upstreams are text-only, so images to them go back to main's behavior: a 400 before the call without drop_params, dropped under drop_params. Add an openrouter/typesafe/jev-1.14 capability row so box questions reach Jev 1.14 through OpenRouter, and log the body that was actually sent when an image drops after remote images were inlined. --- litellm/decisions/call.py | 21 +++-- .../llms/base_llm/decisions/transformation.py | 2 +- .../openrouter/decisions/transformation.py | 15 +++- .../llms/typesafe/decisions/transformation.py | 2 +- ...odel_prices_and_context_window_backup.json | 8 ++ model_prices_and_context_window.json | 8 ++ tests/unit/decisions/test_main.py | 89 +++++++++++++++++-- 7 files changed, 129 insertions(+), 16 deletions(-) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index feebd664246..bcadff6bd8b 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -302,7 +302,7 @@ def _unsupported_params( ir_request.safety_identifier is not None and not provider_config.supports_safety_identifier ) refused_image_param: Final = ( - None if provider_config.supports_images_in(ir_request.input, headers) else image_param(ir_request.input) + None if provider_config.supports_images_in(model, ir_request.input, headers) else image_param(ir_request.input) ) refuses_assets: Final = bool(ir_request.assets) and not provider_config.supports_assets(headers) refuses_box_questions: Final = _has_box_question(ir_request.questions) and not supports_box_questions @@ -333,6 +333,7 @@ def _is_not_box_question(question: DecisionsIRQuestion) -> bool: def _without_unsupported_params( ir_request: DecisionsIRRequest, provider_config: BaseDecisionsConfig, + model: str, headers: Mapping[str, str], supports_box_questions: bool, ) -> DecisionsIRRequest: @@ -340,7 +341,7 @@ def _without_unsupported_params( ir_request, input=( ir_request.input - if provider_config.supports_images_in(ir_request.input, headers) + if provider_config.supports_images_in(model, ir_request.input, headers) else with_images_where(ir_request.input, _no_image) ), safety_identifier=ir_request.safety_identifier if provider_config.supports_safety_identifier else None, @@ -401,7 +402,7 @@ def _provider_ir_request( return ir_request if not _drops_params(kwargs): return _UnsupportedParams(names=unsupported) - supported: Final = _without_unsupported_params(ir_request, provider_config, headers, supports_box_questions) + supported: Final = _without_unsupported_params(ir_request, provider_config, model, headers, supports_box_questions) if _leaves_no_input(supported.input): return _NothingLeftAfterDrop(names=unsupported, missing="input to decide on") if not supported.questions: @@ -716,6 +717,16 @@ def _request_body(call: DecisionsCall) -> Mapping[str, object]: return body +def _image_count(call: DecisionsCall) -> int: + return len(input_images(call.ir_request.input)) + + +def _logged_body(supported: DecisionsCall, inlined: DecisionsCall, body: Mapping[str, object]) -> Mapping[str, object]: + if inlined is supported or _image_count(inlined) < _image_count(supported): + return body + return _request_body(supported) + + def _pricing_model(response: DecisionsIRResponse, call: DecisionsCall) -> str: returned: Final = get_model_cost_key(f"{call.custom_llm_provider}/{response.model}") if returned is not None and cost_map_key_prices_anything(returned): @@ -789,7 +800,7 @@ async def asend(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionRespon provider_config=inlined.provider_config, request=inlined.ir_request, body=body, - logged_body=body if inlined is supported else _request_body(supported), + logged_body=_logged_body(supported, inlined, body), api_base=inlined.api_base, api_key=inlined.api_key, headers=inlined.headers, @@ -813,7 +824,7 @@ def send(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse: provider_config=inlined.provider_config, request=inlined.ir_request, body=body, - logged_body=body if inlined is supported else _request_body(supported), + logged_body=_logged_body(supported, inlined, body), api_base=inlined.api_base, api_key=inlined.api_key, headers=inlined.headers, diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index 7edc96bf525..e5f63ac4f5a 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -666,7 +666,7 @@ class BaseDecisionsConfig(ABC): return headers, None def supports_images_in( - self, decision_input: DecisionsIRState | DecisionsIRMessages, headers: Mapping[str, str] + self, model: str, decision_input: DecisionsIRState | DecisionsIRMessages, headers: Mapping[str, str] ) -> bool: return self.supports_images diff --git a/litellm/llms/openrouter/decisions/transformation.py b/litellm/llms/openrouter/decisions/transformation.py index 49d42036cd7..86573580181 100644 --- a/litellm/llms/openrouter/decisions/transformation.py +++ b/litellm/llms/openrouter/decisions/transformation.py @@ -6,7 +6,7 @@ from litellm.llms.base_llm.decisions.transformation import BaseDecisionsConfig from litellm.llms.cloudflare.decisions.transformation import CloudflareDecisionsConfig from litellm.llms.perplexity.decisions.transformation import PerplexityDecisionsConfig from litellm.llms.typesafe.decisions.transformation import TypeSafeDecisionsConfig -from litellm.types.decisions import DecisionsIRResponse +from litellm.types.decisions import DecisionsIRMessages, DecisionsIRResponse, DecisionsIRState _ORG_IMAGE_RULES: Final[Mapping[str, BaseDecisionsConfig]] = MappingProxyType( { @@ -17,20 +17,27 @@ _ORG_IMAGE_RULES: Final[Mapping[str, BaseDecisionsConfig]] = MappingProxyType( ) +def _upstream_org(model: str) -> str: + return model.removeprefix("~").partition("/")[0] + + class OpenRouterDecisionsConfig(BaseDecisionsConfig): path = "/alpha/decisions" api_key_env = ("OPENROUTER_API_KEY",) api_base_env = ("OPENROUTER_API_BASE",) - supports_images = True - image_media_types = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp"}) inlines_remote_images = True images_in_state = True def get_default_api_base(self) -> str | None: return "https://openrouter.ai/api" + def supports_images_in( + self, model: str, decision_input: DecisionsIRState | DecisionsIRMessages, headers: Mapping[str, str] + ) -> bool: + return _upstream_org(model) in _ORG_IMAGE_RULES + def image_rules_for(self, model: str) -> BaseDecisionsConfig: - return _ORG_IMAGE_RULES.get(model.removeprefix("~").partition("/")[0], self) + return _ORG_IMAGE_RULES.get(_upstream_org(model), self) def provider_reported_cost(self, response: DecisionsIRResponse) -> float | None: cost: Final = response.usage.extra.get("cost") if response.usage is not None else None diff --git a/litellm/llms/typesafe/decisions/transformation.py b/litellm/llms/typesafe/decisions/transformation.py index f77474d2872..3ba6504c576 100644 --- a/litellm/llms/typesafe/decisions/transformation.py +++ b/litellm/llms/typesafe/decisions/transformation.py @@ -84,7 +84,7 @@ class TypeSafeDecisionsConfig(BaseDecisionsConfig): return "https://api.typesafe.ai" def supports_images_in( - self, decision_input: DecisionsIRState | DecisionsIRMessages, headers: Mapping[str, str] + self, model: str, decision_input: DecisionsIRState | DecisionsIRMessages, headers: Mapping[str, str] ) -> bool: return isinstance(decision_input, DecisionsIRMessages) and self.supports_assets(headers) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 9c3bd9dacbb..da437981d72 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -70175,6 +70175,14 @@ "source": "https://docs.typesafe.ai/models", "supports_box_questions": true }, + "openrouter/typesafe/jev-1.14": { + "input_cost_per_token": 4.2e-08, + "litellm_provider": "openrouter", + "mode": "evaluation", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/typesafe/jev-1.14", + "supports_box_questions": true + }, "wandb/deepseek-ai/DeepSeek-V4.1-Flash": { "cache_read_input_token_cost": 3e-08, "input_cost_per_token": 2e-07, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 9c3bd9dacbb..da437981d72 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -70175,6 +70175,14 @@ "source": "https://docs.typesafe.ai/models", "supports_box_questions": true }, + "openrouter/typesafe/jev-1.14": { + "input_cost_per_token": 4.2e-08, + "litellm_provider": "openrouter", + "mode": "evaluation", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/typesafe/jev-1.14", + "supports_box_questions": true + }, "wandb/deepseek-ai/DeepSeek-V4.1-Flash": { "cache_read_input_token_cost": 3e-08, "input_cost_per_token": 2e-07, diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index a9f27fe176c..cc27d78e9cc 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -2467,14 +2467,19 @@ def test_images_over_cloudflares_size_limits_are_refused_before_http( assert not route.called +_SMALL_PNG: Final = _png_data_url(1) +_DROPPED_OVERSIZED_IMAGES: Final[tuple[tuple[Sequence[object], Sequence[object]], ...]] = ( + *((images, images[:kept]) for (images, _), kept in zip(_OVERSIZED_IMAGES, (0, 0, 2), strict=True)), + ((_png_data_url(4 * _MIB + 1), _SMALL_PNG), (_SMALL_PNG,)), +) + + @pytest.mark.usefixtures("cloudflare_account") @pytest.mark.parametrize( - ("images", "kept_count"), - tuple(zip((images for images, _ in _OVERSIZED_IMAGES), (0, 0, 2), strict=True)), - ids=_OVERSIZED_IMAGE_IDS, + ("images", "kept"), _DROPPED_OVERSIZED_IMAGES, ids=(*_OVERSIZED_IMAGE_IDS, "oversized_then_small") ) def test_images_over_cloudflares_size_limits_are_dropped_under_drop_params( - images: Sequence[object], kept_count: int, respx_mock: respx.MockRouter + images: Sequence[object], kept: Sequence[object], respx_mock: respx.MockRouter ) -> None: route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) @@ -2486,7 +2491,28 @@ def test_images_over_cloudflares_size_limits_are_dropped_under_drop_params( drop_params=True, ) - assert json.loads(route.calls.last.request.content).get("images", []) == list(images[:kept_count]) + assert json.loads(route.calls.last.request.content).get("images", []) == list(kept) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("cloudflare_account") +async def test_logging_callbacks_get_only_the_images_left_after_an_oversized_one_drops( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + recording_logger: Final = _InputRecordingLogger() + monkeypatch.setattr(litellm, "callbacks", [recording_logger]) + + await litellm.asystemone( + model="cloudflare/clef", + state=_SCREEN_TEXT, + questions=_SYSTEMONE_PREDICATE, + images=[_png_data_url(4 * _MIB + 1), _SMALL_PNG], + drop_params=True, + ) + await _drain_logging_worker() + + assert recording_logger.inputs == [json.loads(route.calls.last.request.content)] @pytest.mark.usefixtures("cloudflare_account") @@ -3615,6 +3641,31 @@ async def test_a_deployment_that_advertises_box_questions_gets_box_answers_from_ assert [answer.model_dump(mode="json") for answer in response.answers] == [dict(_CRACK_BOX_ANSWER)] +@pytest.mark.asyncio +async def test_openrouter_jev_1_14_gets_box_answers_while_jev_1_13_is_still_refused( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + box_answer: Final = {key: value for key, value in _CRACK_BOX_ANSWER.items() if key != "name"} + route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond( + json={"model": "typesafe/jev-1.14", "answers": {"crack": box_answer}} + ) + + response: Final = await litellm.adecisions( + model="openrouter/typesafe/jev-1.14", input="review", questions=[_CRACK_BOX_QUESTION], api_key="caller-key" + ) + + assert json.loads(route.calls.last.request.content)["questions"] == { + "crack": {"type": "box", "instructions": "the crack on the screen"} + } + assert [answer.model_dump(mode="json") for answer in response.answers] == [dict(_CRACK_BOX_ANSWER)] + with pytest.raises(litellm.UnsupportedParamsError, match=r"\['box questions'\]"): + await litellm.adecisions( + model="openrouter/typesafe/jev-1.13", input="review", questions=[_CRACK_BOX_QUESTION], api_key="caller-key" + ) + assert route.call_count == 1 + + @pytest.mark.asyncio async def test_a_deployment_that_turns_box_questions_off_is_refused_even_when_the_cost_map_allows_them( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch @@ -4309,6 +4360,34 @@ def test_openrouter_jev_rejects_webp_before_http( assert not route.called +@pytest.mark.asyncio +async def test_images_to_an_openrouter_upstream_without_image_rules_are_refused_or_dropped_as_before( + 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) + request: Final[Mapping[str, object]] = MappingProxyType( + { + "model": "openrouter/upstage/solar-decide-flash", + "state": "Product photo", + "questions": _SYSTEMONE_PREDICATE, + "images": [_SMALL_PNG], + "api_key": "caller-key", + } + ) + + with pytest.raises(litellm.UnsupportedParamsError, match=r"\['images'\]"): + await litellm.asystemone(**request) + await litellm.asystemone(**request, drop_params=True) + + assert route.call_count == 1 + assert json.loads(route.calls.last.request.content) == { + "model": "upstage/solar-decide-flash", + "state": "Product photo", + "questions": _SYSTEMONE_PREDICATE, + } + + 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) From ac0da7e2b63a79b914c61c92ac6c5e5a099cc88c Mon Sep 17 00:00:00 2001 From: mtrefilek Date: Thu, 29 Oct 2026 15:28:15 -0500 Subject: [PATCH 09/11] fix(openrouter): price the Jev 1.14 and jev-latest rows from OpenRouter's catalog --- litellm/model_prices_and_context_window_backup.json | 8 ++++++-- model_prices_and_context_window.json | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 6248e9bf227..b6909d88a9f 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45869,15 +45869,19 @@ "supports_vision": false }, "openrouter/typesafe/jev-1.14": { + "input_cost_per_token": 4.2e-08, "litellm_provider": "openrouter", "mode": "evaluation", - "source": "https://openrouter.ai/docs/guides/community/jev", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/typesafe/jev-1.14", "supports_box_questions": true }, "openrouter/~typesafe/jev-latest": { + "input_cost_per_token": 4.2e-08, "litellm_provider": "openrouter", "mode": "evaluation", - "source": "https://openrouter.ai/docs/guides/community/jev", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/~typesafe/jev-latest", "supports_box_questions": true }, "ovhcloud/DeepSeek-R1-Distill-Llama-70B": { diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 6248e9bf227..b6909d88a9f 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45869,15 +45869,19 @@ "supports_vision": false }, "openrouter/typesafe/jev-1.14": { + "input_cost_per_token": 4.2e-08, "litellm_provider": "openrouter", "mode": "evaluation", - "source": "https://openrouter.ai/docs/guides/community/jev", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/typesafe/jev-1.14", "supports_box_questions": true }, "openrouter/~typesafe/jev-latest": { + "input_cost_per_token": 4.2e-08, "litellm_provider": "openrouter", "mode": "evaluation", - "source": "https://openrouter.ai/docs/guides/community/jev", + "output_cost_per_token": 0.0, + "source": "https://openrouter.ai/~typesafe/jev-latest", "supports_box_questions": true }, "ovhcloud/DeepSeek-R1-Distill-Llama-70B": { From 285aad0bfd7dda7ae3be5b70b0046298e4f640bc Mon Sep 17 00:00:00 2001 From: mtrefilek Date: Thu, 29 Oct 2026 15:33:10 -0500 Subject: [PATCH 10/11] revert(openrouter): keep the Jev 1.14 and jev-latest rows price-free This reverts commit ac0da7e2b6. OpenRouter returns usage.cost on every call, and a copied price would go stale. --- litellm/model_prices_and_context_window_backup.json | 8 ++------ model_prices_and_context_window.json | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index b6909d88a9f..6248e9bf227 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45869,19 +45869,15 @@ "supports_vision": false }, "openrouter/typesafe/jev-1.14": { - "input_cost_per_token": 4.2e-08, "litellm_provider": "openrouter", "mode": "evaluation", - "output_cost_per_token": 0.0, - "source": "https://openrouter.ai/typesafe/jev-1.14", + "source": "https://openrouter.ai/docs/guides/community/jev", "supports_box_questions": true }, "openrouter/~typesafe/jev-latest": { - "input_cost_per_token": 4.2e-08, "litellm_provider": "openrouter", "mode": "evaluation", - "output_cost_per_token": 0.0, - "source": "https://openrouter.ai/~typesafe/jev-latest", + "source": "https://openrouter.ai/docs/guides/community/jev", "supports_box_questions": true }, "ovhcloud/DeepSeek-R1-Distill-Llama-70B": { diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index b6909d88a9f..6248e9bf227 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45869,19 +45869,15 @@ "supports_vision": false }, "openrouter/typesafe/jev-1.14": { - "input_cost_per_token": 4.2e-08, "litellm_provider": "openrouter", "mode": "evaluation", - "output_cost_per_token": 0.0, - "source": "https://openrouter.ai/typesafe/jev-1.14", + "source": "https://openrouter.ai/docs/guides/community/jev", "supports_box_questions": true }, "openrouter/~typesafe/jev-latest": { - "input_cost_per_token": 4.2e-08, "litellm_provider": "openrouter", "mode": "evaluation", - "output_cost_per_token": 0.0, - "source": "https://openrouter.ai/~typesafe/jev-latest", + "source": "https://openrouter.ai/docs/guides/community/jev", "supports_box_questions": true }, "ovhcloud/DeepSeek-R1-Distill-Llama-70B": { From 7641991af2b555df6115f2f0326ad5a603665d07 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:53:25 -0700 Subject: [PATCH 11/11] fix(decisions): log the caller's image URLs when an image drops after remote images are inlined --- litellm/decisions/call.py | 34 +++++++++++++++++++------------ tests/unit/decisions/test_main.py | 23 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index bcadff6bd8b..35ae4b42316 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -585,9 +585,9 @@ def _remote_image_urls(call: DecisionsCall) -> tuple[str, ...]: return remote_image_urls(call.ir_request.input) -def _with_data_urls(call: DecisionsCall, data_urls: Mapping[str, str]) -> DecisionsCall: +def _with_images_replaced(call: DecisionsCall, replacements: Mapping[str, str]) -> DecisionsCall: return replace( - call, ir_request=replace(call.ir_request, input=with_inlined_images(call.ir_request.input, data_urls)) + call, ir_request=replace(call.ir_request, input=with_inlined_images(call.ir_request.input, replacements)) ) @@ -595,7 +595,7 @@ def _with_remote_images_inlined(call: DecisionsCall) -> DecisionsCall: remote_urls: Final = _remote_image_urls(call) if not remote_urls: return call - return _with_data_urls( + return _with_images_replaced( call, MappingProxyType({url: convert_url_to_base64(url, timeout=call.timeout) for url in remote_urls}) ) @@ -605,7 +605,7 @@ async def _async_with_remote_images_inlined(call: DecisionsCall) -> DecisionsCal if not remote_urls: return call data_urls: Final = await async_convert_urls_to_base64(remote_urls, timeout=call.timeout) - return _with_data_urls(call, MappingProxyType(dict(zip(remote_urls, data_urls, strict=True)))) + return _with_images_replaced(call, MappingProxyType(dict(zip(remote_urls, data_urls, strict=True)))) def _has_media_type_in(media_types: frozenset[str], image: DecisionsImage) -> bool: @@ -717,14 +717,20 @@ def _request_body(call: DecisionsCall) -> Mapping[str, object]: return body -def _image_count(call: DecisionsCall) -> int: - return len(input_images(call.ir_request.input)) +def _with_caller_urls(sent: DecisionsCall, supported: DecisionsCall, fetched: DecisionsCall) -> DecisionsCall: + pairs: Final = zip(input_images(fetched.ir_request.input), input_images(supported.ir_request.input), strict=True) + caller_urls: Final = MappingProxyType( + {data_url: url for data_url, url in pairs if isinstance(data_url, str) and isinstance(url, str)} + ) + return _with_images_replaced(sent, caller_urls) -def _logged_body(supported: DecisionsCall, inlined: DecisionsCall, body: Mapping[str, object]) -> Mapping[str, object]: - if inlined is supported or _image_count(inlined) < _image_count(supported): +def _logged_body( + supported: DecisionsCall, fetched: DecisionsCall, sent: DecisionsCall, body: Mapping[str, object] +) -> Mapping[str, object]: + if fetched is supported: return body - return _request_body(supported) + return _request_body(_with_caller_urls(sent, supported, fetched)) def _pricing_model(response: DecisionsIRResponse, call: DecisionsCall) -> str: @@ -790,7 +796,8 @@ def _map_upstream_exception(error: Exception, call: DecisionsCall) -> Exception: async def asend(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse: supported: Final = _checked_image_count(_checked_image_types(call)) - inlined: Final = _checked_image_sizes(_checked_image_types(await _async_with_remote_images_inlined(supported))) + fetched: Final = await _async_with_remote_images_inlined(supported) + inlined: Final = _checked_image_sizes(_checked_image_types(fetched)) body: Final = _request_body(inlined) try: response: Final = await _HANDLER.adecisions( @@ -800,7 +807,7 @@ async def asend(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionRespon provider_config=inlined.provider_config, request=inlined.ir_request, body=body, - logged_body=_logged_body(supported, inlined, body), + logged_body=_logged_body(supported, fetched, inlined, body), api_base=inlined.api_base, api_key=inlined.api_key, headers=inlined.headers, @@ -814,7 +821,8 @@ async def asend(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionRespon def send(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse: supported: Final = _checked_image_count(_checked_image_types(call)) - inlined: Final = _checked_image_sizes(_checked_image_types(_with_remote_images_inlined(supported))) + fetched: Final = _with_remote_images_inlined(supported) + inlined: Final = _checked_image_sizes(_checked_image_types(fetched)) body: Final = _request_body(inlined) try: response: Final = _HANDLER.decisions( @@ -824,7 +832,7 @@ def send(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse: provider_config=inlined.provider_config, request=inlined.ir_request, body=body, - logged_body=_logged_body(supported, inlined, body), + logged_body=_logged_body(supported, fetched, inlined, body), api_base=inlined.api_base, api_key=inlined.api_key, headers=inlined.headers, diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index f2c9f1ce314..789237f7e69 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -2515,6 +2515,29 @@ async def test_logging_callbacks_get_only_the_images_left_after_an_oversized_one assert recording_logger.inputs == [json.loads(route.calls.last.request.content)] +@pytest.mark.asyncio +@pytest.mark.usefixtures("cloudflare_account") +async def test_logging_callbacks_get_the_caller_url_of_a_remote_image_kept_after_an_oversized_one_drops( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, async_only_image_fetch +) -> None: + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + recording_logger: Final = _InputRecordingLogger() + monkeypatch.setattr(litellm, "callbacks", [recording_logger]) + + await litellm.asystemone( + model="cloudflare/clef", + state=_SCREEN_TEXT, + questions=_SYSTEMONE_PREDICATE, + images=[_png_data_url(4 * _MIB + 1), _REMOTE_IMAGE_URL], + drop_params=True, + ) + await _drain_logging_worker() + + wire_body: Final = json.loads(route.calls.last.request.content) + assert wire_body["images"] == [async_only_image_fetch.data_url] + assert recording_logger.inputs == [{**wire_body, "images": [_REMOTE_IMAGE_URL]}] + + @pytest.mark.usefixtures("cloudflare_account") def test_images_past_cloudflares_limit_are_dropped_under_drop_params(respx_mock: respx.MockRouter) -> None: route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE)