From 70ef9a560523d991868213c517d8d03b28e93c0e Mon Sep 17 00:00:00 2001 From: mtrefilek Date: Wed, 28 Oct 2026 10:28:44 -0500 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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) From 14d21ebc18c37be84630f18b452152b4e95f6151 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:29:22 -0700 Subject: [PATCH 12/23] fix(decisions): drop failed image downloads and oversized images under drop_params, log caller URLs by position, and accept line-wrapped base64 --- litellm/decisions/call.py | 184 ++++++++++++------ .../prompt_templates/image_handling.py | 25 +++ .../llms/base_llm/decisions/transformation.py | 7 +- tests/unit/decisions/test_main.py | 116 ++++++++++- 4 files changed, 270 insertions(+), 62 deletions(-) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index 35ae4b42316..fcd654cad61 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -18,7 +18,9 @@ from litellm.litellm_core_utils.get_provider_specific_headers import ProviderSpe from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.prompt_templates.image_handling import ( async_convert_urls_to_base64, + async_convert_urls_to_base64_or_errors, convert_url_to_base64, + convert_url_to_base64_or_error, ) from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.decisions.transformation import ( @@ -29,6 +31,7 @@ from litellm.llms.base_llm.decisions.transformation import ( input_images, ir_to_systemone_response, remote_image_urls, + standard_base64, systemone_request_to_ir, with_images_where, with_inlined_images, @@ -591,10 +594,31 @@ def _with_images_replaced(call: DecisionsCall, replacements: Mapping[str, str]) ) +def _is_not_one_of(urls: frozenset[str], image: DecisionsImage) -> bool: + return not isinstance(image, str) or image not in urls + + +def _with_downloads_or_dropped_failures( + call: DecisionsCall, downloads: Mapping[str, str | litellm.ImageFetchError] +) -> DecisionsCall: + failures: Final = tuple(result for result in downloads.values() if isinstance(result, litellm.ImageFetchError)) + failed_urls: Final = frozenset(url for url, result in downloads.items() if not isinstance(result, str)) + if failures and _leaves_no_input(with_images_where(call.ir_request.input, partial(_is_not_one_of, failed_urls))): + raise failures[0] + return _with_images_replaced( + call, MappingProxyType({url: result for url, result in downloads.items() if isinstance(result, str)}) + ) + + def _with_remote_images_inlined(call: DecisionsCall) -> DecisionsCall: remote_urls: Final = _remote_image_urls(call) if not remote_urls: return call + if _drops_params(call.litellm_params): + return _with_downloads_or_dropped_failures( + call, + MappingProxyType({url: convert_url_to_base64_or_error(url, timeout=call.timeout) for url in remote_urls}), + ) return _with_images_replaced( call, MappingProxyType({url: convert_url_to_base64(url, timeout=call.timeout) for url in remote_urls}) ) @@ -604,6 +628,11 @@ async def _async_with_remote_images_inlined(call: DecisionsCall) -> DecisionsCal remote_urls: Final = _remote_image_urls(call) if not remote_urls: return call + if _drops_params(call.litellm_params): + downloads: Final = await async_convert_urls_to_base64_or_errors(remote_urls, timeout=call.timeout) + return _with_downloads_or_dropped_failures( + call, MappingProxyType(dict(zip(remote_urls, downloads, strict=True))) + ) data_urls: Final = await async_convert_urls_to_base64(remote_urls, timeout=call.timeout) return _with_images_replaced(call, MappingProxyType(dict(zip(remote_urls, data_urls, strict=True)))) @@ -613,20 +642,33 @@ def _has_media_type_in(media_types: frozenset[str], image: DecisionsImage) -> bo return media_type is None or media_type in media_types -def _checked_image_types(call: DecisionsCall) -> DecisionsCall: - media_types: Final = call.provider_config.image_rules_for(call.model).image_media_types +def _within(limit: int | None, amount: int) -> bool: + return limit is None or amount <= limit + + +def _media_type_error(media_types: frozenset[str] | None, images: Sequence[DecisionsImage]) -> str | None: if media_types is None: - return call - found: Final = (image_media_type(image) for image in input_images(call.ir_request.input)) + return None + found: Final = (image_media_type(image) for image in images) unsupported: Final = tuple( dict.fromkeys(type_ for type_ in found if type_ is not None and type_ not in media_types) ) if not unsupported: - return call - 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)}", + return None + return f"takes only {', '.join(sorted(media_types))} images, got {', '.join(unsupported)}" + + +def _count_error(max_images: int | None, image_count: int) -> str | None: + if _within(max_images, image_count): + return None + return f"takes at most {max_images} images per request, got {image_count}" + + +def _refusal(call: DecisionsCall, refusal: str) -> litellm.BadRequestError: + return litellm.BadRequestError( + message=f"{call.custom_llm_provider} {refusal}", + model=call.requested_model, + llm_provider=call.custom_llm_provider, ) @@ -642,30 +684,37 @@ def _dropped_or_refused( ) -> 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} {refusal}", - model=call.requested_model, - llm_provider=call.custom_llm_provider, - ) + raise _refusal(call, refusal) -def _checked_image_count(call: DecisionsCall) -> DecisionsCall: - 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: +def _checked_image_types(call: DecisionsCall) -> DecisionsCall: + media_types: Final = call.provider_config.image_rules_for(call.model).image_media_types + error: Final = _media_type_error(media_types, input_images(call.ir_request.input)) + if media_types is None or error is None: return call 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}", + call, with_images_where(call.ir_request.input, partial(_has_media_type_in, media_types)), error ) +def _refused_over_count(call: DecisionsCall) -> DecisionsCall: + error: Final = _count_error( + call.provider_config.image_rules_for(call.model).max_images, len(input_images(call.ir_request.input)) + ) + if error is None or _drops_params(call.litellm_params): + return call + raise _refusal(call, error) + + +def _decoded_byte_count(payload: str) -> int: + return len(standard_base64(payload).rstrip("=")) * 3 // 4 + + def _decoded_image_bytes(image: DecisionsImage) -> int: if isinstance(image, DecisionsBase64Image): - return len(image.base64.rstrip("=")) * 3 // 4 + return _decoded_byte_count(image.base64) marker_at: Final = image.lower().find(_BASE64_MARKER) - return 0 if marker_at < 0 else len(image[marker_at + len(_BASE64_MARKER) :].rstrip("=")) * 3 // 4 + return 0 if marker_at < 0 else _decoded_byte_count(image[marker_at + len(_BASE64_MARKER) :]) def _byte_limit(limit: int) -> str: @@ -681,29 +730,49 @@ def _image_size_error(sizes: Sequence[int], per_image: int | None, total: int | 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: - sizes: Final = tuple(_decoded_image_bytes(image) for image in input_images(call.ir_request.input)) +def _image_limit_error(call: DecisionsCall) -> str | None: 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 - 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, + images: Final = input_images(call.ir_request.input) + return ( + _media_type_error(rules.image_media_types, images) + or _count_error(rules.max_images, len(images)) + or _image_size_error( + tuple(_decoded_image_bytes(image) for image in images), rules.max_image_bytes, rules.max_total_image_bytes + ) ) +def _fits_alone(rules: BaseDecisionsConfig, image: DecisionsImage) -> bool: + return (rules.image_media_types is None or _has_media_type_in(rules.image_media_types, image)) and _within( + rules.max_image_bytes, _decoded_image_bytes(image) + ) + + +def _kept_images(call: DecisionsCall) -> tuple[bool, ...]: + rules: Final = call.provider_config.image_rules_for(call.model) + images: Final = input_images(call.ir_request.input) + failed_downloads: Final = frozenset(_remote_image_urls(call)) + fits: Final = tuple(_is_not_one_of(failed_downloads, image) and _fits_alone(rules, image) for image in images) + spent: Final = accumulate( + _decoded_image_bytes(image) if fit else 0 for image, fit in zip(images, fits, strict=True) + ) + counted: Final = accumulate(int(fit) for fit in fits) + return tuple( + fit and _within(rules.max_total_image_bytes, bytes_so_far) and _within(rules.max_images, images_so_far) + for fit, bytes_so_far, images_so_far in zip(fits, spent, counted, strict=True) + ) + + +def _with_images_within_limits(call: DecisionsCall, kept: Sequence[bool]) -> DecisionsCall: + if all(kept): + return call + kept_input: Final = _with_images_kept(call.ir_request.input, kept) + error: Final = _image_limit_error(call) + if error is None: + return replace(call, ir_request=replace(call.ir_request, input=kept_input)) + return _dropped_or_refused(call, kept_input, error) + + def _request_body(call: DecisionsCall) -> Mapping[str, object]: body: Final = call.provider_config.transform_decisions_request( model=call.model, request=call.ir_request, custom_llm_provider=call.custom_llm_provider @@ -717,20 +786,17 @@ def _request_body(call: DecisionsCall) -> Mapping[str, object]: return body -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, fetched: DecisionsCall, sent: DecisionsCall, body: Mapping[str, object] + supported: DecisionsCall, fetched: DecisionsCall, kept: Sequence[bool], body: Mapping[str, object] ) -> Mapping[str, object]: if fetched is supported: return body - return _request_body(_with_caller_urls(sent, supported, fetched)) + return _request_body( + replace( + supported, + ir_request=replace(supported.ir_request, input=_with_images_kept(supported.ir_request.input, kept)), + ) + ) def _pricing_model(response: DecisionsIRResponse, call: DecisionsCall) -> str: @@ -795,9 +861,10 @@ 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)) + supported: Final = _refused_over_count(_checked_image_types(call)) fetched: Final = await _async_with_remote_images_inlined(supported) - inlined: Final = _checked_image_sizes(_checked_image_types(fetched)) + kept: Final = _kept_images(fetched) + inlined: Final = _with_images_within_limits(fetched, kept) body: Final = _request_body(inlined) try: response: Final = await _HANDLER.adecisions( @@ -807,7 +874,7 @@ async def asend(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionRespon provider_config=inlined.provider_config, request=inlined.ir_request, body=body, - logged_body=_logged_body(supported, fetched, inlined, body), + logged_body=_logged_body(supported, fetched, kept, body), api_base=inlined.api_base, api_key=inlined.api_key, headers=inlined.headers, @@ -820,9 +887,10 @@ async def asend(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionRespon def send(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse: - supported: Final = _checked_image_count(_checked_image_types(call)) + supported: Final = _refused_over_count(_checked_image_types(call)) fetched: Final = _with_remote_images_inlined(supported) - inlined: Final = _checked_image_sizes(_checked_image_types(fetched)) + kept: Final = _kept_images(fetched) + inlined: Final = _with_images_within_limits(fetched, kept) body: Final = _request_body(inlined) try: response: Final = _HANDLER.decisions( @@ -832,7 +900,7 @@ def send(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse: provider_config=inlined.provider_config, request=inlined.ir_request, body=body, - logged_body=_logged_body(supported, fetched, inlined, body), + logged_body=_logged_body(supported, fetched, kept, body), api_base=inlined.api_base, api_key=inlined.api_key, headers=inlined.headers, diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index 3287515c81d..ef421ea7a46 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -322,6 +322,31 @@ async def async_convert_urls_to_base64( raise +def convert_url_to_base64_or_error( + url: str, timeout: float | httpx.Timeout | None = None +) -> "str | litellm.ImageFetchError": + try: + return convert_url_to_base64(url, timeout=timeout) + except litellm.ImageFetchError as error: + return error + + +async def _fetch_data_url_or_error( + url: str, in_flight: asyncio.Semaphore, timeout: float | httpx.Timeout | None +) -> "str | litellm.ImageFetchError": + try: + return await _fetch_data_url(url, in_flight, timeout) + except litellm.ImageFetchError as error: + return error + + +async def async_convert_urls_to_base64_or_errors( + remote_urls: tuple[str, ...], timeout: float | httpx.Timeout | None = None +) -> "tuple[str | litellm.ImageFetchError, ...]": + in_flight: Final = asyncio.Semaphore(MAX_CONCURRENT_REMOTE_MEDIA_FETCHES) + return tuple(await asyncio.gather(*(_fetch_data_url_or_error(url, in_flight, timeout) for url in remote_urls))) + + def _remote_urls_to_inline( messages: Iterable[AllMessageValues], should_inline: Callable[[RemoteMedia], bool] ) -> tuple[str, ...]: diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index e5f63ac4f5a..2d307998243 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -76,7 +76,7 @@ def decisions_text(value: DecisionsJSON) -> str: def standard_base64(value: str) -> str: - return value.translate(_URL_SAFE_TO_STANDARD_BASE64) + return "".join(value.split()).translate(_URL_SAFE_TO_STANDARD_BASE64) def systemone_keys(questions: Sequence[DecisionsIRQuestion]) -> tuple[str, ...]: @@ -269,9 +269,10 @@ def _systemone_image(image: DecisionsImage) -> str | Mapping[str, object]: def _standard_data_url(media_type: str, payload: str) -> str: - padding: Final = "=" * (-len(payload) % 4) + standard_payload: Final = standard_base64(payload) + padding: Final = "=" * (-len(standard_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}" + return f"{_DATA_URL_PREFIX}{standard_type};base64,{standard_payload}{padding}" def _data_url(image: DecisionsImage) -> str: diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 857ec840d05..6d9867541da 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -2481,12 +2481,15 @@ _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,)), + ((*(_png_data_url(4 * _MIB + 1),) * 4, _SMALL_PNG), (_SMALL_PNG,)), ) @pytest.mark.usefixtures("cloudflare_account") @pytest.mark.parametrize( - ("images", "kept"), _DROPPED_OVERSIZED_IMAGES, ids=(*_OVERSIZED_IMAGE_IDS, "oversized_then_small") + ("images", "kept"), + _DROPPED_OVERSIZED_IMAGES, + ids=(*_OVERSIZED_IMAGE_IDS, "oversized_then_small", "four_oversized_then_small"), ) def test_images_over_cloudflares_size_limits_are_dropped_under_drop_params( images: Sequence[object], kept: Sequence[object], respx_mock: respx.MockRouter @@ -2548,6 +2551,24 @@ async def test_logging_callbacks_get_the_caller_url_of_a_remote_image_kept_after assert recording_logger.inputs == [{**wire_body, "images": [_REMOTE_IMAGE_URL]}] +@pytest.mark.asyncio +@pytest.mark.usefixtures("cloudflare_account", "async_only_image_fetch") +async def test_logging_callbacks_get_each_caller_url_when_two_remote_images_have_the_same_bytes( + 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]) + caller_urls: Final = [_REMOTE_IMAGE_URL, f"{_REMOTE_IMAGE_URL}?copy=1"] + + await litellm.asystemone( + model="cloudflare/clef", state=_SCREEN_TEXT, questions=_SYSTEMONE_PREDICATE, images=caller_urls + ) + await _drain_logging_worker() + + assert recording_logger.inputs == [{**json.loads(route.calls.last.request.content), "images": caller_urls}] + + @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) @@ -4476,6 +4497,99 @@ def test_a_list_state_keeps_its_items_ahead_of_the_images( ] +@pytest.mark.parametrize(("model", "url", "upstream_model"), _STATE_IMAGE_PROVIDERS, ids=_STATE_IMAGE_PROVIDER_IDS) +def test_line_wrapped_base64_reaches_state_on_one_line( + model: str, url: str, upstream_model: str, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(url).respond(json=_RESPONSE) + image_bytes: Final = bytes(range(100)) + + litellm.systemone( + model=model, + state=_SCREEN_TEXT, + questions=_SYSTEMONE_PREDICATE, + images=[{"content_type": "image/png", "base64": base64.encodebytes(image_bytes).decode()}], + api_key="k", + ) + + assert json.loads(route.calls.last.request.content)["state"][1] == { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{base64.b64encode(image_bytes).decode()}"}, + } + + +_MISSING_IMAGE_URL: Final = "https://images.example/missing.png" +_FOUND_IMAGE_URL: Final = "https://images.example/found.png" +_FOUND_PNG: Final = b"\x89PNG\r\n\x1a\n" + + +@pytest.fixture +def one_missing_remote_image(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "user_url_validation", False) + monkeypatch.setattr(litellm, "module_level_aclient", AsyncHTTPHandler(transport=httpx.AsyncHTTPTransport())) + respx_mock.get(_MISSING_IMAGE_URL).respond(status_code=404) + respx_mock.get(_FOUND_IMAGE_URL).respond(content=_FOUND_PNG, headers={"content-type": "image/png"}) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("one_missing_remote_image") +@pytest.mark.parametrize("sync", (False, True), ids=("async", "sync")) +async def test_a_remote_image_that_fails_to_download_is_dropped_under_drop_params( + sync: bool, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(_PERPLEXITY_DECISIONS_URL).respond(json=_RESPONSE) + request_kwargs: Final = { + "model": "perplexity/pplx-decider-v1.2", + "state": _SCREEN_TEXT, + "questions": _SYSTEMONE_PREDICATE, + "images": [_MISSING_IMAGE_URL, _FOUND_IMAGE_URL], + "api_key": "k", + "drop_params": True, + } + + if sync: + litellm.systemone(**request_kwargs) + else: + await litellm.asystemone(**request_kwargs) + + assert json.loads(route.calls.last.request.content)["state"] == [ + _SCREEN_TEXT, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64.b64encode(_FOUND_PNG).decode()}"}}, + ] + + +_UNDROPPABLE_DOWNLOAD_FAILURES: Final[tuple[tuple[str, Mapping[str, object]], ...]] = ( + ("asystemone", {"state": _SCREEN_TEXT, "questions": _SYSTEMONE_PREDICATE, "images": [_MISSING_IMAGE_URL]}), + ( + "adecisions", + { + "input": [{"role": "user", "content": [{"type": "input_image", "image_url": _MISSING_IMAGE_URL}]}], + "questions": [{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}], + "drop_params": True, + }, + ), +) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("one_missing_remote_image") +@pytest.mark.parametrize( + ("api", "request_kwargs"), + _UNDROPPABLE_DOWNLOAD_FAILURES, + ids=("without_drop_params", "only_input_under_drop_params"), +) +async def test_a_remote_image_that_fails_to_download_is_refused_before_http_when_it_cannot_be_dropped( + 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) + + with pytest.raises(litellm.BadRequestError, match="Status code: 404"): + await getattr(litellm, api)(model="perplexity/pplx-decider-v1.2", api_key="k", **request_kwargs) + + assert route.call_count == 0 + + _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), From 840decabf181a59905484c44a624461136aa0e56 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:54:16 -0700 Subject: [PATCH 13/23] fix(decisions): drop images whose bytes are not an image the model takes under drop_params, send state as a JSON list, and update the clef integration tests --- litellm/decisions/call.py | 65 ++++++++- .../llms/base_llm/decisions/transformation.py | 2 +- .../test_cloudflare_clef_images_wire.py | 55 +++++--- tests/unit/decisions/test_main.py | 125 ++++++++++++++++-- 4 files changed, 210 insertions(+), 37 deletions(-) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index fcd654cad61..d541fedd486 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -1,3 +1,5 @@ +import base64 +import binascii import json from collections.abc import Mapping, Sequence from dataclasses import dataclass, field, replace @@ -89,6 +91,7 @@ _PROVIDER_SPECIFIC_HEADER_ADAPTER: Final[ ] = TypeAdapter(ProviderSpecificHeader | tuple[ProviderSpecificHeader, ...] | None) _HANDLER: Final = BaseLLMHTTPHandler() _BASE64_MARKER: Final = ";base64," +_SNIFFABLE_MEDIA_TYPES: Final = frozenset({"image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp"}) _IMAGE_ADAPTER: Final[TypeAdapter[DecisionsImage]] = TypeAdapter(DecisionsImage, config=ConfigDict(title="images")) @@ -710,11 +713,51 @@ def _decoded_byte_count(payload: str) -> int: return len(standard_base64(payload).rstrip("=")) * 3 // 4 -def _decoded_image_bytes(image: DecisionsImage) -> int: +def _base64_payload(image: DecisionsImage) -> str | None: if isinstance(image, DecisionsBase64Image): - return _decoded_byte_count(image.base64) + return image.base64 marker_at: Final = image.lower().find(_BASE64_MARKER) - return 0 if marker_at < 0 else _decoded_byte_count(image[marker_at + len(_BASE64_MARKER) :]) + return None if marker_at < 0 else image[marker_at + len(_BASE64_MARKER) :] + + +def _decoded_image_bytes(image: DecisionsImage) -> int: + payload: Final = _base64_payload(image) + return 0 if payload is None else _decoded_byte_count(payload) + + +def _leading_bytes(payload: str) -> bytes: + head: Final = standard_base64(payload)[:16] + try: + return base64.b64decode(head + "=" * (-len(head) % 4)) + except binascii.Error: + return b"" + + +def _sniffed_media_types(head: bytes) -> frozenset[str]: + if head.startswith(b"\x89PNG\r\n\x1a\n"): + return frozenset({"image/png"}) + if head.startswith(b"\xff\xd8\xff"): + return frozenset({"image/jpeg", "image/jpg"}) + if head.startswith((b"GIF87a", b"GIF89a")): + return frozenset({"image/gif"}) + if head.startswith(b"RIFF") and head[8:12] == b"WEBP": + return frozenset({"image/webp"}) + return frozenset() + + +def _is_readable_image(media_types: frozenset[str] | None, image: DecisionsImage) -> bool: + payload: Final = _base64_payload(image) + if media_types is None or not media_types <= _SNIFFABLE_MEDIA_TYPES or payload is None: + return True + return bool(_sniffed_media_types(_leading_bytes(payload)) & media_types) + + +def _unreadable_image_error(call: DecisionsCall) -> str | None: + media_types: Final = call.provider_config.image_rules_for(call.model).image_media_types + unreadable: Final = sum(not _is_readable_image(media_types, image) for image in input_images(call.ir_request.input)) + if media_types is None or not unreadable: + return None + return f"takes only {', '.join(sorted(media_types))} image bytes, got {unreadable} image(s) that are none of these" def _byte_limit(limit: int) -> str: @@ -748,11 +791,21 @@ def _fits_alone(rules: BaseDecisionsConfig, image: DecisionsImage) -> bool: ) +def _is_usable( + rules: BaseDecisionsConfig, failed_downloads: frozenset[str], drops_params: bool, image: DecisionsImage +) -> bool: + return ( + _is_not_one_of(failed_downloads, image) + and _fits_alone(rules, image) + and (not drops_params or _is_readable_image(rules.image_media_types, image)) + ) + + def _kept_images(call: DecisionsCall) -> tuple[bool, ...]: rules: Final = call.provider_config.image_rules_for(call.model) images: Final = input_images(call.ir_request.input) - failed_downloads: Final = frozenset(_remote_image_urls(call)) - fits: Final = tuple(_is_not_one_of(failed_downloads, image) and _fits_alone(rules, image) for image in images) + usable: Final = partial(_is_usable, rules, frozenset(_remote_image_urls(call)), _drops_params(call.litellm_params)) + fits: Final = tuple(map(usable, images)) spent: Final = accumulate( _decoded_image_bytes(image) if fit else 0 for image, fit in zip(images, fits, strict=True) ) @@ -767,7 +820,7 @@ def _with_images_within_limits(call: DecisionsCall, kept: Sequence[bool]) -> Dec if all(kept): return call kept_input: Final = _with_images_kept(call.ir_request.input, kept) - error: Final = _image_limit_error(call) + error: Final = _image_limit_error(call) or _unreadable_image_error(call) if error is None: return replace(call, ir_request=replace(call.ir_request, input=kept_input)) return _dropped_or_refused(call, kept_input, error) diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index 2d307998243..ce82aafb3ee 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -370,7 +370,7 @@ def ir_to_systemone_request( state_carries_images: Final = images_in_state and bool(images) return { "model": model, - "state": _multimodal_state(request.input) if state_carries_images else _systemone_state(request.input), + "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 diff --git a/tests/integration/providers/test_cloudflare_clef_images_wire.py b/tests/integration/providers/test_cloudflare_clef_images_wire.py index bf6d01b2455..9cd951c9861 100644 --- a/tests/integration/providers/test_cloudflare_clef_images_wire.py +++ b/tests/integration/providers/test_cloudflare_clef_images_wire.py @@ -321,7 +321,7 @@ def test_an_openai_decision_model_gets_system_one_images_as_input_image_parts(ga @pytest.mark.parametrize("route", ("/v1/systemone", "/v1/decisions")) -def test_more_images_than_clef_takes_are_refused_before_any_download_or_upstream_call( +def test_more_images_than_clef_takes_are_refused_before_any_download_or_trimmed_under_drop_params( gateway: Gateway, route: Route ) -> None: marker: Final = uuid.uuid4().hex @@ -329,12 +329,15 @@ def test_more_images_than_clef_takes_are_refused_before_any_download_or_upstream strict: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY) dropping: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY, drop_params=True) remote: Final = [f"{wire.url}/image.png?n={index}&id=marker-{marker}" for index in range(5)] - for model in (strict, dropping): - refused = _decide(gateway, route, model, marker, remote) - assert refused.status_code == 400, refused.text - assert "at most 4 images per request, got 5" in refused.text, refused.text + refused: Final = _decide(gateway, route, strict, marker, remote) + assert refused.status_code == 400, refused.text + assert "at most 4 images per request, got 5" in refused.text, refused.text assert wire.drain() == () four: Final = [_PNG_DATA_URL, _JPEG_DATA_URL, _PNG_DATA_URL, _JPEG_DATA_URL] + trimmed: Final = _decide(gateway, route, dropping, marker, [*four, _PNG_DATA_URL]) + assert trimmed.status_code == 200, trimmed.text + (dropped_request,) = wire.drain() + assert _upstream_body(dropped_request) == _expected_upstream_body("clef", _state(marker), four) accepted: Final = _decide(gateway, route, strict, marker, four) assert accepted.status_code == 200, accepted.text (request,) = wire.drain() @@ -500,41 +503,55 @@ def test_an_image_clef_refuses_does_not_count_toward_its_limit_when_the_deployme _MIB: Final = 1024 * 1024 +_PNG_SIGNATURE: Final = b"\x89PNG\r\n\x1a\n" + + +def _png_base64(decoded_bytes: int) -> str: + return base64.b64encode(_PNG_SIGNATURE.ljust(decoded_bytes, b"\0")).decode() + + def _png_data_url(decoded_bytes: int) -> str: - return f"data:image/png;base64,{base64.b64encode(bytes(decoded_bytes)).decode()}" + return f"data:image/png;base64,{_png_base64(decoded_bytes)}" def _png_object(decoded_bytes: int) -> dict[str, JsonValue]: - return {"content_type": "image/png", "base64": base64.b64encode(bytes(decoded_bytes)).decode()} + return {"content_type": "image/png", "base64": _png_base64(decoded_bytes)} -_OVERSIZED: Final[tuple[tuple[str, Route, tuple[JsonValue, ...], str], ...]] = ( - ("one_over_4mib", "/v1/systemone", (_png_object(4 * _MIB + 1),), "got one of 4,194,305 bytes"), - ("one_over_4mib", "/v1/decisions", (_png_data_url(4 * _MIB + 1),), "got one of 4,194,305 bytes"), +_OVERSIZED: Final[tuple[tuple[str, Route, tuple[JsonValue, ...], int, str], ...]] = ( + ("one_over_4mib", "/v1/systemone", (_png_object(4 * _MIB + 1), _PNG_DATA_URL), 0, "got one of 4,194,305 bytes"), + ("one_over_4mib", "/v1/decisions", (_PNG_DATA_URL, _png_data_url(4 * _MIB + 1)), 1, "got one of 4,194,305 bytes"), ( "total_over_8mib", "/v1/systemone", - (_png_data_url(4 * _MIB), _png_object(4 * _MIB), _png_data_url(1)), - "got 8,388,609 bytes", + (_png_data_url(4 * _MIB), _png_object(4 * _MIB), _png_data_url(8)), + 2, + "got 8,388,616 bytes", ), ) @pytest.mark.parametrize( - ("label", "route", "images", "refusal"), _OVERSIZED, ids=[f"{label}{route}" for label, route, _, _ in _OVERSIZED] + ("label", "route", "images", "first_dropped", "refusal"), + _OVERSIZED, + ids=[f"{label}{route}" for label, route, _, _, _ in _OVERSIZED], ) -def test_images_over_clefs_size_limits_are_refused_before_the_upstream_call( - gateway: Gateway, label: str, route: Route, images: tuple[JsonValue, ...], refusal: str +def test_images_over_clefs_size_limits_are_refused_before_the_upstream_call_or_dropped_under_drop_params( + gateway: Gateway, label: str, route: Route, images: tuple[JsonValue, ...], first_dropped: int, refusal: str ) -> None: marker: Final = uuid.uuid4().hex with wire_server(_clef_reply) as wire, gateway.scenario() as scenario: strict: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY) dropping: Final = scenario.model(model=_CLEF, api_base=wire.url, api_key=_API_KEY, drop_params=True) - for model in (strict, dropping): - refused = _decide(gateway, route, model, marker, images) - assert refused.status_code == 400, (label, refused.text[:500]) - assert refusal in refused.text, (label, refused.text[:500]) + refused: Final = _decide(gateway, route, strict, marker, images) + assert refused.status_code == 400, (label, refused.text[:500]) + assert refusal in refused.text, (label, refused.text[:500]) assert wire.drain() == (), label + kept: Final = (*images[:first_dropped], *images[first_dropped + 1 :]) + trimmed: Final = _decide(gateway, route, dropping, marker, images) + assert trimmed.status_code == 200, (label, trimmed.text[:500]) + (dropped_request,) = wire.drain() + assert _upstream_body(dropped_request) == _expected_upstream_body("clef", _state(marker), kept), label at_limits: Final[list[JsonValue]] = [_png_data_url(4 * _MIB), _png_object(4 * _MIB)] accepted: Final = _decide(gateway, "/v1/systemone", strict, marker, at_limits) assert accepted.status_code == 200, accepted.text[:500] diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 6d9867541da..6285c87a71e 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -1915,7 +1915,7 @@ _CLOUDFLARE_RUN_URL: Final = "https://api.cloudflare.com/client/v4/accounts/acct _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==" +_PNG_DATA_URL: Final = "data:image/png;base64,iVBORw0KGgo=" _REMOTE_IMAGE_URL: Final = "https://images.example/screen.png" _SCREEN_TEXT: Final = "Is the screen cracked?" _SYSTEMONE_PREDICATE: Final[Mapping[str, object]] = MappingProxyType( @@ -2216,13 +2216,13 @@ async def test_a_malformed_image_under_drop_params_keeps_the_well_formed_ones( model="cloudflare/clef", state=_SCREEN_TEXT, questions=_SYSTEMONE_PREDICATE, - images=[entry, _PNG_DATA_URL, {"content_type": "image/png", "base64": "AA=="}], + images=[entry, _PNG_DATA_URL, {"content_type": "image/png", "base64": "iVBORw0KGgo="}], drop_params=True, ) assert json.loads(route.calls[0].request.content)["images"] == [ _PNG_DATA_URL, - {"content_type": "image/png", "base64": "AA=="}, + {"content_type": "image/png", "base64": "iVBORw0KGgo="}, ] @@ -2437,24 +2437,33 @@ async def test_dropping_the_only_malformed_input_images_is_rejected_before_http( _MIB: Final = 1024 * 1024 +_PNG_SIGNATURE: Final = b"\x89PNG\r\n\x1a\n" + + +def _png_base64(decoded_bytes: int) -> str: + return base64.b64encode(_PNG_SIGNATURE.ljust(decoded_bytes, b"\0")).decode() + + def _png_data_url(decoded_bytes: int) -> str: - return f"data:image/png;base64,{base64.b64encode(bytes(decoded_bytes)).decode()}" + return f"data:image/png;base64,{_png_base64(decoded_bytes)}" def _png_object(decoded_bytes: int) -> Mapping[str, object]: - return {"content_type": "image/png", "base64": base64.b64encode(bytes(decoded_bytes)).decode()} + return {"content_type": "image/png", "base64": _png_base64(decoded_bytes)} 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)) + return tuple( + f"data:image/png;base64,{base64.b64encode(_PNG_SIGNATURE + 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"), ( - (_png_data_url(4 * _MIB), _png_object(4 * _MIB), _png_data_url(1)), - "at most 8,388,608 bytes (8 MiB) of images per request, got 8,388,609 bytes", + (_png_data_url(4 * _MIB), _png_object(4 * _MIB), _png_data_url(8)), + "at most 8,388,608 bytes (8 MiB) of images per request, got 8,388,616 bytes", ), ) @@ -2646,7 +2655,7 @@ def test_remote_image_urls_with_an_uppercase_scheme_are_inlined_for_cloudflare( ] -_PNG_DATA_URL_WITH_NAME: Final = "data:image/png;name=screen.png;base64,AA==" +_PNG_DATA_URL_WITH_NAME: Final = "data:image/png;name=screen.png;base64,iVBORw0KGgo=" _GIF_DATA_URL: Final = "data:image/gif;base64,R0lGODlh" _CLOUDFLARE_TYPE_REFUSAL: Final = "cloudflare takes only image/jpeg, image/jpg, image/png, image/webp images, got " @@ -4590,6 +4599,100 @@ async def test_a_remote_image_that_fails_to_download_is_refused_before_http_when assert route.call_count == 0 +_NOT_AN_IMAGE_BASE64: Final = base64.b64encode(b"not an image").decode() +_PERPLEXITY_IMAGE_TYPE_MODELS: Final = ( + ("perplexity/pplx-decider-v1.2", _PERPLEXITY_DECISIONS_URL), + ("openrouter/perplexity/pplx-decider-v1.2", "https://openrouter.ai/api/alpha/decisions"), +) +_PERPLEXITY_IMAGE_TYPE_MODEL_IDS: Final = ("perplexity", "openrouter-perplexity") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", (False, True), ids=("async", "sync")) +@pytest.mark.parametrize(("model", "url"), _PERPLEXITY_IMAGE_TYPE_MODELS, ids=_PERPLEXITY_IMAGE_TYPE_MODEL_IDS) +async def test_image_bytes_that_are_not_an_image_the_model_takes_are_dropped_under_drop_params( + sync: bool, model: str, url: str, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(url).respond(json=_RESPONSE) + gif: Final = f"data:image/png;base64,{base64.b64encode(b'GIF89a').decode()}" + request_kwargs: Final = { + "model": model, + "state": _SCREEN_TEXT, + "questions": _SYSTEMONE_PREDICATE, + "images": [{"content_type": "image/png", "base64": _NOT_AN_IMAGE_BASE64}, gif, _PNG_DATA_URL], + "api_key": "k", + "drop_params": True, + } + + if sync: + litellm.systemone(**request_kwargs) + else: + await litellm.asystemone(**request_kwargs) + + assert json.loads(route.calls.last.request.content)["state"] == [ + _SCREEN_TEXT, + {"type": "image_url", "image_url": {"url": _PNG_DATA_URL}}, + ] + + +@pytest.mark.parametrize(("model", "url"), _PERPLEXITY_IMAGE_TYPE_MODELS, ids=_PERPLEXITY_IMAGE_TYPE_MODEL_IDS) +def test_image_bytes_that_are_not_an_image_still_reach_the_model_without_drop_params( + model: str, url: str, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + route: Final = respx_mock.post(url).respond(json=_RESPONSE) + not_an_image: Final = f"data:image/png;base64,{_NOT_AN_IMAGE_BASE64}" + + litellm.systemone(model=model, state=_SCREEN_TEXT, questions=_SYSTEMONE_PREDICATE, images=[not_an_image], api_key="k") + + assert json.loads(route.calls.last.request.content)["state"] == [ + _SCREEN_TEXT, + {"type": "image_url", "image_url": {"url": not_an_image}}, + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("model", "url"), _PERPLEXITY_IMAGE_TYPE_MODELS, ids=_PERPLEXITY_IMAGE_TYPE_MODEL_IDS) +async def test_image_bytes_that_are_not_an_image_are_refused_before_http_when_nothing_else_is_left( + model: str, url: 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=r"image bytes, got 1 image\(s\) that are none of these"): + await litellm.adecisions( + model=model, + api_key="k", + input=[ + { + "role": "user", + "content": [{"type": "input_image", "image_url": f"data:image/png;base64,{_NOT_AN_IMAGE_BASE64}"}], + } + ], + questions=[{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}], + drop_params=True, + ) + + assert route.call_count == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("model", "url", "upstream_model"), _STATE_IMAGE_PROVIDERS, ids=_STATE_IMAGE_PROVIDER_IDS) +async def test_logging_callbacks_get_the_state_that_was_sent_with_its_images( + model: str, url: str, upstream_model: str, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + route: Final = respx_mock.post(url).respond(json=_RESPONSE) + recording_logger: Final = _InputRecordingLogger() + monkeypatch.setattr(litellm, "callbacks", [recording_logger]) + + await litellm.asystemone( + model=model, state=_SCREEN_TEXT, questions=_SYSTEMONE_PREDICATE, images=[_PNG_DATA_URL], api_key="k" + ) + await _drain_logging_worker() + + assert recording_logger.inputs == [json.loads(route.calls.last.request.content)] + + _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), @@ -4737,14 +4840,14 @@ def test_openrouter_jev_drops_webp_and_keeps_png_under_drop_params(respx_mock: r model="openrouter/typesafe/jev-1.14", state=_SCREEN_TEXT, questions=_SYSTEMONE_PREDICATE, - images=["data:image/webp;base64,BB==", "data:image/png;base64,AA=="], + images=["data:image/webp;base64,BB==", _PNG_DATA_URL], 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=="}}, + {"type": "image_url", "image_url": {"url": _PNG_DATA_URL}}, ] From 4406dedad6a1d49d4e248254ffddea24177ff4fa Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:00:42 -0700 Subject: [PATCH 14/23] test(decisions): give the clef endpoint image test real PNG bytes --- tests/unit/proxy/decisions_endpoints/test_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/proxy/decisions_endpoints/test_endpoints.py b/tests/unit/proxy/decisions_endpoints/test_endpoints.py index b202de567fa..bf7c56506e9 100644 --- a/tests/unit/proxy/decisions_endpoints/test_endpoints.py +++ b/tests/unit/proxy/decisions_endpoints/test_endpoints.py @@ -937,7 +937,7 @@ def test_a_non_string_safety_identifier_is_refused_unless_the_deployment_drops_p _IMAGE_TEXT: Final = "Is the screen cracked?" -_PNG_DATA_URL: Final = "data:image/png;base64,AA==" +_PNG_DATA_URL: Final = "data:image/png;base64,iVBORw0KGgo=" _IMAGE_ENDPOINTS: Final = ("/v1/decisions", "/v1/systemone") _IMAGE_REQUEST_BODIES: Final[tuple[Mapping[str, object], ...]] = ( { From 568d25c4d8255bda3ac92b358f5b2a1053de3b62 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:11:12 -0700 Subject: [PATCH 15/23] fix(decisions): under drop_params, send a request again without its images when the provider refuses them, and keep a later image that still fits the total --- litellm/decisions/call.py | 142 ++++++++++++++++++++++-------- tests/unit/decisions/test_main.py | 97 +++++++++++++++++++- 2 files changed, 201 insertions(+), 38 deletions(-) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index d541fedd486..cc30ec510ec 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -4,7 +4,7 @@ import json from collections.abc import Mapping, Sequence from dataclasses import dataclass, field, replace from functools import partial -from itertools import accumulate +from itertools import accumulate, islice from types import MappingProxyType from typing import Final, Literal, TypeAlias @@ -726,7 +726,7 @@ def _decoded_image_bytes(image: DecisionsImage) -> int: def _leading_bytes(payload: str) -> bytes: - head: Final = standard_base64(payload)[:16] + head: Final = standard_base64(payload[:64])[:16] try: return base64.b64decode(head + "=" * (-len(head) % 4)) except binascii.Error: @@ -801,19 +801,32 @@ def _is_usable( ) +@dataclass(frozen=True, slots=True) +class _ImageBudget: + spent: int = 0 + count: int = 0 + kept: bool = False + + +def _after_image(rules: BaseDecisionsConfig, budget: _ImageBudget, sized_fit: tuple[int, bool]) -> _ImageBudget: + size, fit = sized_fit + if not fit or not _within(rules.max_total_image_bytes, budget.spent + size): + return replace(budget, kept=False) + if not _within(rules.max_images, budget.count + 1): + return replace(budget, kept=False) + return _ImageBudget(spent=budget.spent + size, count=budget.count + 1, kept=True) + + def _kept_images(call: DecisionsCall) -> tuple[bool, ...]: rules: Final = call.provider_config.image_rules_for(call.model) images: Final = input_images(call.ir_request.input) usable: Final = partial(_is_usable, rules, frozenset(_remote_image_urls(call)), _drops_params(call.litellm_params)) - fits: Final = tuple(map(usable, images)) - spent: Final = accumulate( - _decoded_image_bytes(image) if fit else 0 for image, fit in zip(images, fits, strict=True) - ) - counted: Final = accumulate(int(fit) for fit in fits) - return tuple( - fit and _within(rules.max_total_image_bytes, bytes_so_far) and _within(rules.max_images, images_so_far) - for fit, bytes_so_far, images_so_far in zip(fits, spent, counted, strict=True) + budgets: Final = accumulate( + zip(map(_decoded_image_bytes, images), map(usable, images), strict=True), + partial(_after_image, rules), + initial=_ImageBudget(), ) + return tuple(budget.kept for budget in islice(budgets, 1, None)) def _with_images_within_limits(call: DecisionsCall, kept: Sequence[bool]) -> DecisionsCall: @@ -913,6 +926,85 @@ def _map_upstream_exception(error: Exception, call: DecisionsCall) -> Exception: ) +_CONTENT_REFUSAL_STATUSES: Final = frozenset({400, 413, 422}) + + +def _refuses_request_content(error: BaseLLMException) -> bool: + return not error.status_code_is_synthesized and error.status_code in _CONTENT_REFUSAL_STATUSES + + +def _without_images_after_refusal(call: DecisionsCall, error: BaseLLMException) -> DecisionsCall | None: + images: Final = input_images(call.ir_request.input) + if not images or not _drops_params(call.litellm_params) or not _refuses_request_content(error): + return None + without_images: Final = _with_images_kept(call.ir_request.input, tuple(False for _ in images)) + if _leaves_no_input(without_images): + return None + return replace(call, ir_request=replace(call.ir_request, input=without_images)) + + +async def _aposted( + call: DecisionsCall, body: Mapping[str, object], logged_body: Mapping[str, object] +) -> DecisionsIRResponse: + return await _HANDLER.adecisions( + model=call.model, + custom_llm_provider=call.custom_llm_provider, + logging_obj=call.logging_obj, + provider_config=call.provider_config, + request=call.ir_request, + body=body, + logged_body=logged_body, + api_base=call.api_base, + api_key=call.api_key, + headers=call.headers, + timeout=call.timeout, + litellm_params=call.litellm_params, + ) + + +def _posted(call: DecisionsCall, body: Mapping[str, object], logged_body: Mapping[str, object]) -> DecisionsIRResponse: + return _HANDLER.decisions( + model=call.model, + custom_llm_provider=call.custom_llm_provider, + logging_obj=call.logging_obj, + provider_config=call.provider_config, + request=call.ir_request, + body=body, + logged_body=logged_body, + api_base=call.api_base, + api_key=call.api_key, + headers=call.headers, + timeout=call.timeout, + litellm_params=call.litellm_params, + ) + + +async def _aposted_without_refused_images( + call: DecisionsCall, body: Mapping[str, object], logged_body: Mapping[str, object] +) -> DecisionsIRResponse: + try: + return await _aposted(call, body, logged_body) + except BaseLLMException as error: + retry: Final = _without_images_after_refusal(call, error) + if retry is None: + raise + retry_body: Final = _request_body(retry) + return await _aposted(retry, retry_body, retry_body) + + +def _posted_without_refused_images( + call: DecisionsCall, body: Mapping[str, object], logged_body: Mapping[str, object] +) -> DecisionsIRResponse: + try: + return _posted(call, body, logged_body) + except BaseLLMException as error: + retry: Final = _without_images_after_refusal(call, error) + if retry is None: + raise + retry_body: Final = _request_body(retry) + return _posted(retry, retry_body, retry_body) + + async def asend(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse: supported: Final = _refused_over_count(_checked_image_types(call)) fetched: Final = await _async_with_remote_images_inlined(supported) @@ -920,19 +1012,8 @@ async def asend(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionRespon inlined: Final = _with_images_within_limits(fetched, kept) body: Final = _request_body(inlined) try: - response: Final = await _HANDLER.adecisions( - model=inlined.model, - custom_llm_provider=inlined.custom_llm_provider, - logging_obj=inlined.logging_obj, - provider_config=inlined.provider_config, - request=inlined.ir_request, - body=body, - logged_body=_logged_body(supported, fetched, kept, body), - api_base=inlined.api_base, - api_key=inlined.api_key, - headers=inlined.headers, - timeout=inlined.timeout, - litellm_params=inlined.litellm_params, + response: Final = await _aposted_without_refused_images( + inlined, body, _logged_body(supported, fetched, kept, body) ) except Exception as error: raise _map_upstream_exception(error, inlined) from error @@ -946,20 +1027,7 @@ def send(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse: inlined: Final = _with_images_within_limits(fetched, kept) body: Final = _request_body(inlined) try: - response: Final = _HANDLER.decisions( - model=inlined.model, - custom_llm_provider=inlined.custom_llm_provider, - logging_obj=inlined.logging_obj, - provider_config=inlined.provider_config, - request=inlined.ir_request, - body=body, - logged_body=_logged_body(supported, fetched, kept, body), - api_base=inlined.api_base, - api_key=inlined.api_key, - headers=inlined.headers, - timeout=inlined.timeout, - litellm_params=inlined.litellm_params, - ) + response: Final = _posted_without_refused_images(inlined, body, _logged_body(supported, fetched, kept, body)) except Exception as error: raise _map_upstream_exception(error, inlined) from error return _format_response(response, inlined) diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 6285c87a71e..083166f58bc 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -2491,6 +2491,7 @@ _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,)), ((*(_png_data_url(4 * _MIB + 1),) * 4, _SMALL_PNG), (_SMALL_PNG,)), + ((*(_png_data_url(3 * _MIB),) * 3, _SMALL_PNG), (*(_png_data_url(3 * _MIB),) * 2, _SMALL_PNG)), ) @@ -2498,7 +2499,7 @@ _DROPPED_OVERSIZED_IMAGES: Final[tuple[tuple[Sequence[object], Sequence[object]] @pytest.mark.parametrize( ("images", "kept"), _DROPPED_OVERSIZED_IMAGES, - ids=(*_OVERSIZED_IMAGE_IDS, "oversized_then_small", "four_oversized_then_small"), + ids=(*_OVERSIZED_IMAGE_IDS, "oversized_then_small", "four_oversized_then_small", "over_the_total_then_small"), ) def test_images_over_cloudflares_size_limits_are_dropped_under_drop_params( images: Sequence[object], kept: Sequence[object], respx_mock: respx.MockRouter @@ -4676,6 +4677,100 @@ async def test_image_bytes_that_are_not_an_image_are_refused_before_http_when_no assert route.call_count == 0 +_UNDECODABLE_PNG_DATA_URL: Final = f"data:image/png;base64,{base64.b64encode(_PNG_SIGNATURE + b'IHDR').decode()}" +_UNDECODABLE_PNG_REQUEST: Final[Mapping[str, object]] = MappingProxyType( + { + "state": _SCREEN_TEXT, + "questions": _SYSTEMONE_PREDICATE, + "images": [_UNDECODABLE_PNG_DATA_URL, _PNG_DATA_URL], + "api_key": "k", + "drop_params": True, + } +) + + +def _refused_then_answered(status: int) -> tuple[httpx.Response, httpx.Response]: + return httpx.Response(status, json={"error": {"message": "refused"}}), httpx.Response(200, json=_RESPONSE) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", (False, True), ids=("async", "sync")) +@pytest.mark.parametrize("status", (400, 413, 422)) +@pytest.mark.parametrize(("model", "url"), _PERPLEXITY_IMAGE_TYPE_MODELS, ids=_PERPLEXITY_IMAGE_TYPE_MODEL_IDS) +async def test_a_request_whose_images_the_model_refuses_is_sent_again_without_them_under_drop_params( + sync: bool, status: int, model: str, url: str, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(url).mock(side_effect=_refused_then_answered(status)) + + if sync: + litellm.systemone(model=model, **_UNDECODABLE_PNG_REQUEST) + else: + await litellm.asystemone(model=model, **_UNDECODABLE_PNG_REQUEST) + + assert [json.loads(call.request.content)["state"] for call in route.calls] == [ + [ + _SCREEN_TEXT, + {"type": "image_url", "image_url": {"url": _UNDECODABLE_PNG_DATA_URL}}, + {"type": "image_url", "image_url": {"url": _PNG_DATA_URL}}, + ], + _SCREEN_TEXT, + ] + + +@pytest.mark.asyncio +async def test_logging_callbacks_get_the_request_sent_again_without_images( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + route: Final = respx_mock.post(_PERPLEXITY_DECISIONS_URL).mock(side_effect=_refused_then_answered(400)) + recording_logger: Final = _InputRecordingLogger() + monkeypatch.setattr(litellm, "callbacks", [recording_logger]) + + await litellm.asystemone(model="perplexity/pplx-decider-v1.2", **_UNDECODABLE_PNG_REQUEST) + await _drain_logging_worker() + + assert recording_logger.inputs == [json.loads(route.calls.last.request.content)] + + +_REFUSALS_SENT_ONCE: Final[tuple[tuple[str, Mapping[str, object], int, type[Exception]], ...]] = ( + ("asystemone", {**_UNDECODABLE_PNG_REQUEST, "drop_params": False}, 400, litellm.BadRequestError), + ("asystemone", _UNDECODABLE_PNG_REQUEST, 401, litellm.AuthenticationError), + ( + "adecisions", + { + "input": [{"role": "user", "content": [{"type": "input_image", "image_url": _UNDECODABLE_PNG_DATA_URL}]}], + "questions": [{"type": "predicate", "name": "is_defect", "instructions": "Is this a defect?"}], + "api_key": "k", + "drop_params": True, + }, + 400, + litellm.BadRequestError, + ), +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("api", "request_kwargs", "status", "error_class"), + _REFUSALS_SENT_ONCE, + ids=("without_drop_params", "not_a_refusal_of_the_content", "no_input_left_without_images"), +) +async def test_a_refusal_reaches_the_caller_after_one_call_when_the_images_cannot_be_dropped( + api: str, + request_kwargs: Mapping[str, object], + status: int, + error_class: type[Exception], + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + route: Final = respx_mock.post(_PERPLEXITY_DECISIONS_URL).mock(side_effect=_refused_then_answered(status)) + + with pytest.raises(error_class): + await getattr(litellm, api)(model="perplexity/pplx-decider-v1.2", **request_kwargs) + + assert route.call_count == 1 + + @pytest.mark.asyncio @pytest.mark.parametrize(("model", "url", "upstream_model"), _STATE_IMAGE_PROVIDERS, ids=_STATE_IMAGE_PROVIDER_IDS) async def test_logging_callbacks_get_the_state_that_was_sent_with_its_images( From 1c82abd23df49a99846bde7857e32210a7a6581b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:59:59 -0700 Subject: [PATCH 16/23] fix(decisions): send again without images only on Perplexity and OpenRouter, cap downloads at the image count, and keep empty requests refused --- litellm/decisions/call.py | 55 +++++--- .../test_cloudflare_clef_images_wire.py | 10 ++ tests/unit/decisions/test_main.py | 132 +++++++++++++++++- 3 files changed, 179 insertions(+), 18 deletions(-) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index cc30ec510ec..47bab503d32 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -1,5 +1,4 @@ import base64 -import binascii import json from collections.abc import Mapping, Sequence from dataclasses import dataclass, field, replace @@ -601,12 +600,24 @@ def _is_not_one_of(urls: frozenset[str], image: DecisionsImage) -> bool: return not isinstance(image, str) or image not in urls +def _leaves_nothing_to_decide_on(call: DecisionsCall, kept_input: DecisionsIRState | DecisionsIRMessages) -> bool: + if _leaves_no_input(kept_input): + return True + return ( + not call.provider_config.images_in_state + and isinstance(kept_input, DecisionsIRState) + and not kept_input.images + and not kept_input.state + ) + + def _with_downloads_or_dropped_failures( call: DecisionsCall, downloads: Mapping[str, str | litellm.ImageFetchError] ) -> DecisionsCall: failures: Final = tuple(result for result in downloads.values() if isinstance(result, litellm.ImageFetchError)) failed_urls: Final = frozenset(url for url, result in downloads.items() if not isinstance(result, str)) - if failures and _leaves_no_input(with_images_where(call.ir_request.input, partial(_is_not_one_of, failed_urls))): + kept_input: Final = with_images_where(call.ir_request.input, partial(_is_not_one_of, failed_urls)) + if failures and _leaves_nothing_to_decide_on(call, kept_input): raise failures[0] return _with_images_replaced( call, MappingProxyType({url: result for url, result in downloads.items() if isinstance(result, str)}) @@ -685,7 +696,7 @@ def _with_images_kept( 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): + if _drops_params(call.litellm_params) and not _leaves_nothing_to_decide_on(call, kept_input): return replace(call, ir_request=replace(call.ir_request, input=kept_input)) raise _refusal(call, refusal) @@ -701,12 +712,14 @@ def _checked_image_types(call: DecisionsCall) -> DecisionsCall: def _refused_over_count(call: DecisionsCall) -> DecisionsCall: - error: Final = _count_error( - call.provider_config.image_rules_for(call.model).max_images, len(input_images(call.ir_request.input)) - ) - if error is None or _drops_params(call.litellm_params): + max_images: Final = call.provider_config.image_rules_for(call.model).max_images + error: Final = _count_error(max_images, len(input_images(call.ir_request.input))) + if error is None: return call - raise _refusal(call, error) + past_the_count: Final = frozenset(_remote_image_urls(call)[max_images:]) + return _dropped_or_refused( + call, with_images_where(call.ir_request.input, partial(_is_not_one_of, past_the_count)), error + ) def _decoded_byte_count(payload: str) -> int: @@ -729,7 +742,7 @@ def _leading_bytes(payload: str) -> bytes: head: Final = standard_base64(payload[:64])[:16] try: return base64.b64decode(head + "=" * (-len(head) % 4)) - except binascii.Error: + except ValueError: return b"" @@ -926,16 +939,26 @@ def _map_upstream_exception(error: Exception, call: DecisionsCall) -> Exception: ) -_CONTENT_REFUSAL_STATUSES: Final = frozenset({400, 413, 422}) +_RESENT_WITHOUT_IMAGES_STATUSES: Final = frozenset({400, 408, 413, 422}) -def _refuses_request_content(error: BaseLLMException) -> bool: - return not error.status_code_is_synthesized and error.status_code in _CONTENT_REFUSAL_STATUSES +def _may_be_caused_by_images(error: BaseLLMException | litellm.Timeout) -> bool: + if isinstance(error, litellm.Timeout): + return True + return not error.status_code_is_synthesized and error.status_code in _RESENT_WITHOUT_IMAGES_STATUSES -def _without_images_after_refusal(call: DecisionsCall, error: BaseLLMException) -> DecisionsCall | None: +def _goes_again_without_images(call: DecisionsCall, error: BaseLLMException | litellm.Timeout) -> bool: + return ( + call.provider_config.images_in_state and _drops_params(call.litellm_params) and _may_be_caused_by_images(error) + ) + + +def _without_images_after_refusal( + call: DecisionsCall, error: BaseLLMException | litellm.Timeout +) -> DecisionsCall | None: images: Final = input_images(call.ir_request.input) - if not images or not _drops_params(call.litellm_params) or not _refuses_request_content(error): + if not images or not _goes_again_without_images(call, error): return None without_images: Final = _with_images_kept(call.ir_request.input, tuple(False for _ in images)) if _leaves_no_input(without_images): @@ -984,7 +1007,7 @@ async def _aposted_without_refused_images( ) -> DecisionsIRResponse: try: return await _aposted(call, body, logged_body) - except BaseLLMException as error: + except (BaseLLMException, litellm.Timeout) as error: retry: Final = _without_images_after_refusal(call, error) if retry is None: raise @@ -997,7 +1020,7 @@ def _posted_without_refused_images( ) -> DecisionsIRResponse: try: return _posted(call, body, logged_body) - except BaseLLMException as error: + except (BaseLLMException, litellm.Timeout) as error: retry: Final = _without_images_after_refusal(call, error) if retry is None: raise diff --git a/tests/integration/providers/test_cloudflare_clef_images_wire.py b/tests/integration/providers/test_cloudflare_clef_images_wire.py index 9cd951c9861..829d9d85ffe 100644 --- a/tests/integration/providers/test_cloudflare_clef_images_wire.py +++ b/tests/integration/providers/test_cloudflare_clef_images_wire.py @@ -865,6 +865,16 @@ def test_a_missing_remote_image_is_refused_without_a_call_to_clef(allowlisted: G @pytest.mark.timeout(_OWNED_TIMEOUT) +def test_remote_images_past_clefs_count_are_never_downloaded_under_drop_params( + allowlisted: Gateway, wire: Wire +) -> None: + marker: Final = uuid.uuid4().hex + images: Final = [f"{wire.url}/image.png?n={index}&id=marker-{marker}" for index in range(6)] + response: Final = _decide(allowlisted, "/v1/systemone", _ALLOWLISTED_MODEL, marker, images, drop_params=True) + assert response.status_code == 200, response.text + assert sorted(method for method, _ in _routes(wire.drain())) == ["GET"] * 4 + ["POST"] + + def test_a_remote_page_that_is_not_an_image_is_refused_without_a_call_to_clef(allowlisted: Gateway, wire: Wire) -> None: marker: Final = uuid.uuid4().hex response: Final = _decide( diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 083166f58bc..14ce4616982 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -4568,6 +4568,35 @@ async def test_a_remote_image_that_fails_to_download_is_dropped_under_drop_param ] +@pytest.mark.asyncio +@pytest.mark.usefixtures("one_missing_remote_image") +@pytest.mark.parametrize("sync", (False, True), ids=("async", "sync")) +async def test_remote_images_past_the_count_are_never_downloaded_under_drop_params( + sync: bool, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(_PERPLEXITY_DECISIONS_URL).respond(json=_RESPONSE) + urls: Final = tuple(f"https://images.example/{index}.png" for index in range(10)) + downloads: Final = tuple( + respx_mock.get(url).respond(content=_FOUND_PNG, headers={"content-type": "image/png"}) for url in urls + ) + request_kwargs: Final = { + "model": "perplexity/pplx-decider-v1.2", + "state": _SCREEN_TEXT, + "questions": _SYSTEMONE_PREDICATE, + "images": list(urls), + "api_key": "k", + "drop_params": True, + } + + if sync: + litellm.systemone(**request_kwargs) + else: + await litellm.asystemone(**request_kwargs) + + assert [download.call_count for download in downloads] == [1] * 8 + [0] * 2 + assert len(json.loads(route.calls.last.request.content)["state"]) == 9 + + _UNDROPPABLE_DOWNLOAD_FAILURES: Final[tuple[tuple[str, Mapping[str, object]], ...]] = ( ("asystemone", {"state": _SCREEN_TEXT, "questions": _SYSTEMONE_PREDICATE, "images": [_MISSING_IMAGE_URL]}), ( @@ -4620,7 +4649,12 @@ async def test_image_bytes_that_are_not_an_image_the_model_takes_are_dropped_und "model": model, "state": _SCREEN_TEXT, "questions": _SYSTEMONE_PREDICATE, - "images": [{"content_type": "image/png", "base64": _NOT_AN_IMAGE_BASE64}, gif, _PNG_DATA_URL], + "images": [ + {"content_type": "image/png", "base64": _NOT_AN_IMAGE_BASE64}, + {"content_type": "image/png", "base64": "\u00e9" * 8}, + gif, + _PNG_DATA_URL, + ], "api_key": "k", "drop_params": True, } @@ -4695,7 +4729,7 @@ def _refused_then_answered(status: int) -> tuple[httpx.Response, httpx.Response] @pytest.mark.asyncio @pytest.mark.parametrize("sync", (False, True), ids=("async", "sync")) -@pytest.mark.parametrize("status", (400, 413, 422)) +@pytest.mark.parametrize("status", (400, 408, 413, 422)) @pytest.mark.parametrize(("model", "url"), _PERPLEXITY_IMAGE_TYPE_MODELS, ids=_PERPLEXITY_IMAGE_TYPE_MODEL_IDS) async def test_a_request_whose_images_the_model_refuses_is_sent_again_without_them_under_drop_params( sync: bool, status: int, model: str, url: str, respx_mock: respx.MockRouter @@ -4717,6 +4751,100 @@ async def test_a_request_whose_images_the_model_refuses_is_sent_again_without_th ] +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", (False, True), ids=("async", "sync")) +async def test_a_request_whose_images_time_out_is_sent_again_without_them_under_drop_params( + sync: bool, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(_PERPLEXITY_DECISIONS_URL).mock( + side_effect=(httpx.ReadTimeout("too slow"), httpx.Response(200, json=_RESPONSE)) + ) + + if sync: + litellm.systemone(model="perplexity/pplx-decider-v1.2", **_UNDECODABLE_PNG_REQUEST) + else: + await litellm.asystemone(model="perplexity/pplx-decider-v1.2", **_UNDECODABLE_PNG_REQUEST) + + assert route.call_count == 2 + assert json.loads(route.calls.last.request.content)["state"] == _SCREEN_TEXT + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("cloudflare_account") +@pytest.mark.parametrize("sync", (False, True), ids=("async", "sync")) +async def test_a_refusal_from_a_model_that_always_took_images_is_not_sent_again_under_drop_params( + sync: bool, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").mock(side_effect=_refused_then_answered(422)) + request_kwargs: Final = { + "model": "cloudflare/clef", + "state": _SCREEN_TEXT, + "questions": _SYSTEMONE_PREDICATE, + "images": [_PNG_DATA_URL], + "drop_params": True, + } + + if sync: + with pytest.raises(litellm.BadRequestError): + litellm.systemone(**request_kwargs) + else: + with pytest.raises(litellm.BadRequestError): + await litellm.asystemone(**request_kwargs) + + assert route.call_count == 1 + + +_NOT_AN_IMAGE_REQUEST: Final[Mapping[str, object]] = MappingProxyType( + { + "questions": _SYSTEMONE_PREDICATE, + "images": [{"content_type": "image/png", "base64": _NOT_AN_IMAGE_BASE64}], + "drop_params": True, + } +) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("cloudflare_account") +@pytest.mark.parametrize("state", ("", [], {}), ids=("string", "list", "object")) +async def test_an_empty_state_with_only_a_dropped_image_is_refused_when_the_model_always_took_images( + state: object, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + + with pytest.raises(litellm.BadRequestError): + await litellm.asystemone(model="cloudflare/clef", state=state, **_NOT_AN_IMAGE_REQUEST) + + assert route.call_count == 0 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("cloudflare_account") +async def test_an_empty_state_keeps_the_images_that_survive_a_drop(respx_mock: respx.MockRouter) -> None: + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + + await litellm.asystemone( + model="cloudflare/clef", + state="", + questions=_SYSTEMONE_PREDICATE, + images=[{"content_type": "image/png", "base64": _NOT_AN_IMAGE_BASE64}, _PNG_DATA_URL], + drop_params=True, + ) + + assert json.loads(route.calls.last.request.content)["images"] == [_PNG_DATA_URL] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("state", ("", [], {}), ids=("string", "list", "object")) +async def test_an_empty_state_with_only_a_dropped_image_still_reaches_a_model_that_took_no_images_on_main( + state: object, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(_PERPLEXITY_DECISIONS_URL).respond(json=_RESPONSE) + + await litellm.asystemone(model="perplexity/pplx-decider-v1.2", state=state, api_key="k", **_NOT_AN_IMAGE_REQUEST) + + assert json.loads(route.calls.last.request.content)["state"] == state + + @pytest.mark.asyncio async def test_logging_callbacks_get_the_request_sent_again_without_images( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch From 2e3b4a53265f21f91f2cf835a1f33e2992c505fc Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:29:38 -0700 Subject: [PATCH 17/23] fix(decisions): keep a request with audio or video clips when an empty state loses its images --- litellm/decisions/call.py | 2 ++ tests/unit/decisions/test_main.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index 47bab503d32..cbf086281d5 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -605,6 +605,8 @@ def _leaves_nothing_to_decide_on(call: DecisionsCall, kept_input: DecisionsIRSta return True return ( not call.provider_config.images_in_state + and not call.ir_request.audio + and not call.ir_request.videos and isinstance(kept_input, DecisionsIRState) and not kept_input.images and not kept_input.state diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 14ce4616982..6fe5fc9f690 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -4817,6 +4817,25 @@ async def test_an_empty_state_with_only_a_dropped_image_is_refused_when_the_mode assert route.call_count == 0 +@pytest.mark.asyncio +@pytest.mark.usefixtures("cloudflare_account") +@pytest.mark.parametrize( + "clips", + ({"audio": ["data:audio/wav;base64,YQ=="]}, {"videos": ["data:video/mp4;base64,YQ=="]}), + ids=("audio", "videos"), +) +async def test_an_empty_state_with_only_a_dropped_image_still_reaches_the_model_with_its_clips( + clips: Mapping[str, list[str]], respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef-omni").respond(json=_RESPONSE) + + await litellm.asystemone(model="cloudflare/clef-omni", state="", **_NOT_AN_IMAGE_REQUEST, **clips) + + sent: Final = json.loads(route.calls.last.request.content) + assert sent.get("images", []) == [] + assert {name: sent[name] for name in clips} == clips + + @pytest.mark.asyncio @pytest.mark.usefixtures("cloudflare_account") async def test_an_empty_state_keeps_the_images_that_survive_a_drop(respx_mock: respx.MockRouter) -> None: From e92efed07890cbffc953d1a23032e176a68a2f08 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:41:22 -0700 Subject: [PATCH 18/23] fix(decisions): refuse a request with no text left once every image is dropped, on /v1/decisions and blank state --- litellm/decisions/call.py | 6 +- .../llms/base_llm/decisions/transformation.py | 24 +++++- tests/unit/decisions/test_main.py | 75 ++++++++++++++++++- 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index cbf086281d5..74f654f0034 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -29,6 +29,7 @@ from litellm.llms.base_llm.decisions.transformation import ( DecisionsResponseValidationError, image_media_type, image_param, + input_has_no_text, input_images, ir_to_systemone_response, remote_image_urls, @@ -607,9 +608,8 @@ def _leaves_nothing_to_decide_on(call: DecisionsCall, kept_input: DecisionsIRSta not call.provider_config.images_in_state and not call.ir_request.audio and not call.ir_request.videos - and isinstance(kept_input, DecisionsIRState) - and not kept_input.images - and not kept_input.state + and not input_images(kept_input) + and input_has_no_text(kept_input) ) diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index ce82aafb3ee..d8c613d3917 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -9,7 +9,7 @@ from typing import Final, TypeAlias import httpx from pydantic import TypeAdapter, ValidationError from pydantic_core import ErrorDetails -from typing_extensions import assert_never +from typing_extensions import TypeIs, assert_never # noqa: TID251 # TypeIs reaches typing only on 3.13 import litellm from litellm.litellm_core_utils.url_utils import join_api_base @@ -247,6 +247,28 @@ def _systemone_state(decision_input: DecisionsIRState | DecisionsIRMessages) -> assert_never(decision_input) +def _is_json_object(value: object) -> TypeIs[Mapping[str, object]]: # guard-ok: JSON object keys are strings + return isinstance(value, Mapping) + + +def _is_json_array(value: object) -> TypeIs[Sequence[object]]: # guard-ok: only the items are read + return isinstance(value, Sequence) + + +def _holds_no_text(value: object) -> bool: + if isinstance(value, str): + return not value.strip() + if _is_json_object(value): + return all(map(_holds_no_text, value.values())) + if _is_json_array(value): + return all(map(_holds_no_text, value)) + return False + + +def input_has_no_text(decision_input: DecisionsIRState | DecisionsIRMessages) -> bool: + return _holds_no_text(_systemone_state(decision_input)) + + def _media_type(content_type: str) -> str: return content_type.split(";", 1)[0].strip().lower() diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 6fe5fc9f690..18201be3e84 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -4805,7 +4805,11 @@ _NOT_AN_IMAGE_REQUEST: Final[Mapping[str, object]] = MappingProxyType( @pytest.mark.asyncio @pytest.mark.usefixtures("cloudflare_account") -@pytest.mark.parametrize("state", ("", [], {}), ids=("string", "list", "object")) +@pytest.mark.parametrize( + "state", + ("", [], {}, " ", [""], {"a": ""}, {"a": [" "]}), + ids=("string", "list", "object", "blank", "list-of-empty", "object-of-empty", "nested-blank"), +) async def test_an_empty_state_with_only_a_dropped_image_is_refused_when_the_model_always_took_images( state: object, respx_mock: respx.MockRouter ) -> None: @@ -4817,6 +4821,75 @@ async def test_an_empty_state_with_only_a_dropped_image_is_refused_when_the_mode assert route.call_count == 0 +@pytest.mark.asyncio +@pytest.mark.usefixtures("cloudflare_account") +@pytest.mark.parametrize("state", ("a", ["a"], {"a": "b"}, {"a": 0}, [False]), ids=str) +async def test_a_state_with_something_in_it_still_reaches_the_model_after_its_only_image_is_dropped( + state: object, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + + await litellm.asystemone(model="cloudflare/clef", state=state, **_NOT_AN_IMAGE_REQUEST) + + sent: Final = json.loads(route.calls.last.request.content) + assert sent["state"] == state + assert sent.get("images", []) == [] + + +_NOT_AN_IMAGE_DATA_URL: Final = f"data:image/png;base64,{_NOT_AN_IMAGE_BASE64}" +_DECISIONS_PREDICATE: Final[list[dict[str, str]]] = [{"type": "predicate", "name": "red", "instructions": "Is it red?"}] + + +def _decisions_input_with_text_and_dropped_image(text: str) -> list[dict[str, object]]: + return [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": text}, + {"type": "input_image", "image_url": _NOT_AN_IMAGE_DATA_URL}, + ], + } + ] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("cloudflare_account") +@pytest.mark.parametrize("text", ("", " "), ids=("empty", "blank")) +async def test_decisions_input_with_no_text_and_only_a_dropped_image_is_refused( + text: str, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + + with pytest.raises(litellm.BadRequestError): + await litellm.adecisions( + model="cloudflare/clef", + input=_decisions_input_with_text_and_dropped_image(text), + questions=_DECISIONS_PREDICATE, + drop_params=True, + ) + + assert route.call_count == 0 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("cloudflare_account") +async def test_decisions_input_with_text_still_reaches_the_model_after_its_only_image_is_dropped( + respx_mock: respx.MockRouter, +) -> None: + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + + await litellm.adecisions( + model="cloudflare/clef", + input=_decisions_input_with_text_and_dropped_image("Product photo"), + questions=_DECISIONS_PREDICATE, + drop_params=True, + ) + + sent: Final = json.loads(route.calls.last.request.content) + assert sent["state"] == "Product photo" + assert sent.get("images", []) == [] + + @pytest.mark.asyncio @pytest.mark.usefixtures("cloudflare_account") @pytest.mark.parametrize( From 96a80073eb67033edc6b80c5fff2b26f36dc6e0e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:10:29 -0700 Subject: [PATCH 19/23] fix(decisions): keep main's answer when a blank request loses its image to the declared type check, and count null as no text --- litellm/decisions/call.py | 7 ++- .../llms/base_llm/decisions/transformation.py | 11 +++- .../code_coverage_tests/recursive_detector.py | 1 + tests/unit/decisions/test_main.py | 63 ++++++++++++++++++- .../test_base_decisions_transformation.py | 14 +++++ 5 files changed, 88 insertions(+), 8 deletions(-) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index 74f654f0034..9e1c454c85c 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -708,9 +708,10 @@ def _checked_image_types(call: DecisionsCall) -> DecisionsCall: error: Final = _media_type_error(media_types, input_images(call.ir_request.input)) if media_types is None or error is None: return call - return _dropped_or_refused( - call, with_images_where(call.ir_request.input, partial(_has_media_type_in, media_types)), error - ) + kept_input: 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(kept_input): + return replace(call, ir_request=replace(call.ir_request, input=kept_input)) + raise _refusal(call, error) def _refused_over_count(call: DecisionsCall) -> DecisionsCall: diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index d8c613d3917..00a39cfdef6 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -12,6 +12,7 @@ from pydantic_core import ErrorDetails from typing_extensions import TypeIs, assert_never # noqa: TID251 # TypeIs reaches typing only on 3.13 import litellm +from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.secret_managers.main import get_secret_str @@ -255,13 +256,17 @@ def _is_json_array(value: object) -> TypeIs[Sequence[object]]: # guard-ok: only return isinstance(value, Sequence) -def _holds_no_text(value: object) -> bool: +def _holds_no_text(value: object, depth: int = 0) -> bool: + if value is None: + return True if isinstance(value, str): return not value.strip() + if depth >= DEFAULT_MAX_RECURSE_DEPTH: + return False if _is_json_object(value): - return all(map(_holds_no_text, value.values())) + return all(_holds_no_text(item, depth + 1) for name, item in value.items() if name != "type") if _is_json_array(value): - return all(map(_holds_no_text, value)) + return all(_holds_no_text(item, depth + 1) for item in value) return False diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index a5bb5e0b340..4eaedbae48a 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -50,6 +50,7 @@ IGNORE_FUNCTIONS = [ "_convert_to_json_serializable_dict", # max depth set (default 20) and circular reference protection to prevent infinite recursion. "dict", # max depth set. _LiteLLMParamsDictView.dict() calls builtin dict(), not itself. "_read_image_bytes", # max depth set. + "_holds_no_text", # max depth set. "get_masked_values", # max depth set (default 20) to prevent infinite recursion while masking nested sensitive config dicts. "_redact_sensitive_litellm_params", # max depth set (default 10). "_redact_secret_values_in_obj", # max depth set (default 10, _REDACT_SECRET_MAX_DEPTH); fails closed by returning "REDACTED" at the cap. diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 18201be3e84..2fe0e6cc8ab 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -4807,8 +4807,19 @@ _NOT_AN_IMAGE_REQUEST: Final[Mapping[str, object]] = MappingProxyType( @pytest.mark.usefixtures("cloudflare_account") @pytest.mark.parametrize( "state", - ("", [], {}, " ", [""], {"a": ""}, {"a": [" "]}), - ids=("string", "list", "object", "blank", "list-of-empty", "object-of-empty", "nested-blank"), + ("", [], {}, " ", [""], {"a": ""}, {"a": [" "]}, [None], {"a": None}, [{"type": "input_text", "text": ""}]), + ids=( + "string", + "list", + "object", + "blank", + "list-of-empty", + "object-of-empty", + "nested-blank", + "list-of-null", + "object-of-null", + "empty-text-part", + ), ) async def test_an_empty_state_with_only_a_dropped_image_is_refused_when_the_model_always_took_images( state: object, respx_mock: respx.MockRouter @@ -4836,6 +4847,27 @@ async def test_a_state_with_something_in_it_still_reaches_the_model_after_its_on assert sent.get("images", []) == [] +@pytest.mark.asyncio +@pytest.mark.usefixtures("cloudflare_account") +@pytest.mark.parametrize("state", ("", [], {}, " ", [""], {"a": ""}, [None]), ids=str) +async def test_a_blank_state_still_reaches_the_model_when_its_only_image_drops_for_its_declared_type( + state: object, respx_mock: respx.MockRouter +) -> None: + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + + await litellm.asystemone( + model="cloudflare/clef", + state=state, + questions=_SYSTEMONE_PREDICATE, + images=[{"content_type": "image/gif", "base64": "R0lGODlh"}], + drop_params=True, + ) + + sent: Final = json.loads(route.calls.last.request.content) + assert sent["state"] == state + assert sent.get("images", []) == [] + + _NOT_AN_IMAGE_DATA_URL: Final = f"data:image/png;base64,{_NOT_AN_IMAGE_BASE64}" _DECISIONS_PREDICATE: Final[list[dict[str, str]]] = [{"type": "predicate", "name": "red", "instructions": "Is it red?"}] @@ -4871,6 +4903,33 @@ async def test_decisions_input_with_no_text_and_only_a_dropped_image_is_refused( assert route.call_count == 0 +@pytest.mark.asyncio +@pytest.mark.usefixtures("cloudflare_account") +async def test_decisions_input_with_no_text_still_reaches_the_model_when_its_image_drops_for_its_declared_type( + respx_mock: respx.MockRouter, +) -> None: + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + + await litellm.adecisions( + model="cloudflare/clef", + input=[ + { + "role": "user", + "content": [ + {"type": "input_text", "text": ""}, + {"type": "input_image", "image_url": "data:image/gif;base64,R0lGODlh"}, + ], + } + ], + questions=_DECISIONS_PREDICATE, + drop_params=True, + ) + + sent: Final = json.loads(route.calls.last.request.content) + assert sent["state"] == "" + assert sent.get("images", []) == [] + + @pytest.mark.asyncio @pytest.mark.usefixtures("cloudflare_account") async def test_decisions_input_with_text_still_reaches_the_model_after_its_only_image_is_dropped( diff --git a/tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py b/tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py index 03459ba16c8..2ec3d71bdfe 100644 --- a/tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py +++ b/tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py @@ -1,5 +1,7 @@ import json +import sys from collections.abc import Mapping +from functools import reduce from typing import Final import httpx @@ -8,6 +10,7 @@ from pydantic import TypeAdapter, ValidationError from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.decisions.transformation import ( + input_has_no_text, ir_to_systemone_request, ir_to_systemone_response, parse_systemone_response, @@ -19,6 +22,7 @@ from litellm.types.decisions import ( DecisionsIRRefusal, DecisionsIRRequest, DecisionsIRScoreAnswer, + DecisionsIRState, DecisionsRequestBody, OpenAIDecisionRequestBody, UnsupportedDecisionsRequest, @@ -419,3 +423,13 @@ def test_non_json_provider_response_is_an_api_error(raw_content: bytes, provider with pytest.raises(BaseLLMException, match="unexpected response") as error: config.transform_decisions_response("drex-v2.0", provider, httpx.Response(200, content=raw_content), request) assert error.value.status_code == 500 + + +def _nested_once(inner: object, _: int) -> object: + return [inner] + + +def test_input_has_no_text_stops_walking_a_state_nested_past_the_stack() -> None: + deepest_state: Final = reduce(_nested_once, range(sys.getrecursionlimit() * 2), "") + + assert input_has_no_text(DecisionsIRState(state=deepest_state, images=())) is False From 3d5a2674341589c8c25d16b0ee4c4ed287839391 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:07:53 -0700 Subject: [PATCH 20/23] fix(decisions): keep main's answer when a blank request's only download is a type the model refuses --- litellm/decisions/call.py | 3 +++ tests/unit/decisions/test_main.py | 44 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index 9e1c454c85c..9fcd5369e43 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -852,6 +852,9 @@ def _with_images_within_limits(call: DecisionsCall, kept: Sequence[bool]) -> Dec error: Final = _image_limit_error(call) or _unreadable_image_error(call) if error is None: return replace(call, ir_request=replace(call.ir_request, input=kept_input)) + type_checked: Final = _checked_image_types(call) + if input_images(type_checked.ir_request.input) == input_images(kept_input): + return type_checked return _dropped_or_refused(call, kept_input, error) diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 2fe0e6cc8ab..13b6bcd63a0 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -4868,6 +4868,50 @@ async def test_a_blank_state_still_reaches_the_model_when_its_only_image_drops_f assert sent.get("images", []) == [] +@pytest.mark.usefixtures("cloudflare_account") +@pytest.mark.parametrize("state", ("", [], " ", [None]), ids=str) +def test_a_blank_state_still_reaches_the_model_when_its_only_download_is_a_type_the_model_refuses( + state: object, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "user_url_validation", False) + respx_mock.get(_REMOTE_IMAGE_URL).respond(content=b"GIF89a", headers={"content-type": "image/gif"}) + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + + litellm.systemone( + model="cloudflare/clef", + state=state, + questions=_SYSTEMONE_PREDICATE, + images=[_REMOTE_IMAGE_URL], + drop_params=True, + ) + + sent: Final = json.loads(route.calls.last.request.content) + assert sent["state"] == state + assert sent.get("images", []) == [] + + +@pytest.mark.usefixtures("cloudflare_account") +def test_a_blank_state_is_refused_when_a_download_type_drop_comes_with_a_download_size_drop( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "user_url_validation", False) + gif_url: Final = "https://images.example/screen.gif" + respx_mock.get(gif_url).respond(content=b"GIF89a", headers={"content-type": "image/gif"}) + respx_mock.get(_REMOTE_IMAGE_URL).respond(content=bytes(4 * _MIB + 1), headers={"content-type": "image/png"}) + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + + with pytest.raises(litellm.BadRequestError): + litellm.systemone( + model="cloudflare/clef", + state="", + questions=_SYSTEMONE_PREDICATE, + images=[gif_url, _REMOTE_IMAGE_URL], + drop_params=True, + ) + + assert not route.called + + _NOT_AN_IMAGE_DATA_URL: Final = f"data:image/png;base64,{_NOT_AN_IMAGE_BASE64}" _DECISIONS_PREDICATE: Final[list[dict[str, str]]] = [{"type": "predicate", "name": "red", "instructions": "Is it red?"}] From 691b35c5f508cda1789ed70232c983511afa9fdf Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:50:44 -0700 Subject: [PATCH 21/23] fix(decisions): count a state nested past the depth limit as no text, so its byte drops keep main's error --- litellm/llms/base_llm/decisions/transformation.py | 2 +- .../decisions/test_base_decisions_transformation.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index 3af86e1f6c4..33b1e60465d 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -262,7 +262,7 @@ def _holds_no_text(value: object, depth: int = 0) -> bool: if isinstance(value, str): return not value.strip() if depth >= DEFAULT_MAX_RECURSE_DEPTH: - return False + return True if _is_json_object(value): return all(_holds_no_text(item, depth + 1) for name, item in value.items() if name != "type") if _is_json_array(value): diff --git a/tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py b/tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py index 6d318d0067a..5be130a012b 100644 --- a/tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py +++ b/tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py @@ -489,7 +489,7 @@ def _nested_once(inner: object, _: int) -> object: return [inner] -def test_input_has_no_text_stops_walking_a_state_nested_past_the_stack() -> None: - deepest_state: Final = reduce(_nested_once, range(sys.getrecursionlimit() * 2), "") +def test_input_has_no_text_counts_a_state_nested_past_the_stack_as_no_text() -> None: + deepest_state: Final = reduce(_nested_once, range(sys.getrecursionlimit() * 2), "Product photo") - assert input_has_no_text(DecisionsIRState(state=deepest_state, images=())) is False + assert input_has_no_text(DecisionsIRState(state=deepest_state, images=())) is True From b21b8ac5abc4ae741898cd0e94ee339e0a14cb73 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:13:13 -0700 Subject: [PATCH 22/23] fix(decisions): count type values in a state as text, so a dropped image keeps a typed state's request --- litellm/llms/base_llm/decisions/transformation.py | 2 +- tests/unit/decisions/test_main.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index 33b1e60465d..a48169fb527 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -264,7 +264,7 @@ def _holds_no_text(value: object, depth: int = 0) -> bool: if depth >= DEFAULT_MAX_RECURSE_DEPTH: return True if _is_json_object(value): - return all(_holds_no_text(item, depth + 1) for name, item in value.items() if name != "type") + return all(_holds_no_text(item, depth + 1) for item in value.values()) if _is_json_array(value): return all(_holds_no_text(item, depth + 1) for item in value) return False diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 7b6cb94027e..a38c6d722a7 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -4808,7 +4808,7 @@ _NOT_AN_IMAGE_REQUEST: Final[Mapping[str, object]] = MappingProxyType( @pytest.mark.usefixtures("cloudflare_account") @pytest.mark.parametrize( "state", - ("", [], {}, " ", [""], {"a": ""}, {"a": [" "]}, [None], {"a": None}, [{"type": "input_text", "text": ""}]), + ("", [], {}, " ", [""], {"a": ""}, {"a": [" "]}, [None], {"a": None}), ids=( "string", "list", @@ -4819,7 +4819,6 @@ _NOT_AN_IMAGE_REQUEST: Final[Mapping[str, object]] = MappingProxyType( "nested-blank", "list-of-null", "object-of-null", - "empty-text-part", ), ) async def test_an_empty_state_with_only_a_dropped_image_is_refused_when_the_model_always_took_images( @@ -4835,7 +4834,7 @@ async def test_an_empty_state_with_only_a_dropped_image_is_refused_when_the_mode @pytest.mark.asyncio @pytest.mark.usefixtures("cloudflare_account") -@pytest.mark.parametrize("state", ("a", ["a"], {"a": "b"}, {"a": 0}, [False]), ids=str) +@pytest.mark.parametrize("state", ("a", ["a"], {"a": "b"}, {"a": 0}, [False], {"type": "refund"}), ids=str) async def test_a_state_with_something_in_it_still_reaches_the_model_after_its_only_image_is_dropped( state: object, respx_mock: respx.MockRouter ) -> None: From 2faf7e70f3a656721f76bba3dc09f3bca0884294 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:44:26 -0700 Subject: [PATCH 23/23] fix(decisions): refuse a blank typed text part or a count drop that leaves no input A `type` value now counts as text only when its object has no `text` key, so a blank text part with every image dropped answers 400 again while {"type": "refund"} keeps main's 200. After remote images past the count are dropped, a downloaded image dropped for its type no longer keeps the lenient no-input check, so a blank request keeps main's 400 instead of a billed 200 with nothing in it. --- litellm/decisions/call.py | 18 +++++--- .../llms/base_llm/decisions/transformation.py | 4 +- tests/unit/decisions/test_main.py | 45 ++++++++++++++++++- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index ed020919f95..be92c50e0cf 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -845,7 +845,11 @@ def _kept_images(call: DecisionsCall) -> tuple[bool, ...]: return tuple(budget.kept for budget in islice(budgets, 1, None)) -def _with_images_within_limits(call: DecisionsCall, kept: Sequence[bool]) -> DecisionsCall: +def _drops_images(before: DecisionsCall, after: DecisionsCall) -> bool: + return len(input_images(after.ir_request.input)) < len(input_images(before.ir_request.input)) + + +def _with_images_within_limits(call: DecisionsCall, kept: Sequence[bool], dropped_past_count: bool) -> DecisionsCall: if all(kept): return call kept_input: Final = _with_images_kept(call.ir_request.input, kept) @@ -853,7 +857,7 @@ def _with_images_within_limits(call: DecisionsCall, kept: Sequence[bool]) -> Dec if error is None: return replace(call, ir_request=replace(call.ir_request, input=kept_input)) type_checked: Final = _checked_image_types(call) - if input_images(type_checked.ir_request.input) == input_images(kept_input): + if not dropped_past_count and input_images(type_checked.ir_request.input) == input_images(kept_input): return type_checked return _dropped_or_refused(call, kept_input, error) @@ -1035,10 +1039,11 @@ def _posted_without_refused_images( async def asend(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse: - supported: Final = _refused_over_count(_checked_image_types(call)) + type_checked: Final = _checked_image_types(call) + supported: Final = _refused_over_count(type_checked) fetched: Final = await _async_with_remote_images_inlined(supported) kept: Final = _kept_images(fetched) - inlined: Final = _with_images_within_limits(fetched, kept) + inlined: Final = _with_images_within_limits(fetched, kept, _drops_images(type_checked, supported)) body: Final = _request_body(inlined) try: response: Final = await _aposted_without_refused_images( @@ -1050,10 +1055,11 @@ async def asend(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionRespon def send(call: DecisionsCall) -> DecisionsResponse | OpenAIDecisionResponse: - supported: Final = _refused_over_count(_checked_image_types(call)) + type_checked: Final = _checked_image_types(call) + supported: Final = _refused_over_count(type_checked) fetched: Final = _with_remote_images_inlined(supported) kept: Final = _kept_images(fetched) - inlined: Final = _with_images_within_limits(fetched, kept) + inlined: Final = _with_images_within_limits(fetched, kept, _drops_images(type_checked, supported)) body: Final = _request_body(inlined) try: response: Final = _posted_without_refused_images(inlined, body, _logged_body(supported, fetched, kept, body)) diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index a48169fb527..4c66f9f03b5 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -264,7 +264,9 @@ def _holds_no_text(value: object, depth: int = 0) -> bool: if depth >= DEFAULT_MAX_RECURSE_DEPTH: return True if _is_json_object(value): - return all(_holds_no_text(item, depth + 1) for item in value.values()) + return all( + _holds_no_text(item, depth + 1) for key, item in value.items() if key != "type" or "text" not in value + ) if _is_json_array(value): return all(_holds_no_text(item, depth + 1) for item in value) return False diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index a38c6d722a7..b3f81d00fd1 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -4808,7 +4808,19 @@ _NOT_AN_IMAGE_REQUEST: Final[Mapping[str, object]] = MappingProxyType( @pytest.mark.usefixtures("cloudflare_account") @pytest.mark.parametrize( "state", - ("", [], {}, " ", [""], {"a": ""}, {"a": [" "]}, [None], {"a": None}), + ( + "", + [], + {}, + " ", + [""], + {"a": ""}, + {"a": [" "]}, + [None], + {"a": None}, + [{"type": "input_text", "text": ""}], + [{"type": "text", "text": " "}], + ), ids=( "string", "list", @@ -4819,6 +4831,8 @@ _NOT_AN_IMAGE_REQUEST: Final[Mapping[str, object]] = MappingProxyType( "nested-blank", "list-of-null", "object-of-null", + "input-text-part-with-empty-text", + "text-part-with-blank-text", ), ) async def test_an_empty_state_with_only_a_dropped_image_is_refused_when_the_model_always_took_images( @@ -4890,6 +4904,35 @@ def test_a_blank_state_still_reaches_the_model_when_its_only_download_is_a_type_ assert sent.get("images", []) == [] +@pytest.mark.asyncio +@pytest.mark.usefixtures("cloudflare_account") +@pytest.mark.parametrize("sync", (False, True), ids=("async", "sync")) +async def test_a_blank_state_is_refused_when_its_downloads_drop_for_their_type_after_the_count_drop( + sync: bool, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "user_url_validation", False) + respx_mock.get(url__regex=r"^https://images\.example/screen-\d\.gif$").respond( + content=b"GIF89a", headers={"content-type": "image/gif"} + ) + route: Final = respx_mock.post(f"{_CLOUDFLARE_RUN_URL}/clef").respond(json=_RESPONSE) + request_kwargs: Final = { + "model": "cloudflare/clef", + "state": "", + "questions": _SYSTEMONE_PREDICATE, + "images": [f"https://images.example/screen-{index}.gif" for index in range(5)], + "drop_params": True, + } + + if sync: + with pytest.raises(litellm.BadRequestError): + litellm.systemone(**request_kwargs) + else: + with pytest.raises(litellm.BadRequestError): + await litellm.asystemone(**request_kwargs) + + assert not route.called + + @pytest.mark.usefixtures("cloudflare_account") def test_a_blank_state_is_refused_when_a_download_type_drop_comes_with_a_download_size_drop( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch