Files
hermes-webui/tests/test_issue7404_models_discovered_not_allowlist.py
T
b1286878a4 Release: models_discovered/discover_models live-catalog fix (#7409) (#7538)
* Apply reviewed change

* fix(models): honor discover_models opt-out + preserve discovered catalog on live-probe failure

Codex gate found two CORE edge cases in the #7404 fix:
1. discover_models:false must override models_discovered:true (explicit opt-out
   pins the configured allowlist; honor Agent string forms false/no/0).
2. a transient empty live-catalog probe dropped a discovered provider's models
   (cached empty ~24h); now falls back to configured discovered IDs merged with
   any static catalog (deduped, ordered first).

Mirrors the Hermes Agent canonical semantics (model_switch_providers._discover_flag,
model_switch._models_config_is_allowlist). Adds 3 mutation-proven regressions.

* docs(changelog): stamp #7409 (models_discovered/discover_models live catalog)

---------

Co-authored-by: evgenyponomarev <712802+evgenyponomarev@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: n <a@n>
2026-09-12 05:32:16 +00:00

179 lines
7.5 KiB
Python

"""Regression coverage for #7404: models_discovered suppresses live catalog.
When a provider sets ``models_discovered: true`` in its config alongside a
``models:`` dict of per-model metadata, WebUI must not treat the dict as a
strict allowlist. It should fall through to the live ``/v1/models`` probe,
matching the upstream Hermes Agent behaviour.
"""
def _provider_group(payload: dict, provider_id: str) -> dict:
for group in payload.get("groups", []):
if group.get("provider_id") == provider_id:
return group
raise AssertionError(f"provider group {provider_id!r} not found: {payload.get('groups')!r}")
def test_models_discovered_skips_config_allowlist_and_uses_live_catalog(monkeypatch, tmp_path):
import api.config as config
cfg = {
"model": {"default": "model-a", "provider": "custom-llm"},
"providers": {
"custom-llm": {
"name": "Custom LLM",
"api": "https://llm.example.com",
"transport": "chat_completions",
"models_discovered": True,
"models": {
"model-a": {"supports_vision": True},
"model-b": {"context_length": 128000},
},
}
},
}
live_ids = ["model-a", "model-b", "model-c", "model-d", "model-e"]
monkeypatch.setattr(config, "cfg", cfg, raising=False)
monkeypatch.setattr(config, "_get_config_path", lambda: tmp_path / "config.yaml")
monkeypatch.setattr(config, "_get_auth_store_path", lambda: tmp_path / "auth.json")
monkeypatch.setattr(config, "_get_models_cache_path", lambda: tmp_path / "models_cache.json")
monkeypatch.setattr(config, "_models_cache_source_fingerprint", lambda: {"test": "fingerprint"})
monkeypatch.setattr(config, "reload_config_if_stale", lambda: None)
monkeypatch.setattr(config, "reload_config", lambda: None)
monkeypatch.setattr(config, "_cfg_mtime", 0.0, raising=False)
monkeypatch.setattr(config, "_LIVE_REBUILD_BUDGET_SECONDS", 0.0, raising=False)
monkeypatch.setattr(
config, "_read_live_provider_model_ids",
lambda pid: live_ids if pid == "custom-llm" else [],
)
config.invalidate_models_cache()
payload = config.get_available_models(force_refresh=True)
group = _provider_group(payload, "custom-llm")
ids = [m["id"] for m in group["models"]]
assert ids == live_ids
def _setup_provider(monkeypatch, tmp_path, provider_cfg, live_ids):
"""Wire config with a single custom-llm provider and a controllable live catalog."""
import api.config as config
cfg = {
"model": {"default": "model-a", "provider": "custom-llm"},
"providers": {"custom-llm": provider_cfg},
}
monkeypatch.setattr(config, "cfg", cfg, raising=False)
monkeypatch.setattr(config, "_get_config_path", lambda: tmp_path / "config.yaml")
monkeypatch.setattr(config, "_get_auth_store_path", lambda: tmp_path / "auth.json")
monkeypatch.setattr(config, "_get_models_cache_path", lambda: tmp_path / "models_cache.json")
monkeypatch.setattr(config, "_models_cache_source_fingerprint", lambda: {"test": "fingerprint"})
monkeypatch.setattr(config, "reload_config_if_stale", lambda: None)
monkeypatch.setattr(config, "reload_config", lambda: None)
monkeypatch.setattr(config, "_cfg_mtime", 0.0, raising=False)
monkeypatch.setattr(config, "_LIVE_REBUILD_BUDGET_SECONDS", 0.0, raising=False)
monkeypatch.setattr(
config, "_read_live_provider_model_ids",
lambda pid: live_ids if pid == "custom-llm" else [],
)
config.invalidate_models_cache()
return config
def test_discover_models_false_pins_configured_allowlist_despite_discovered_flag(monkeypatch, tmp_path):
"""`discover_models: false` overrides `models_discovered: true` (explicit opt-out wins).
A provider that persisted a discovered catalog but then pinned it with
``discover_models: false`` must keep exactly its configured ``models:`` and NOT
probe/expose the broader live catalog.
"""
provider_cfg = {
"name": "Custom LLM",
"api": "https://llm.example.com",
"transport": "chat_completions",
"models_discovered": True,
"discover_models": False,
"models": {
"model-a": {"supports_vision": True},
"model-b": {"context_length": 128000},
},
}
live_ids = ["model-a", "model-b", "model-c", "model-d", "model-e"]
config = _setup_provider(monkeypatch, tmp_path, provider_cfg, live_ids)
payload = config.get_available_models(force_refresh=True)
group = _provider_group(payload, "custom-llm")
ids = [m["id"] for m in group["models"]]
assert ids == ["model-a", "model-b"]
def test_discover_models_false_string_form_also_pins(monkeypatch, tmp_path):
"""Agent-compatible string opt-out (`discover_models: "false"`) also pins the allowlist."""
provider_cfg = {
"name": "Custom LLM",
"api": "https://llm.example.com",
"transport": "chat_completions",
"models_discovered": True,
"discover_models": "false",
"models": {"model-a": {}, "model-b": {}},
}
live_ids = ["model-a", "model-b", "model-c"]
config = _setup_provider(monkeypatch, tmp_path, provider_cfg, live_ids)
payload = config.get_available_models(force_refresh=True)
group = _provider_group(payload, "custom-llm")
ids = [m["id"] for m in group["models"]]
assert ids == ["model-a", "model-b"]
def test_discovered_catalog_survives_transient_live_probe_failure(monkeypatch, tmp_path):
"""A transient empty live probe must not drop a discovered provider's configured models.
With ``models_discovered: true`` (and discovery allowed), the live catalog is
authoritative — but when the probe transiently returns nothing, the provider group
must fall back to the configured discovered IDs rather than vanishing (which would be
cached empty for up to 24h). A provider absent from the static built-in catalog is
the exact case #7404's fix must not regress.
"""
provider_cfg = {
"name": "Custom LLM",
"api": "https://llm.example.com",
"transport": "chat_completions",
"models_discovered": True,
"models": {"model-a": {"supports_vision": True}, "model-b": {"context_length": 128000}},
}
# Live probe returns NOTHING (transient failure), provider not in _PROVIDER_MODELS.
config = _setup_provider(monkeypatch, tmp_path, provider_cfg, [])
payload = config.get_available_models(force_refresh=True)
group = _provider_group(payload, "custom-llm")
ids = [m["id"] for m in group["models"]]
assert ids == ["model-a", "model-b"]
def test_discovered_flag_without_models_key_and_empty_probe_does_not_500(monkeypatch, tmp_path):
"""models_discovered:true with NO models: key + empty live probe must not raise (KeyError->500).
_provider_models_are_discovered_catalog is True on models_discovered alone, so the
probe-failure fallback must read models via .get() (absent -> no configured rows),
degrading to the static catalog rather than crashing /api/models.
"""
provider_cfg = {
"name": "Custom LLM",
"api": "https://llm.example.com",
"transport": "chat_completions",
"models_discovered": True,
# no "models" key at all
}
config = _setup_provider(monkeypatch, tmp_path, provider_cfg, [])
# Must not raise; provider absent from the static catalog degrades to an empty
# group (filtered out) rather than a 500.
payload = config.get_available_models(force_refresh=True)
assert isinstance(payload.get("groups"), list)