e0c3caf3b8
* fix(model-picker): serve cached custom-provider catalog on no-probe opens #58183 stopped GUI picker opens from live-probing saved custom OpenAI-compatible endpoints so a stopped local server could not stall the picker. It gated the whole discovery block, not just the network call, so `cached_fetch_api_models()` was skipped too — and with it the catalog an earlier probe had already written to `provider_models_cache.json`. A custom endpoint that is not the current provider therefore renders only the models named in its config entry. A local server with 8 models loaded shows the 1 model that was saved when the provider was first added, on every picker open, while an explicit Refresh shows all 8. Add `cache_only` to `cached_fetch_api_models()`: answer from disk within the existing stale-serve window, never fetch, never revalidate off-thread, return None on a miss. Split the three call sites in `list_authenticated_providers()` into what the user's config permits (`discover_models`, an explicit `models:` allowlist) and how we may obtain it, so suppressing the probe now downgrades to a cached read instead of skipping discovery outright. `discover_models: false` still pins, and a cache hit no longer writes back to config since the probe that populated it already did. The latency win stands: a cold cache is a miss, so picker opens against offline endpoints still make zero network calls. * test(model-picker): pin the cached-catalog contract for no-probe opens Cover both halves of the invariant, since fixing either one alone reintroduces a bug the other guards against. `cache_only` on `cached_fetch_api_models()`: a fresh entry and an entry past its TTL but inside the stale-serve window both serve; an entry beyond that window, an empty cache, rotated credentials, `force_refresh`, and a missing base_url are all misses — and none of them fetch or spawn a background revalidation. `list_authenticated_providers()` on the GUI path: a non-current endpoint with a warm cache reports its full catalog across all three provider shapes (`custom_providers`, `providers:`, bare `provider: custom`) with no live fetch attempted. A cold cache keeps the configured list and still makes no network call, which is the #58183 guarantee. `discover_models: false` keeps pinning, and a cache hit does not write back to config. * fix: persist discovered custom-provider models in the hermes model flow The `hermes model` named-custom-provider flow (_model_flow_named_custom) probes the endpoint and shows the full catalog, but never persists it to the entry's `models:` list. No-probe surfaces (dashboard, desktop, ACP) call build_models_payload(..., probe_custom_providers=False) and only render the configured `models:` list, so a provider added via `hermes model` collapses to the single `model:` default everywhere except the CLI. OpenAI-compatible providers added via a probing picker already benefit from _save_discovered_models_to_config; the CLI flow did not. Persist the live catalog after a successful probe, mirroring the picker path in model_switch.py. A failed save is non-fatal. * fix(model-picker): stop an auto-saved catalog pinning a keyless endpoint The cached-catalog read added for no-probe picker opens still sat behind the no-key discovery gate, so it never reached the shape that motivated it: a keyless local model server. `bool(api_key) or not has_explicit_models` is a network-cost gate. It exists so Hermes does not probe an endpoint it cannot authenticate to when that endpoint already declares its catalog (5f00f36ba,1039e90b5). Reading a catalog an earlier probe already paid for costs nothing, so the gate belongs on the probe, not on discovery as a whole. Left on the discovery side it re-pins the endpoint it was meant to spare. A successful probe calls `_save_discovered_models_to_config()`, which writes a plain list into `models:` — exactly the shape `_models_config_is_allowlist()` reads back as an explicit user allowlist. A keyless server therefore froze on the catalog of its first probe and could never widen again, which is the "lineup changes after config was written" case.f66319097already carved the dict shape out of this trap for the same reason; the list shape is the other door into it. Move the clause to `_probe_live` at both custom-endpoint sites. Probe suppression is unchanged — verified byte-identical to main across the keyed/keyless x declared/undeclared matrix — and `discover_models: false` remains the documented way to pin a catalog. * test(model-picker): cover the keyless auto-save pinning trap Three tests around the gate move, each failing on the code before it: - a keyless endpoint carrying an auto-saved `models:` list still reads its full cached catalog - the same row, cold cache and probing enabled, still makes zero live fetches — the network-cost gate the clause exists for - an end-to-end round trip: persist a probe result via `_save_discovered_models_to_config()`, reload it, and assert the shape we wrote does not read back as a user pin The round-trip test guards the whole chain rather than one branch, so a future change that makes the saved shape look like an intentional allowlist fails here even if the gate logic is refactored. * fix(model-picker): key the custom-endpoint model cache by api_mode `cached_fetch_api_models()` fingerprints entries with `api_mode`, but no call site in `list_authenticated_providers()` passed it, so every custom row resolved to the `api_mode=None` fingerprint. Two rows sharing a base_url and credential but differing by `api_mode` are deliberately distinct picker rows — it is part of `group_key` at both sites — yet they collapsed onto one cache entry. That was latent while probing was the only way to fill a row: a mismatched entry was overwritten by the row's own live fetch. Serving that entry without a probe makes it visible, so an `anthropic_messages` row could render the catalog an OpenAI-mode row cached against the same URL. The wire protocols differ (`x-api-key` + `anthropic-version` vs `Authorization: Bearer`), so those catalogs are not interchangeable. Persist `api_mode` on the group at both grouping sites — it is already part of `group_key`, so it is constant across the group — and pass it into the cache read. Section 3b (bare `provider: custom`) has no `api_mode` in scope and already reads with the empty-credential fingerprint, so it is unchanged. Reported by Copilot review on #81973. --------- Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com> Co-authored-by: Navlem <114683850+Navlem@users.noreply.github.com>
1446 lines
52 KiB
Python
1446 lines
52 KiB
Python
"""Regression tests for /model support of config.yaml custom_providers.
|
|
|
|
The terminal `hermes model` flow already exposes `custom_providers`, but the
|
|
shared slash-command pipeline (`/model` in CLI/gateway/Telegram) historically
|
|
only looked at `providers:`.
|
|
"""
|
|
|
|
import time
|
|
|
|
import hermes_cli.providers as providers_mod
|
|
import pytest
|
|
import yaml
|
|
from hermes_cli.model_switch import (
|
|
_save_discovered_models_to_config,
|
|
list_authenticated_providers,
|
|
switch_model,
|
|
)
|
|
from hermes_cli.providers import resolve_provider_full
|
|
|
|
|
|
_MOCK_VALIDATION = {
|
|
"accepted": True,
|
|
"persist": True,
|
|
"recognized": True,
|
|
"message": None,
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _disable_live_custom_provider_model_probe(monkeypatch):
|
|
"""Keep custom-provider picker fixtures independent of local model servers."""
|
|
monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *_a, **_kw: None)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.models.cached_provider_model_ids", lambda *_a, **_kw: []
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.models.provider_model_ids", lambda *_a, **_kw: []
|
|
)
|
|
|
|
|
|
def test_list_authenticated_providers_includes_custom_providers(monkeypatch):
|
|
"""No-args /model menus should include saved custom_providers entries."""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *a, **k: [])
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="openai-codex",
|
|
user_providers={},
|
|
custom_providers=[
|
|
{
|
|
"name": "Local (127.0.0.1:4141)",
|
|
"base_url": "http://127.0.0.1:4141/v1",
|
|
"model": "rotator-openrouter-coding",
|
|
}
|
|
],
|
|
max_models=50,
|
|
)
|
|
|
|
assert any(
|
|
p["slug"] == "custom:local-(127.0.0.1:4141)"
|
|
and p["name"] == "Local (127.0.0.1:4141)"
|
|
and p["models"] == ["rotator-openrouter-coding"]
|
|
and p["api_url"] == "http://127.0.0.1:4141/v1"
|
|
for p in providers
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_provider_full_finds_named_custom_provider():
|
|
"""Explicit /model --provider should resolve saved custom_providers entries."""
|
|
resolved = resolve_provider_full(
|
|
"custom:local-(127.0.0.1:4141)",
|
|
user_providers={},
|
|
custom_providers=[
|
|
{
|
|
"name": "Local (127.0.0.1:4141)",
|
|
"base_url": "http://127.0.0.1:4141/v1",
|
|
}
|
|
],
|
|
)
|
|
|
|
assert resolved is not None
|
|
assert resolved.id == "custom:local-(127.0.0.1:4141)"
|
|
assert resolved.name == "Local (127.0.0.1:4141)"
|
|
assert resolved.base_url == "http://127.0.0.1:4141/v1"
|
|
assert resolved.source == "user-config"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"requested",
|
|
[
|
|
"Local Ollama",
|
|
"local-ollama",
|
|
"local-127.0.0.1:11434",
|
|
"custom:local-ollama",
|
|
"custom:local-127.0.0.1:11434",
|
|
],
|
|
)
|
|
def test_keyed_custom_provider_legacy_aliases_resolve_to_stable_key(requested):
|
|
"""Every historical identity resolves, but keyed providers return one ID."""
|
|
resolved = resolve_provider_full(
|
|
requested,
|
|
user_providers={},
|
|
custom_providers=[
|
|
{
|
|
"name": "Local Ollama",
|
|
"provider_key": "local-127.0.0.1:11434",
|
|
"base_url": "http://127.0.0.1:11434/v1",
|
|
}
|
|
],
|
|
)
|
|
|
|
assert resolved is not None
|
|
assert resolved.id == "custom:local-127.0.0.1:11434"
|
|
assert resolved.name == "Local Ollama"
|
|
|
|
|
|
def test_keyed_custom_provider_bare_custom_fallback_uses_stable_key():
|
|
resolved = resolve_provider_full(
|
|
"custom",
|
|
user_providers={},
|
|
custom_providers=[
|
|
{
|
|
"name": "Local Ollama",
|
|
"provider_key": "local-127.0.0.1:11434",
|
|
"base_url": "http://127.0.0.1:11434/v1",
|
|
}
|
|
],
|
|
)
|
|
|
|
assert resolved is not None
|
|
assert resolved.id == "custom:local-127.0.0.1:11434"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"requested",
|
|
["foo", "custom:foo", "custom:custom:foo"],
|
|
)
|
|
def test_prefixed_provider_key_does_not_accumulate_custom_prefixes(requested):
|
|
"""Accept the historical doubled form without writing a third identity."""
|
|
resolved = resolve_provider_full(
|
|
requested,
|
|
user_providers={},
|
|
custom_providers=[
|
|
{
|
|
"name": "Foo Relay",
|
|
"provider_key": "custom:foo",
|
|
"base_url": "https://foo.example/v1",
|
|
}
|
|
],
|
|
)
|
|
|
|
assert resolved is not None
|
|
assert resolved.id == "custom:foo"
|
|
|
|
|
|
def test_list_authenticated_providers_includes_active_bare_custom_endpoint(monkeypatch):
|
|
"""Bare model.provider=custom + model.base_url should still populate /model.
|
|
|
|
Users can configure a one-off OpenAI-compatible endpoint directly under
|
|
``model:`` without a named ``providers:`` or ``custom_providers:`` row.
|
|
The gateway picker receives only the current model/base_url slice, so it
|
|
must surface that active endpoint rather than looking like config was
|
|
ignored.
|
|
"""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="custom",
|
|
current_base_url="https://www.ccsub.net/v1",
|
|
current_model="gpt-4o",
|
|
user_providers={},
|
|
custom_providers=[],
|
|
max_models=50,
|
|
)
|
|
|
|
bare_custom = next((p for p in providers if p["slug"] == "custom"), None)
|
|
assert bare_custom is not None
|
|
assert bare_custom["name"] == "Custom endpoint"
|
|
assert bare_custom["is_current"] is True
|
|
assert bare_custom["is_user_defined"] is True
|
|
assert bare_custom["models"] == ["gpt-4o"]
|
|
assert bare_custom["api_url"] == "https://www.ccsub.net/v1"
|
|
|
|
|
|
def test_list_authenticated_providers_can_probe_active_bare_custom_endpoint(monkeypatch):
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
monkeypatch.setattr(
|
|
"hermes_cli.models.fetch_api_models",
|
|
lambda api_key, api_url, **kwargs: ["gpt-4o", "gpt-4o-mini"],
|
|
)
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="custom",
|
|
current_base_url="https://www.ccsub.net/v1",
|
|
current_model="gpt-4o",
|
|
user_providers={},
|
|
custom_providers=[],
|
|
probe_custom_providers=False,
|
|
probe_current_custom_provider=True,
|
|
)
|
|
|
|
bare_custom = next(p for p in providers if p["slug"] == "custom")
|
|
assert bare_custom["is_current"] is True
|
|
assert bare_custom["models"] == ["gpt-4o", "gpt-4o-mini"]
|
|
|
|
|
|
def test_switch_model_accepts_explicit_bare_custom_current_endpoint(monkeypatch):
|
|
"""Picker selections for bare custom endpoints should route to current base_url."""
|
|
monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
|
|
monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
|
|
monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)
|
|
|
|
result = switch_model(
|
|
raw_input="gpt-4o-mini",
|
|
current_provider="custom",
|
|
current_model="gpt-4o",
|
|
current_base_url="https://www.ccsub.net/v1",
|
|
current_api_key="sk-test",
|
|
explicit_provider="custom",
|
|
user_providers={},
|
|
custom_providers=[],
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.target_provider == "custom"
|
|
assert result.provider_label == "Custom endpoint"
|
|
assert result.new_model == "gpt-4o-mini"
|
|
assert result.base_url == "https://www.ccsub.net/v1"
|
|
assert result.api_key == "sk-test"
|
|
|
|
|
|
def test_is_aggregator_recognizes_named_custom_provider():
|
|
assert providers_mod.is_aggregator("custom:hpc-ai") is True
|
|
assert providers_mod.is_aggregator("custom:litellm") is True
|
|
|
|
|
|
def test_is_aggregator_leaves_unknown_provider_non_aggregator():
|
|
assert providers_mod.is_aggregator("not-a-provider") is False
|
|
|
|
|
|
def test_is_routing_aggregator_excludes_flat_namespace_resellers():
|
|
"""opencode-go / opencode-zen stay ``is_aggregator=True`` (model-switch
|
|
relies on it to search their flat bare-name catalog), but they are NOT
|
|
routing aggregators — their models are first-party, so the picker dedup
|
|
must not strip them. (#47077)"""
|
|
# Still aggregators for model-switch flat-catalog resolution.
|
|
assert providers_mod.is_aggregator("opencode-go") is True
|
|
assert providers_mod.is_aggregator("opencode-zen") is True
|
|
# But NOT routing aggregators for picker-dedup purposes.
|
|
assert providers_mod.is_routing_aggregator("opencode-go") is False
|
|
assert providers_mod.is_routing_aggregator("opencode-zen") is False
|
|
# True routers and custom proxies remain routing aggregators.
|
|
assert providers_mod.is_routing_aggregator("openrouter") is True
|
|
assert providers_mod.is_routing_aggregator("custom:litellm") is True
|
|
assert providers_mod.is_routing_aggregator("not-a-provider") is False
|
|
|
|
|
|
def test_picker_selection_resolves_named_custom_provider_model_id(monkeypatch):
|
|
"""Picker prefixes must not leak into a named custom provider API model id."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
|
lambda **kwargs: {
|
|
"api_key": "test-key",
|
|
"base_url": "https://token.sensenova.cn/v1",
|
|
"api_mode": "chat_completions",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.models.validate_requested_model",
|
|
lambda *a, **k: _MOCK_VALIDATION,
|
|
)
|
|
monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.model_switch.get_model_capabilities",
|
|
lambda *a, **k: None,
|
|
)
|
|
|
|
result = switch_model(
|
|
raw_input="sensenova/deepseek-v4-flash",
|
|
current_provider="openai-codex",
|
|
current_model="gpt-5.4",
|
|
explicit_provider="custom:sensenova",
|
|
user_providers={},
|
|
custom_providers=[
|
|
{
|
|
"name": "sensenova",
|
|
"base_url": "https://token.sensenova.cn/v1",
|
|
"models": [
|
|
{"id": "deepseek-v4-flash", "name": "deepseek-v4-flash"}
|
|
],
|
|
}
|
|
],
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.target_provider == "custom:sensenova"
|
|
assert result.new_model == "deepseek-v4-flash"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# #9210: group custom_providers by (base_url, api_key) in /model picker
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_list_authenticated_providers_bare_custom_slug_recovers(monkeypatch):
|
|
"""Regression for #17478: when a prior failed switch left the bare
|
|
literal "custom" in model.provider, the picker must NOT propagate
|
|
that broken slug. It must fall back to the canonical
|
|
``custom:<name>`` form so the picker stays usable."""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="custom",
|
|
current_base_url="http://localhost:11434/v1",
|
|
user_providers={},
|
|
custom_providers=[
|
|
{"name": "Ollama — GLM 5.1", "base_url": "http://localhost:11434/v1",
|
|
"api_key": "ollama", "model": "glm-5.1"},
|
|
],
|
|
max_models=50,
|
|
)
|
|
|
|
matches = [p for p in providers if p.get("is_user_defined")]
|
|
assert len(matches) == 1
|
|
group = matches[0]
|
|
# Canonical slug, NOT the bare "custom" that caused #17478
|
|
assert group["slug"] == "custom:ollama"
|
|
assert group["is_current"] is True
|
|
|
|
|
|
def test_compatible_keyed_provider_uses_stable_key_and_accepts_legacy_current_name(
|
|
monkeypatch,
|
|
):
|
|
"""The merged providers view keeps the config key while old IDs stay current."""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="custom:local-ollama",
|
|
user_providers={},
|
|
custom_providers=[
|
|
{
|
|
"name": "Local Ollama",
|
|
"provider_key": "local-127.0.0.1:11434",
|
|
"base_url": "http://127.0.0.1:11434/v1",
|
|
"model": "qwen3.5:9b",
|
|
}
|
|
],
|
|
max_models=50,
|
|
probe_custom_providers=False,
|
|
)
|
|
|
|
row = next(p for p in providers if p.get("is_user_defined"))
|
|
assert row["slug"] == "custom:local-127.0.0.1:11434"
|
|
assert row["is_current"] is True
|
|
|
|
|
|
def test_user_provider_row_recognizes_stable_custom_key_as_current(monkeypatch):
|
|
"""Section 3 keeps its legacy row slug but recognizes the stable ID."""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="custom:local-127.0.0.1:11434",
|
|
user_providers={
|
|
"local-127.0.0.1:11434": {
|
|
"name": "Local Ollama",
|
|
"base_url": "http://127.0.0.1:11434/v1",
|
|
"default_model": "qwen3.5:9b",
|
|
}
|
|
},
|
|
custom_providers=[],
|
|
max_models=50,
|
|
probe_custom_providers=False,
|
|
)
|
|
|
|
row = next(p for p in providers if p.get("is_user_defined"))
|
|
assert row["slug"] == "local-127.0.0.1:11434"
|
|
assert row["is_current"] is True
|
|
|
|
|
|
def test_list_authenticated_providers_distinct_endpoints_stay_separate(monkeypatch):
|
|
"""Entries with different base_urls must produce separate picker rows
|
|
even if some display names happen to be similar."""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
|
|
providers = list_authenticated_providers(
|
|
user_providers={},
|
|
custom_providers=[
|
|
{"name": "Ollama — GLM 5.1", "base_url": "http://localhost:11434/v1",
|
|
"api_key": "ollama", "model": "glm-5.1"},
|
|
{"name": "Moonshot", "base_url": "https://api.moonshot.cn/v1",
|
|
"api_key": "sk-m", "model": "moonshot-v1"},
|
|
{"name": "Ollama — Qwen3-coder", "base_url": "http://localhost:11434/v1",
|
|
"api_key": "ollama", "model": "qwen3-coder"},
|
|
],
|
|
max_models=50,
|
|
probe_custom_providers=False,
|
|
)
|
|
|
|
custom_groups = [p for p in providers if p.get("is_user_defined")]
|
|
assert len(custom_groups) == 2
|
|
# Ollama endpoint collapses to one row with both models
|
|
ollama = next(p for p in custom_groups if p["name"] == "Ollama")
|
|
assert set(ollama["models"]) == {"glm-5.1", "qwen3-coder"}
|
|
moonshot = next(p for p in custom_groups if p["name"] == "Moonshot")
|
|
assert moonshot["models"] == ["moonshot-v1"]
|
|
|
|
|
|
def test_list_authenticated_providers_same_url_different_keys_disambiguated(monkeypatch):
|
|
"""Two custom_providers entries with the same base_url but different
|
|
api_keys (and identical cleaned names) must both stay visible in the
|
|
picker — slug is suffixed to disambiguate."""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
|
|
providers = list_authenticated_providers(
|
|
user_providers={},
|
|
custom_providers=[
|
|
{"name": "OpenAI — key A", "base_url": "https://api.openai.com/v1",
|
|
"api_key": "sk-AAA", "model": "gpt-5.4"},
|
|
{"name": "OpenAI — key B", "base_url": "https://api.openai.com/v1",
|
|
"api_key": "sk-BBB", "model": "gpt-4.6"},
|
|
],
|
|
max_models=50,
|
|
)
|
|
|
|
custom_groups = [p for p in providers if p.get("is_user_defined")]
|
|
assert len(custom_groups) == 2
|
|
slugs = sorted(p["slug"] for p in custom_groups)
|
|
# First group keeps the base slug, second gets a numeric suffix
|
|
assert slugs == ["custom:openai", "custom:openai-2"]
|
|
# Each row has a distinct model
|
|
models = {p["slug"]: p["models"] for p in custom_groups}
|
|
assert models["custom:openai"] == ["gpt-5.4"]
|
|
assert models["custom:openai-2"] == ["gpt-4.6"]
|
|
|
|
|
|
def test_list_authenticated_providers_same_url_different_key_env_and_api_mode_stay_separate(monkeypatch):
|
|
"""Same gateway host but different key_env/api_mode entries are distinct providers."""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="custom:gpt",
|
|
current_base_url="https://gateway.example.com",
|
|
user_providers={},
|
|
custom_providers=[
|
|
{
|
|
"name": "gpt",
|
|
"base_url": "https://gateway.example.com",
|
|
"key_env": "GPT_KEY",
|
|
"api_mode": "codex_responses",
|
|
"model": "gpt-5.5",
|
|
},
|
|
{
|
|
"name": "claude",
|
|
"base_url": "https://gateway.example.com",
|
|
"key_env": "CLAUDE_KEY",
|
|
"api_mode": "anthropic_messages",
|
|
"model": "claude-opus-4-8",
|
|
},
|
|
],
|
|
max_models=50,
|
|
)
|
|
|
|
custom = [p for p in providers if p.get("is_user_defined")]
|
|
by_slug = {p["slug"]: p for p in custom}
|
|
|
|
assert set(by_slug) == {"custom:gpt", "custom:claude"}
|
|
assert by_slug["custom:gpt"]["models"] == ["gpt-5.5"]
|
|
assert by_slug["custom:claude"]["models"] == ["claude-opus-4-8"]
|
|
assert by_slug["custom:gpt"]["is_current"] is True
|
|
assert by_slug["custom:claude"]["is_current"] is False
|
|
|
|
|
|
def test_list_authenticated_providers_total_models_reflects_grouped_count(monkeypatch):
|
|
"""After grouping six entries into one row, total_models must reflect
|
|
the full count, and every grouped model appears in the list."""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
|
|
entries = [
|
|
{"name": f"Ollama \u2014 Model {i}", "base_url": "http://localhost:11434/v1",
|
|
"api_key": "ollama", "model": f"model-{i}"}
|
|
for i in range(6)
|
|
]
|
|
providers = list_authenticated_providers(
|
|
user_providers={},
|
|
custom_providers=entries,
|
|
max_models=4,
|
|
probe_custom_providers=False,
|
|
)
|
|
|
|
groups = [p for p in providers if p.get("is_user_defined")]
|
|
assert len(groups) == 1
|
|
group = groups[0]
|
|
assert group["total_models"] == 6
|
|
# All six models are preserved in the grouped row.
|
|
assert sorted(group["models"]) == sorted(f"model-{i}" for i in range(6))
|
|
|
|
|
|
def test_lmstudio_picker_probes_active_config_base_url(monkeypatch):
|
|
"""When `provider: lmstudio` is saved with a remote base_url and no
|
|
LM_BASE_URL env var, the picker must probe the saved base_url — not
|
|
127.0.0.1. Regression: prior behavior always probed localhost, so users
|
|
with LM Studio on a lab box saw the wrong (or empty) model list.
|
|
"""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
monkeypatch.delenv("LM_BASE_URL", raising=False)
|
|
monkeypatch.delenv("LM_API_KEY", raising=False)
|
|
|
|
captured: dict = {}
|
|
|
|
def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
|
|
captured["base_url"] = base_url
|
|
captured["api_key"] = api_key
|
|
return ["qwen/qwen3-coder-30b"]
|
|
|
|
monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch)
|
|
|
|
list_authenticated_providers(
|
|
current_provider="lmstudio",
|
|
current_base_url="http://192.168.1.10:1234/v1",
|
|
current_model="qwen/qwen3-coder-30b",
|
|
)
|
|
|
|
assert captured["base_url"] == "http://192.168.1.10:1234/v1"
|
|
|
|
|
|
def test_lmstudio_picker_lm_base_url_env_wins_over_active_config(monkeypatch):
|
|
"""LM_BASE_URL env var must still take precedence over the saved
|
|
base_url so users can temporarily redirect the picker without editing
|
|
config.yaml.
|
|
"""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
monkeypatch.setenv("LM_BASE_URL", "http://override.local:9999/v1")
|
|
monkeypatch.delenv("LM_API_KEY", raising=False)
|
|
|
|
captured: dict = {}
|
|
|
|
def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
|
|
captured["base_url"] = base_url
|
|
return []
|
|
|
|
monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch)
|
|
|
|
list_authenticated_providers(
|
|
current_provider="lmstudio",
|
|
current_base_url="http://192.168.1.10:1234/v1",
|
|
)
|
|
|
|
assert captured["base_url"] == "http://override.local:9999/v1"
|
|
|
|
|
|
def test_lmstudio_picker_skips_probe_when_not_configured(monkeypatch):
|
|
"""If the user has never configured LM Studio (no LM_API_KEY / LM_BASE_URL
|
|
and not on lmstudio), the picker must not pay the localhost probe cost
|
|
just to discover LM Studio is unavailable.
|
|
"""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
monkeypatch.delenv("LM_BASE_URL", raising=False)
|
|
monkeypatch.delenv("LM_API_KEY", raising=False)
|
|
|
|
captured: dict = {}
|
|
|
|
def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
|
|
captured["base_url"] = base_url
|
|
return []
|
|
|
|
monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch)
|
|
|
|
list_authenticated_providers(
|
|
current_provider="openrouter",
|
|
current_base_url="https://openrouter.ai/api/v1",
|
|
)
|
|
|
|
assert "base_url" not in captured
|
|
|
|
|
|
def test_custom_providers_uses_live_models_for_multi_model_endpoint(monkeypatch):
|
|
"""Custom providers with api_key + base_url should prefer live /models.
|
|
|
|
Custom providers (section 4 of list_authenticated_providers) point at
|
|
gateways like Bifrost that expose hundreds of models. Reading only the
|
|
static ``models:`` dict from config.yaml leaves the /model picker with
|
|
a stale subset. Live discovery fills the picker with all available
|
|
models from the endpoint.
|
|
"""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
|
|
|
|
calls = []
|
|
|
|
def fake_fetch_api_models(api_key, base_url, **kwargs):
|
|
calls.append((api_key, base_url, kwargs))
|
|
return ["gateway-model-a", "gateway-model-b", "gateway-model-c"]
|
|
|
|
monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)
|
|
|
|
custom_providers = [
|
|
{
|
|
"name": "my-gateway",
|
|
"api_key": "sk-gateway-key",
|
|
"base_url": "https://gateway.example.com/v1",
|
|
"model": "gateway-model-a",
|
|
"models": {
|
|
"gateway-model-a": {"context_length": 128000},
|
|
"gateway-model-b": {"context_length": 128000},
|
|
},
|
|
}
|
|
]
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="openrouter",
|
|
current_base_url="https://openrouter.ai/api/v1",
|
|
custom_providers=custom_providers,
|
|
max_models=50,
|
|
)
|
|
|
|
gateway_prov = next(
|
|
(
|
|
p
|
|
for p in providers
|
|
if p.get("api_url") == "https://gateway.example.com/v1"
|
|
),
|
|
None,
|
|
)
|
|
|
|
assert gateway_prov is not None, "Custom provider group not found in results"
|
|
assert calls == [
|
|
(
|
|
"sk-gateway-key",
|
|
"https://gateway.example.com/v1",
|
|
{"timeout": 5.0, "api_mode": None, "headers": None},
|
|
)
|
|
], "fetch_api_models must be called with the custom provider's credentials"
|
|
assert gateway_prov["models"] == [
|
|
"gateway-model-a",
|
|
"gateway-model-b",
|
|
"gateway-model-c",
|
|
], "Live models must replace the static subset"
|
|
assert gateway_prov["total_models"] == 3
|
|
|
|
|
|
def test_same_endpoint_different_extra_headers_not_collapsed(monkeypatch):
|
|
"""Entries sharing (api_url, credential, api_mode) but declaring different
|
|
extra_headers must NOT collapse into one picker row — each is a distinct
|
|
header-authenticated endpoint (e.g. per-tenant routing behind one proxy)
|
|
and must probe /models with its own headers."""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
|
|
|
|
calls = []
|
|
|
|
def fake_fetch_api_models(api_key, base_url, **kwargs):
|
|
calls.append((api_key, base_url, kwargs.get("headers")))
|
|
# Return a per-tenant model list keyed by the routing header so we can
|
|
# assert each row got its OWN probe rather than a shared one.
|
|
tenant = (kwargs.get("headers") or {}).get("X-Tenant", "none")
|
|
return [f"model-{tenant}"]
|
|
|
|
monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="openrouter",
|
|
current_base_url="https://openrouter.ai/api/v1",
|
|
custom_providers=[
|
|
{
|
|
"name": "Proxy Tenant A",
|
|
"api_key": "shared-key",
|
|
"base_url": "http://localhost:8081/v1",
|
|
"extra_headers": {"X-Tenant": "a"},
|
|
},
|
|
{
|
|
"name": "Proxy Tenant B",
|
|
"api_key": "shared-key",
|
|
"base_url": "http://localhost:8081/v1",
|
|
"extra_headers": {"X-Tenant": "b"},
|
|
},
|
|
],
|
|
max_models=50,
|
|
)
|
|
|
|
rows = [
|
|
p for p in providers if p.get("api_url") == "http://localhost:8081/v1"
|
|
]
|
|
# Two distinct rows, not one collapsed row.
|
|
assert len(rows) == 2, f"expected 2 rows, got {len(rows)}: {rows}"
|
|
|
|
# Each tenant was probed with its OWN header set (order-independent).
|
|
assert ("shared-key", "http://localhost:8081/v1", {"X-Tenant": "a"}) in calls
|
|
assert ("shared-key", "http://localhost:8081/v1", {"X-Tenant": "b"}) in calls
|
|
|
|
# Each row surfaces the model list its own headers unlocked.
|
|
models_by_row = {tuple(r["models"]) for r in rows}
|
|
assert models_by_row == {("model-a",), ("model-b",)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_custom_provider_passes_key_env():
|
|
"""resolve_custom_provider should propagate key_env into api_key_env_vars.
|
|
|
|
Regression: previously api_key_env_vars was always (), silently dropping
|
|
the configured env var and causing 401s on every request.
|
|
"""
|
|
from hermes_cli.providers import resolve_custom_provider
|
|
|
|
resolved = resolve_custom_provider(
|
|
"custom:token-plan",
|
|
custom_providers=[
|
|
{
|
|
"name": "token-plan",
|
|
"base_url": "https://token-plan-sgp.xiaomimimo.com/v1",
|
|
"key_env": "XIAOMI_MIMO_API_KEY",
|
|
"model": "mimo-v2-pro",
|
|
}
|
|
],
|
|
)
|
|
|
|
assert resolved is not None
|
|
assert resolved.api_key_env_vars == ("XIAOMI_MIMO_API_KEY",)
|
|
assert resolved.base_url == "https://token-plan-sgp.xiaomimimo.com/v1"
|
|
|
|
|
|
def test_discovered_models_auto_saved_to_cache(monkeypatch):
|
|
"""Discovered models are persisted to config so ``discover_models: false``
|
|
has a populated cache on the next read (#65652).
|
|
|
|
When a successful probe returns live models, ``_save_discovered_models_to_config``
|
|
must be called with the provider's base_url and the discovered model list.
|
|
"""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
|
|
|
|
save_calls = []
|
|
|
|
def fake_fetch_api_models(api_key, base_url, **kwargs):
|
|
return ["discovered-a", "discovered-b", "discovered-c"]
|
|
|
|
monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.model_switch._save_discovered_models_to_config",
|
|
lambda api_url, model_ids: save_calls.append((api_url, model_ids)),
|
|
)
|
|
|
|
custom_providers = [
|
|
{
|
|
"name": "my-gateway",
|
|
"api_key": "***",
|
|
"base_url": "https://gateway.example.com/v1",
|
|
"discover_models": True,
|
|
"model": "only-model",
|
|
"models": {"only-model": {"context_length": 128000}},
|
|
}
|
|
]
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="my-gateway",
|
|
current_base_url="https://gateway.example.com/v1",
|
|
custom_providers=custom_providers,
|
|
max_models=50,
|
|
probe_custom_providers=True,
|
|
)
|
|
|
|
assert len(save_calls) == 1, (
|
|
"_save_discovered_models_to_config must be called after a successful probe"
|
|
)
|
|
assert save_calls[0][0] == "https://gateway.example.com/v1"
|
|
assert save_calls[0][1] == ["discovered-a", "discovered-b", "discovered-c"]
|
|
|
|
gateway_prov = next(
|
|
(p for p in providers if p.get("api_url") == "https://gateway.example.com/v1"),
|
|
None,
|
|
)
|
|
assert gateway_prov is not None
|
|
assert gateway_prov["models"] == ["discovered-a", "discovered-b", "discovered-c"]
|
|
|
|
|
|
|
|
|
|
def test_save_discovered_models_preserves_dict_form(monkeypatch):
|
|
"""``_save_discovered_models_to_config`` must not replace a dict-form
|
|
``models`` mapping (per-model metadata like ``context_length``) with
|
|
a flat list of strings (#67841)."""
|
|
from hermes_cli.model_switch import _save_discovered_models_to_config
|
|
|
|
save_calls = []
|
|
|
|
def fake_save(config):
|
|
save_calls.append(dict(config))
|
|
|
|
monkeypatch.setattr("hermes_cli.config.save_config", fake_save)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config",
|
|
lambda: {
|
|
"custom_providers": [
|
|
{
|
|
"name": "my-gateway",
|
|
"base_url": "https://gateway.example.com/v1",
|
|
"models": {
|
|
"configured-model": {"context_length": 8192},
|
|
},
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
# Dict-form models must NOT be overwritten by discovered models
|
|
_save_discovered_models_to_config(
|
|
"https://gateway.example.com/v1",
|
|
["configured-model", "discovered-model"],
|
|
)
|
|
assert save_calls == [], (
|
|
"Dict-form models must not be replaced with a flat list"
|
|
)
|
|
|
|
|
|
def test_model_flow_named_custom_persists_discovered_models(monkeypatch):
|
|
"""The ``hermes model`` named-custom-provider flow persists the discovered
|
|
catalog back to the entry's ``models:`` list.
|
|
|
|
No-probe surfaces (dashboard, desktop, ACP) call
|
|
``build_models_payload(..., probe_custom_providers=False)`` and only show
|
|
the configured ``models:`` list. The CLI flow probes and shows the full
|
|
catalog but (before this fix) never saved it, so a provider added via
|
|
``hermes model`` collapsed to the single ``model:`` default everywhere but
|
|
the CLI. It must persist discovered models the same way the picker path in
|
|
``_save_discovered_models_to_config`` does.
|
|
"""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.models.fetch_api_models",
|
|
lambda api_key, base_url, **kw: [
|
|
"discovered-a",
|
|
"discovered-b",
|
|
"discovered-c",
|
|
],
|
|
)
|
|
# Non-interactive model selection.
|
|
monkeypatch.setattr(
|
|
"hermes_cli.curses_ui.curses_radiolist", lambda *a, **k: 0
|
|
)
|
|
# No-op downstream writes so the test never touches a real config.
|
|
monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *a, **k: None)
|
|
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda *a, **k: None)
|
|
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda *a, **k: None)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config",
|
|
lambda: {"model": {}, "providers": {}, "custom_providers": []},
|
|
)
|
|
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
|
|
|
|
save_calls = []
|
|
monkeypatch.setattr(
|
|
"hermes_cli.model_switch._save_discovered_models_to_config",
|
|
lambda api_url, model_ids: save_calls.append((api_url, model_ids)),
|
|
)
|
|
|
|
from hermes_cli.model_setup_flows import _model_flow_named_custom
|
|
|
|
_model_flow_named_custom(
|
|
{},
|
|
{
|
|
"name": "Dragomes",
|
|
"base_url": "http://example.com/v1",
|
|
"api_mode": "anthropic_messages",
|
|
"api_key": "sk-test",
|
|
"key_env": "",
|
|
"model": "MiniMax-M3",
|
|
"provider_key": "",
|
|
"discover_models": True,
|
|
"models": {},
|
|
},
|
|
)
|
|
|
|
assert save_calls == [
|
|
(
|
|
"http://example.com/v1",
|
|
["discovered-a", "discovered-b", "discovered-c"],
|
|
)
|
|
], (
|
|
"_model_flow_named_custom must persist discovered models "
|
|
"(base_url, model_ids) after a successful probe"
|
|
)
|
|
|
|
|
|
def test_shared_url_different_display_names_are_separate_rows(monkeypatch):
|
|
"""Multiple custom_providers entries sharing base_url + api_key + api_mode
|
|
but with *different* display-name prefixes (e.g. a proxy fronting
|
|
cerebras, groq and perplexity at one URL) must each get their own picker
|
|
row, not collapse into one."""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
# Stub live discovery so the test is deterministic regardless of network.
|
|
monkeypatch.setattr(
|
|
"hermes_cli.models.fetch_api_models",
|
|
lambda api_key, base_url, **kwargs: [],
|
|
)
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="openrouter",
|
|
current_base_url="https://openrouter.ai/api/v1",
|
|
user_providers={},
|
|
custom_providers=[
|
|
{"name": "Cerebras", "base_url": "https://proxy.example.com/v1",
|
|
"api_key": "proxy-key", "model": "llama-4-scout"},
|
|
{"name": "Groq", "base_url": "https://proxy.example.com/v1",
|
|
"api_key": "proxy-key", "model": "llama-4-scout"},
|
|
{"name": "Perplexity", "base_url": "https://proxy.example.com/v1",
|
|
"api_key": "proxy-key", "model": "sonar-pro"},
|
|
],
|
|
max_models=50,
|
|
)
|
|
|
|
custom = [p for p in providers if p.get("is_user_defined")]
|
|
names = sorted(p["name"] for p in custom)
|
|
assert names == ["Cerebras", "Groq", "Perplexity"], (
|
|
f"expected three separate rows, got {names}"
|
|
)
|
|
# Each row carries only its own model (no cross-contamination).
|
|
by_name = {p["name"]: p["models"] for p in custom}
|
|
assert by_name["Cerebras"] == ["llama-4-scout"]
|
|
assert by_name["Groq"] == ["llama-4-scout"]
|
|
assert by_name["Perplexity"] == ["sonar-pro"]
|
|
|
|
|
|
def test_excluded_providers_hides_builtin_row(monkeypatch):
|
|
"""``excluded_providers`` must hide a built-in provider row that would
|
|
otherwise surface when its credentials are present."""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
|
|
|
|
baseline = list_authenticated_providers(
|
|
current_provider="openrouter",
|
|
current_base_url="https://openrouter.ai/api/v1",
|
|
user_providers={},
|
|
custom_providers=[],
|
|
max_models=50,
|
|
)
|
|
assert any(p["slug"] == "openrouter" for p in baseline), (
|
|
"sanity: openrouter row must appear when OPENROUTER_API_KEY is set"
|
|
)
|
|
|
|
filtered = list_authenticated_providers(
|
|
current_provider="openrouter",
|
|
current_base_url="https://openrouter.ai/api/v1",
|
|
user_providers={},
|
|
custom_providers=[],
|
|
max_models=50,
|
|
excluded_providers=["openrouter"],
|
|
)
|
|
assert not any(p["slug"] == "openrouter" for p in filtered), (
|
|
"excluded_providers=['openrouter'] must hide the openrouter row"
|
|
)
|
|
|
|
|
|
def test_custom_provider_context_length_models_dict_still_probes(monkeypatch):
|
|
"""Dict-shaped ``models:`` from ``_save_custom_provider`` is metadata.
|
|
|
|
``hermes model`` writes ``models: {default: {context_length: N}}`` for
|
|
local Ollama. That must not suppress live /v1/models discovery — otherwise
|
|
Desktop/Telegram only show the saved default and Refresh does nothing.
|
|
"""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
calls = []
|
|
|
|
def fetch(api_key, base_url, **kwargs):
|
|
calls.append((api_key, base_url, kwargs))
|
|
return ["qwen3.6:35b-mlx", "gemma4:31b", "llama3"]
|
|
|
|
monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="custom:local-ollama",
|
|
user_providers={},
|
|
custom_providers=[
|
|
{
|
|
"name": "Local Ollama",
|
|
"base_url": "http://localhost:11434/v1",
|
|
"model": "qwen3.6:35b-mlx",
|
|
"models": {"qwen3.6:35b-mlx": {"context_length": 32768}},
|
|
}
|
|
],
|
|
# GUI picker path: probe current custom provider only.
|
|
probe_custom_providers=False,
|
|
probe_current_custom_provider=True,
|
|
current_base_url="http://localhost:11434/v1",
|
|
)
|
|
|
|
assert len(calls) == 1
|
|
assert calls[0][0] == ""
|
|
assert calls[0][1] == "http://localhost:11434/v1"
|
|
row = next(p for p in providers if p["name"] == "Local Ollama")
|
|
assert row["models"] == ["qwen3.6:35b-mlx", "gemma4:31b", "llama3"]
|
|
assert row["total_models"] == 3
|
|
|
|
|
|
def test_custom_provider_dict_models_pin_requires_discover_false(monkeypatch):
|
|
"""Dict-shaped catalogs pin only when ``discover_models: false``."""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
calls = []
|
|
|
|
def fetch(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return ["unexpected-live-model"]
|
|
|
|
monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="custom:local-ollama",
|
|
user_providers={},
|
|
custom_providers=[
|
|
{
|
|
"name": "Local Ollama",
|
|
"base_url": "http://localhost:11434/v1",
|
|
"model": "llama3",
|
|
"models": {"llama3": {}},
|
|
"discover_models": False,
|
|
}
|
|
],
|
|
)
|
|
|
|
row = next(p for p in providers if p["name"] == "Local Ollama")
|
|
assert calls == []
|
|
assert row["models"] == ["llama3"]
|
|
|
|
|
|
# ─── No-probe picker opens still serve the cached catalog ───────────────
|
|
#
|
|
# #58183 stopped GUI picker opens from live-probing saved custom endpoints so
|
|
# a stopped local server could not stall the picker. It skipped the cached
|
|
# read along with the network one, so a non-current endpoint collapsed to the
|
|
# one model named in config even with a full catalog already on disk. These
|
|
# pin both halves: the cache is served, the network is not touched.
|
|
|
|
|
|
_LOCAL_ENDPOINT = "http://127.0.0.1:8000/v1"
|
|
_LOCAL_CATALOG = [f"omlx-model-{i}" for i in range(1, 9)]
|
|
_SHARED_PROXY_URL = "https://proxy.example.com/v1"
|
|
|
|
|
|
def _seed_custom_model_cache(monkeypatch, models, *, age_seconds=10):
|
|
"""Put *models* on disk for ``_LOCAL_ENDPOINT`` under the no-credential
|
|
fingerprint the picker probes local endpoints with."""
|
|
import hermes_cli.models as models_mod
|
|
|
|
fp = models_mod._custom_endpoint_fingerprint("", None, None)
|
|
cache = {
|
|
f"custom:{_LOCAL_ENDPOINT}": {
|
|
"fp": fp,
|
|
"at": time.time() - age_seconds,
|
|
"models": list(models),
|
|
}
|
|
}
|
|
monkeypatch.setattr(models_mod, "_load_provider_models_cache", lambda: cache)
|
|
|
|
|
|
def _no_probe_local_row(monkeypatch, *, custom_providers=None, user_providers=None,
|
|
current_provider="nous", **kwargs):
|
|
"""Run the GUI picker path (no live probing) and return the local row
|
|
plus every base_url a live fetch was attempted against."""
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
fetched = []
|
|
|
|
def fetch(_api_key, base_url, **_kwargs):
|
|
fetched.append(base_url)
|
|
return ["should-not-be-reached"]
|
|
|
|
monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider=current_provider,
|
|
user_providers=user_providers or {},
|
|
custom_providers=custom_providers or [],
|
|
for_picker=True,
|
|
refresh=False,
|
|
probe_custom_providers=False,
|
|
probe_current_custom_provider=True,
|
|
**kwargs,
|
|
)
|
|
row = next(
|
|
(p for p in providers if _LOCAL_ENDPOINT in str(p.get("api_url", ""))), None
|
|
)
|
|
return row, fetched
|
|
|
|
|
|
def test_no_probe_open_serves_cached_catalog_for_custom_provider(monkeypatch):
|
|
"""A ``custom_providers`` endpoint that is not the current provider still
|
|
shows its full discovered catalog, from cache, with no network call."""
|
|
_seed_custom_model_cache(monkeypatch, _LOCAL_CATALOG)
|
|
|
|
row, fetched = _no_probe_local_row(
|
|
monkeypatch,
|
|
custom_providers=[
|
|
{
|
|
"name": "Local (127.0.0.1:8000)",
|
|
"base_url": _LOCAL_ENDPOINT,
|
|
"model": "omlx-model-1",
|
|
}
|
|
],
|
|
)
|
|
|
|
assert row is not None
|
|
assert row["is_current"] is False
|
|
assert row["models"] == _LOCAL_CATALOG
|
|
assert row["total_models"] == len(_LOCAL_CATALOG)
|
|
assert fetched == []
|
|
|
|
|
|
def test_no_probe_open_serves_cached_catalog_for_user_provider(monkeypatch):
|
|
"""Same contract for a ``providers:`` entry (section 3)."""
|
|
_seed_custom_model_cache(monkeypatch, _LOCAL_CATALOG)
|
|
|
|
row, fetched = _no_probe_local_row(
|
|
monkeypatch,
|
|
user_providers={
|
|
"local-8000": {
|
|
"name": "Local (127.0.0.1:8000)",
|
|
"base_url": _LOCAL_ENDPOINT,
|
|
"default_model": "omlx-model-1",
|
|
}
|
|
},
|
|
)
|
|
|
|
assert row is not None
|
|
assert row["models"] == _LOCAL_CATALOG
|
|
assert fetched == []
|
|
|
|
|
|
def test_no_probe_open_serves_cached_catalog_for_bare_custom_endpoint(monkeypatch):
|
|
"""Same contract for the bare ``provider: custom`` shape (section 3b),
|
|
where the fallback would otherwise be the single active model."""
|
|
_seed_custom_model_cache(monkeypatch, _LOCAL_CATALOG)
|
|
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
fetched = []
|
|
monkeypatch.setattr(
|
|
"hermes_cli.models.fetch_api_models",
|
|
lambda _k, base_url, **_kw: (fetched.append(base_url), None)[1],
|
|
)
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="custom",
|
|
current_base_url=_LOCAL_ENDPOINT,
|
|
current_model="omlx-model-1",
|
|
user_providers={},
|
|
custom_providers=[],
|
|
for_picker=True,
|
|
refresh=False,
|
|
probe_custom_providers=False,
|
|
probe_current_custom_provider=False,
|
|
)
|
|
|
|
row = next(p for p in providers if p["slug"] == "custom")
|
|
assert row["models"] == _LOCAL_CATALOG
|
|
assert fetched == []
|
|
|
|
|
|
def test_no_probe_open_without_cache_keeps_configured_models_and_stays_offline(
|
|
monkeypatch,
|
|
):
|
|
"""The #58183 guarantee: a cold cache must not trigger a live probe. The
|
|
row degrades to its configured list rather than stalling on a dead port."""
|
|
_seed_custom_model_cache(monkeypatch, [], age_seconds=10)
|
|
|
|
row, fetched = _no_probe_local_row(
|
|
monkeypatch,
|
|
custom_providers=[
|
|
{
|
|
"name": "Local (127.0.0.1:8000)",
|
|
"base_url": _LOCAL_ENDPOINT,
|
|
"model": "omlx-model-1",
|
|
}
|
|
],
|
|
)
|
|
|
|
assert row is not None
|
|
assert row["models"] == ["omlx-model-1"]
|
|
assert fetched == []
|
|
|
|
|
|
def test_no_probe_open_respects_discover_models_false(monkeypatch):
|
|
"""A user who pinned their catalog must not have it replaced from cache."""
|
|
_seed_custom_model_cache(monkeypatch, _LOCAL_CATALOG)
|
|
|
|
row, fetched = _no_probe_local_row(
|
|
monkeypatch,
|
|
custom_providers=[
|
|
{
|
|
"name": "Local (127.0.0.1:8000)",
|
|
"base_url": _LOCAL_ENDPOINT,
|
|
"model": "pinned-model",
|
|
"models": ["pinned-model"],
|
|
"discover_models": False,
|
|
}
|
|
],
|
|
)
|
|
|
|
assert row is not None
|
|
assert row["models"] == ["pinned-model"]
|
|
assert fetched == []
|
|
|
|
|
|
def test_cached_catalog_is_not_written_back_to_config(monkeypatch):
|
|
"""Only a real probe persists discovered models; a cache hit is already
|
|
the product of the probe that saved it."""
|
|
_seed_custom_model_cache(monkeypatch, _LOCAL_CATALOG)
|
|
saves = []
|
|
monkeypatch.setattr(
|
|
"hermes_cli.model_switch._save_discovered_models_to_config",
|
|
lambda api_url, model_ids: saves.append((api_url, model_ids)),
|
|
)
|
|
|
|
row, _ = _no_probe_local_row(
|
|
monkeypatch,
|
|
custom_providers=[
|
|
{
|
|
"name": "Local (127.0.0.1:8000)",
|
|
"base_url": _LOCAL_ENDPOINT,
|
|
"model": "omlx-model-1",
|
|
}
|
|
],
|
|
)
|
|
|
|
assert row["models"] == _LOCAL_CATALOG
|
|
assert saves == []
|
|
|
|
|
|
def test_keyless_endpoint_with_saved_catalog_still_reads_cache(monkeypatch):
|
|
"""A keyless local server must not be pinned by Hermes' own auto-save.
|
|
|
|
``_save_discovered_models_to_config()`` writes a plain list into
|
|
``models:``, which ``_models_config_is_allowlist()`` reads back as an
|
|
explicit allowlist. Combined with the no-key discovery gate, a keyless
|
|
endpoint (the common local-model-server shape) froze on the catalog of
|
|
its first probe and could never widen again — the exact "lineup changes
|
|
after config was written" case. The cache read must not be subject to the
|
|
probe's network-cost gate.
|
|
"""
|
|
_seed_custom_model_cache(monkeypatch, _LOCAL_CATALOG)
|
|
|
|
row, fetched = _no_probe_local_row(
|
|
monkeypatch,
|
|
custom_providers=[
|
|
{
|
|
"name": "Local (127.0.0.1:8000)",
|
|
"base_url": _LOCAL_ENDPOINT,
|
|
"model": "omlx-model-1",
|
|
# No api_key, and a models: list of the shape our own
|
|
# auto-save writes after a successful probe.
|
|
"models": ["omlx-model-1"],
|
|
}
|
|
],
|
|
)
|
|
|
|
assert row is not None
|
|
assert row["models"] == _LOCAL_CATALOG
|
|
assert fetched == []
|
|
|
|
|
|
def test_keyless_endpoint_with_saved_catalog_is_still_not_probed(monkeypatch):
|
|
"""...but the network-cost gate it rides on must survive intact.
|
|
|
|
The no-key + declared-catalog combination exists to keep Hermes from
|
|
probing an endpoint it cannot authenticate to. Serving that endpoint from
|
|
a warm cache is free; hitting the network is not. With a cold cache and
|
|
live probing fully enabled, this row must still make zero fetches.
|
|
"""
|
|
_seed_custom_model_cache(monkeypatch, []) # cold: only a probe could answer
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
|
|
fetched = []
|
|
|
|
def fetch(_api_key, base_url, **_kwargs):
|
|
fetched.append(base_url)
|
|
return ["should-not-be-reached"]
|
|
|
|
monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)
|
|
|
|
providers = list_authenticated_providers(
|
|
current_provider="nous",
|
|
user_providers={},
|
|
custom_providers=[
|
|
{
|
|
"name": "Local (127.0.0.1:8000)",
|
|
"base_url": _LOCAL_ENDPOINT,
|
|
"model": "omlx-model-1",
|
|
"models": ["omlx-model-1"],
|
|
}
|
|
],
|
|
for_picker=True,
|
|
refresh=False,
|
|
probe_custom_providers=True, # live probing fully enabled
|
|
)
|
|
row = next(
|
|
(p for p in providers if _LOCAL_ENDPOINT in str(p.get("api_url", ""))), None
|
|
)
|
|
|
|
assert row is not None
|
|
assert row["models"] == ["omlx-model-1"]
|
|
assert fetched == []
|
|
|
|
|
|
def test_api_mode_rows_do_not_share_a_cached_catalog(monkeypatch):
|
|
"""Two rows differing only by ``api_mode`` must not share a cache entry.
|
|
|
|
``api_mode`` selects the wire protocol — ``x-api-key`` +
|
|
``anthropic-version`` versus ``Authorization: Bearer`` — so it is part of
|
|
both the picker's group identity and
|
|
``_custom_endpoint_fingerprint()``. The cache read has to pass it through
|
|
or an ``anthropic_messages`` row renders whatever the OpenAI-mode row
|
|
cached against the same base_url.
|
|
"""
|
|
import hermes_cli.models as models_mod
|
|
|
|
openai_catalog = ["gpt-oss-a", "gpt-oss-b"]
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
|
|
|
fetched = []
|
|
|
|
def fetch(_api_key, base_url, **_kwargs):
|
|
fetched.append(base_url)
|
|
return ["should-not-be-reached"]
|
|
|
|
monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)
|
|
|
|
# Only the OpenAI-mode probe (api_mode=None) is on disk.
|
|
fp = models_mod._custom_endpoint_fingerprint("sk-shared", None, None)
|
|
cache = {
|
|
f"custom:{_SHARED_PROXY_URL}": {
|
|
"fp": fp,
|
|
"at": time.time() - 10,
|
|
"models": list(openai_catalog),
|
|
}
|
|
}
|
|
monkeypatch.setattr(models_mod, "_load_provider_models_cache", lambda: cache)
|
|
|
|
def _row(entry):
|
|
providers = list_authenticated_providers(
|
|
current_provider="nous",
|
|
user_providers={},
|
|
custom_providers=[entry],
|
|
for_picker=True,
|
|
refresh=False,
|
|
probe_custom_providers=False,
|
|
probe_current_custom_provider=True,
|
|
)
|
|
return next(
|
|
(p for p in providers if _SHARED_PROXY_URL in str(p.get("api_url", ""))),
|
|
None,
|
|
)
|
|
|
|
anthropic_row = _row(
|
|
{
|
|
"name": "Proxy Anthropic",
|
|
"base_url": _SHARED_PROXY_URL,
|
|
"api_key": "sk-shared",
|
|
"api_mode": "anthropic_messages",
|
|
"model": "claude-via-proxy",
|
|
}
|
|
)
|
|
openai_row = _row(
|
|
{
|
|
"name": "Proxy OpenAI",
|
|
"base_url": _SHARED_PROXY_URL,
|
|
"api_key": "sk-shared",
|
|
"model": "gpt-via-proxy",
|
|
}
|
|
)
|
|
|
|
assert anthropic_row is not None and openai_row is not None
|
|
assert anthropic_row["models"] == ["claude-via-proxy"], (
|
|
"an anthropic_messages row must not render the OpenAI-mode catalog "
|
|
"cached against the same base_url"
|
|
)
|
|
# ...while the row the entry actually belongs to still resolves.
|
|
assert openai_row["models"] == openai_catalog
|
|
assert fetched == []
|
|
|
|
|
|
def test_auto_saved_catalog_round_trips_without_pinning(tmp_path, monkeypatch):
|
|
"""End-to-end: the shape we persist must not read back as a user pin.
|
|
|
|
Guards the whole chain rather than one branch — probe saves a catalog,
|
|
config is reloaded, and the endpoint must still be discoverable. If a
|
|
future change makes the saved shape look like an intentional allowlist
|
|
again, this fails even if the gate logic above is refactored away.
|
|
"""
|
|
import hermes_cli.config as config_mod
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
cfg_path = tmp_path / "config.yaml"
|
|
cfg_path.write_text(
|
|
"custom_providers:\n"
|
|
f" - name: Local MLX\n base_url: {_LOCAL_ENDPOINT}\n"
|
|
" model: omlx-model-1\n"
|
|
)
|
|
monkeypatch.setattr(config_mod, "CONFIG_PATH", str(cfg_path), raising=False)
|
|
|
|
_save_discovered_models_to_config(_LOCAL_ENDPOINT, list(_LOCAL_CATALOG))
|
|
|
|
saved = yaml.safe_load(cfg_path.read_text())["custom_providers"][0]
|
|
assert saved["models"] == _LOCAL_CATALOG, "probe result should be persisted"
|
|
|
|
# The persisted shape is what the picker will read on the next open. It
|
|
# must not, on a keyless entry, suppress discovery of a wider catalog.
|
|
_seed_custom_model_cache(monkeypatch, [*_LOCAL_CATALOG, "omlx-model-9"])
|
|
row, fetched = _no_probe_local_row(
|
|
monkeypatch, custom_providers=[saved]
|
|
)
|
|
|
|
assert row is not None
|
|
assert row["models"] == [*_LOCAL_CATALOG, "omlx-model-9"], (
|
|
"an auto-saved catalog must not pin the endpoint against a newer "
|
|
"cached lineup"
|
|
)
|
|
assert fetched == []
|