From 41f07142876b4285b92325297ed735e9e64cad67 Mon Sep 17 00:00:00 2001 From: islam666 Date: Sun, 7 Jun 2026 08:55:19 +0000 Subject: [PATCH] fix(vision): honor custom_providers per-model supports_vision (#41036) _supports_vision_override() in image_routing.py checked model.supports_vision and providers..models, but not the legacy list-style custom_providers config. A custom provider entry like: custom_providers: - name: my-provider models: my-model: supports_vision: true was ignored, causing image_input_mode=auto to route through the auxiliary vision_analyze path instead of natively attaching images. Fix: added a lookup step for custom_providers list entries, matching by provider name (including 'custom:' variants at runtime). providers..models still takes precedence over custom_providers. 13 new tests covering: true/false override, custom: prefix matching, no-match fallback, non-dict entries, empty lists, models key missing. --- agent/image_routing.py | 29 +++ tests/agent/test_custom_providers_vision.py | 263 ++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 tests/agent/test_custom_providers_vision.py diff --git a/agent/image_routing.py b/agent/image_routing.py index 74b29af7cd8..c8b3f6640c6 100644 --- a/agent/image_routing.py +++ b/agent/image_routing.py @@ -219,6 +219,35 @@ def _supports_vision_override( coerced = _coerce_capability_bool(per_model.get("supports_vision")) if coerced is not None: return coerced + + # 2b. Legacy list-style custom_providers. Entries are dicts with a + # "name" key and a nested "models" dict. Match by provider name (which + # may appear as the raw name or "custom:" at runtime). + custom_providers = cfg.get("custom_providers") + if isinstance(custom_providers, list): + # Build candidate names: the provider value and the config provider + # value, both raw and with "custom:" prefix stripped/added. + candidate_names: set = set() + for p in filter(None, (provider, config_provider)): + candidate_names.add(p) + if p.startswith("custom:"): + candidate_names.add(p[len("custom:"):]) + else: + candidate_names.add(f"custom:{p}") + for entry_raw in custom_providers: + if not isinstance(entry_raw, dict): + continue + entry_name = str(entry_raw.get("name") or "").strip() + if entry_name not in candidate_names: + continue + models_raw = entry_raw.get("models") + models_cfg = models_raw if isinstance(models_raw, dict) else {} + per_model_raw = models_cfg.get(model) + per_model = per_model_raw if isinstance(per_model_raw, dict) else {} + coerced = _coerce_capability_bool(per_model.get("supports_vision")) + if coerced is not None: + return coerced + return None diff --git a/tests/agent/test_custom_providers_vision.py b/tests/agent/test_custom_providers_vision.py new file mode 100644 index 00000000000..ccd4e9936f7 --- /dev/null +++ b/tests/agent/test_custom_providers_vision.py @@ -0,0 +1,263 @@ +"""Tests for custom_providers[].models[].supports_vision override (#41036). + +When a named custom provider declares per-model supports_vision via the +legacy list-style custom_providers config, image_routing should honor it +and route images natively instead of falling through to models.dev or +the auxiliary vision_analyze path. +""" + +from __future__ import annotations + +import pytest + + +# --------------------------------------------------------------------------- +# _supports_vision_override — custom_providers lookup +# --------------------------------------------------------------------------- + + +class TestCustomProvidersVisionOverride: + """_supports_vision_override should check custom_providers list entries.""" + + def test_custom_providers_supports_vision_true(self): + """custom_providers entry with supports_vision=true → native routing.""" + from agent.image_routing import _supports_vision_override + cfg = { + "custom_providers": [ + { + "name": "9router-anthropic", + "models": { + "mimoanth/mimo-v2.5": { + "supports_vision": True, + } + } + } + ] + } + result = _supports_vision_override( + cfg, "9router-anthropic", "mimoanth/mimo-v2.5" + ) + assert result is True + + def test_custom_providers_supports_vision_false(self): + """custom_providers entry with supports_vision=False → explicit false.""" + from agent.image_routing import _supports_vision_override + cfg = { + "custom_providers": [ + { + "name": "my-llm", + "models": { + "some-model": { + "supports_vision": False, + } + } + } + ] + } + result = _supports_vision_override(cfg, "my-llm", "some-model") + assert result is False + + def test_custom_providers_custom_prefix(self): + """Provider name at runtime may be 'custom:'.""" + from agent.image_routing import _supports_vision_override + cfg = { + "custom_providers": [ + { + "name": "9router-anthropic", + "models": { + "mimoanth/mimo-v2.5": { + "supports_vision": True, + } + } + } + ] + } + # Runtime provider is "custom:9router-anthropic" + result = _supports_vision_override( + cfg, "custom:9router-anthropic", "mimoanth/mimo-v2.5" + ) + assert result is True + + def test_custom_providers_no_match_returns_none(self): + """No matching custom_providers entry → falls through (returns None).""" + from agent.image_routing import _supports_vision_override + cfg = { + "custom_providers": [ + { + "name": "other-provider", + "models": { + "other-model": { + "supports_vision": True, + } + } + } + ] + } + result = _supports_vision_override( + cfg, "my-provider", "my-model" + ) + assert result is None + + def test_custom_providers_model_not_listed(self): + """Entry exists but model is not listed → falls through.""" + from agent.image_routing import _supports_vision_override + cfg = { + "custom_providers": [ + { + "name": "my-provider", + "models": { + "other-model": { + "supports_vision": True, + } + } + } + ] + } + result = _supports_vision_override( + cfg, "my-provider", "unlisted-model" + ) + assert result is None + + def test_custom_providers_ignores_non_dict_entries(self): + """Non-dict entries in custom_providers list are skipped.""" + from agent.image_routing import _supports_vision_override + cfg = { + "custom_providers": [ + "not-a-dict", + 123, + None, + { + "name": "my-provider", + "models": { + "my-model": { + "supports_vision": True, + } + } + } + ] + } + result = _supports_vision_override( + cfg, "my-provider", "my-model" + ) + assert result is True + + def test_custom_providers_empty_list(self): + """Empty custom_providers list → no override.""" + from agent.image_routing import _supports_vision_override + cfg = {"custom_providers": []} + result = _supports_vision_override(cfg, "any", "any") + assert result is None + + def test_custom_providers_no_models_key(self): + """Entry without models key → skipped gracefully.""" + from agent.image_routing import _supports_vision_override + cfg = { + "custom_providers": [ + {"name": "my-provider"} # no models key + ] + } + result = _supports_vision_override( + cfg, "my-provider", "my-model" + ) + assert result is None + + def test_custom_providers_empty_name(self): + """Entry with empty name → skipped.""" + from agent.image_routing import _supports_vision_override + cfg = { + "custom_providers": [ + { + "name": "", + "models": {"m": {"supports_vision": True}}, + } + ] + } + result = _supports_vision_override(cfg, "any", "m") + assert result is None + + +# --------------------------------------------------------------------------- +# decide_image_input_mode integration +# --------------------------------------------------------------------------- + + +class TestDecideImageInputMode: + """End-to-end: custom_providers overrides should produce 'native' mode.""" + + def test_custom_providers_true_returns_native(self): + from agent.image_routing import decide_image_input_mode + cfg = { + "custom_providers": [ + { + "name": "9router-anthropic", + "models": { + "mimoanth/mimo-v2.5": { + "supports_vision": True, + } + } + } + ] + } + result = decide_image_input_mode( + "9router-anthropic", "mimoanth/mimo-v2.5", cfg + ) + assert result == "native" + + def test_custom_providers_false_returns_text(self): + from agent.image_routing import decide_image_input_mode + cfg = { + "custom_providers": [ + { + "name": "my-provider", + "models": { + "my-model": { + "supports_vision": False, + } + } + } + ] + } + result = decide_image_input_mode("my-provider", "my-model", cfg) + assert result == "text" + + def test_top_level_supports_vision_takes_precedence(self): + """Top-level model.supports_vision still wins over custom_providers.""" + from agent.image_routing import decide_image_input_mode + cfg = { + "model": {"supports_vision": False}, + "custom_providers": [ + { + "name": "my-provider", + "models": { + "my-model": { + "supports_vision": True, + } + } + } + ] + } + result = decide_image_input_mode("my-provider", "my-model", cfg) + assert result == "text" + + def test_providers_dict_takes_precedence(self): + """providers..models takes precedence over custom_providers.""" + from agent.image_routing import decide_image_input_mode + cfg = { + "providers": { + "my-provider": { + "models": { + "my-model": {"supports_vision": False} + } + } + }, + "custom_providers": [ + { + "name": "my-provider", + "models": { + "my-model": {"supports_vision": True} + } + } + ] + } + result = decide_image_input_mode("my-provider", "my-model", cfg) + assert result == "text"