HERMES-GIELIE/tests/test_model_resolver.py

922 lines
38 KiB
Python
Raw Normal View History

"""
Tests for resolve_model_provider() model routing logic.
Verifies that model IDs are correctly resolved to (model, provider, base_url)
tuples for different provider configurations.
"""
import pytest
import api.config as config
def _resolve_with_config(model_id, provider=None, base_url=None, default=None, custom_providers=None):
"""Helper: temporarily set config.cfg model/custom provider sections, call resolve, restore."""
old_cfg = dict(config.cfg)
model_cfg = {}
if provider:
model_cfg['provider'] = provider
if base_url:
model_cfg['base_url'] = base_url
if default:
model_cfg['default'] = default
config.cfg['model'] = model_cfg if model_cfg else {}
if custom_providers is not None:
config.cfg['custom_providers'] = custom_providers
try:
return config.resolve_model_provider(model_id)
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
# ── OpenRouter prefix handling ────────────────────────────────────────────
def test_openrouter_free_keeps_full_path():
"""openrouter/free must NOT be stripped to 'free' when provider is openrouter."""
model, provider, base_url = _resolve_with_config(
'openrouter/free', provider='openrouter',
base_url='https://openrouter.ai/api/v1',
)
assert model == 'openrouter/free', f"Expected 'openrouter/free', got '{model}'"
assert provider == 'openrouter'
def test_openrouter_model_with_provider_prefix():
"""anthropic/claude-sonnet-4.6 via openrouter keeps full path."""
model, provider, base_url = _resolve_with_config(
'anthropic/claude-sonnet-4.6', provider='openrouter',
base_url='https://openrouter.ai/api/v1',
)
assert model == 'anthropic/claude-sonnet-4.6'
assert provider == 'openrouter'
# ── Direct provider prefix stripping ─────────────────────────────────────
def test_anthropic_prefix_stripped_for_direct_api():
"""anthropic/claude-sonnet-4.6 strips prefix when provider is anthropic."""
model, provider, base_url = _resolve_with_config(
'anthropic/claude-sonnet-4.6', provider='anthropic',
)
assert model == 'claude-sonnet-4.6'
assert provider == 'anthropic'
def test_openai_prefix_stripped_for_direct_api():
"""openai/gpt-5.4-mini strips prefix when provider is openai."""
model, provider, base_url = _resolve_with_config(
'openai/gpt-5.4-mini', provider='openai',
)
assert model == 'gpt-5.4-mini'
assert provider == 'openai'
# ── Cross-provider routing ───────────────────────────────────────────────
def test_cross_provider_routes_through_openrouter():
"""Picking openai model when config is anthropic routes via openrouter."""
model, provider, base_url = _resolve_with_config(
'openai/gpt-5.4-mini', provider='anthropic',
)
assert model == 'openai/gpt-5.4-mini'
assert provider == 'openrouter'
assert base_url is None # openrouter uses its own endpoint
# ── Bare model names ─────────────────────────────────────────────────────
def test_bare_model_uses_config_provider():
"""A model name without / uses the config provider and base_url."""
model, provider, base_url = _resolve_with_config(
'gemma-4-26B', provider='custom',
base_url='http://192.168.1.160:4000',
)
assert model == 'gemma-4-26B'
assert provider == 'custom'
assert base_url == 'http://192.168.1.160:4000'
def test_empty_model_returns_config_defaults():
"""Empty model string returns config provider and base_url."""
model, provider, base_url = _resolve_with_config(
'', provider='anthropic',
)
assert model == ''
assert provider == 'anthropic'
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
# ── @provider:model hint routing (Issue #138 v2) ────────────────────────
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
def test_provider_hint_routes_to_specific_provider():
"""@minimax:MiniMax-M2.7 routes to minimax provider directly."""
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
model, provider, base_url = _resolve_with_config(
'@minimax:MiniMax-M2.7', provider='anthropic',
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
)
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'
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
def test_slash_prefix_non_default_still_routes_openrouter():
"""minimax/MiniMax-M2.7 (old format) still routes through openrouter."""
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
model, provider, base_url = _resolve_with_config(
'minimax/MiniMax-M2.7', provider='anthropic',
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
)
assert model == 'minimax/MiniMax-M2.7'
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
assert provider == 'openrouter'
def test_custom_provider_model_with_slash_routes_to_named_custom_provider():
"""Slash-containing custom endpoint model IDs must not be mistaken for OpenRouter models."""
model, provider, base_url = _resolve_with_config(
'google/gemma-4-26b-a4b',
provider='openrouter',
base_url='https://openrouter.ai/api/v1',
custom_providers=[{
'name': 'Local LM Studio',
'base_url': 'http://lmstudio.local:1234/v1',
'model': 'google/gemma-4-26b-a4b',
}],
)
assert model == 'google/gemma-4-26b-a4b'
assert provider == 'custom:local-lm-studio'
assert base_url == 'http://lmstudio.local:1234/v1'
fix(config): preserve intrinsic vendor prefix in model ID for custom proxy (#3872) (#3918) A bare 'custom' provider with a remote base_url is a vendor-routing proxy (LiteLLM, Bedrock gateway, etc.). A slashed model id like bedrock/opus-4-6 is intrinsic — the proxy routes on the full string. The prior heuristic stripped ANY known-provider prefix (bedrock is also a provider name), truncating it to opus-4-6 and causing 403 'model not allowed'. Fix: in the custom-base_url branch, strip the prefix only when (a) the configured provider is a real first-party provider pointed at an OpenAI-compatible proxy (provider=openai + proxy base_url — the #433 path), OR (b) for a bare 'custom' provider, the bare id is genuinely a first-party model of that prefix's catalog (openai/gpt-5.4 -> gpt-5.4, since gpt-5.4 IS an OpenAI model). An intrinsic routing prefix whose bare id is NOT first-party of that namespace is preserved (bedrock/opus-4-6 stays whole). Unknown prefixes (zai-org/...) preserved as before. Both prior behaviors work in tandem with the fix: - #433 sprint40: bare custom + openai/gpt-5.4 -> gpt-5.4 (redundant, strip) - #433 model_resolver: provider=openai + google/gemma -> gemma (proxy strip) - #548: custom + zai-org/GLM-5.1 -> preserved (unknown prefix) - #1625: custom + loopback base_url -> preserved (local server) - #3872: custom + bedrock/opus-4-6 -> preserved (intrinsic vendor prefix) Adds regression tests covering all of the above. Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: haolf000 <haolf000@users.noreply.github.com>
2026-06-10 08:56:01 +00:00
# ── #3872: bare ``custom`` provider is a vendor-routing proxy — preserve the
# full model id (the prefix is intrinsic). #433's redundant-prefix strip is
# scoped to real first-party providers (provider=openai + proxy base_url),
# which is covered by test_custom_endpoint_slash_model_routes_to_custom_not_openrouter.
def test_custom_remote_preserves_intrinsic_vendor_prefix_3872():
"""#3872: bedrock/opus-4-6 on a bare-custom remote proxy keeps its full id.
A bare ``custom`` provider with a remote base_url is a vendor-routing proxy
(LiteLLM, Bedrock gateway). ``bedrock/`` is an intrinsic routing segment the
proxy needs whole; stripping it to ``opus-4-6`` makes the proxy return 403
"model not allowed for your group".
"""
model, provider, base_url = _resolve_with_config(
'bedrock/opus-4-6',
provider='custom',
base_url='https://router.example.com/v1',
)
assert model == 'bedrock/opus-4-6', f"intrinsic prefix must be preserved, got {model!r}"
assert provider == 'custom'
assert base_url == 'https://router.example.com/v1'
def test_custom_remote_strips_redundant_first_party_prefix_433():
"""#433: bare-custom remote proxy still strips a REDUNDANT first-party prefix.
``gpt-5.4`` IS a first-party OpenAI model, so ``openai/`` is a redundant
leftover and the proxy expects the bare id. This is the behaviour pinned by
test_sprint40_ui_polish.py::test_prefixed_model_stripped_for_custom_endpoint;
the #3872 fix must keep it while preserving intrinsic vendor prefixes.
"""
model, provider, base_url = _resolve_with_config(
'openai/gpt-5.4',
provider='custom',
base_url='https://router.example.com/v1',
)
assert model == 'gpt-5.4', f"redundant first-party prefix must be stripped, got {model!r}"
assert provider == 'custom'
def test_custom_remote_preserves_unknown_prefix_548():
"""#548: an unknown vendor prefix (zai-org/GLM-5.1) is always preserved."""
model, provider, base_url = _resolve_with_config(
'zai-org/GLM-5.1',
provider='custom',
base_url='https://api.deepinfra.com/v1/openai',
)
assert model == 'zai-org/GLM-5.1', f"unknown prefix must be preserved, got {model!r}"
assert provider == 'custom'
def test_named_custom_slug_preserves_intrinsic_vendor_prefix_3872():
"""#3872 (named-custom variant): provider=custom:<slug> + remote base_url also
preserves an intrinsic vendor prefix when the typed model isn't in the entry.
A model typed/selected that is not listed in the custom_providers[] entry
falls through to the base_url branch; a bare id NOT first-party of the prefix
(bedrock/opus-4-6) must still be kept whole, same as bare ``custom``.
"""
model, provider, base_url = _resolve_with_config(
'bedrock/opus-4-6',
provider='custom:my-gateway',
base_url='https://router.example.com/v1',
)
assert model == 'bedrock/opus-4-6', f"intrinsic prefix must be preserved for custom:slug, got {model!r}"
def test_first_party_provider_proxy_still_strips_prefix_433():
"""#433/dc2334c5: provider=openai + remote proxy still strips the prefix.
This is the deliberate behaviour the #3872 fix must NOT regress: a real
first-party provider pointed at an OpenAI-compatible proxy expects the bare
id. (Mirrors the public-host branch of
test_custom_endpoint_slash_model_routes_to_custom_not_openrouter.)
"""
model, provider, base_url = _resolve_with_config(
'openai/gpt-5.4',
provider='openai',
base_url='https://litellm.example.com/v1',
)
assert model == 'gpt-5.4', f"redundant first-party prefix must be stripped, got {model!r}"
def test_custom_provider_models_dict_routes_to_named_custom_provider():
"""Models listed only under custom_providers[].models still route to that endpoint."""
model, provider, base_url = _resolve_with_config(
'sensenova-6.7-flash-lite',
provider='xiaomi',
custom_providers=[{
'name': 'LiteLLM Proxy',
'base_url': 'http://127.0.0.1:8080/v1',
'model': 'deepseek-v4-flash',
'models': {
'deepseek-v4-flash': {},
'sensenova-6.7-flash-lite': {},
},
}],
)
assert model == 'sensenova-6.7-flash-lite'
assert provider == 'custom:litellm-proxy'
assert base_url == 'http://127.0.0.1:8080/v1'
# ── Issue #2047: parenthesized local provider names with ports ────────────
def test_custom_provider_name_with_parenthesized_port_uses_safe_slug():
"""Setup-generated names like 'Local (host:port)' must not leak ':' into slugs."""
model, provider, base_url = _resolve_with_config(
'deepseek-v4-flash',
provider='custom',
custom_providers=[{
'name': 'Local (127.0.0.1:15721)',
'base_url': 'http://127.0.0.1:15721/v1',
'model': 'deepseek-v4-flash',
}],
)
assert model == 'deepseek-v4-flash'
assert provider == 'custom:local-127.0.0.1-15721'
assert base_url == 'http://127.0.0.1:15721/v1'
def test_safe_custom_provider_hint_keeps_model_after_port_slug():
"""The safe slug emitted by the picker must parse back without corrupting the model."""
model, provider, base_url = _resolve_with_config(
'@custom:local-127.0.0.1-15721:deepseek-v4-flash',
provider='custom',
)
assert model == 'deepseek-v4-flash'
assert provider == 'custom:local-127.0.0.1-15721'
assert base_url is None
# ── Issue #1922: default model shadowed by overlapping custom_providers[] ──
def test_default_model_not_shadowed_by_overlapping_custom_provider():
r'''Regression test for #1922.
When the active provider is an explicit non-custom provider (e.g. ai-gateway,
openrouter, xiaomi) AND the requested model_id matches the configured default
model, the active provider's base_url must take precedence over an overlapping
custom_providers[] entry. Otherwise the WebUI routes to 'custom:<name>' with
the wrong endpoint, causing 401 errors.
This test mirrors the reported scenario:
- provider: ai-gateway
- base_url: https://api.ai-gateway.example/v1
- default: gpt-5.4
- An overlapping custom_providers[] entry with the same default model
Expected: active provider (ai-gateway) wins over custom provider.
'''
model, provider, base_url = _resolve_with_config(
'gpt-5.4',
provider='ai-gateway',
base_url='https://api.ai-gateway.example/v1',
default='gpt-5.4',
custom_providers=[{
'name': 'My Custom Endpoint',
'base_url': 'http://localhost:8080/v1',
'model': 'gpt-5.4',
}],
)
assert model == 'gpt-5.4', f'Expected model=gpt-5.4, got {model!r}'
assert provider == 'ai-gateway', f'Expected provider=ai-gateway, got {provider!r}'
assert base_url == 'https://api.ai-gateway.example/v1', f'Expected base_url from active provider, got {base_url!r}'
def test_default_model_shadowed_with_xiaomi_provider():
r'''Same regression test with provider=xiaomi instead of ai-gateway.'''
model, provider, base_url = _resolve_with_config(
'deepseek-v4-flash',
provider='xiaomi',
default='deepseek-v4-flash',
custom_providers=[{
'name': 'LiteLLM Proxy',
'base_url': 'http://127.0.0.1:8080/v1',
'model': 'deepseek-v4-flash',
}],
)
assert model == 'deepseek-v4-flash'
assert provider == 'xiaomi'
assert base_url is None # xiaomi has no config base_url in this test
# ── get_available_models() @provider: hint behaviour ──────────────────────
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
@pytest.fixture(autouse=True)
def _isolate_models_cache():
"""Invalidate the models TTL cache before and after every test in this file.
Several helpers here mutate ``config.cfg`` in-memory and call
``get_available_models()``. Without this guard, a prior test that called
``get_available_models()`` leaves a 60-second TTL cache entry; the next
test that mutates cfg and calls the function gets a cache hit instead of
running the function body, causing silently wrong results (e.g. the
``test_custom_endpoint_uses_model_config_api_key_for_model_discovery``
``KeyError: 'auth'`` on CI where ``urlopen`` is never reached).
"""
try:
config.invalidate_models_cache()
except Exception:
pass
yield
try:
config.invalidate_models_cache()
except Exception:
pass
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
def _available_models_with_provider(provider):
"""Helper: temporarily set active_provider in config."""
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
old_cfg = dict(config.cfg)
config.cfg['model'] = {'provider': provider}
try:
return config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
def test_non_default_provider_models_use_hint_prefix():
"""With anthropic as default, minimax model IDs should use @minimax: prefix."""
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
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}"
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
)
def test_no_duplicate_when_default_model_is_prefixed():
"""Issue #147 Bug 2: 'anthropic/claude-opus-4.6' as default_model must not
inject a duplicate alongside the existing bare 'claude-opus-4.6' entry in
the same provider group."""
import api.config as _cfg
old_cfg = dict(_cfg.cfg)
_cfg.cfg['model'] = {
'provider': 'anthropic',
'default': 'anthropic/claude-opus-4.6',
}
try:
result = _cfg.get_available_models()
norm = lambda mid: mid.split('/', 1)[-1] if '/' in mid else mid
# Check each group individually: no group should have two entries that
# normalize to the same bare model name
for g in result['groups']:
bare_ids = [norm(m['id']) for m in g['models']]
duplicates = [mid for mid in set(bare_ids) if bare_ids.count(mid) > 1]
assert not duplicates, (
f"Provider group '{g['provider']}' has duplicate models after normalization: "
f"{duplicates}\nFull group: {[m['id'] for m in g['models']]}"
)
finally:
_cfg.cfg.clear()
_cfg.cfg.update(old_cfg)
def test_default_provider_models_not_prefixed(monkeypatch):
"""The active provider's models remain bare (no @prefix added)."""
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
import api.config as _cfg
monkeypatch.setattr(_cfg, "_read_live_provider_model_ids", lambda pid: ["claude-sonnet-5.0"] if pid == "anthropic" else [])
fix: prefix non-default provider model IDs for correct routing (#142) * fix: prefix non-default provider model IDs for correct routing When multiple providers are configured, models from non-default providers (e.g. MiniMax when Anthropic is default) were sent as bare names without provider context. resolve_model_provider() couldn't determine the target provider and routed them to the default provider's API, which failed. Fix: get_available_models() now prefixes model IDs with the provider name (e.g. minimax/MiniMax-M2.7) for providers that are NOT the active config provider. The default provider's models keep bare names for direct API routing. This matches the existing pattern for OpenRouter models. Added 2 tests to test_model_resolver.py for cross-provider routing. Closes #138 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: model prefix — null-guard, case normalization, mutation safety, tests Four fixes on top of original PR: - active_provider=None guard: without a confirmed provider all models were being prefixed. Only prefix when active_provider is set. - Case normalisation: compare pid against active_provider.lower() so config.yaml entries like 'Anthropic' match pid 'anthropic'. - Mutation safety: default branch used raw reference to _PROVIDER_MODELS[pid]; the default_model injector later calls list.insert() on that reference, permanently mutating the shared constant. Both branches now use a copy. - Already-prefixed model IDs pass through as-is (no double-prefix). Added 3 tests for get_available_models() prefix behaviour: - Non-default provider models are prefixed - Active provider's own entries remain bare - No double-prefix when active_provider is absent --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:05:44 +00:00
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']}
assert "claude-sonnet-5.0" in returned_ids
assert not any(mid.startswith('@anthropic:') for mid in returned_ids), returned_ids
fix: do not build phantom Custom group when active provider is set (#206) * fix: do not build phantom "Custom" group when active provider is set When model.provider is a real provider (e.g. openai-codex) and model.base_url is configured, hermes_cli reports 'custom' as an authenticated provider. The WebUI model picker was building a separate "Custom" group for it and parking the configured default_model there instead of under the active provider's group — diverging from the TUI which correctly shows the model under its configured provider. Two fixes in api/config.py get_available_models(): 1. Discard 'custom' from detected_providers when active_provider is set and isn't 'custom' itself. The base_url belongs to the active provider. 2. Replace the substring-based default-model injection check with an exact match against _PROVIDER_DISPLAY. The old check `active_provider.lower() in g.get('provider', '').lower()` silently failed for hyphenated IDs like 'openai-codex' vs display name 'OpenAI Codex' (hyphen vs. space), falling through to groups[0] and landing the model in the alphabetical first group instead. Adds two regression tests in tests/test_model_resolver.py covering both conditions. * fix: do not build phantom Custom group when active provider is set Two bugs in get_available_models(): 1. Phantom Custom group: hermes_cli reports 'custom' as authenticated whenever model.base_url is set. With provider=openai-codex + base_url, detected_providers contained both 'openai-codex' and 'custom', producing a duplicate group. Fixed by discarding 'custom' from detected_providers when the active provider is any real named provider. 2. Hyphen/space mismatch in default_model injection: the substring check 'openai-codex' in 'openai codex' is False (hyphen vs space), causing the default model to fall through to groups[0] (alphabetically first provider) instead of the active provider group. Fixed by using _PROVIDER_DISPLAY for exact display-name comparison. Also fixes test helper _available_models_with_full_cfg to clear model env vars during the call, preventing real hermes profile env from leaking into the test assertions. --------- Co-authored-by: mbac <marco.baciarello@gmail.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 01:33:24 +00:00
# ── get_available_models(): phantom "Custom" group regression ─────────────
#
# When the user has model.provider set to a real provider (e.g. openai-codex)
# AND a model.base_url set, hermes_cli reports the 'custom' pseudo-provider as
# authenticated. The WebUI picker must NOT build a separate "Custom" group in
# that case — the base_url belongs to the active provider.
def _available_models_with_full_cfg(provider, default, base_url):
"""Helper: set model.provider, model.default, model.base_url at once.
Clears model-override env vars (HERMES_MODEL, OPENAI_MODEL, LLM_MODEL)
during the call so the real hermes profile environment doesn't leak into
the test and override the fixture's default model.
"""
import os
import api.config as _cfg
old_cfg = dict(_cfg.cfg)
_cfg.cfg['model'] = {
'provider': provider,
'default': default,
'base_url': base_url,
}
try:
_cfg._cfg_mtime = _cfg.Path(_cfg._get_config_path()).stat().st_mtime
except Exception:
# No config.yaml on this machine (e.g. CI); pin to 0.0 so the mtime check
# inside get_available_models() sees 0.0 == 0.0 and doesn't call reload_config(),
# which would overwrite the in-memory cfg we just set up.
_cfg._cfg_mtime = 0.0
fix: do not build phantom Custom group when active provider is set (#206) * fix: do not build phantom "Custom" group when active provider is set When model.provider is a real provider (e.g. openai-codex) and model.base_url is configured, hermes_cli reports 'custom' as an authenticated provider. The WebUI model picker was building a separate "Custom" group for it and parking the configured default_model there instead of under the active provider's group — diverging from the TUI which correctly shows the model under its configured provider. Two fixes in api/config.py get_available_models(): 1. Discard 'custom' from detected_providers when active_provider is set and isn't 'custom' itself. The base_url belongs to the active provider. 2. Replace the substring-based default-model injection check with an exact match against _PROVIDER_DISPLAY. The old check `active_provider.lower() in g.get('provider', '').lower()` silently failed for hyphenated IDs like 'openai-codex' vs display name 'OpenAI Codex' (hyphen vs. space), falling through to groups[0] and landing the model in the alphabetical first group instead. Adds two regression tests in tests/test_model_resolver.py covering both conditions. * fix: do not build phantom Custom group when active provider is set Two bugs in get_available_models(): 1. Phantom Custom group: hermes_cli reports 'custom' as authenticated whenever model.base_url is set. With provider=openai-codex + base_url, detected_providers contained both 'openai-codex' and 'custom', producing a duplicate group. Fixed by discarding 'custom' from detected_providers when the active provider is any real named provider. 2. Hyphen/space mismatch in default_model injection: the substring check 'openai-codex' in 'openai codex' is False (hyphen vs space), causing the default model to fall through to groups[0] (alphabetically first provider) instead of the active provider group. Fixed by using _PROVIDER_DISPLAY for exact display-name comparison. Also fixes test helper _available_models_with_full_cfg to clear model env vars during the call, preventing real hermes profile env from leaking into the test assertions. --------- Co-authored-by: mbac <marco.baciarello@gmail.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 01:33:24 +00:00
# Clear model-override env vars to prevent the real profile from leaking in
_model_env_keys = ('HERMES_MODEL', 'OPENAI_MODEL', 'LLM_MODEL')
_saved_env = {k: os.environ.pop(k, None) for k in _model_env_keys}
try:
return _cfg.get_available_models()
finally:
_cfg.cfg.clear()
_cfg.cfg.update(old_cfg)
for k, v in _saved_env.items():
if v is not None:
os.environ[k] = v
def test_no_phantom_custom_group_when_active_provider_is_set(monkeypatch):
"""Issue: with provider=openai-codex + base_url set, gpt-5.4 was landing
under a phantom "Custom" group instead of the "OpenAI Codex" group."""
import sys, types
# Force hermes_cli to report both the real provider and the phantom
# 'custom' as authenticated, simulating what list_available_providers()
# returns when base_url is configured.
fake_mod = types.ModuleType('hermes_cli.models')
fake_mod.list_available_providers = lambda: [
{'id': 'openai-codex', 'authenticated': True},
{'id': 'custom', 'authenticated': True},
]
fake_auth = types.ModuleType('hermes_cli.auth')
fake_auth.get_auth_status = lambda pid: {'key_source': 'env'}
monkeypatch.setitem(sys.modules, 'hermes_cli.models', fake_mod)
monkeypatch.setitem(sys.modules, 'hermes_cli.auth', fake_auth)
result = _available_models_with_full_cfg(
provider='openai-codex',
default='gpt-5.4',
base_url='https://chatgpt.com/backend-api/codex',
)
group_names = [g['provider'] for g in result['groups']]
assert 'Custom' not in group_names, (
f"Phantom 'Custom' group present; full groups: {group_names}"
)
def test_default_model_lands_under_active_provider_group(monkeypatch):
"""The configured default_model must appear under the active provider's
display group, even when the model isn't in _PROVIDER_MODELS[provider]
AND the active provider isn't the alphabetical first detected provider.
Regression guard for a hyphen-vs-space bug in the "ensure default_model
appears" post-pass: the substring check `active_provider.lower() in
g.get('provider', '').lower()` was failing for 'openai-codex' vs
display name 'OpenAI Codex' (hyphen vs. space), silently falling back
to groups[0] — which, when another provider sorted earlier
alphabetically (e.g. 'anthropic'), placed gpt-5.4 in the WRONG group.
"""
import sys, types
fake_mod = types.ModuleType('hermes_cli.models')
fake_mod.list_available_providers = lambda: [
{'id': 'anthropic', 'authenticated': True}, # sorts before openai-codex
{'id': 'openai-codex', 'authenticated': True},
{'id': 'custom', 'authenticated': True},
]
fake_auth = types.ModuleType('hermes_cli.auth')
fake_auth.get_auth_status = lambda pid: {'key_source': 'env'}
monkeypatch.setitem(sys.modules, 'hermes_cli.models', fake_mod)
monkeypatch.setitem(sys.modules, 'hermes_cli.auth', fake_auth)
result = _available_models_with_full_cfg(
provider='openai-codex',
default='gpt-5.4',
base_url='https://chatgpt.com/backend-api/codex',
)
groups = {g['provider']: [m['id'] for m in g['models']] for g in result['groups']}
assert 'OpenAI Codex' in groups, f"OpenAI Codex group missing: {list(groups)}"
norm = lambda mid: mid.split('/', 1)[-1].split(':', 1)[-1]
assert 'gpt-5.4' in {norm(mid) for mid in groups['OpenAI Codex']}, (
fix: do not build phantom Custom group when active provider is set (#206) * fix: do not build phantom "Custom" group when active provider is set When model.provider is a real provider (e.g. openai-codex) and model.base_url is configured, hermes_cli reports 'custom' as an authenticated provider. The WebUI model picker was building a separate "Custom" group for it and parking the configured default_model there instead of under the active provider's group — diverging from the TUI which correctly shows the model under its configured provider. Two fixes in api/config.py get_available_models(): 1. Discard 'custom' from detected_providers when active_provider is set and isn't 'custom' itself. The base_url belongs to the active provider. 2. Replace the substring-based default-model injection check with an exact match against _PROVIDER_DISPLAY. The old check `active_provider.lower() in g.get('provider', '').lower()` silently failed for hyphenated IDs like 'openai-codex' vs display name 'OpenAI Codex' (hyphen vs. space), falling through to groups[0] and landing the model in the alphabetical first group instead. Adds two regression tests in tests/test_model_resolver.py covering both conditions. * fix: do not build phantom Custom group when active provider is set Two bugs in get_available_models(): 1. Phantom Custom group: hermes_cli reports 'custom' as authenticated whenever model.base_url is set. With provider=openai-codex + base_url, detected_providers contained both 'openai-codex' and 'custom', producing a duplicate group. Fixed by discarding 'custom' from detected_providers when the active provider is any real named provider. 2. Hyphen/space mismatch in default_model injection: the substring check 'openai-codex' in 'openai codex' is False (hyphen vs space), causing the default model to fall through to groups[0] (alphabetically first provider) instead of the active provider group. Fixed by using _PROVIDER_DISPLAY for exact display-name comparison. Also fixes test helper _available_models_with_full_cfg to clear model env vars during the call, preventing real hermes profile env from leaking into the test assertions. --------- Co-authored-by: mbac <marco.baciarello@gmail.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 01:33:24 +00:00
f"gpt-5.4 not in OpenAI Codex group; contents: {groups['OpenAI Codex']}"
)
# And crucially, it must NOT have landed in the alphabetically-first
# group (Anthropic) via the fallback path.
assert 'gpt-5.4' not in {norm(mid) for mid in groups.get('Anthropic', [])}, (
fix: do not build phantom Custom group when active provider is set (#206) * fix: do not build phantom "Custom" group when active provider is set When model.provider is a real provider (e.g. openai-codex) and model.base_url is configured, hermes_cli reports 'custom' as an authenticated provider. The WebUI model picker was building a separate "Custom" group for it and parking the configured default_model there instead of under the active provider's group — diverging from the TUI which correctly shows the model under its configured provider. Two fixes in api/config.py get_available_models(): 1. Discard 'custom' from detected_providers when active_provider is set and isn't 'custom' itself. The base_url belongs to the active provider. 2. Replace the substring-based default-model injection check with an exact match against _PROVIDER_DISPLAY. The old check `active_provider.lower() in g.get('provider', '').lower()` silently failed for hyphenated IDs like 'openai-codex' vs display name 'OpenAI Codex' (hyphen vs. space), falling through to groups[0] and landing the model in the alphabetical first group instead. Adds two regression tests in tests/test_model_resolver.py covering both conditions. * fix: do not build phantom Custom group when active provider is set Two bugs in get_available_models(): 1. Phantom Custom group: hermes_cli reports 'custom' as authenticated whenever model.base_url is set. With provider=openai-codex + base_url, detected_providers contained both 'openai-codex' and 'custom', producing a duplicate group. Fixed by discarding 'custom' from detected_providers when the active provider is any real named provider. 2. Hyphen/space mismatch in default_model injection: the substring check 'openai-codex' in 'openai codex' is False (hyphen vs space), causing the default model to fall through to groups[0] (alphabetically first provider) instead of the active provider group. Fixed by using _PROVIDER_DISPLAY for exact display-name comparison. Also fixes test helper _available_models_with_full_cfg to clear model env vars during the call, preventing real hermes profile env from leaking into the test assertions. --------- Co-authored-by: mbac <marco.baciarello@gmail.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 01:33:24 +00:00
f"gpt-5.4 leaked into Anthropic group via fallback: {groups.get('Anthropic')}"
)
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix * fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239) Adds decode() helper in renderMd() to fix double-escaping of HTML entities from LLM output (e.g. &lt;code&gt; becoming &amp;lt;code&amp;gt; instead of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns. Also adds 40+ missing zh (Simplified Chinese) translation keys and a new zh-Hant (Traditional Chinese) locale with 163 keys. Fix applied: removed duplicate settings_label_notifications key in both zh and zh-Hant locales. Fixes #240 * fix: restore custom model list discovery with config api key (#238) get_available_models() now reads api_key from config.yaml before env vars: 1. model.api_key 2. providers.<active>.api_key / providers.custom.api_key 3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.) Also adds OpenAI/Python User-Agent header and a regression test covering authenticated /v1/models discovery. Fixes users with LM Studio / Ollama custom endpoints configured in config.yaml whose model picker silently collapsed to the default model. * feat: Docker UID/GID matching to avoid root-owned .hermes files (#237) Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so container files match the host user UID/GID. Prevents .hermes volume mounts from being owned by root when using a non-root host user. Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000). Readme updated with setup instructions. Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml that was overriding the ${GID:-1000} variable expansion. * security: redact credentials from API responses and fix credential file permissions (#243) Adds response-layer credential redaction to three endpoints: - GET /api/session — messages[], tool_calls[], and title - GET /api/session/export — download also redacted - SSE done event — session payload in stream - GET /api/memory — MEMORY.md and USER.md content Adds api/startup.py with fix_credential_permissions() at server startup. Adds 13 tests in tests/test_security_redaction.py. Merged with #237 container detection changes in server.py. * fix: cancel button now interrupts agent and cleans up UI state (#244) Wires agent.interrupt() into cancel_stream() so the backend actually stops tool execution when the user clicks Cancel, rather than only stopping the SSE stream while the agent keeps running. Changes: - api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent) - api/streaming.py: stores agent in AGENT_INSTANCES after creation, checks CANCEL_FLAGS immediately after store (race condition fix), calls agent.interrupt() in cancel_stream(), cleans up in finally block - static/boot.js: removes stale setStatus(cancelling) call - static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel Race condition fix: after storing agent in AGENT_INSTANCES, immediately checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during agent init) and interrupts before starting. Check is inside the same STREAMS_LOCK acquisition, making it atomic. New test file: tests/test_cancel_interrupt.py with 6 unit tests. * docs: v0.46.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
def test_unknown_providers_do_not_inherit_default_model(monkeypatch):
"""Detected providers without their own model catalog must not be filled
with the global default_model placeholder.
2026-04-29 07:50:32 +00:00
Regression guard for the bug where unknown providers ended up showing
gpt-5.4-mini even though those providers do not serve it. Minimax-Cn is
now known and should show its own catalog instead.
"""
import sys, types
fake_mod = types.ModuleType('hermes_cli.models')
fake_mod.list_available_providers = lambda: [
{'id': 'openai-codex', 'authenticated': True},
{'id': 'alibaba', 'authenticated': True},
{'id': 'minimax-cn', 'authenticated': True},
]
fake_auth = types.ModuleType('hermes_cli.auth')
fake_auth.get_auth_status = lambda pid: {'key_source': 'env'}
monkeypatch.setitem(sys.modules, 'hermes_cli.models', fake_mod)
monkeypatch.setitem(sys.modules, 'hermes_cli.auth', fake_auth)
result = _available_models_with_full_cfg(
provider='openai-codex',
default='gpt-5.4-mini',
base_url='',
)
groups = {g['provider']: [m['id'] for m in g['models']] for g in result['groups']}
norm = lambda mid: mid.split('/', 1)[-1].split(':', 1)[-1]
assert 'Alibaba' not in groups, (
f"Alibaba should not inherit the default model placeholder: {groups}"
)
2026-04-29 07:50:32 +00:00
assert 'MiniMax (China)' in groups, (
f"Minimax-Cn should render its own static catalog: {groups}"
)
assert not any(
norm(mid) == 'gpt-5.4-mini'
2026-04-29 07:50:32 +00:00
for mid in groups.get('Alibaba', []) + groups.get('MiniMax (China)', [])
), (
f"Unknown provider groups still inherited the default model: {groups}"
)
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix * fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239) Adds decode() helper in renderMd() to fix double-escaping of HTML entities from LLM output (e.g. &lt;code&gt; becoming &amp;lt;code&amp;gt; instead of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns. Also adds 40+ missing zh (Simplified Chinese) translation keys and a new zh-Hant (Traditional Chinese) locale with 163 keys. Fix applied: removed duplicate settings_label_notifications key in both zh and zh-Hant locales. Fixes #240 * fix: restore custom model list discovery with config api key (#238) get_available_models() now reads api_key from config.yaml before env vars: 1. model.api_key 2. providers.<active>.api_key / providers.custom.api_key 3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.) Also adds OpenAI/Python User-Agent header and a regression test covering authenticated /v1/models discovery. Fixes users with LM Studio / Ollama custom endpoints configured in config.yaml whose model picker silently collapsed to the default model. * feat: Docker UID/GID matching to avoid root-owned .hermes files (#237) Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so container files match the host user UID/GID. Prevents .hermes volume mounts from being owned by root when using a non-root host user. Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000). Readme updated with setup instructions. Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml that was overriding the ${GID:-1000} variable expansion. * security: redact credentials from API responses and fix credential file permissions (#243) Adds response-layer credential redaction to three endpoints: - GET /api/session — messages[], tool_calls[], and title - GET /api/session/export — download also redacted - SSE done event — session payload in stream - GET /api/memory — MEMORY.md and USER.md content Adds api/startup.py with fix_credential_permissions() at server startup. Adds 13 tests in tests/test_security_redaction.py. Merged with #237 container detection changes in server.py. * fix: cancel button now interrupts agent and cleans up UI state (#244) Wires agent.interrupt() into cancel_stream() so the backend actually stops tool execution when the user clicks Cancel, rather than only stopping the SSE stream while the agent keeps running. Changes: - api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent) - api/streaming.py: stores agent in AGENT_INSTANCES after creation, checks CANCEL_FLAGS immediately after store (race condition fix), calls agent.interrupt() in cancel_stream(), cleans up in finally block - static/boot.js: removes stale setStatus(cancelling) call - static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel Race condition fix: after storing agent in AGENT_INSTANCES, immediately checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during agent init) and interrupts before starting. Check is inside the same STREAMS_LOCK acquisition, making it atomic. New test file: tests/test_cancel_interrupt.py with 6 unit tests. * docs: v0.46.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
def test_custom_endpoint_uses_model_config_api_key_for_model_discovery(monkeypatch):
"""Custom endpoint model discovery must use model.api_key from config.yaml,
not only environment variables, otherwise the dropdown collapses to the
default model when /v1/models requires auth."""
import json as _json
import api.config as _cfg
old_cfg = dict(_cfg.cfg)
_cfg.cfg['model'] = {
'provider': 'custom',
'default': 'gpt-5.4',
'base_url': 'https://example.test/v1',
'api_key': 'sk-test-model-key',
}
try:
_cfg._cfg_mtime = _cfg.Path(_cfg._get_config_path()).stat().st_mtime
except Exception:
# No config.yaml on this machine (e.g. CI); pin to 0.0 so the mtime check
# inside get_available_models() sees 0.0 == 0.0 and skips reload_config().
_cfg._cfg_mtime = 0.0
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix * fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239) Adds decode() helper in renderMd() to fix double-escaping of HTML entities from LLM output (e.g. &lt;code&gt; becoming &amp;lt;code&amp;gt; instead of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns. Also adds 40+ missing zh (Simplified Chinese) translation keys and a new zh-Hant (Traditional Chinese) locale with 163 keys. Fix applied: removed duplicate settings_label_notifications key in both zh and zh-Hant locales. Fixes #240 * fix: restore custom model list discovery with config api key (#238) get_available_models() now reads api_key from config.yaml before env vars: 1. model.api_key 2. providers.<active>.api_key / providers.custom.api_key 3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.) Also adds OpenAI/Python User-Agent header and a regression test covering authenticated /v1/models discovery. Fixes users with LM Studio / Ollama custom endpoints configured in config.yaml whose model picker silently collapsed to the default model. * feat: Docker UID/GID matching to avoid root-owned .hermes files (#237) Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so container files match the host user UID/GID. Prevents .hermes volume mounts from being owned by root when using a non-root host user. Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000). Readme updated with setup instructions. Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml that was overriding the ${GID:-1000} variable expansion. * security: redact credentials from API responses and fix credential file permissions (#243) Adds response-layer credential redaction to three endpoints: - GET /api/session — messages[], tool_calls[], and title - GET /api/session/export — download also redacted - SSE done event — session payload in stream - GET /api/memory — MEMORY.md and USER.md content Adds api/startup.py with fix_credential_permissions() at server startup. Adds 13 tests in tests/test_security_redaction.py. Merged with #237 container detection changes in server.py. * fix: cancel button now interrupts agent and cleans up UI state (#244) Wires agent.interrupt() into cancel_stream() so the backend actually stops tool execution when the user clicks Cancel, rather than only stopping the SSE stream while the agent keeps running. Changes: - api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent) - api/streaming.py: stores agent in AGENT_INSTANCES after creation, checks CANCEL_FLAGS immediately after store (race condition fix), calls agent.interrupt() in cancel_stream(), cleans up in finally block - static/boot.js: removes stale setStatus(cancelling) call - static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel Race condition fix: after storing agent in AGENT_INSTANCES, immediately checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during agent init) and interrupts before starting. Check is inside the same STREAMS_LOCK acquisition, making it atomic. New test file: tests/test_cancel_interrupt.py with 6 unit tests. * docs: v0.46.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
_cfg.cfg.pop('providers', None)
captured = {}
class _Resp:
def read(self):
return _json.dumps({'data': [{'id': 'gpt-5.2', 'name': 'GPT-5.2'}]}).encode('utf-8')
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def _fake_urlopen(req, timeout=10):
url = getattr(req, 'full_url', '')
if 'example.test' in url:
captured['auth'] = req.get_header('Authorization')
captured['ua'] = req.get_header('User-agent')
v0.46.0: security, Docker UID/GID, model discovery, i18n, cancel fix * fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239) Adds decode() helper in renderMd() to fix double-escaping of HTML entities from LLM output (e.g. &lt;code&gt; becoming &amp;lt;code&amp;gt; instead of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns. Also adds 40+ missing zh (Simplified Chinese) translation keys and a new zh-Hant (Traditional Chinese) locale with 163 keys. Fix applied: removed duplicate settings_label_notifications key in both zh and zh-Hant locales. Fixes #240 * fix: restore custom model list discovery with config api key (#238) get_available_models() now reads api_key from config.yaml before env vars: 1. model.api_key 2. providers.<active>.api_key / providers.custom.api_key 3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.) Also adds OpenAI/Python User-Agent header and a regression test covering authenticated /v1/models discovery. Fixes users with LM Studio / Ollama custom endpoints configured in config.yaml whose model picker silently collapsed to the default model. * feat: Docker UID/GID matching to avoid root-owned .hermes files (#237) Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so container files match the host user UID/GID. Prevents .hermes volume mounts from being owned by root when using a non-root host user. Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000). Readme updated with setup instructions. Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml that was overriding the ${GID:-1000} variable expansion. * security: redact credentials from API responses and fix credential file permissions (#243) Adds response-layer credential redaction to three endpoints: - GET /api/session — messages[], tool_calls[], and title - GET /api/session/export — download also redacted - SSE done event — session payload in stream - GET /api/memory — MEMORY.md and USER.md content Adds api/startup.py with fix_credential_permissions() at server startup. Adds 13 tests in tests/test_security_redaction.py. Merged with #237 container detection changes in server.py. * fix: cancel button now interrupts agent and cleans up UI state (#244) Wires agent.interrupt() into cancel_stream() so the backend actually stops tool execution when the user clicks Cancel, rather than only stopping the SSE stream while the agent keeps running. Changes: - api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent) - api/streaming.py: stores agent in AGENT_INSTANCES after creation, checks CANCEL_FLAGS immediately after store (race condition fix), calls agent.interrupt() in cancel_stream(), cleans up in finally block - static/boot.js: removes stale setStatus(cancelling) call - static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel Race condition fix: after storing agent in AGENT_INSTANCES, immediately checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during agent init) and interrupts before starting. Check is inside the same STREAMS_LOCK acquisition, making it atomic. New test file: tests/test_cancel_interrupt.py with 6 unit tests. * docs: v0.46.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 17:17:52 +00:00
return _Resp()
monkeypatch.setattr('urllib.request.urlopen', _fake_urlopen)
monkeypatch.setattr('socket.getaddrinfo', lambda *a, **k: [])
monkeypatch.delenv('OPENAI_API_KEY', raising=False)
monkeypatch.delenv('HERMES_API_KEY', raising=False)
monkeypatch.delenv('HERMES_OPENAI_API_KEY', raising=False)
monkeypatch.delenv('LOCAL_API_KEY', raising=False)
monkeypatch.delenv('OPENROUTER_API_KEY', raising=False)
monkeypatch.delenv('API_KEY', raising=False)
try:
result = _cfg.get_available_models()
finally:
_cfg.cfg.clear()
_cfg.cfg.update(old_cfg)
assert captured['auth'] == 'Bearer sk-test-model-key'
assert captured['ua'] == 'OpenAI/Python 1.0'
groups = {g['provider']: [m['id'] for m in g['models']] for g in result['groups']}
assert 'Custom' in groups
# Model ID may be prefixed with @provider: due to cross-provider dedup (#1228)
assert any('gpt-5.2' in m for m in groups['Custom']), f'gpt-5.2 not found in Custom: {groups}'
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite * fix: custom provider with slash model name no longer rerouted to OpenRouter (#255) When base_url is configured in config.yaml, resolve_model_provider() now trusts the configured provider/base_url entirely and skips the slash-based OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom being silently routed to OpenRouter, resulting in 401 errors. Fixes #230 * test: mobile layout regression suite — 14 tests for every QA run (#254) Adds tests/test_mobile_layout.py with 14 static regression tests that run on every QA pass to catch mobile layout breakage before it reaches prod. Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile overlay, bottom nav, files button, profile dropdown z-index, chip overflow, workspace close, 100dvh, 44px touch targets, 16px font-size on textarea. * feat: /skills slash command lists and filters available Hermes skills (#257) Adds /skills [query] command to commands.js. Fetches from /api/skills, groups by category (alphabetically sorted), displays as a formatted assistant message. Optional query filters by name, description, or category. i18n keys added for en, de, zh, zh-Hant. 1 regression test added. Fixes #248 * feat: shared app dialogs replace native confirm()/prompt() calls (#251) Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt call sites across panels.js, sessions.js, ui.js, workspace.js. Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore, ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant. 5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of native dialog calls. Extracted from PR #242. * fix: Android Chrome mobile — workspace panel close + profile dropdown (#256) Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when toggling the right workspace panel. New closeMobileFiles() helper closes the panel with correct overlay state tracking. Overlay onclick calls both closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x) added to workspace panel header. Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at max-width:900px, escaping the overflow-x:auto stacking context that was clipping it on Android Chrome. Fix applied during review: closeMobileSidebar() now checks if the right panel is still open before hiding the overlay, preventing the overlay from disappearing when only the sidebar is closed. Fixes #247 Fixes #246 * feat: session ⋯ action dropdown replaces per-row buttons (#252) Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash) with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full keyboard (Escape), click-outside, scroll, and resize-reposition handling. Position:fixed prevents sidebar clipping. 5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete (danger style). Each with icon and descriptive subtitle. Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons asserts the new trigger and menu functions exist, old per-row classes are gone. Extracted from PR #242. * docs: v0.47.0 release notes, bump version, update test counts (645) --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
# -- Issue #230: custom provider with slash model name -----------------------
def test_custom_endpoint_slash_model_routes_to_custom_not_openrouter():
fix(streaming): SSE heartbeat alignment, repair grace period, local-server model id preservation (#1623, #1624, #1625) Closes #1623 — Lower SSE app heartbeat from 30s to 5s at every long-lived handler (main agent, terminal, gateway-watcher, approval-poller, clarify-poller). Kernel TCP keepalive declares peer dead at 25s worst-case (10s KEEPIDLE + 5s KEEPINTVL * 3 KEEPCNT, added v0.50.289 #1581). 30s app heartbeat let the kernel tear sockets down on flaky networks before the app sent its first keepalive byte — drops at ~10s during long thinking phases. New named constant _SSE_HEARTBEAT_INTERVAL_SECONDS=5; regression test pins the inequality (app_heartbeat * 2 <= kernel_window) so future tuning can't re-introduce the misalignment. Closes #1624 — Add 30s grace period to _repair_stale_pending() trigger. Without it, any narrow race between the streaming thread clearing pending_user_message and STREAMS.pop(stream_id) produces a false-positive 'Previous turn did not complete.' marker on a turn that finished correctly (reproducible after every command-approval turn). Defense-in-depth, not the root-cause fix — the actual streaming-thread leak path is tracked separately. Falsy pending_started_at (legacy sidecars) treated as 'old enough' so legitimate legacy-data recovery still works. Plus logger.warning telemetry on every legitimate repair so the next batch of user reports tells us whether the underlying race still fires. Closes #1625 — Local model servers (LM Studio, Ollama, llama.cpp, vLLM, TabbyAPI, koboldcpp, textgen-webui) now keep the full HuggingFace-style model id (e.g. 'qwen/qwen3.6-27b' instead of stripped 'qwen3.6-27b'). New _LOCAL_SERVER_PROVIDERS set + _base_url_points_at_local_server() loopback/ RFC1918 heuristic — either signal triggers no-strip. Backward compat preserved for OpenAI-compatible proxies on public hosts (LiteLLM at litellm.example.com still strips openai/gpt-5.4 -> gpt-5.4). Updated the existing #230/#433 test to reflect that #1625 supersedes the strip-on-custom rule for loopback hosts (see api/config.py and test_model_resolver.py docstring update). Reported by @akarichan8231 in Discord on 2026-05-04. 42 regression tests across: tests/test_issue1623_sse_heartbeat_alignment.py (3) tests/test_issue1624_repair_stale_pending_grace.py (9) tests/test_issue1625_local_server_model_id_preservation.py (30) 4142 -> 4184 passing. 0 regressions.
2026-05-04 16:31:32 +00:00
"""Regression test for #230, updated for #1625.
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite * fix: custom provider with slash model name no longer rerouted to OpenRouter (#255) When base_url is configured in config.yaml, resolve_model_provider() now trusts the configured provider/base_url entirely and skips the slash-based OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom being silently routed to OpenRouter, resulting in 401 errors. Fixes #230 * test: mobile layout regression suite — 14 tests for every QA run (#254) Adds tests/test_mobile_layout.py with 14 static regression tests that run on every QA pass to catch mobile layout breakage before it reaches prod. Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile overlay, bottom nav, files button, profile dropdown z-index, chip overflow, workspace close, 100dvh, 44px touch targets, 16px font-size on textarea. * feat: /skills slash command lists and filters available Hermes skills (#257) Adds /skills [query] command to commands.js. Fetches from /api/skills, groups by category (alphabetically sorted), displays as a formatted assistant message. Optional query filters by name, description, or category. i18n keys added for en, de, zh, zh-Hant. 1 regression test added. Fixes #248 * feat: shared app dialogs replace native confirm()/prompt() calls (#251) Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt call sites across panels.js, sessions.js, ui.js, workspace.js. Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore, ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant. 5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of native dialog calls. Extracted from PR #242. * fix: Android Chrome mobile — workspace panel close + profile dropdown (#256) Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when toggling the right workspace panel. New closeMobileFiles() helper closes the panel with correct overlay state tracking. Overlay onclick calls both closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x) added to workspace panel header. Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at max-width:900px, escaping the overflow-x:auto stacking context that was clipping it on Android Chrome. Fix applied during review: closeMobileSidebar() now checks if the right panel is still open before hiding the overlay, preventing the overlay from disappearing when only the sidebar is closed. Fixes #247 Fixes #246 * feat: session ⋯ action dropdown replaces per-row buttons (#252) Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash) with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full keyboard (Escape), click-outside, scroll, and resize-reposition handling. Position:fixed prevents sidebar clipping. 5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete (danger style). Each with icon and descriptive subtitle. Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons asserts the new trigger and menu functions exist, old per-row classes are gone. Extracted from PR #242. * docs: v0.47.0 release notes, bump version, update test counts (645) --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
When provider=custom (or any non-openrouter provider) and base_url is set,
a model name containing a slash (e.g. google/gemma-4-26b-a4b) must NOT be
rerouted to OpenRouter -- it should stay on the configured custom endpoint.
fix(streaming): SSE heartbeat alignment, repair grace period, local-server model id preservation (#1623, #1624, #1625) Closes #1623 — Lower SSE app heartbeat from 30s to 5s at every long-lived handler (main agent, terminal, gateway-watcher, approval-poller, clarify-poller). Kernel TCP keepalive declares peer dead at 25s worst-case (10s KEEPIDLE + 5s KEEPINTVL * 3 KEEPCNT, added v0.50.289 #1581). 30s app heartbeat let the kernel tear sockets down on flaky networks before the app sent its first keepalive byte — drops at ~10s during long thinking phases. New named constant _SSE_HEARTBEAT_INTERVAL_SECONDS=5; regression test pins the inequality (app_heartbeat * 2 <= kernel_window) so future tuning can't re-introduce the misalignment. Closes #1624 — Add 30s grace period to _repair_stale_pending() trigger. Without it, any narrow race between the streaming thread clearing pending_user_message and STREAMS.pop(stream_id) produces a false-positive 'Previous turn did not complete.' marker on a turn that finished correctly (reproducible after every command-approval turn). Defense-in-depth, not the root-cause fix — the actual streaming-thread leak path is tracked separately. Falsy pending_started_at (legacy sidecars) treated as 'old enough' so legitimate legacy-data recovery still works. Plus logger.warning telemetry on every legitimate repair so the next batch of user reports tells us whether the underlying race still fires. Closes #1625 — Local model servers (LM Studio, Ollama, llama.cpp, vLLM, TabbyAPI, koboldcpp, textgen-webui) now keep the full HuggingFace-style model id (e.g. 'qwen/qwen3.6-27b' instead of stripped 'qwen3.6-27b'). New _LOCAL_SERVER_PROVIDERS set + _base_url_points_at_local_server() loopback/ RFC1918 heuristic — either signal triggers no-strip. Backward compat preserved for OpenAI-compatible proxies on public hosts (LiteLLM at litellm.example.com still strips openai/gpt-5.4 -> gpt-5.4). Updated the existing #230/#433 test to reflect that #1625 supersedes the strip-on-custom rule for loopback hosts (see api/config.py and test_model_resolver.py docstring update). Reported by @akarichan8231 in Discord on 2026-05-04. 42 regression tests across: tests/test_issue1623_sse_heartbeat_alignment.py (3) tests/test_issue1624_repair_stale_pending_grace.py (9) tests/test_issue1625_local_server_model_id_preservation.py (30) 4142 -> 4184 passing. 0 regressions.
2026-05-04 16:31:32 +00:00
#1625 layered an additional rule on top: a base_url pointing at a loopback
or private-IP host is treated as a local model server (LM Studio, Ollama,
llama.cpp, vLLM, TabbyAPI), which register models under their full
HuggingFace path. On such hosts the prefix is now PRESERVED. The original
#433 strip behaviour still applies on public hosts (real OpenAI-compatible
proxies like LiteLLM at https://litellm.example.com/v1).
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite * fix: custom provider with slash model name no longer rerouted to OpenRouter (#255) When base_url is configured in config.yaml, resolve_model_provider() now trusts the configured provider/base_url entirely and skips the slash-based OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom being silently routed to OpenRouter, resulting in 401 errors. Fixes #230 * test: mobile layout regression suite — 14 tests for every QA run (#254) Adds tests/test_mobile_layout.py with 14 static regression tests that run on every QA pass to catch mobile layout breakage before it reaches prod. Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile overlay, bottom nav, files button, profile dropdown z-index, chip overflow, workspace close, 100dvh, 44px touch targets, 16px font-size on textarea. * feat: /skills slash command lists and filters available Hermes skills (#257) Adds /skills [query] command to commands.js. Fetches from /api/skills, groups by category (alphabetically sorted), displays as a formatted assistant message. Optional query filters by name, description, or category. i18n keys added for en, de, zh, zh-Hant. 1 regression test added. Fixes #248 * feat: shared app dialogs replace native confirm()/prompt() calls (#251) Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt call sites across panels.js, sessions.js, ui.js, workspace.js. Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore, ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant. 5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of native dialog calls. Extracted from PR #242. * fix: Android Chrome mobile — workspace panel close + profile dropdown (#256) Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when toggling the right workspace panel. New closeMobileFiles() helper closes the panel with correct overlay state tracking. Overlay onclick calls both closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x) added to workspace panel header. Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at max-width:900px, escaping the overflow-x:auto stacking context that was clipping it on Android Chrome. Fix applied during review: closeMobileSidebar() now checks if the right panel is still open before hiding the overlay, preventing the overlay from disappearing when only the sidebar is closed. Fixes #247 Fixes #246 * feat: session ⋯ action dropdown replaces per-row buttons (#252) Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash) with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full keyboard (Escape), click-outside, scroll, and resize-reposition handling. Position:fixed prevents sidebar clipping. 5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete (danger style). Each with icon and descriptive subtitle. Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons asserts the new trigger and menu functions exist, old per-row classes are gone. Extracted from PR #242. * docs: v0.47.0 release notes, bump version, update test counts (645) --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
"""
# --- custom provider with slash model name should NOT go to openrouter ---
model, provider, base_url = _resolve_with_config(
'google/gemma-4-26b-a4b',
provider='custom',
base_url='http://127.0.0.1:1234/v1',
default='google/gemma-4-26b-a4b',
)
assert provider.startswith('custom'), (
"Expected provider starting with 'custom', got '{}'. "
"Slash in model name should NOT trigger OpenRouter rerouting when base_url is set.".format(provider)
)
assert base_url == 'http://127.0.0.1:1234/v1', (
"Expected base_url 'http://127.0.0.1:1234/v1', got '{}'.".format(base_url)
)
fix(streaming): SSE heartbeat alignment, repair grace period, local-server model id preservation (#1623, #1624, #1625) Closes #1623 — Lower SSE app heartbeat from 30s to 5s at every long-lived handler (main agent, terminal, gateway-watcher, approval-poller, clarify-poller). Kernel TCP keepalive declares peer dead at 25s worst-case (10s KEEPIDLE + 5s KEEPINTVL * 3 KEEPCNT, added v0.50.289 #1581). 30s app heartbeat let the kernel tear sockets down on flaky networks before the app sent its first keepalive byte — drops at ~10s during long thinking phases. New named constant _SSE_HEARTBEAT_INTERVAL_SECONDS=5; regression test pins the inequality (app_heartbeat * 2 <= kernel_window) so future tuning can't re-introduce the misalignment. Closes #1624 — Add 30s grace period to _repair_stale_pending() trigger. Without it, any narrow race between the streaming thread clearing pending_user_message and STREAMS.pop(stream_id) produces a false-positive 'Previous turn did not complete.' marker on a turn that finished correctly (reproducible after every command-approval turn). Defense-in-depth, not the root-cause fix — the actual streaming-thread leak path is tracked separately. Falsy pending_started_at (legacy sidecars) treated as 'old enough' so legitimate legacy-data recovery still works. Plus logger.warning telemetry on every legitimate repair so the next batch of user reports tells us whether the underlying race still fires. Closes #1625 — Local model servers (LM Studio, Ollama, llama.cpp, vLLM, TabbyAPI, koboldcpp, textgen-webui) now keep the full HuggingFace-style model id (e.g. 'qwen/qwen3.6-27b' instead of stripped 'qwen3.6-27b'). New _LOCAL_SERVER_PROVIDERS set + _base_url_points_at_local_server() loopback/ RFC1918 heuristic — either signal triggers no-strip. Backward compat preserved for OpenAI-compatible proxies on public hosts (LiteLLM at litellm.example.com still strips openai/gpt-5.4 -> gpt-5.4). Updated the existing #230/#433 test to reflect that #1625 supersedes the strip-on-custom rule for loopback hosts (see api/config.py and test_model_resolver.py docstring update). Reported by @akarichan8231 in Discord on 2026-05-04. 42 regression tests across: tests/test_issue1623_sse_heartbeat_alignment.py (3) tests/test_issue1624_repair_stale_pending_grace.py (9) tests/test_issue1625_local_server_model_id_preservation.py (30) 4142 -> 4184 passing. 0 regressions.
2026-05-04 16:31:32 +00:00
# #1625 (supersedes the v0.50 #433 strip-on-custom rule for loopback hosts):
# 127.0.0.1 base_url is almost certainly a local LM Studio / Ollama / etc.,
# which keys models on the full HuggingFace path. Preserve the prefix.
assert model == 'google/gemma-4-26b-a4b', (
"Model name prefix must be PRESERVED on loopback base_url (#1625), got '{}'.".format(model)
)
# --- public-host openai-compatible proxy STILL strips per #433 ----------
model2, provider2, base_url2 = _resolve_with_config(
'google/gemma-4-26b-a4b',
provider='openai',
base_url='https://litellm.example.com/v1',
default='google/gemma-4-26b-a4b',
)
assert model2 == 'gemma-4-26b-a4b', (
"Public-host OpenAI-compat proxy must still strip prefix per #433, got '{}'.".format(model2)
v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite * fix: custom provider with slash model name no longer rerouted to OpenRouter (#255) When base_url is configured in config.yaml, resolve_model_provider() now trusts the configured provider/base_url entirely and skips the slash-based OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom being silently routed to OpenRouter, resulting in 401 errors. Fixes #230 * test: mobile layout regression suite — 14 tests for every QA run (#254) Adds tests/test_mobile_layout.py with 14 static regression tests that run on every QA pass to catch mobile layout breakage before it reaches prod. Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile overlay, bottom nav, files button, profile dropdown z-index, chip overflow, workspace close, 100dvh, 44px touch targets, 16px font-size on textarea. * feat: /skills slash command lists and filters available Hermes skills (#257) Adds /skills [query] command to commands.js. Fetches from /api/skills, groups by category (alphabetically sorted), displays as a formatted assistant message. Optional query filters by name, description, or category. i18n keys added for en, de, zh, zh-Hant. 1 regression test added. Fixes #248 * feat: shared app dialogs replace native confirm()/prompt() calls (#251) Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt call sites across panels.js, sessions.js, ui.js, workspace.js. Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore, ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant. 5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of native dialog calls. Extracted from PR #242. * fix: Android Chrome mobile — workspace panel close + profile dropdown (#256) Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when toggling the right workspace panel. New closeMobileFiles() helper closes the panel with correct overlay state tracking. Overlay onclick calls both closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x) added to workspace panel header. Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at max-width:900px, escaping the overflow-x:auto stacking context that was clipping it on Android Chrome. Fix applied during review: closeMobileSidebar() now checks if the right panel is still open before hiding the overlay, preventing the overlay from disappearing when only the sidebar is closed. Fixes #247 Fixes #246 * feat: session ⋯ action dropdown replaces per-row buttons (#252) Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash) with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full keyboard (Escape), click-outside, scroll, and resize-reposition handling. Position:fixed prevents sidebar clipping. 5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete (danger style). Each with icon and descriptive subtitle. Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons asserts the new trigger and menu functions exist, old per-row classes are gone. Extracted from PR #242. * docs: v0.47.0 release notes, bump version, update test counts (645) --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-11 19:19:12 +00:00
)
# --- openrouter with slash model name MUST still route to openrouter -----
model_or, provider_or, _ = _resolve_with_config(
'google/gemma-4-26b-a4b',
provider='openrouter',
base_url='https://openrouter.ai/api/v1',
default='google/gemma-4-26b-a4b',
)
assert provider_or == 'openrouter', (
"Expected provider 'openrouter', got '{}'. "
"Slash model via openrouter provider must still resolve to openrouter.".format(provider_or)
)
assert model_or == 'google/gemma-4-26b-a4b', (
"Model name should be preserved for openrouter, got '{}'.".format(model_or)
)
fix(providers): keep custom provider when model id has known-provider prefix (#4210) (#4220) * fix: keep custom provider when model id has known-provider prefix (#4210) Siblings of #3872 (v0.51.349, custom provider with a configured base_url preserves intrinsic routing prefixes). The base_url unset variant of the same bug was missed: with provider=custom or custom:<slug> and no base_url set, a model id like 'x-ai/grok-2' or 'google/gemma-2' was hijacked to provider='openrouter', base_url=None. The backend then failed to initialize with 'RuntimeError: No LLM provider configured' because the user had no OpenRouter credentials configured. resolve_model_provider() now applies the same _is_custom exemption the config_base_url branch already carries to the cross-provider redirect at the end of the slash branch. The custom provider keeps the request and the full model id; real first-party providers pointed at an OpenAI-compatible proxy (e.g. provider=openai + base_url=litellm) are unaffected because their strip path is in the base_url branch and the 'prefix == config_provider' branch above the change. Verified: - test_model_resolver.py 32 passed (30 existing + 2 new RED tests) - test_custom_provider_bare_model_reasoning.py 34 passed - Pre-existing F401 (api/config.py:23,24,28) are not introduced by this change; same 3 errors exist on upstream/master. Refs #4210 * chore(release): reposition #4210 CHANGELOG entry under v0.51.426 (OM) --------- Co-authored-by: b3nw <b3nw@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-06-15 02:56:12 +00:00
# ── #4210: custom provider (no base_url) must not be hijacked to openrouter
# when the model id has a known-provider prefix (sibling of #3872, which
# only covered the base_url-set variant). Bug-report case 1.
def test_custom_provider_no_base_url_with_known_prefix_keeps_custom_and_full_id_4210():
"""#4210: provider=custom:llm-proxy (no base_url) + 'x-ai/grok-2' must NOT
be redirected to openrouter. The prefix is intrinsic to the custom proxy's
routing; the user did not pick anything from the OpenRouter dropdown."""
model, provider, base_url = _resolve_with_config(
'x-ai/grok-2',
provider='custom:llm-proxy',
default='x-ai/grok-2',
)
assert provider == 'custom:llm-proxy', (
"Custom provider must not be hijacked to openrouter when no base_url is "
"set; got provider={!r} model={!r}".format(provider, model)
)
assert model == 'x-ai/grok-2', (
"Custom provider must preserve the full model id; got model={!r}".format(model)
)
assert base_url is None
def test_bare_custom_provider_no_base_url_with_known_prefix_keeps_custom_and_full_id_4210():
"""#4210 sibling: bare 'custom' (no 'custom:<slug>') with no base_url
must also not be hijacked to openrouter for a known-prefix model id."""
model, provider, base_url = _resolve_with_config(
'google/gemma-2-9b',
provider='custom',
default='google/gemma-2-9b',
)
assert provider == 'custom', (
"Bare 'custom' provider must not be hijacked to openrouter when no "
"base_url is set; got provider={!r}".format(provider)
)
assert model == 'google/gemma-2-9b'
assert base_url is None
# ── providers: (config.yaml user-defined provider) scan (#5511) ─────────────
def _resolve_with_providers(model_id, providers_cfg, *, provider=None, default=None):
"""Helper: temporarily set config.cfg['providers'] + model, call resolve, restore."""
old_cfg = dict(config.cfg)
model_cfg = {}
if provider:
model_cfg['provider'] = provider
if default:
model_cfg['default'] = default
config.cfg['model'] = model_cfg
config.cfg['providers'] = providers_cfg
try:
return config.resolve_model_provider(model_id)
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
def test_providers_scan_routes_user_defined_allowlist_5511():
"""A user-defined providers.<slug>.models allowlist routes a bare model id
to that provider (the feature #5511 adds)."""
model, provider, base_url = _resolve_with_providers(
'my-model-1',
{'myprov': {'base_url': 'https://my.example/v1', 'models': ['my-model-1', 'my-model-2']}},
provider='openai',
default='gpt-5',
)
assert provider == 'myprov', f"user-defined provider allowlist must route; got {provider!r}"
assert model == 'my-model-1'
assert base_url == 'https://my.example/v1'
def test_providers_scan_skips_copilot_settings_map_5511():
"""providers.copilot.models is a per-model SETTINGS map, NOT a routable
allowlist — a Copilot per-model settings entry must NOT hijack routing away
from the model's real provider (#5511 gate-cert CORE finding)."""
model, provider, base_url = _resolve_with_providers(
'gpt-5',
{'copilot': {'models': {'gpt-5': {'reasoning_effort': 'high'}}}},
provider='openai',
default='gpt-5',
)
assert provider == 'openai', (
"Copilot settings-map entry must NOT hijack routing; "
f"gpt-5 must stay on openai, got {provider!r}"
)
assert model == 'gpt-5'
def test_providers_scan_copilot_list_shape_also_skipped_5511():
"""Defense in depth: even if providers.copilot.models is a list shape, the
Copilot exclusion still prevents a routing hijack."""
model, provider, base_url = _resolve_with_providers(
'gpt-5',
{'copilot': {'models': ['gpt-5', 'gpt-5-mini']}},
provider='openai',
default='gpt-5',
)
assert provider == 'openai', (
f"Copilot (list shape) must not hijack routing; got {provider!r}"
)
assert model == 'gpt-5'
def test_providers_scan_honors_active_provider_ownership_5511():
"""When the active provider owns the model (it's the configured default),
another provider's overlapping providers.<slug>.models entry must NOT hijack
routing away from the active provider (#5511 gate finding — active ai-gateway
+ default gpt-5 was being pulled to providers.openai.models.gpt-5)."""
model, provider, base_url = _resolve_with_providers(
'gpt-5',
{'openai': {'models': ['gpt-5']}},
provider='ai-gateway',
default='gpt-5',
)
assert provider == 'ai-gateway', (
"active provider that owns the default model must keep routing; "
f"gpt-5 must stay on ai-gateway, got {provider!r}"
)
assert model == 'gpt-5'
def test_providers_scan_active_provider_own_entry_still_matches_5511():
"""The ownership guard still lets the ACTIVE provider's own providers: entry
match (e.g. active myprov + a model in providers.myprov.models resolves to
myprov with its base_url)."""
model, provider, base_url = _resolve_with_providers(
'gpt-5',
{'myprov': {'base_url': 'https://my.example/v1', 'models': ['gpt-5']}},
provider='myprov',
default='gpt-5',
)
assert provider == 'myprov', f"active provider's own entry must match; got {provider!r}"
assert base_url == 'https://my.example/v1'
def test_providers_scan_ownership_guard_canonicalises_aliased_active_provider_5511():
"""An ALIASED active provider (e.g. 'z-ai' → canonical 'zai') must still be
recognized as owning its catalog models, so another providers.<slug>.models
entry can't hijack an active-owned model (#5511 latent-bug gate finding —
_provider_models_set was built with the raw alias, missing the canonical
_PROVIDER_MODELS key, so the ownership guard silently failed)."""
import api.config as config
# Pick a real catalog model id owned by the canonical 'zai' provider.
zai_models = config._PROVIDER_MODELS.get('zai') or []
zai_ids = [m.get('id') for m in zai_models if isinstance(m, dict) and m.get('id')]
if not zai_ids:
import pytest
pytest.skip("no zai catalog models to exercise the alias ownership guard")
owned = zai_ids[0]
model, provider, base_url = _resolve_with_providers(
owned,
{'openai': {'models': [owned]}},
provider='z-ai', # aliased form the user may write in config
)
assert config._canonicalise_provider_id('z-ai') == 'zai'
assert provider == 'z-ai', (
"aliased active provider (z-ai→zai) that owns the model must not be "
f"hijacked by providers.openai.models; got {provider!r}"
)
def test_providers_scan_ownership_guard_canonicalises_gemini_alias_5511():
"""A Gemini-family alias (google-gemini → gemini) must canonicalise so the
active provider is recognized as owning its catalog models (#5511 latent bug:
`gemini` is in _PROVIDER_MODELS but not _PROVIDER_DISPLAY, so the alias was
rejected and the ownership guard silently failed)."""
import api.config as config
assert config._canonicalise_provider_id('google-gemini') == 'gemini', (
"google-gemini must canonicalise to gemini"
)
gem_models = config._PROVIDER_MODELS.get('gemini') or []
gem_ids = [m.get('id') for m in gem_models if isinstance(m, dict) and m.get('id')]
if not gem_ids:
import pytest
pytest.skip("no gemini catalog models to exercise the alias ownership guard")
owned = gem_ids[0]
model, provider, base_url = _resolve_with_providers(
owned,
{'openai': {'models': [owned]}},
provider='google-gemini',
)
assert provider == 'google-gemini', (
"aliased active Gemini provider that owns the model must not be hijacked "
f"by providers.openai.models; got {provider!r}"
)
def test_providers_scan_active_own_providers_entry_owns_over_other_slug_5511():
"""An active provider defined purely via config.yaml `providers:` (no static
catalog entry) owns the models in its OWN providers.<active>.models allowlist,
so another provider's entry listing the same bare id (even earlier in config
order) must NOT hijack it (#5511 gate finding 5)."""
# 'openai' entry lists gpt-x first, but the ACTIVE provider (myprov) also
# declares gpt-x in its own providers.myprov.models — active must win.
model, provider, base_url = _resolve_with_providers(
'gpt-x',
{
'openai': {'models': ['gpt-x']},
'myprov': {'base_url': 'https://my.example/v1', 'models': ['gpt-x']},
},
provider='myprov',
)
assert provider == 'myprov', (
"active provider's own providers: allowlist must own its model over "
f"another slug's overlapping entry; got {provider!r}"
)
assert base_url == 'https://my.example/v1'