fix(router): skip declared text-only deployments for image requests on every routing strategy (#48241)

* fix(router): skip declared text-only deployments for image requests without enable_pre_call_checks

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(router): run the vision filter after the cooldown filter so a cooled vision leg falls back to the text leg

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(router): apply the vision filter after pre-call checks and tidy the new tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(router): mark the remaining locals in the vision filter tests Final

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: joshua <joshua@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-24 16:11:33 -07:00 • committed by GitHub
parent 1418c1990f
commit 34ec3fcdfa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 135 additions and 46 deletions

View file

@ -4821,10 +4821,15 @@ class ProxyBaseLLMRequestProcessing:
model_info = metadata.get("model_info") or {}
model_id = model_info.get("id", None)
# 3. Final fallback to self.data["litellm_metadata"] (for routes like /v1/responses that populate data before error)
# 3. Final fallback to the request metadata the router updates in place with the selected deployment
if not model_id:
litellm_metadata: Final = self.data.get("litellm_metadata", {}) or {}
model_info = litellm_metadata.get("model_info", {}) or {}
model_id = model_info.get("id", None)
model_id = self._metadata_model_id("litellm_metadata") or self._metadata_model_id("metadata")
return model_id
def _metadata_model_id(self, metadata_key: str) -> str | None:
match self.data.get(metadata_key):
case {"model_info": {"id": str() as request_model_id}}:
return request_model_id
case _:
return None

View file

@ -14315,6 +14315,30 @@ class Router:
return healthy_deployments
return cast(_RouteDeploymentsT, filtered_deployments) # cast-ok: same list shape as the input candidates
@staticmethod
def _without_declared_text_only_deployments(
healthy_deployments: _RouteDeploymentsT,
group: str,
messages: list[dict[str, str]] | None, # mutable-ok: handed to _request_carries_image as-is
request_kwargs: Mapping[str, object] | None,
) -> _RouteDeploymentsT:
if isinstance(healthy_deployments, Mapping) or not Router._request_carries_image(
messages, request_kwargs or {}
):
return healthy_deployments
vision_capable_deployments: Final = [
deployment
for deployment in healthy_deployments
if not Router._deployment_declares_no_vision(
cast(DeploymentTypedDict, deployment), # cast-ok: router candidates use the deployment shape
group,
)
]
if not vision_capable_deployments:
return healthy_deployments
return cast(_RouteDeploymentsT, vision_capable_deployments) # cast-ok: same list shape as the input candidates
@staticmethod
def _request_carries_image(
messages: list[dict[str, str]] | None, # mutable-ok: handed to resolve_structured_messages as-is
@ -14355,7 +14379,6 @@ class Router:
- model context window < message length. For azure openai models, requires 'base_model' is set. - https://docs.litellm.ai/docs/proxy/cost_tracking#spend-tracking-for-azure-openai-models
- filter models above rpm limits
- if region given, filter out models not in that region / unknown region
- request carries an image and the deployment declares supports_vision false, unless every candidate does
- [TODO] function call and model doesn't support function calling
"""
@ -14515,20 +14538,9 @@ class Router:
model=model,
llm_provider="",
)
remaining: Final = tuple(
(idx, deployment) for idx, deployment in param_candidates if idx not in excluded_indices
)
text_only_indices: Final[frozenset[int]] = (
frozenset(idx for idx, deployment in remaining if self._deployment_declares_no_vision(deployment, model))
if remaining and self._request_carries_image(messages, request_settings)
else frozenset()
)
dropped_indices: Final[frozenset[int]] = excluded_indices | (
text_only_indices if len(text_only_indices) < len(remaining) else frozenset()
)
if len(dropped_indices) > 0:
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 dropped_indices]
_returned_deployments = [d for i, d in enumerate(_returned_deployments) if i not in excluded_indices]
return _returned_deployments
@ -15051,8 +15063,11 @@ class Router:
## ORDER FILTERING ## -> if user set 'order' in deployments, return deployments with lowest order (e.g. order=1 > order=2)
_target_order: Final = (request_kwargs or {}).pop("_target_order", None)
batch_candidates: Final = cast( # cast-ok: router deployments are plain dicts
list[dict[str, object]], healthy_deployments
batch_candidates: Final = self._without_declared_text_only_deployments(
cast(list[dict[str, object]], healthy_deployments), # cast-ok: router deployments are plain dicts
model,
messages,
cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict
)
embedding_batch_admitting: Final = deployments_admitting_embedding_batch(
batch_candidates,
@ -16291,8 +16306,11 @@ class Router:
## ORDER FILTERING ## -> if user set 'order' in deployments, return deployments with lowest order (e.g. order=1 > order=2)
_target_order: Final = (request_kwargs or {}).pop("_target_order", None)
batch_candidates: Final = cast( # cast-ok: router deployments are plain dicts
list[dict[str, object]], healthy_deployments
batch_candidates: Final = self._without_declared_text_only_deployments(
cast(list[dict[str, object]], healthy_deployments), # cast-ok: router deployments are plain dicts
model,
messages,
cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict
)
embedding_batch_admitting: Final = deployments_admitting_embedding_batch(
batch_candidates,

View file

@ -12027,3 +12027,24 @@ class TestResponseCostFromLoggingObjForCacheHits:
)
assert cost == ""
def test_maybe_get_model_id_reads_the_chat_metadata_the_router_updated_in_place():
processing_obj: Final = ProxyBaseLLMRequestProcessing(data={"metadata": {"model_info": {"id": "declared-text"}}})
assert processing_obj.maybe_get_model_id(None) == "declared-text"
def test_maybe_get_model_id_prefers_litellm_metadata_over_caller_metadata_without_a_deployment():
processing_obj: Final = ProxyBaseLLMRequestProcessing(
data={
"metadata": {"trace_id": "t-1"},
"litellm_metadata": {"model_info": {"id": "vision"}},
}
)
assert processing_obj.maybe_get_model_id(None) == "vision"
def test_maybe_get_model_id_returns_none_before_the_router_picked_a_deployment():
assert ProxyBaseLLMRequestProcessing(data={"metadata": {}}).maybe_get_model_id(None) is None

View file

@ -30022,64 +30022,109 @@ _DECLARED_TEXT_LEG: Final[dict[str, object]] = {
}
def _pre_call_router(*deployments: dict[str, object], routing_strategy: str = "latency-based-routing") -> litellm.Router:
def _mixed_router(*deployments: dict[str, object], routing_strategy: str = "latency-based-routing") -> litellm.Router:
return litellm.Router(
model_list=[{"model_name": "mixed", **deployment} for deployment in deployments],
routing_strategy=routing_strategy,
enable_pre_call_checks=True,
)
def _pre_call_ids(router: litellm.Router, deployments: tuple[dict[str, object], ...], **request: object) -> list[str]:
return [d["model_info"]["id"] for d in router._pre_call_checks(model="mixed", healthy_deployments=list(deployments), **request)]
def _candidate_ids(
router: litellm.Router,
messages: list[dict[str, object]] | None = None,
request_kwargs: dict[str, object] | None = None,
) -> list[str]:
candidates: Final = router._without_declared_text_only_deployments(
router.get_model_list(model_name="mixed"), "mixed", messages, request_kwargs
)
return [d["model_info"]["id"] for d in candidates]
def test_pre_call_checks_drop_declared_text_only_legs_for_image_requests():
router = _pre_call_router(_VISION_LEG, _DECLARED_TEXT_LEG)
assert _pre_call_ids(router, (_VISION_LEG, _DECLARED_TEXT_LEG), messages=_IMAGE_CHAT) == ["vision"]
def test_router_picks_the_vision_leg_for_image_requests_without_enable_pre_call_checks():
router: Final = _mixed_router(_VISION_LEG, _DECLARED_TEXT_LEG)
assert router.enable_pre_call_checks is False
for _ in range(5):
assert router.get_available_deployment(model="mixed", messages=_IMAGE_CHAT)["model_info"]["id"] == "vision"
def test_pre_call_checks_keep_declared_text_only_legs_for_text_requests():
router = _pre_call_router(_VISION_LEG, _DECLARED_TEXT_LEG)
assert _pre_call_ids(router, (_VISION_LEG, _DECLARED_TEXT_LEG), messages=[{"role": "user", "content": "Say hi"}]) == [
@pytest.mark.asyncio
async def test_async_router_picks_the_vision_leg_for_responses_images_without_enable_pre_call_checks():
router: Final = _mixed_router(_VISION_LEG, _DECLARED_TEXT_LEG)
for _ in range(5):
deployment: Final = await router.async_get_available_deployment(
model="mixed", request_kwargs={"input": _IMAGE_RESPONSES_INPUT}
)
assert deployment["model_info"]["id"] == "vision"
def test_router_sends_an_image_to_the_text_only_leg_when_pre_call_checks_drop_the_vision_leg():
router: Final = litellm.Router(
model_list=[
{"model_name": "mixed", **_VISION_LEG, "model_info": {"id": "vision", "max_input_tokens": 1}},
{"model_name": "mixed", **_DECLARED_TEXT_LEG},
],
routing_strategy="latency-based-routing",
enable_pre_call_checks=True,
)
assert router.get_available_deployment(model="mixed", messages=_IMAGE_CHAT)["model_info"]["id"] == "declared-text"
def test_router_sends_an_image_to_the_text_only_leg_when_the_vision_leg_is_cooling_down():
router: Final = _mixed_router(_VISION_LEG, _DECLARED_TEXT_LEG)
router.cooldown_cache.add_deployment_to_cooldown(
model_id="vision", original_exception=Exception("bad key"), exception_status=401, cooldown_time=60
)
assert router.get_available_deployment(model="mixed", messages=_IMAGE_CHAT)["model_info"]["id"] == "declared-text"
def test_without_declared_text_only_deployments_passes_a_chosen_deployment_through():
assert litellm.Router._without_declared_text_only_deployments(_DECLARED_TEXT_LEG, "mixed", _IMAGE_CHAT, None) is _DECLARED_TEXT_LEG
def test_image_routing_drop_declared_text_only_legs_for_image_requests():
router: Final = _mixed_router(_VISION_LEG, _DECLARED_TEXT_LEG)
assert _candidate_ids(router, messages=_IMAGE_CHAT) == ["vision"]
def test_image_routing_keep_declared_text_only_legs_for_text_requests():
router: Final = _mixed_router(_VISION_LEG, _DECLARED_TEXT_LEG)
assert _candidate_ids(router, messages=[{"role": "user", "content": "Say hi"}]) == [
"vision",
"declared-text",
]
def test_pre_call_checks_drop_declared_text_only_legs_for_responses_input_image():
router = _pre_call_router(_VISION_LEG, _DECLARED_TEXT_LEG)
assert _pre_call_ids(
router, (_VISION_LEG, _DECLARED_TEXT_LEG), messages=None, request_kwargs={"input": _IMAGE_RESPONSES_INPUT}
) == ["vision"]
def test_image_routing_drop_declared_text_only_legs_for_responses_input_image():
router: Final = _mixed_router(_VISION_LEG, _DECLARED_TEXT_LEG)
assert _candidate_ids(router, request_kwargs={"input": _IMAGE_RESPONSES_INPUT}) == ["vision"]
def test_pre_call_checks_send_image_to_the_group_when_every_leg_is_declared_text_only():
def test_image_routing_send_image_to_the_group_when_every_leg_is_declared_text_only():
other_text_leg = {**_DECLARED_TEXT_LEG, "model_info": {"id": "declared-text-2", "supports_vision": False}}
router = _pre_call_router(_DECLARED_TEXT_LEG, other_text_leg)
assert _pre_call_ids(router, (_DECLARED_TEXT_LEG, other_text_leg), messages=_IMAGE_CHAT) == [
router: Final = _mixed_router(_DECLARED_TEXT_LEG, other_text_leg)
assert _candidate_ids(router, messages=_IMAGE_CHAT) == [
"declared-text",
"declared-text-2",
]
def test_pre_call_checks_keep_undeclared_custom_legs_for_image_requests():
def test_image_routing_keep_undeclared_custom_legs_for_image_requests():
custom_leg = {
"litellm_params": {"model": "hosted_vllm/Qwen2.5-VL-7B-Instruct", "api_base": "http://localhost:8000"},
"model_info": {"id": "custom"},
}
router = _pre_call_router(_VISION_LEG, custom_leg)
assert _pre_call_ids(router, (_VISION_LEG, custom_leg), messages=_IMAGE_CHAT) == ["vision", "custom"]
router: Final = _mixed_router(_VISION_LEG, custom_leg)
assert _candidate_ids(router, messages=_IMAGE_CHAT) == ["vision", "custom"]
def test_pre_call_checks_resolve_vision_through_the_cost_map_and_base_model():
def test_image_routing_resolve_vision_through_the_cost_map_and_base_model():
catalog_text_leg = {"litellm_params": {"model": "bedrock/deepseek.v3.2"}, "model_info": {"id": "catalog-text"}}
azure_text_leg = {
"litellm_params": {"model": "azure/my-audio-deployment", "api_base": "https://x.openai.azure.com"},
"model_info": {"id": "azure-text", "base_model": "azure/gpt-audio"},
}
router = _pre_call_router(_VISION_LEG, catalog_text_leg, azure_text_leg)
assert _pre_call_ids(router, (_VISION_LEG, catalog_text_leg, azure_text_leg), messages=_IMAGE_CHAT) == ["vision"]
router: Final = _mixed_router(_VISION_LEG, catalog_text_leg, azure_text_leg)
assert _candidate_ids(router, messages=_IMAGE_CHAT) == ["vision"]
@pytest.mark.parametrize(