Skip to content

Commit ab271eb

Browse files
Mibayyteknium1
authored andcommitted
fix(vision): simplify vision auto-detection to openrouter → nous → active provider
Simplify the vision auto-detection chain from 5 backends (openrouter, nous, codex, anthropic, custom) down to 3: 1. OpenRouter (known vision-capable default model) 2. Nous Portal (known vision-capable default model) 3. Active provider + model (whatever the user is running) 4. Stop This is simpler and more predictable. The active provider step uses resolve_provider_client() which handles all provider types including named custom providers (from #5978). Removed the complex preferred-provider promotion logic and API-level fallback — the chain is short enough that it doesn't need them. Based on PR #5376 by Mibay. Closes #5366.
1 parent e1befe5 commit ab271eb

File tree

2 files changed

+69
-83
lines changed

2 files changed

+69
-83
lines changed

agent/auxiliary_client.py

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,9 +1425,6 @@ def get_async_text_auxiliary_client(task: str = ""):
14251425
_VISION_AUTO_PROVIDER_ORDER = (
14261426
"openrouter",
14271427
"nous",
1428-
"openai-codex",
1429-
"anthropic",
1430-
"custom",
14311428
)
14321429

14331430

@@ -1473,17 +1470,20 @@ def _preferred_main_vision_provider() -> Optional[str]:
14731470
def get_available_vision_backends() -> List[str]:
14741471
"""Return the currently available vision backends in auto-selection order.
14751472
1476-
This is the single source of truth for setup, tool gating, and runtime
1477-
auto-routing of vision tasks. The selected main provider is preferred when
1478-
it is also a known-good vision backend; otherwise Hermes falls back through
1479-
the standard conservative order.
1473+
Order: OpenRouter → Nous → active provider. This is the single source
1474+
of truth for setup, tool gating, and runtime auto-routing of vision tasks.
14801475
"""
1481-
ordered = list(_VISION_AUTO_PROVIDER_ORDER)
1482-
preferred = _preferred_main_vision_provider()
1483-
if preferred in ordered:
1484-
ordered.remove(preferred)
1485-
ordered.insert(0, preferred)
1486-
return [provider for provider in ordered if _strict_vision_backend_available(provider)]
1476+
available = [p for p in _VISION_AUTO_PROVIDER_ORDER
1477+
if _strict_vision_backend_available(p)]
1478+
# Also check the user's active provider (may be DeepSeek, Alibaba, named
1479+
# custom, etc.) — resolve_provider_client handles all provider types.
1480+
main_provider = _read_main_provider()
1481+
if (main_provider and main_provider not in ("auto", "")
1482+
and main_provider not in available):
1483+
client, _ = resolve_provider_client(main_provider, _read_main_model())
1484+
if client is not None:
1485+
available.append(main_provider)
1486+
return available
14871487

14881488

14891489
def resolve_vision_provider_client(
@@ -1528,16 +1528,30 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[
15281528
return "custom", client, final_model
15291529

15301530
if requested == "auto":
1531-
ordered = list(_VISION_AUTO_PROVIDER_ORDER)
1532-
preferred = _preferred_main_vision_provider()
1533-
if preferred in ordered:
1534-
ordered.remove(preferred)
1535-
ordered.insert(0, preferred)
1536-
1537-
for candidate in ordered:
1531+
# Vision auto-detection order:
1532+
# 1. OpenRouter (known vision-capable default model)
1533+
# 2. Nous Portal (known vision-capable default model)
1534+
# 3. Active provider + model (user's main chat config)
1535+
# 4. Stop
1536+
for candidate in _VISION_AUTO_PROVIDER_ORDER:
15381537
sync_client, default_model = _resolve_strict_vision_backend(candidate)
15391538
if sync_client is not None:
15401539
return _finalize(candidate, sync_client, default_model)
1540+
1541+
# Fall back to the user's active provider + model.
1542+
main_provider = _read_main_provider()
1543+
main_model = _read_main_model()
1544+
if main_provider and main_provider not in ("auto", ""):
1545+
sync_client, resolved_model = resolve_provider_client(
1546+
main_provider, main_model)
1547+
if sync_client is not None:
1548+
logger.info(
1549+
"Vision auto-detect: using active provider %s (%s)",
1550+
main_provider, resolved_model or main_model,
1551+
)
1552+
return _finalize(
1553+
main_provider, sync_client, resolved_model or main_model)
1554+
15411555
logger.debug("Auxiliary vision client: none available")
15421556
return None, None, None
15431557

tests/agent/test_auxiliary_client.py

Lines changed: 35 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -641,12 +641,15 @@ def test_vision_returns_none_without_any_credentials(self):
641641
assert client is None
642642
assert model is None
643643

644-
def test_vision_auto_includes_anthropic_when_configured(self, monkeypatch):
645-
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key")
644+
def test_vision_auto_includes_active_provider_when_configured(self, monkeypatch):
645+
"""Active provider appears in available backends when credentials exist."""
646+
monkeypatch.setenv("ANTHROPIC_API_KEY", "***")
646647
with (
647648
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
649+
patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"),
650+
patch("agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4"),
648651
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
649-
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"),
652+
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="***"),
650653
):
651654
backends = get_available_vision_backends()
652655

@@ -719,88 +722,50 @@ def test_resolve_provider_client_copilot_uses_runtime_credentials(self, monkeypa
719722
assert call_kwargs["base_url"] == "https://api.githubcopilot.com"
720723
assert call_kwargs["default_headers"]["Editor-Version"]
721724

722-
def test_vision_auto_uses_anthropic_when_no_higher_priority_backend(self, monkeypatch):
723-
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key")
725+
def test_vision_auto_uses_active_provider_as_fallback(self, monkeypatch):
726+
"""When no OpenRouter/Nous available, vision auto falls back to active provider."""
727+
monkeypatch.setenv("ANTHROPIC_API_KEY", "***")
724728
with (
725729
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
730+
patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"),
731+
patch("agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4"),
726732
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
727-
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"),
733+
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="***"),
728734
):
729735
client, model = get_vision_auxiliary_client()
730736

731737
assert client is not None
732738
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
733-
assert model == "claude-haiku-4-5-20251001"
734739

735-
def test_selected_anthropic_provider_is_preferred_for_vision_auto(self, monkeypatch):
740+
def test_vision_auto_prefers_openrouter_over_active_provider(self, monkeypatch):
741+
"""OpenRouter is tried before the active provider in vision auto."""
736742
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
737-
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key")
738-
739-
def fake_load_config():
740-
return {"model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}}
743+
monkeypatch.setenv("ANTHROPIC_API_KEY", "***")
741744

742745
with (
743746
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
744-
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
745-
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"),
747+
patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"),
748+
patch("agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4"),
746749
patch("agent.auxiliary_client.OpenAI") as mock_openai,
747-
patch("hermes_cli.config.load_config", fake_load_config),
748-
):
749-
client, model = get_vision_auxiliary_client()
750-
751-
assert client is not None
752-
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
753-
assert model == "claude-haiku-4-5-20251001"
754-
755-
def test_selected_codex_provider_short_circuits_vision_auto(self, monkeypatch):
756-
def fake_load_config():
757-
return {"model": {"provider": "openai-codex", "default": "gpt-5.2-codex"}}
758-
759-
codex_client = MagicMock()
760-
with (
761-
patch("hermes_cli.config.load_config", fake_load_config),
762-
patch("agent.auxiliary_client._try_codex", return_value=(codex_client, "gpt-5.2-codex")) as mock_codex,
763-
patch("agent.auxiliary_client._try_openrouter") as mock_openrouter,
764-
patch("agent.auxiliary_client._try_nous") as mock_nous,
765-
patch("agent.auxiliary_client._try_anthropic") as mock_anthropic,
766-
patch("agent.auxiliary_client._try_custom_endpoint") as mock_custom,
767750
):
768751
provider, client, model = resolve_vision_provider_client()
769752

770-
assert provider == "openai-codex"
771-
assert client is codex_client
772-
assert model == "gpt-5.2-codex"
773-
mock_codex.assert_called_once()
774-
mock_openrouter.assert_not_called()
775-
mock_nous.assert_not_called()
776-
mock_anthropic.assert_not_called()
777-
mock_custom.assert_not_called()
778-
779-
def test_vision_auto_includes_codex(self, codex_auth_dir):
780-
"""Codex supports vision (gpt-5.3-codex), so auto mode should use it."""
781-
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
782-
patch("agent.auxiliary_client.OpenAI"):
783-
client, model = get_vision_auxiliary_client()
784-
from agent.auxiliary_client import CodexAuxiliaryClient
785-
assert isinstance(client, CodexAuxiliaryClient)
786-
assert model == "gpt-5.2-codex"
787-
788-
def test_vision_auto_falls_back_to_custom_endpoint(self, monkeypatch):
789-
"""Custom endpoint is used as fallback in vision auto mode.
753+
# OpenRouter should win over anthropic active provider
754+
assert provider == "openrouter"
790755

791-
Many local models (Qwen-VL, LLaVA, etc.) support vision.
792-
When no OpenRouter/Nous/Codex is available, try the custom endpoint.
793-
"""
756+
def test_vision_auto_uses_named_custom_as_active_provider(self, monkeypatch):
757+
"""Named custom provider works as active provider fallback in vision auto."""
794758
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
795759
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
796760
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
797761
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)), \
798-
patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \
799-
patch("agent.auxiliary_client._resolve_custom_runtime",
800-
return_value=("http://localhost:1234/v1", "local-key")), \
801-
patch("agent.auxiliary_client.OpenAI") as mock_openai:
802-
client, model = get_vision_auxiliary_client()
803-
assert client is not None # Custom endpoint picked up as fallback
762+
patch("agent.auxiliary_client._read_main_provider", return_value="custom:local"), \
763+
patch("agent.auxiliary_client._read_main_model", return_value="my-local-model"), \
764+
patch("agent.auxiliary_client.resolve_provider_client",
765+
return_value=(MagicMock(), "my-local-model")) as mock_resolve:
766+
provider, client, model = resolve_vision_provider_client()
767+
assert client is not None
768+
assert provider == "custom:local"
804769

805770
def test_vision_direct_endpoint_override(self, monkeypatch):
806771
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
@@ -888,7 +853,14 @@ def test_vision_forced_main_returns_none_without_creds(self, monkeypatch):
888853
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main")
889854
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
890855
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
856+
# Clear client cache to avoid stale entries from previous tests
857+
from agent.auxiliary_client import _client_cache
858+
_client_cache.clear()
891859
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
860+
patch("agent.auxiliary_client._read_main_provider", return_value=""), \
861+
patch("agent.auxiliary_client._read_main_model", return_value=""), \
862+
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)), \
863+
patch("agent.auxiliary_client._resolve_custom_runtime", return_value=(None, None)), \
892864
patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \
893865
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
894866
client, model = get_vision_auxiliary_client()

0 commit comments

Comments
 (0)