fix: multi-provider model routing via @provider: hint (#138) (#146)

The previous fix (#142) prefixed non-default provider models with
'provider/model' which then hit the cross-provider guard and routed
to OpenRouter — worse than before for users without an OpenRouter key.

New approach: non-default provider models use '@provider:model' format
(e.g. @minimax:MiniMax-M2.7). resolve_model_provider() parses this
hint and returns (bare_model, provider, None). streaming.py and
routes.py then pass the resolved provider to
resolve_runtime_provider(requested=provider) which gets the correct
per-provider API key and base_url from hermes-agent.

This uses the agent's own credential resolution instead of reinventing
routing logic in the webui.

Fixes #138

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Esquenazi
2026-04-06 14:10:26 -07:00
committed by GitHub
parent 3bba645364
commit 442b0d872a
4 changed files with 67 additions and 61 deletions

View File

@@ -367,14 +367,16 @@ _PROVIDER_MODELS = {
def resolve_model_provider(model_id: str) -> tuple:
"""Resolve bare model name, provider, and base_url for AIAgent.
"""Resolve model name, provider, and base_url for AIAgent.
Model IDs from the dropdown may include a provider prefix
(e.g. 'anthropic/claude-sonnet-4.6'). Direct-API providers expect
bare model names, while OpenRouter expects the full provider/model path.
Model IDs from the dropdown can be in several formats:
- 'claude-sonnet-4.6' (bare name, uses config default provider)
- 'anthropic/claude-sonnet-4.6' (OpenRouter format, provider/model)
- '@minimax:MiniMax-M2.7' (explicit provider hint from dropdown)
Also reads base_url from config.yaml so providers with custom endpoints
(e.g. MiniMax, Z.AI) are routed correctly.
The @provider:model format is used for models from non-default provider
groups in the dropdown, so we can route them through the correct provider
via resolve_runtime_provider(requested=provider) instead of the default.
Returns (model, provider, base_url) where provider and base_url may be None.
"""
@@ -389,6 +391,13 @@ def resolve_model_provider(model_id: str) -> tuple:
if not model_id:
return model_id, config_provider, config_base_url
# @provider:model format — explicit provider hint from the dropdown.
# Route through that provider directly (resolve_runtime_provider will
# resolve credentials in streaming.py).
if model_id.startswith('@') and ':' in model_id:
provider_hint, bare_model = model_id[1:].split(':', 1)
return bare_model, provider_hint, None
if '/' in model_id:
prefix, bare = model_id.split('/', 1)
# OpenRouter always needs the full provider/model path (e.g. openrouter/free,
@@ -402,7 +411,6 @@ def resolve_model_provider(model_id: str) -> tuple:
# If prefix does NOT match config provider, the user picked a cross-provider model
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
# In this case always route through openrouter with the full provider/model string.
# Never strip the prefix and try a direct-API call to a provider whose key may not exist.
if prefix in _PROVIDER_MODELS and prefix != config_provider:
return model_id, 'openrouter', None
@@ -585,23 +593,23 @@ def get_available_models() -> dict:
'models': [{'id': m['id'], 'label': m['label']} for m in _FALLBACK_MODELS],
})
elif pid in _PROVIDER_MODELS:
# For non-default providers, prefix model IDs with provider name
# so resolve_model_provider() can route them correctly (e.g.
# \"minimax/MiniMax-M2.7\" instead of bare \"MiniMax-M2.7\").
# For non-default providers, prefix model IDs with @provider:model
# so resolve_model_provider() routes through that specific provider
# via resolve_runtime_provider(requested=provider).
# The default provider's models keep bare names for direct API routing.
# Guard: only prefix when we have a confirmed active_provider, and
# normalise case before comparing (config.yaml may use 'Anthropic').
raw_models = _PROVIDER_MODELS[pid]
_active = (active_provider or '').lower()
if _active and pid != _active:
# Shallow copy — don't mutate the shared _PROVIDER_MODELS list.
# Bare IDs get prefixed; already-prefixed IDs pass through as-is.
models = []
for m in raw_models:
mid = m['id']
models.append({'id': mid if '/' in mid else f'{pid}/{mid}', 'label': m['label']})
# Don't double-prefix; use @provider: hint for bare names
if mid.startswith('@') or '/' in mid:
models.append({'id': mid, 'label': m['label']})
else:
models = list(raw_models) # shallow copy to protect against insert() mutations
models.append({'id': f'@{pid}:{mid}', 'label': m['label']})
else:
models = list(raw_models)
groups.append({
'provider': provider_name,
'models': models,

View File

@@ -1061,7 +1061,7 @@ def _handle_chat_sync(handler, body):
_api_key = None
try:
from hermes_cli.runtime_provider import resolve_runtime_provider
_rt = resolve_runtime_provider()
_rt = resolve_runtime_provider(requested=_provider)
_api_key = _rt.get("api_key")
# Also use runtime provider/base_url if the webui config didn't resolve them
if not _provider:

View File

@@ -143,11 +143,12 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
raise ImportError("AIAgent not available -- check that hermes-agent is on sys.path")
resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(model)
# Resolve API key via Hermes runtime provider (matches gateway behaviour)
# Resolve API key via Hermes runtime provider (matches gateway behaviour).
# Pass the resolved provider so non-default providers get their own credentials.
resolved_api_key = None
try:
from hermes_cli.runtime_provider import resolve_runtime_provider
_rt = resolve_runtime_provider()
_rt = resolve_runtime_provider(requested=resolved_provider)
resolved_api_key = _rt.get("api_key")
if not resolved_provider:
resolved_provider = _rt.get("provider")

View File

@@ -100,10 +100,38 @@ def test_empty_model_returns_config_defaults():
assert provider == 'anthropic'
# ── Non-default provider prefix routing (Issue #138) ────────────────────
# ── @provider:model hint routing (Issue #138 v2) ────────────────────────
def test_prefixed_non_default_provider_routes_through_openrouter():
"""minimax/MiniMax-M2.7 with anthropic as default should route via openrouter."""
def test_provider_hint_routes_to_specific_provider():
"""@minimax:MiniMax-M2.7 routes to minimax provider directly."""
model, provider, base_url = _resolve_with_config(
'@minimax:MiniMax-M2.7', provider='anthropic',
)
assert model == 'MiniMax-M2.7'
assert provider == 'minimax'
assert base_url is None # resolve_runtime_provider will fill this
def test_provider_hint_zai():
"""@zai:GLM-5 routes to zai provider directly."""
model, provider, base_url = _resolve_with_config(
'@zai:GLM-5', provider='openai',
)
assert model == 'GLM-5'
assert provider == 'zai'
def test_provider_hint_deepseek():
"""@deepseek:deepseek-chat routes to deepseek provider."""
model, provider, base_url = _resolve_with_config(
'@deepseek:deepseek-chat', provider='anthropic',
)
assert model == 'deepseek-chat'
assert provider == 'deepseek'
def test_slash_prefix_non_default_still_routes_openrouter():
"""minimax/MiniMax-M2.7 (old format) still routes through openrouter."""
model, provider, base_url = _resolve_with_config(
'minimax/MiniMax-M2.7', provider='anthropic',
)
@@ -111,19 +139,10 @@ def test_prefixed_non_default_provider_routes_through_openrouter():
assert provider == 'openrouter'
def test_prefixed_non_default_provider_zai():
"""zai/GLM-5 with openai as default should route via openrouter."""
model, provider, base_url = _resolve_with_config(
'zai/GLM-5', provider='openai',
)
assert model == 'zai/GLM-5'
assert provider == 'openrouter'
# ── get_available_models() prefix behaviour ───────────────────────────────
# ── get_available_models() @provider: hint behaviour ──────────────────────
def _available_models_with_provider(provider):
"""Helper: temporarily set active_provider in auth store simulation via config.cfg."""
"""Helper: temporarily set active_provider in config."""
old_cfg = dict(config.cfg)
config.cfg['model'] = {'provider': provider}
try:
@@ -133,48 +152,26 @@ def _available_models_with_provider(provider):
config.cfg.update(old_cfg)
def test_non_default_provider_models_are_prefixed():
"""With anthropic as default, minimax model IDs should be prefixed 'minimax/...'."""
def test_non_default_provider_models_use_hint_prefix():
"""With anthropic as default, minimax model IDs should use @minimax: prefix."""
result = _available_models_with_provider('anthropic')
groups = {g['provider']: g['models'] for g in result['groups']}
if 'MiniMax' in groups:
for m in groups['MiniMax']:
assert m['id'].startswith('minimax/'), (
f"Expected minimax/ prefix, got: {m['id']!r}"
assert m['id'].startswith('@minimax:'), (
f"Expected @minimax: prefix, got: {m['id']!r}"
)
def test_default_provider_models_not_prefixed():
"""The active provider's _PROVIDER_MODELS entries remain bare (no prefix added)."""
"""The active provider's models remain bare (no @prefix added)."""
import api.config as _cfg
# The bare IDs as stored in _PROVIDER_MODELS (e.g. 'claude-sonnet-4.6')
raw_anthropic_ids = {m['id'] for m in _cfg._PROVIDER_MODELS.get('anthropic', [])}
result = _available_models_with_provider('anthropic')
groups = {g['provider']: g['models'] for g in result['groups']}
if 'Anthropic' in groups:
returned_ids = {m['id'] for m in groups['Anthropic']}
# Every bare _PROVIDER_MODELS ID must still appear bare (not turned into 'anthropic/...')
for bare_id in raw_anthropic_ids:
assert bare_id in returned_ids, (
f"_PROVIDER_MODELS entry '{bare_id}' is missing from the Anthropic group "
f"(returned: {sorted(returned_ids)})"
f"_PROVIDER_MODELS entry '{bare_id}' is missing from the Anthropic group"
)
def test_no_active_provider_models_not_prefixed():
"""With no confirmed active_provider, models should not be prefixed."""
old_cfg = dict(config.cfg)
config.cfg['model'] = {} # no provider set
try:
result = config.get_available_models()
for g in result['groups']:
for m in g['models']:
# No model should have a double-prefix like 'minimax/minimax/...'
parts = m['id'].split('/')
if len(parts) >= 2:
assert parts[0] != parts[1], (
f"Double-prefix detected: {m['id']!r}"
)
finally:
config.cfg.clear()
config.cfg.update(old_cfg)