hermes-agent/tests/hermes_cli/test_user_providers_model_switch.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

610 lines
22 KiB
Python

"""Tests for user-defined providers (providers: dict) in /model.
These tests ensure that providers defined in the config.yaml ``providers:`` section
are properly resolved for model switching and that their full ``models:`` lists
are exposed in the model picker.
"""
import pytest
from hermes_cli.model_switch import list_authenticated_providers, switch_model
from hermes_cli import runtime_provider as rp
@pytest.fixture(autouse=True)
def _no_live_builtin_provider_probes(monkeypatch):
"""Keep picker tests offline: builtin-provider catalog fetches hit the network."""
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: []
)
# =============================================================================
# Tests for list_authenticated_providers including full models list
# =============================================================================
def test_list_authenticated_providers_includes_full_models_list_from_user_providers(monkeypatch):
"""User-defined providers should expose both default_model and full models list.
Regression test: previously only default_model was shown in /model picker.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
user_providers = {
"local-ollama": {
"name": "Local Ollama",
"api": "http://localhost:11434/v1",
"default_model": "minimax-m2.7:cloud",
"models": [
"minimax-m2.7:cloud",
"kimi-k2.5:cloud",
"glm-5.1:cloud",
"qwen3.5:cloud",
],
}
}
providers = list_authenticated_providers(
current_provider="local-ollama",
user_providers=user_providers,
custom_providers=[],
max_models=50,
)
# Find our user provider
user_prov = next(
(p for p in providers if p.get("is_user_defined") and p["slug"] == "local-ollama"),
None
)
assert user_prov is not None, "User provider 'local-ollama' should be in results"
assert user_prov["total_models"] == 4, f"Expected 4 models, got {user_prov['total_models']}"
assert "minimax-m2.7:cloud" in user_prov["models"]
assert "kimi-k2.5:cloud" in user_prov["models"]
assert "glm-5.1:cloud" in user_prov["models"]
assert "qwen3.5:cloud" in user_prov["models"]
def test_list_authenticated_providers_enumerates_dict_format_models(monkeypatch):
"""providers: dict entries with ``models:`` as a dict keyed by model id
(canonical Hermes write format) should surface every key in the picker.
Regression: the ``providers:`` dict path previously only accepted
list-format ``models:`` and silently dropped dict-format entries,
even though Hermes's own writer and downstream readers use dict format.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
user_providers = {
"local-ollama": {
"name": "Local Ollama",
"api": "http://localhost:11434/v1",
"default_model": "minimax-m2.7:cloud",
"models": {
"minimax-m2.7:cloud": {"context_length": 196608},
"kimi-k2.5:cloud": {"context_length": 200000},
"glm-5.1:cloud": {"context_length": 202752},
},
}
}
providers = list_authenticated_providers(
current_provider="local-ollama",
user_providers=user_providers,
custom_providers=[],
max_models=50,
)
user_prov = next(
(p for p in providers if p.get("is_user_defined") and p["slug"] == "local-ollama"),
None,
)
assert user_prov is not None
assert user_prov["total_models"] == 3
assert user_prov["models"] == [
"minimax-m2.7:cloud",
"kimi-k2.5:cloud",
"glm-5.1:cloud",
]
def test_list_authenticated_providers_uses_live_models_for_user_provider(monkeypatch):
"""User-defined OpenAI-compatible providers should prefer live /models.
Regression: CRS-style providers with a stale config ``models:`` dict kept
showing only the configured subset in the /model picker, even though their
/v1/models endpoint exposed newly added models.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
monkeypatch.setenv("CRS_TEST_KEY", "sk-test")
calls = []
def fake_fetch_api_models(api_key, base_url, **kwargs):
calls.append((api_key, base_url, kwargs))
return ["old-configured-model", "new-live-model"]
monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)
user_providers = {
"crs-henkee": {
"name": "CRS Henkee",
"base_url": "http://127.0.0.1:3000/api/v1",
"key_env": "CRS_TEST_KEY",
"model": "old-configured-model",
"models": {
"old-configured-model": {"context_length": 200000},
},
}
}
providers = list_authenticated_providers(
current_provider="crs-henkee",
user_providers=user_providers,
custom_providers=[],
max_models=50,
)
user_prov = next(
(p for p in providers if p.get("is_user_defined") and p["slug"] == "crs-henkee"),
None,
)
assert user_prov is not None
assert calls == [("sk-test", "http://127.0.0.1:3000/api/v1", {"headers": None})]
assert user_prov["models"] == ["old-configured-model", "new-live-model"]
assert user_prov["total_models"] == 2
def test_list_authenticated_providers_accepts_base_url_and_singular_model(monkeypatch):
"""providers: dict entries written in canonical Hermes shape
(``base_url`` + singular ``model``) should resolve the same as the
legacy ``api`` + ``default_model`` shape.
Regression: section 3 previously only read ``api``/``url`` and
``default_model``, so new-shape entries written by Hermes's own writer
surfaced with empty ``api_url`` and no default.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
user_providers = {
"custom": {
"base_url": "http://example.com/v1",
"model": "gpt-5.4",
"models": {
"gpt-5.4": {},
"grok-4.20-beta": {},
"minimax-m2.7": {},
},
}
}
providers = list_authenticated_providers(
current_provider="custom",
user_providers=user_providers,
custom_providers=[],
max_models=50,
)
custom = next((p for p in providers if p["slug"] == "custom"), None)
assert custom is not None
assert custom["api_url"] == "http://example.com/v1"
assert custom["models"] == ["gpt-5.4", "grok-4.20-beta", "minimax-m2.7"]
assert custom["total_models"] == 3
def test_list_authenticated_providers_dedupes_when_user_and_custom_overlap(monkeypatch):
"""When the same slug appears in both ``providers:`` dict and
``custom_providers:`` list, emit exactly one row (providers: dict wins
since it is processed first).
Regression: section 3 previously had no ``seen_slugs`` check, so
overlapping entries produced two picker rows for the same provider.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom",
user_providers={
"custom": {
"base_url": "http://example.com/v1",
"model": "gpt-5.4",
"models": {
"gpt-5.4": {},
"grok-4.20-beta": {},
},
}
},
custom_providers=[
{
"name": "custom",
"base_url": "http://example.com/v1",
"model": "legacy-only-model",
}
],
max_models=50,
)
matches = [p for p in providers if p["slug"] == "custom"]
assert len(matches) == 1
# providers: dict wins — legacy-only-model is suppressed.
assert matches[0]["models"] == ["gpt-5.4", "grok-4.20-beta"]
def test_list_authenticated_providers_no_duplicate_labels_across_schemas(monkeypatch):
"""Regression: same endpoint in both ``providers:`` dict AND ``custom_providers:``
list (e.g. via ``get_compatible_custom_providers()``) must not emit two picker
rows with identical display names.
Before the fix, section 3 emitted bare-slug rows ("openrouter") and section 4
emitted ``custom:openrouter`` rows for the same endpoint — both labelled
identically, bypassing ``seen_slugs`` dedup because the slug shapes differ.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
# Singular ``model:``-only entries are un-narrowed → section 3 now probes
# them; stub the probe so the test stays hermetic (endpoints are fake).
monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *a, **k: None)
shared_entries = [
("endpoint-a", "http://a.local/v1"),
("endpoint-b", "http://b.local/v1"),
("endpoint-c", "http://c.local/v1"),
]
user_providers = {
name: {"name": name, "base_url": url, "model": "m1"}
for name, url in shared_entries
}
custom_providers = [
{"name": name, "base_url": url, "model": "m1"}
for name, url in shared_entries
]
providers = list_authenticated_providers(
current_provider="none",
user_providers=user_providers,
custom_providers=custom_providers,
max_models=50,
)
user_rows = [p for p in providers if p.get("source") == "user-config"]
# Expect one row per shared entry — not two.
assert len(user_rows) == len(shared_entries), (
f"Expected {len(shared_entries)} rows, got {len(user_rows)}: "
f"{[(p['slug'], p['name']) for p in user_rows]}"
)
# And zero duplicate display labels.
labels = [p["name"].lower() for p in user_rows]
assert len(labels) == len(set(labels)), (
f"Duplicate labels across picker rows: {labels}"
)
def test_list_authenticated_providers_dedup_honors_base_url_env_override(monkeypatch):
"""The dedup must track the EFFECTIVE endpoint — if DASHSCOPE_BASE_URL
overrides the static inference_base_url, a custom provider pointing at
the overridden URL (not the static one) should still be recognized as
a duplicate."""
monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-test")
monkeypatch.setenv(
"DASHSCOPE_BASE_URL",
"https://custom-dashscope.example.com/v1",
)
monkeypatch.setattr(
"agent.models_dev.fetch_models_dev",
lambda: {
"alibaba": {
"name": "Alibaba Cloud (DashScope)",
"env": ["DASHSCOPE_API_KEY"],
}
},
)
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
custom_providers = [
{
"name": "my-dashscope-override",
# Same URL as DASHSCOPE_BASE_URL env override above.
"base_url": "https://custom-dashscope.example.com/v1",
"api_key": "sk-test",
"model": "qwen3.6-plus",
}
]
providers = list_authenticated_providers(
current_provider="alibaba",
user_providers={},
custom_providers=custom_providers,
max_models=50,
)
slugs = [p["slug"] for p in providers]
assert not any("my-dashscope-override" in s for s in slugs), (
f"Custom entry matching env-overridden built-in endpoint should be "
f"dedup'd, got: {slugs}"
)
# =============================================================================
# Tests for _get_named_custom_provider with providers: dict
# =============================================================================
# =============================================================================
# Integration test for switch_model with user providers
# =============================================================================
def test_switch_model_resolves_user_provider_credentials(monkeypatch, tmp_path):
"""/model switch should resolve credentials for providers: dict providers."""
import yaml
config = {
"providers": {
"local-ollama": {
"api": "http://localhost:11434/v1",
"name": "Local Ollama",
"default_model": "minimax-m2.7:cloud",
}
}
}
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump(config))
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Mock validation to pass
monkeypatch.setattr(
"hermes_cli.models.validate_requested_model",
lambda *a, **k: {"accepted": True, "persist": True, "recognized": True, "message": None}
)
result = switch_model(
raw_input="kimi-k2.5:cloud",
current_provider="local-ollama",
current_model="minimax-m2.7:cloud",
current_base_url="http://localhost:11434/v1",
is_global=False,
user_providers=config["providers"],
)
assert result.success is True
assert result.error_message == ""
# =============================================================================
# Regression: providers: dict ``transport`` field must be honored
# =============================================================================
# =============================================================================
# Regression: user_providers override for private models not listed by /v1/models
# =============================================================================
_REJECTED_VALIDATION = {
"accepted": False,
"persist": False,
"recognized": False,
"message": "not found",
}
def _run_user_provider_override_case(
*,
slug,
name,
base_url,
models,
raw_input,
):
"""Run ``switch_model`` with a private user provider and a rejected API check.
The bug in PR #17964 was that ``user_providers`` was treated like a list,
so private models listed in ``models:`` never triggered the override path.
These tests keep the validation failure in place and prove the config list
still wins for both dict- and list-shaped ``models`` entries.
"""
from unittest.mock import patch
user_providers = {
slug: {
"name": name,
"api": base_url,
"discover_models": False,
"models": models,
}
}
with patch("hermes_cli.model_switch.resolve_alias", return_value=None), \
patch("hermes_cli.model_switch.list_provider_models", return_value=[]), \
patch("hermes_cli.model_switch.normalize_model_for_provider", side_effect=lambda model, provider: model), \
patch("hermes_cli.models.validate_requested_model", return_value=_REJECTED_VALIDATION), \
patch("hermes_cli.models.detect_provider_for_model", return_value=None), \
patch("hermes_cli.model_switch.get_model_info", return_value=None), \
patch("hermes_cli.model_switch.get_model_capabilities", return_value=None), \
patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value={"api_key": "***", "base_url": base_url, "api_mode": "anthropic_messages"}):
return switch_model(
raw_input=raw_input,
current_provider=slug,
current_model="old-model",
current_base_url=base_url,
user_providers=user_providers,
custom_providers=[],
)
# =============================================================================
# Section 3 no-auth live discovery (PR #29575)
# =============================================================================
def test_section3_probes_no_key_endpoint_without_explicit_models(monkeypatch):
"""A providers: entry with no api_key and no explicit models: list should
still probe /v1/models for live discovery — mirroring section 4's policy.
Regression for #29575: local self-hosted backends (llama.cpp, Ollama,
vLLM) that don't require auth previously showed an empty/minimal model
list because section 3 gated probing on ``api_url and api_key``.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
probed = {}
def _fake_fetch(api_key, api_url, **kwargs):
probed["called"] = True
probed["api_key"] = api_key
probed["api_url"] = api_url
probed["kwargs"] = kwargs
return ["live-model-1", "live-model-2", "live-model-3"]
monkeypatch.setattr("hermes_cli.models.fetch_api_models", _fake_fetch)
user_providers = {
"local-llamacpp": {
"name": "Local llama.cpp",
"api": "http://localhost:8080/v1",
# No api_key, no models list — bare local endpoint.
}
}
providers = list_authenticated_providers(
current_provider="local-llamacpp",
user_providers=user_providers,
custom_providers=[],
max_models=50,
)
assert probed.get("called") is True, "no-key bare endpoint should be probed"
assert probed["api_key"] == ""
assert probed["kwargs"] == {"headers": None}
row = next(p for p in providers if p["slug"] == "local-llamacpp")
assert row["models"] == ["live-model-1", "live-model-2", "live-model-3"]
assert row["total_models"] == 3
def test_section3_probes_no_key_endpoint_with_singular_default_model(monkeypatch):
"""A providers: entry with no api_key and only a singular ``default_model``
(no explicit ``models:`` list) must still probe /v1/models — the singular
field is just the active selection, not the user narrowing the endpoint.
Regression for #40554 / PR #68984 (@vigilancetech-com): section 3 derived
``has_explicit_models`` from the merged models list, so the lone
``default_model`` entry suppressed live discovery and the /model picker
showed a one-line menu for local no-auth endpoints.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
probed = {}
def _fake_fetch(api_key, api_url, **kwargs):
probed["called"] = True
probed["api_key"] = api_key
return ["live-model-1", "live-model-2", "live-model-3"]
monkeypatch.setattr("hermes_cli.models.fetch_api_models", _fake_fetch)
user_providers = {
"local-ollama": {
"name": "Local Ollama",
"api": "http://localhost:11434/v1",
"default_model": "llama3",
# No api_key, no models: list — singular default only.
}
}
providers = list_authenticated_providers(
current_provider="local-ollama",
user_providers=user_providers,
custom_providers=[],
max_models=50,
)
assert probed.get("called") is True, (
"singular default_model must not suppress live discovery"
)
assert probed["api_key"] == ""
row = next(p for p in providers if p["slug"] == "local-ollama")
assert row["models"] == ["live-model-1", "live-model-2", "live-model-3"]
assert row["total_models"] == 3
def test_current_custom_model_is_surfaced_in_builtin_provider_row(monkeypatch):
"""A custom/uncurated model selected via the CLI must appear in its
provider's picker row.
Regression: selecting `/model openrouter/<uncurated-name>` left the model
invisible in every picker (main model picker AND the MoA reference/aggregator
slot pickers, which read these rows), because the row only carried the
curated catalog. The current model is now injected at the front of the
current provider's list.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-test")
# Pin a small curated catalog so the assertion is deterministic.
monkeypatch.setattr(
"hermes_cli.models.cached_provider_model_ids",
lambda slug, **kw: ["anthropic/claude-opus-4.8", "openai/gpt-5.5"]
if slug == "openrouter"
else [],
)
custom = "some-vendor/totally-custom-model-v9"
providers = list_authenticated_providers(
current_provider="openrouter",
current_model=custom,
user_providers={},
custom_providers=[],
)
row = next(p for p in providers if p["slug"] == "openrouter")
assert custom in row["models"], row["models"]
assert row["models"][0] == custom # injected at the front
assert row["total_models"] == 3
def test_current_custom_model_not_leaked_into_other_provider_rows(monkeypatch):
"""The current model is only injected into the CURRENT provider's row,
never into other providers (which can't serve it)."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-test")
monkeypatch.setenv("NOUS_API_KEY", "sk-test")
monkeypatch.setattr(
"hermes_cli.models.cached_provider_model_ids",
lambda slug, **kw: ["curated/one"],
)
custom = "some-vendor/totally-custom-model-v9"
providers = list_authenticated_providers(
current_provider="openrouter",
current_model=custom,
user_providers={},
custom_providers=[],
)
for row in providers:
if row["slug"] != "openrouter" and not row.get("is_current"):
assert custom not in row.get("models", []), f"leaked into {row['slug']}"