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.
This commit is contained in:
@@ -1425,9 +1425,6 @@ def get_async_text_auxiliary_client(task: str = ""):
|
|||||||
_VISION_AUTO_PROVIDER_ORDER = (
|
_VISION_AUTO_PROVIDER_ORDER = (
|
||||||
"openrouter",
|
"openrouter",
|
||||||
"nous",
|
"nous",
|
||||||
"openai-codex",
|
|
||||||
"anthropic",
|
|
||||||
"custom",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1473,17 +1470,20 @@ def _preferred_main_vision_provider() -> Optional[str]:
|
|||||||
def get_available_vision_backends() -> List[str]:
|
def get_available_vision_backends() -> List[str]:
|
||||||
"""Return the currently available vision backends in auto-selection order.
|
"""Return the currently available vision backends in auto-selection order.
|
||||||
|
|
||||||
This is the single source of truth for setup, tool gating, and runtime
|
Order: OpenRouter → Nous → active provider. This is the single source
|
||||||
auto-routing of vision tasks. The selected main provider is preferred when
|
of truth for setup, tool gating, and runtime auto-routing of vision tasks.
|
||||||
it is also a known-good vision backend; otherwise Hermes falls back through
|
|
||||||
the standard conservative order.
|
|
||||||
"""
|
"""
|
||||||
ordered = list(_VISION_AUTO_PROVIDER_ORDER)
|
available = [p for p in _VISION_AUTO_PROVIDER_ORDER
|
||||||
preferred = _preferred_main_vision_provider()
|
if _strict_vision_backend_available(p)]
|
||||||
if preferred in ordered:
|
# Also check the user's active provider (may be DeepSeek, Alibaba, named
|
||||||
ordered.remove(preferred)
|
# custom, etc.) — resolve_provider_client handles all provider types.
|
||||||
ordered.insert(0, preferred)
|
main_provider = _read_main_provider()
|
||||||
return [provider for provider in ordered if _strict_vision_backend_available(provider)]
|
if (main_provider and main_provider not in ("auto", "")
|
||||||
|
and main_provider not in available):
|
||||||
|
client, _ = resolve_provider_client(main_provider, _read_main_model())
|
||||||
|
if client is not None:
|
||||||
|
available.append(main_provider)
|
||||||
|
return available
|
||||||
|
|
||||||
|
|
||||||
def resolve_vision_provider_client(
|
def resolve_vision_provider_client(
|
||||||
@@ -1528,16 +1528,30 @@ def resolve_vision_provider_client(
|
|||||||
return "custom", client, final_model
|
return "custom", client, final_model
|
||||||
|
|
||||||
if requested == "auto":
|
if requested == "auto":
|
||||||
ordered = list(_VISION_AUTO_PROVIDER_ORDER)
|
# Vision auto-detection order:
|
||||||
preferred = _preferred_main_vision_provider()
|
# 1. OpenRouter (known vision-capable default model)
|
||||||
if preferred in ordered:
|
# 2. Nous Portal (known vision-capable default model)
|
||||||
ordered.remove(preferred)
|
# 3. Active provider + model (user's main chat config)
|
||||||
ordered.insert(0, preferred)
|
# 4. Stop
|
||||||
|
for candidate in _VISION_AUTO_PROVIDER_ORDER:
|
||||||
for candidate in ordered:
|
|
||||||
sync_client, default_model = _resolve_strict_vision_backend(candidate)
|
sync_client, default_model = _resolve_strict_vision_backend(candidate)
|
||||||
if sync_client is not None:
|
if sync_client is not None:
|
||||||
return _finalize(candidate, sync_client, default_model)
|
return _finalize(candidate, sync_client, default_model)
|
||||||
|
|
||||||
|
# Fall back to the user's active provider + model.
|
||||||
|
main_provider = _read_main_provider()
|
||||||
|
main_model = _read_main_model()
|
||||||
|
if main_provider and main_provider not in ("auto", ""):
|
||||||
|
sync_client, resolved_model = resolve_provider_client(
|
||||||
|
main_provider, main_model)
|
||||||
|
if sync_client is not None:
|
||||||
|
logger.info(
|
||||||
|
"Vision auto-detect: using active provider %s (%s)",
|
||||||
|
main_provider, resolved_model or main_model,
|
||||||
|
)
|
||||||
|
return _finalize(
|
||||||
|
main_provider, sync_client, resolved_model or main_model)
|
||||||
|
|
||||||
logger.debug("Auxiliary vision client: none available")
|
logger.debug("Auxiliary vision client: none available")
|
||||||
return None, None, None
|
return None, None, None
|
||||||
|
|
||||||
|
|||||||
@@ -641,12 +641,15 @@ class TestVisionClientFallback:
|
|||||||
assert client is None
|
assert client is None
|
||||||
assert model is None
|
assert model is None
|
||||||
|
|
||||||
def test_vision_auto_includes_anthropic_when_configured(self, monkeypatch):
|
def test_vision_auto_includes_active_provider_when_configured(self, monkeypatch):
|
||||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key")
|
"""Active provider appears in available backends when credentials exist."""
|
||||||
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "***")
|
||||||
with (
|
with (
|
||||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
||||||
|
patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"),
|
||||||
|
patch("agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4"),
|
||||||
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
|
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
|
||||||
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"),
|
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="***"),
|
||||||
):
|
):
|
||||||
backends = get_available_vision_backends()
|
backends = get_available_vision_backends()
|
||||||
|
|
||||||
@@ -719,88 +722,50 @@ class TestAuxiliaryPoolAwareness:
|
|||||||
assert call_kwargs["base_url"] == "https://api.githubcopilot.com"
|
assert call_kwargs["base_url"] == "https://api.githubcopilot.com"
|
||||||
assert call_kwargs["default_headers"]["Editor-Version"]
|
assert call_kwargs["default_headers"]["Editor-Version"]
|
||||||
|
|
||||||
def test_vision_auto_uses_anthropic_when_no_higher_priority_backend(self, monkeypatch):
|
def test_vision_auto_uses_active_provider_as_fallback(self, monkeypatch):
|
||||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key")
|
"""When no OpenRouter/Nous available, vision auto falls back to active provider."""
|
||||||
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "***")
|
||||||
with (
|
with (
|
||||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
||||||
|
patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"),
|
||||||
|
patch("agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4"),
|
||||||
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
|
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
|
||||||
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"),
|
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="***"),
|
||||||
):
|
):
|
||||||
client, model = get_vision_auxiliary_client()
|
client, model = get_vision_auxiliary_client()
|
||||||
|
|
||||||
assert client is not None
|
assert client is not None
|
||||||
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
|
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
|
||||||
assert model == "claude-haiku-4-5-20251001"
|
|
||||||
|
|
||||||
def test_selected_anthropic_provider_is_preferred_for_vision_auto(self, monkeypatch):
|
def test_vision_auto_prefers_openrouter_over_active_provider(self, monkeypatch):
|
||||||
|
"""OpenRouter is tried before the active provider in vision auto."""
|
||||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key")
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "***")
|
||||||
|
|
||||||
def fake_load_config():
|
|
||||||
return {"model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}}
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
||||||
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
|
patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"),
|
||||||
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"),
|
patch("agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4"),
|
||||||
patch("agent.auxiliary_client.OpenAI") as mock_openai,
|
patch("agent.auxiliary_client.OpenAI") as mock_openai,
|
||||||
patch("hermes_cli.config.load_config", fake_load_config),
|
|
||||||
):
|
|
||||||
client, model = get_vision_auxiliary_client()
|
|
||||||
|
|
||||||
assert client is not None
|
|
||||||
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
|
|
||||||
assert model == "claude-haiku-4-5-20251001"
|
|
||||||
|
|
||||||
def test_selected_codex_provider_short_circuits_vision_auto(self, monkeypatch):
|
|
||||||
def fake_load_config():
|
|
||||||
return {"model": {"provider": "openai-codex", "default": "gpt-5.2-codex"}}
|
|
||||||
|
|
||||||
codex_client = MagicMock()
|
|
||||||
with (
|
|
||||||
patch("hermes_cli.config.load_config", fake_load_config),
|
|
||||||
patch("agent.auxiliary_client._try_codex", return_value=(codex_client, "gpt-5.2-codex")) as mock_codex,
|
|
||||||
patch("agent.auxiliary_client._try_openrouter") as mock_openrouter,
|
|
||||||
patch("agent.auxiliary_client._try_nous") as mock_nous,
|
|
||||||
patch("agent.auxiliary_client._try_anthropic") as mock_anthropic,
|
|
||||||
patch("agent.auxiliary_client._try_custom_endpoint") as mock_custom,
|
|
||||||
):
|
):
|
||||||
provider, client, model = resolve_vision_provider_client()
|
provider, client, model = resolve_vision_provider_client()
|
||||||
|
|
||||||
assert provider == "openai-codex"
|
# OpenRouter should win over anthropic active provider
|
||||||
assert client is codex_client
|
assert provider == "openrouter"
|
||||||
assert model == "gpt-5.2-codex"
|
|
||||||
mock_codex.assert_called_once()
|
|
||||||
mock_openrouter.assert_not_called()
|
|
||||||
mock_nous.assert_not_called()
|
|
||||||
mock_anthropic.assert_not_called()
|
|
||||||
mock_custom.assert_not_called()
|
|
||||||
|
|
||||||
def test_vision_auto_includes_codex(self, codex_auth_dir):
|
def test_vision_auto_uses_named_custom_as_active_provider(self, monkeypatch):
|
||||||
"""Codex supports vision (gpt-5.3-codex), so auto mode should use it."""
|
"""Named custom provider works as active provider fallback in vision auto."""
|
||||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
|
||||||
patch("agent.auxiliary_client.OpenAI"):
|
|
||||||
client, model = get_vision_auxiliary_client()
|
|
||||||
from agent.auxiliary_client import CodexAuxiliaryClient
|
|
||||||
assert isinstance(client, CodexAuxiliaryClient)
|
|
||||||
assert model == "gpt-5.2-codex"
|
|
||||||
|
|
||||||
def test_vision_auto_falls_back_to_custom_endpoint(self, monkeypatch):
|
|
||||||
"""Custom endpoint is used as fallback in vision auto mode.
|
|
||||||
|
|
||||||
Many local models (Qwen-VL, LLaVA, etc.) support vision.
|
|
||||||
When no OpenRouter/Nous/Codex is available, try the custom endpoint.
|
|
||||||
"""
|
|
||||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||||
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)), \
|
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)), \
|
||||||
patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \
|
patch("agent.auxiliary_client._read_main_provider", return_value="custom:local"), \
|
||||||
patch("agent.auxiliary_client._resolve_custom_runtime",
|
patch("agent.auxiliary_client._read_main_model", return_value="my-local-model"), \
|
||||||
return_value=("http://localhost:1234/v1", "local-key")), \
|
patch("agent.auxiliary_client.resolve_provider_client",
|
||||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
return_value=(MagicMock(), "my-local-model")) as mock_resolve:
|
||||||
client, model = get_vision_auxiliary_client()
|
provider, client, model = resolve_vision_provider_client()
|
||||||
assert client is not None # Custom endpoint picked up as fallback
|
assert client is not None
|
||||||
|
assert provider == "custom:local"
|
||||||
|
|
||||||
def test_vision_direct_endpoint_override(self, monkeypatch):
|
def test_vision_direct_endpoint_override(self, monkeypatch):
|
||||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||||
@@ -888,7 +853,14 @@ class TestAuxiliaryPoolAwareness:
|
|||||||
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main")
|
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main")
|
||||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||||
|
# Clear client cache to avoid stale entries from previous tests
|
||||||
|
from agent.auxiliary_client import _client_cache
|
||||||
|
_client_cache.clear()
|
||||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||||
|
patch("agent.auxiliary_client._read_main_provider", return_value=""), \
|
||||||
|
patch("agent.auxiliary_client._read_main_model", return_value=""), \
|
||||||
|
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)), \
|
||||||
|
patch("agent.auxiliary_client._resolve_custom_runtime", return_value=(None, None)), \
|
||||||
patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \
|
patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \
|
||||||
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
|
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
|
||||||
client, model = get_vision_auxiliary_client()
|
client, model = get_vision_auxiliary_client()
|
||||||
|
|||||||
Reference in New Issue
Block a user