From 0afbca29c2b889feb7964fb7f12f7518639fe085 Mon Sep 17 00:00:00 2001 From: Nacl-HCl <308349766+Nacl-HCl@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:25:41 -0400 Subject: [PATCH 1/7] fix(router): skip non-vision deployments for image requests in pre-call checks --- .../prompt_templates/common_utils.py | 30 ++++- litellm/router.py | 21 ++++ tests/unit/test_router/test_router.py | 105 ++++++++++++++++++ 3 files changed, 151 insertions(+), 5 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index b704dfa2675..25ac393f709 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -293,17 +293,37 @@ def as_openai_image_part(part: Mapping[str, object]) -> ChatCompletionImageObjec return {"type": "image_url", "image_url": {"url": url}} if url else None -def request_contains_image_content(messages: Sequence[Mapping[str, object]]) -> bool: - """Whether any message carries an image content part, across the dialects that reach - pre-routing hooks untranslated: chat-completions ``image_url``, Responses ``input_image``, - and Anthropic Messages ``image``, including images nested inside ``tool_result`` blocks.""" - return any( +def request_contains_image_content( + messages: Sequence[Mapping[str, object]] | None = None, + input: Sequence[object] | str | None = None, +) -> bool: + """Whether a request carries image input, across both API surfaces. + + Chat Completions carry images in ``messages[*].content`` (``image_url``, Responses + ``input_image``, Anthropic Messages ``image``, including images nested inside + ``tool_result`` blocks); the Responses API carries them in ``input`` items, either as + ``input_image`` blocks or as messages holding such blocks. + """ + if messages is not None and any( isinstance(content, list) and _content_parts_contain_image(content) for message in messages for content in (message.get("content"),) + ): + return True + return isinstance(input, list) and any( + _responses_input_item_contains_image(item) for item in input ) +def _responses_input_item_contains_image(item: object) -> bool: + if not isinstance(item, Mapping): + return False + if item.get("type") in _IMAGE_CONTENT_PART_TYPES: + return True + content = item.get("content") + return isinstance(content, list) and _content_parts_contain_image(content) + + def audio_or_image_in_message_content(message: AllMessageValues) -> bool: """ Checks if message content contains an image or audio diff --git a/litellm/router.py b/litellm/router.py index c30108b4e38..470aeeb38b2 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -105,6 +105,9 @@ from litellm.litellm_core_utils.hidden_params import ( ) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.litellm_core_utils.llm_cost_calc.utils import SERVICE_TIER_COST_KEY_SUFFIXES +from litellm.litellm_core_utils.prompt_templates.common_utils import ( + request_contains_image_content, +) from litellm.litellm_core_utils.ptu_pricing import ( PTU_COST_ATTRIBUTION_ENV_VAR, declares_ptu, @@ -13659,6 +13662,7 @@ class Router: _context_window_error = False _potential_error_str = "" _rate_limit_error = False + _vision_error = False parent_otel_span: Final = get_parent_otel_span_from_kwargs(request_kwargs) has_countable_input: Final = (messages is not None or input is not None) and not compaction_pending( @@ -13682,6 +13686,7 @@ class Router: if request_kwargs is not None and litellm.drop_params is False else frozenset() ) + requires_vision_support: Final = request_contains_image_content(messages=messages, input=input) ## get model group RPM ## dt: Final = get_utc_datetime() @@ -13695,6 +13700,11 @@ class Router: _litellm_params = deployment.get("litellm_params", {}) _model_info = deployment.get("model_info", {}) + if requires_vision_support and _model_info.get("supports_vision") is False: + invalid_model_indices.add(idx) + _vision_error = True + continue + max_input_tokens = deployment_max_input_tokens[idx] if ( max_input_tokens is not None @@ -13804,6 +13814,17 @@ class Router: model=model, llm_provider="", ) + + elif _vision_error is True: + raise litellm.BadRequestError( + message=( + f"litellm._pre_call_checks: No deployment of model={model} supports image input. " + "Declare vision support with `model_info: supports_vision: true` on a deployment, " + "or remove the image from the request." + ), + model=model, + llm_provider="", + ) if len(excluded_indices) > 0: # Single-pass filter using set for O(1) lookups (avoids O(n^2) from repeated pops) _returned_deployments = [d for i, d in enumerate(_returned_deployments) if i not in excluded_indices] diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 1dec4de45f4..823cb521efc 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -28687,3 +28687,108 @@ def test_pre_call_checks_ignores_an_image_response_format_string(monkeypatch: py ) assert [d["model_info"]["id"] for d in filtered] == ["only"] + + +def test_pre_call_checks_drops_deployments_declaring_no_vision_for_image_requests(): + model_list: Final = [ + { + "model_name": "mixed", + "litellm_params": {"model": "gpt-4o"}, + "model_info": {"id": "vision", "supports_vision": True}, + }, + { + "model_name": "mixed", + "litellm_params": {"model": "gpt-audio"}, + "model_info": {"id": "text-only", "supports_vision": False}, + }, + { + "model_name": "mixed", + "litellm_params": {"model": "gpt-4o"}, + "model_info": {"id": "unknown"}, + }, + ] + router: Final = Router(model_list=model_list, enable_pre_call_checks=True) + + filtered: Final = router._pre_call_checks( + model="mixed", + healthy_deployments=model_list, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "what is in this image?"}, + {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}, + ], + } + ], + ) + + assert [d["model_info"]["id"] for d in filtered] == ["vision", "unknown"] + + +def test_pre_call_checks_keeps_non_vision_deployments_for_text_requests(): + model_list: Final = [ + { + "model_name": "mixed", + "litellm_params": {"model": "gpt-audio"}, + "model_info": {"id": "text-only", "supports_vision": False}, + }, + ] + router: Final = Router(model_list=model_list, enable_pre_call_checks=True) + + filtered: Final = router._pre_call_checks( + model="mixed", + healthy_deployments=model_list, + messages=[{"role": "user", "content": "hello"}], + ) + + assert [d["model_info"]["id"] for d in filtered] == ["text-only"] + + +def test_pre_call_checks_drops_non_vision_deployments_for_responses_image_input(): + model_list: Final = [ + { + "model_name": "mixed", + "litellm_params": {"model": "gpt-4o"}, + "model_info": {"id": "vision", "supports_vision": True}, + }, + { + "model_name": "mixed", + "litellm_params": {"model": "gpt-audio"}, + "model_info": {"id": "text-only", "supports_vision": False}, + }, + ] + router: Final = Router(model_list=model_list, enable_pre_call_checks=True) + + filtered: Final = router._pre_call_checks( + model="mixed", + healthy_deployments=model_list, + input=[{"type": "input_image", "image_url": "https://example.com/cat.png"}], + ) + + assert [d["model_info"]["id"] for d in filtered] == ["vision"] + + +def test_pre_call_checks_raises_bad_request_when_no_deployment_supports_vision(): + model_list: Final = [ + { + "model_name": "mixed", + "litellm_params": {"model": "gpt-audio"}, + "model_info": {"id": "text-only", "supports_vision": False}, + }, + ] + router: Final = Router(model_list=model_list, enable_pre_call_checks=True) + + with pytest.raises(litellm.BadRequestError, match="supports image input"): + router._pre_call_checks( + model="mixed", + healthy_deployments=model_list, + messages=[ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}, + ], + } + ], + ) From 0415ea14126226c8ec3de75aa3990cdf02c73931 Mon Sep 17 00:00:00 2001 From: Nacl-HCl <308349766+Nacl-HCl@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:34:22 -0400 Subject: [PATCH 2/7] style: apply ruff format to request_contains_image_content --- litellm/litellm_core_utils/prompt_templates/common_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 25ac393f709..35d38349df5 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -310,9 +310,7 @@ def request_contains_image_content( for content in (message.get("content"),) ): return True - return isinstance(input, list) and any( - _responses_input_item_contains_image(item) for item in input - ) + return isinstance(input, list) and any(_responses_input_item_contains_image(item) for item in input) def _responses_input_item_contains_image(item: object) -> bool: From 766f917b517a5e7666abedd59d5b31b39b89cf0f Mon Sep 17 00:00:00 2001 From: Nacl-HCl <308349766+Nacl-HCl@users.noreply.github.com> Date: Sun, 18 Oct 2026 08:05:17 -0400 Subject: [PATCH 3/7] refactor(router): pre-filter vision-incompatible deployments before pre-call loop --- litellm/router.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 470aeeb38b2..f7bb5751fac 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1096,6 +1096,17 @@ def _base_model_widened_window( return MappingProxyType({"max_input_tokens": base_max_input_tokens}) +def _vision_excluded_deployment_indices(requires_vision_support: bool, deployments: list) -> set[int]: + """Indices of deployments to skip because the request needs vision support they lack.""" + if not requires_vision_support: + return set() + return { + idx + for idx, deployment in enumerate(deployments) + if deployment.get("model_info", {}).get("supports_vision") is False + } + + class Router: @property def _routing_groups(self) -> dict[str, RoutingGroup]: @@ -13662,7 +13673,6 @@ class Router: _context_window_error = False _potential_error_str = "" _rate_limit_error = False - _vision_error = False parent_otel_span: Final = get_parent_otel_span_from_kwargs(request_kwargs) has_countable_input: Final = (messages is not None or input is not None) and not compaction_pending( @@ -13688,6 +13698,15 @@ class Router: ) requires_vision_support: Final = request_contains_image_content(messages=messages, input=input) + ## Vision pre-filter: exclude deployments that declare no vision support when the + ## request carries image input. Computed up front so the per-deployment loop below + ## keeps its existing shape (and complexity budget). + _vision_excluded_indices: Final = _vision_excluded_deployment_indices( + requires_vision_support, _returned_deployments + ) + _vision_error: Final = len(_vision_excluded_indices) > 0 + invalid_model_indices.update(_vision_excluded_indices) + ## get model group RPM ## dt: Final = get_utc_datetime() current_minute: Final = dt.strftime("%H-%M") @@ -13700,11 +13719,6 @@ class Router: _litellm_params = deployment.get("litellm_params", {}) _model_info = deployment.get("model_info", {}) - if requires_vision_support and _model_info.get("supports_vision") is False: - invalid_model_indices.add(idx) - _vision_error = True - continue - max_input_tokens = deployment_max_input_tokens[idx] if ( max_input_tokens is not None From 66cc38de4b6d2e357b5b8da7d3589b205f1051bc Mon Sep 17 00:00:00 2001 From: Nacl-HCl <308349766+Nacl-HCl@users.noreply.github.com> Date: Sun, 18 Oct 2026 08:32:52 -0400 Subject: [PATCH 4/7] refactor(router): use read-only types for vision pre-filter helper; add direct test --- litellm/router.py | 10 ++++++---- tests/unit/test_router/test_router.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index f7bb5751fac..43f6d3e7605 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1096,15 +1096,17 @@ def _base_model_widened_window( return MappingProxyType({"max_input_tokens": base_max_input_tokens}) -def _vision_excluded_deployment_indices(requires_vision_support: bool, deployments: list) -> set[int]: +def _vision_excluded_deployment_indices( + requires_vision_support: bool, deployments: Sequence[DeploymentTypedDict] +) -> frozenset[int]: """Indices of deployments to skip because the request needs vision support they lack.""" if not requires_vision_support: - return set() - return { + return frozenset() + return frozenset( idx for idx, deployment in enumerate(deployments) if deployment.get("model_info", {}).get("supports_vision") is False - } + ) class Router: diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 823cb521efc..19a82add59b 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -28792,3 +28792,16 @@ def test_pre_call_checks_raises_bad_request_when_no_deployment_supports_vision() } ], ) + + +def test_vision_excluded_deployment_indices(): + from litellm.router import _vision_excluded_deployment_indices + + deployments: Final = [ + {"model_info": {"id": "vision", "supports_vision": True}}, + {"model_info": {"id": "text-only", "supports_vision": False}}, + {"model_info": {"id": "unknown"}}, + ] + + assert _vision_excluded_deployment_indices(True, deployments) == frozenset({1}) + assert _vision_excluded_deployment_indices(False, deployments) == frozenset() From e58235e9e3d0d3d8ba888bfe73392415b62d007f Mon Sep 17 00:00:00 2001 From: Nacl-HCl <308349766+Nacl-HCl@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:42:50 -0400 Subject: [PATCH 5/7] style: annotate Final per LIT010 in _responses_input_item_contains_image --- litellm/litellm_core_utils/prompt_templates/common_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 35d38349df5..9cebf3f22e9 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -318,7 +318,7 @@ def _responses_input_item_contains_image(item: object) -> bool: return False if item.get("type") in _IMAGE_CONTENT_PART_TYPES: return True - content = item.get("content") + content: Final = item.get("content") return isinstance(content, list) and _content_parts_contain_image(content) From 2485106424048fe44dce98c2204d78d927b8b009 Mon Sep 17 00:00:00 2001 From: Nacl-HCl <308349766+Nacl-HCl@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:52:01 -0400 Subject: [PATCH 6/7] fix(router): tighten types to satisfy basedpyright unknown-type gate Use cast with # cast-ok: reasons for Mapping/Sequence types, add _deployment_declares_no_vision helper with explicit None guard, and cover new helpers with direct tests. --- .../prompt_templates/common_utils.py | 9 ++++-- litellm/router.py | 31 +++++++++++++------ tests/unit/test_router/test_router.py | 10 ++++++ 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 9cebf3f22e9..48f4076d0b5 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -316,10 +316,13 @@ def request_contains_image_content( def _responses_input_item_contains_image(item: object) -> bool: if not isinstance(item, Mapping): return False - if item.get("type") in _IMAGE_CONTENT_PART_TYPES: + mapping: Final = cast(Mapping[str, object], item) # cast-ok: Responses input items are string-keyed mappings + if mapping.get("type") in _IMAGE_CONTENT_PART_TYPES: return True - content: Final = item.get("content") - return isinstance(content, list) and _content_parts_contain_image(content) + content: Final = mapping.get("content") + if not isinstance(content, list): + return False + return _content_parts_contain_image(cast(Sequence[object], content)) # cast-ok: content verified as list above def audio_or_image_in_message_content(message: AllMessageValues) -> bool: diff --git a/litellm/router.py b/litellm/router.py index 43f6d3e7605..4a2379e75b6 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1096,17 +1096,21 @@ def _base_model_widened_window( return MappingProxyType({"max_input_tokens": base_max_input_tokens}) +def _deployment_declares_no_vision(deployment: DeploymentTypedDict) -> bool: + """Whether the deployment's model_info explicitly declares no vision support.""" + if "model_info" not in deployment or deployment["model_info"] is None: + return False + model_info: Final = cast(dict[str, object], deployment["model_info"]) # cast-ok: model_info is a string-keyed dict + return model_info.get("supports_vision") is False + + def _vision_excluded_deployment_indices( requires_vision_support: bool, deployments: Sequence[DeploymentTypedDict] ) -> frozenset[int]: """Indices of deployments to skip because the request needs vision support they lack.""" if not requires_vision_support: return frozenset() - return frozenset( - idx - for idx, deployment in enumerate(deployments) - if deployment.get("model_info", {}).get("supports_vision") is False - ) + return frozenset(idx for idx, deployment in enumerate(deployments) if _deployment_declares_no_vision(deployment)) class Router: @@ -13698,16 +13702,19 @@ class Router: if request_kwargs is not None and litellm.drop_params is False else frozenset() ) - requires_vision_support: Final = request_contains_image_content(messages=messages, input=input) + requires_vision_support: Final = request_contains_image_content( + messages=messages, + input=cast(Sequence[object] | str | None, input), # cast-ok: forwarded from pre-call args + ) ## Vision pre-filter: exclude deployments that declare no vision support when the ## request carries image input. Computed up front so the per-deployment loop below ## keeps its existing shape (and complexity budget). _vision_excluded_indices: Final = _vision_excluded_deployment_indices( - requires_vision_support, _returned_deployments + requires_vision_support, + cast(Sequence[DeploymentTypedDict], _returned_deployments), # cast-ok: router deployments are plain dicts ) _vision_error: Final = len(_vision_excluded_indices) > 0 - invalid_model_indices.update(_vision_excluded_indices) ## get model group RPM ## dt: Final = get_utc_datetime() @@ -13787,8 +13794,12 @@ class Router: if no_deployment_takes_params else candidates_lacking_params ) - excluded_indices: Final = frozenset(invalid_model_indices) | frozenset( - idx for (idx, _), fails in zip(param_candidates, candidates_failing_params, strict=True) if fails + excluded_indices: Final = ( + frozenset(invalid_model_indices) + | _vision_excluded_indices + | frozenset( + idx for (idx, _), fails in zip(param_candidates, candidates_failing_params, strict=True) if fails + ) ) if len(excluded_indices) == len(_returned_deployments): diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 19a82add59b..8854704148d 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -28805,3 +28805,13 @@ def test_vision_excluded_deployment_indices(): assert _vision_excluded_deployment_indices(True, deployments) == frozenset({1}) assert _vision_excluded_deployment_indices(False, deployments) == frozenset() + + +def test_deployment_declares_no_vision(): + from litellm.router import _deployment_declares_no_vision + + assert _deployment_declares_no_vision({"model_info": {"supports_vision": False}}) is True + assert _deployment_declares_no_vision({"model_info": {"supports_vision": True}}) is False + assert _deployment_declares_no_vision({"model_info": {}}) is False + assert _deployment_declares_no_vision({"model_info": None}) is False + assert _deployment_declares_no_vision({}) is False From 724e9c1fcaf408bf6153588a0d86782ae94e8131 Mon Sep 17 00:00:00 2001 From: Nacl-HCl <308349766+Nacl-HCl@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:17:43 -0400 Subject: [PATCH 7/7] fix(router): avoid reportUnnecessaryComparison in _deployment_declares_no_vision Cast model_info to dict[str, object] | None first so the None check is a necessary comparison instead of an unnecessary one. --- litellm/router.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 4a2379e75b6..d6359b980ba 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1098,9 +1098,13 @@ def _base_model_widened_window( def _deployment_declares_no_vision(deployment: DeploymentTypedDict) -> bool: """Whether the deployment's model_info explicitly declares no vision support.""" - if "model_info" not in deployment or deployment["model_info"] is None: + if "model_info" not in deployment: + return False + model_info: Final = cast( # cast-ok: model_info is a string-keyed dict, None at runtime + dict[str, object] | None, deployment["model_info"] + ) + if model_info is None: return False - model_info: Final = cast(dict[str, object], deployment["model_info"]) # cast-ok: model_info is a string-keyed dict return model_info.get("supports_vision") is False