Files
hermes-webui/tests/test_issue6335_catalog_admission_regression.py
T
0b69346970 Release: provider-filter + memory diagnostics (#6338 #6363) (#6994)
* fix(#6335): filter unconfigured providers from model selector

When a known provider (e.g. openai-api) is listed in config.yaml's
providers section with only metadata (name, display_name) and no route
configuration or credentials, it should not appear in the chat model
selector dropdown.

Previously, any provider recognised as a 'known provider' (present in
_PROVIDER_MODELS or _PROVIDER_DISPLAY) would be admitted to the picker
simply by being listed in the config.yaml providers block — even after
the user removed its API key.  This caused a persistent ghost provider
entry in the dropdown (#6335).

The fix gates known-provider admission on credential evidence: the
provider must already have been detected from a credential source (env
vars, hermes auth, or credential pool) OR carry explicit route
configuration (api/base_url/api_key/key_env) OR be the active provider
with a configured models allowlist.

* fix(#6338): normalize provider aliases in credential check

The credential check compared _canonical (config key like 'x-ai',
'google') directly against detected_providers (alias-resolved values
like 'xai', 'gemini').  Alias-named authenticated providers were
therefore incorrectly treated as unconfigured and removed from the
model selector.

Fix: resolve _canonical through _resolve_provider_alias() before the
membership test, so config keys like 'x-ai' match credential evidence
reported as 'xai'.  Also check the raw canonical for cases where the
config key is itself the alias target.

Refs: #6335, #6338

* fix(#6338): normalize detected_providers through alias resolver

Normalize both _canonical and detected_providers entries through
_resolve_provider_alias before the membership test, so that a WebUI
canonical form in detected_providers (e.g. x-ai added by a prior
loop iteration) also matches the alias-resolved form used in
credential detection (e.g. xai from hermes_cli).

This is belt-and-braces on the existing _resolve_provider_alias(_canonical)
check: both sides now map into the same alias-resolved namespace,
closing any edge case where detected_providers contains a WebUI-canonical
form that happens to differ from the alias target.

* test(ui): catalog-admission regression for credentialed providers (#6338)

* test(ui): pin config path state in catalog-admission regression (#6338)

* fix(system-health): expose runtime memory diagnostics without blocking owner locks (#6351)

* fix(system-health): keep owner contention local to runtime status (#6351)

* docs(changelog): stamp #6338 #6363 (certified batch)

---------

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@gmail.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-08-13 17:47:46 +00:00

247 lines
10 KiB
Python

"""Regression test for #6335 / #6338 catalog admission gate.
Issue #6335: after removing a provider's API key, a metadata-only
``providers.<name>: {name: ...}`` entry in config.yaml still rendered the
provider in the chat model selector. The fix gates known-provider admission
on credential evidence: a provider listed in config.yaml is only admitted
when it was *already* detected from a credential source (env vars, hermes
auth, credential pool) — or carries an explicit route (``api`` /
``base_url`` / ``api_key`` / ``key_env``), or is the active provider with a
configured models allowlist.
Follow-up #6338: Hermes reports detected credentials under canonical alias
names (``xai``, ``gemini``) while WebUI preserves config.yaml keys as
``x-ai``, ``google``. The credential check must compare in an
alias-normalised identity space or authenticated providers disappear from
the selector.
This test drives ``api/config.py::get_available_models()`` end-to-end with
a stubbed ``hermes_cli`` (deterministic, offline) and asserts the
catalog-builder admission behaviour:
1. Mocked Hermes provider detection reports authenticated ``xai`` and
``gemini`` (the agent's canonical alias names).
2. ``providers.x-ai`` and ``providers.google`` are metadata-only entries
(name only — no route, no models allowlist).
3. The active provider is a *different* provider (``openai-codex``), so the
test cannot pass through the unconditional active-provider path.
4. The xAI and Google groups remain admitted (alias-normalised credential
match).
5. A metadata-only known provider with no matching credential, route field,
or configured model list (``openai-api``) stays absent.
"""
from __future__ import annotations
import json
import sys
import types
import api.config as config
import api.profiles as profiles
def _install_fake_hermes_cli(monkeypatch):
"""Stub hermes_cli so detection is deterministic and offline.
``list_available_providers()`` reports authenticated ``xai`` and
``gemini`` — the agent's canonical alias names — so the ONLY way the
metadata-only ``providers.x-ai`` / ``providers.google`` entries can be
admitted is via the alias-normalised ``_already_credentialed`` gate
under test.
"""
fake_pkg = types.ModuleType("hermes_cli")
fake_pkg.__path__ = []
fake_models = types.ModuleType("hermes_cli.models")
fake_models.list_available_providers = lambda: [
{"id": "xai", "authenticated": True},
{"id": "gemini", "authenticated": True},
]
fake_models.provider_model_ids = lambda pid: []
fake_models._PROVIDER_ALIASES = {
"x-ai": "xai",
"x.ai": "xai",
"grok": "xai",
"google": "gemini",
"google-gemini": "gemini",
"google-ai-studio": "gemini",
}
fake_auth = types.ModuleType("hermes_cli.auth")
fake_auth.get_auth_status = lambda _pid: {}
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
# Remove the real agent.credential_pool so no pool evidence leaks in
# from the host environment.
monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False)
monkeypatch.delitem(sys.modules, "agent", raising=False)
def _call_get_available_models(monkeypatch, tmp_path):
_install_fake_hermes_cli(monkeypatch)
# Active provider is a DIFFERENT provider (openai-codex), so the
# x-ai/google entries can only be admitted through the credential gate,
# never through the active-provider path.
(tmp_path / "auth.json").write_text(
json.dumps(
{
"version": 1,
"providers": {},
"active_provider": "openai-codex",
"credential_pool": {},
}
),
encoding="utf-8",
)
# config.yaml must exist on disk: get_available_models() reloads the
# config from disk when the path/mtime changed, wiping in-memory cfg
# overrides. Metadata-only entries only (name) — no api/base_url/
# api_key/key_env route fields, no models allowlist.
(tmp_path / "config.yaml").write_text(
"model:\n provider: openai-codex\nproviders:\n"
" x-ai:\n name: xAI\n"
" google:\n name: Google\n"
" openai-api:\n name: OpenAI API\n",
encoding="utf-8",
)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
for var in (
"OPENAI_API_KEY",
"HERMES_API_KEY",
"HERMES_OPENAI_API_KEY",
"LOCAL_API_KEY",
"OPENROUTER_API_KEY",
"API_KEY",
"XAI_API_KEY",
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
):
monkeypatch.delenv(var, raising=False)
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
old_path = config._cfg_path
old_fingerprint = config._cfg_fingerprint
config.cfg.clear()
config.cfg["model"] = {"provider": "openai-codex"}
config.cfg["providers"] = {
"x-ai": {"name": "xAI"},
"google": {"name": "Google"},
"openai-api": {"name": "OpenAI API"},
}
cfg_path = config.Path(config._get_config_path())
try:
config._cfg_mtime = cfg_path.stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
# Pin the path/fingerprint globals to THIS fixture so the path_changed /
# fingerprint staleness checks cannot force a disk reload that wipes the
# in-memory overrides above. A preceding config test (e.g.
# test_byok_model_dropdown.py) can leave _cfg_path pointing at its own
# deleted temp config; without the pin, get_available_models() sees
# path_changed=True and reloads from disk, dropping the fixture's x-ai /
# google entries (gate certification 2026-08-07: byok -> 6335 = 1 failed).
config._cfg_path = cfg_path
config._cfg_fingerprint = config._fingerprint_config(config.cfg)
config.invalidate_models_cache()
try:
return config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
config._cfg_path = old_path
config._cfg_fingerprint = old_fingerprint
config.invalidate_models_cache()
def _group_ids(result):
return [g.get("provider_id") for g in result.get("groups", [])]
def _group_names(result):
return [g.get("provider") for g in result.get("groups", [])]
def test_predecessor_leaked_cfg_path_fingerprint_does_not_break_admission(
monkeypatch, tmp_path
):
"""Predecessor-order regression: leaked ``_cfg_path``/``_cfg_fingerprint``
must not trigger a disk reload that wipes the fixture providers.
A preceding config test (``tests/test_byok_model_dropdown.py`` sorts
before this file) repoints ``_get_config_path`` at its own temporary
config and leaves ``config._cfg_path`` / ``config._cfg_fingerprint``
pointing at that now-deleted file. The next
``get_available_models()`` call then sees ``path_changed=True`` and
reloads from disk, dropping the fixture's ``x-ai`` / ``google`` entries
(reproduced by the 2026-08-07 gate certification: byok -> 6335 = 1
failed, ``got ['openai-codex', 'gemini']``).
This test runs FIRST in this file, deliberately simulates that exact
leaked state, and asserts the helper pins over it — proving the two
admission tests below also pass after a leaking predecessor.
"""
import api.config as config
leaked_path = tmp_path / "deleted-predecessor-config.yaml"
old_path, old_fingerprint = config._cfg_path, config._cfg_fingerprint
try:
config._cfg_path = leaked_path
config._cfg_fingerprint = "stale-predecessor-fingerprint"
result = _call_get_available_models(monkeypatch, tmp_path)
ids = _group_ids(result)
assert "openai-codex" in ids, f"active provider group missing; got {ids}"
assert "x-ai" in ids, f"leaked _cfg_path must not wipe x-ai; got {ids}"
assert "google" in ids, f"leaked _cfg_path must not wipe google; got {ids}"
assert "xAI" in _group_names(result)
assert "Google" in _group_names(result)
finally:
config._cfg_path = old_path
config._cfg_fingerprint = old_fingerprint
def test_credentialed_alias_config_providers_stay_admitted(monkeypatch, tmp_path):
"""Authenticated xai/gemini keep metadata-only x-ai/google entries in the picker.
Covers review points 1-4: detection reports authenticated ``xai`` /
``gemini`` (alias names), config has metadata-only ``x-ai`` / ``google``
entries, the active provider is ``openai-codex`` (a different provider),
and the xAI / Google groups remain admitted through the alias-normalised
credential check.
"""
result = _call_get_available_models(monkeypatch, tmp_path)
ids = _group_ids(result)
# Point 3 sanity: the active provider is a different provider, so these
# groups cannot be explained by the unconditional active-provider path.
assert "openai-codex" in ids, f"active provider group missing; got {ids}"
# Point 4: the alias-matched credential evidence keeps both groups.
assert "x-ai" in ids, f"authenticated xai must admit configured x-ai group; got {ids}"
assert "google" in ids, f"authenticated gemini must admit configured google group; got {ids}"
assert "xAI" in _group_names(result)
assert "Google" in _group_names(result)
def test_keyless_metadata_only_known_provider_stays_absent(monkeypatch, tmp_path):
"""A metadata-only known provider with no credential/route/models is dropped.
Review point 5: ``providers.openai-api: {name: ...}`` is a *known*
provider (in ``_PROVIDER_MODELS`` / ``_PROVIDER_DISPLAY``) but carries no
credential evidence, no route field, and no configured model list — the
#6335 bug. It must not render in the model selector.
"""
result = _call_get_available_models(monkeypatch, tmp_path)
ids = _group_ids(result)
assert "openai-api" not in ids, (
f"keyless metadata-only openai-api must stay absent; got {ids}"
)