Files
hermes-webui/tests/test_live_models_ttl_cache.py
T
nesquena-hermes 0b0e2a3213 Release PG (v0.51.446): model picker show-all overflow for large catalogs (#3691) (#4282)
* stage-3712: model picker show-all overflow + fix per-row count leak (#3691)

Self-rebased rodboev's #3712 onto master. FIX (Nathan-caught): the per-row
provider chip stripped the '(N of M)' overflow count — that count belongs on the
group heading only, and was reading as nonsense post-expand (row 30 showing
'15 of 30'). Updated test to assert heading-carries-count + rows-do-not.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

* stage-3712: + dedupe overflow expand (Codex SILENT) — reuse existing injected option

Codex caught: select hidden overflow model from search → reopen → Show all could
duplicate the row (_appendOverflowOptionsToGroup blindly appended every extra
model without checking the select for an already-injected option). Now moves/reuses
an existing same-value option instead of re-creating it.

* stage-3712: + fix expanded-heading double-count (Opus) — strip decoration, count rendered rows

Opus caught: post-expand the heading showed 'Nous (2 of 4) (4)' (appended its own
count onto the already-decorated backend label) and used modelCount (incl hoisted-
configured) vs rendered rows. Now strips the '(N of M)' suffix when expanded +
counts groupRows.length. Test asserts no double count on out[expanded].

* stage-3712: + include extra_models in Settings/Cron/Profile/Aux native selectors (Codex CORE)

Codex caught: the server overflow split moves models>15 into extra_models for ALL
providers, but the 4 native <select> builders in panels.js (settingsModel:6690,
cronFormModel:1112, profileFormModel:5784, _auxProviders:8287) read g.models only
— silently dropping the overflow tail for large providers. Now concat
models+extra_models at all 4 sites. Test guard added.

* Release PG: model picker show-all overflow for large provider catalogs (#3691)

Self-rebased rodboev's #3712 onto v0.51.445; full browser-tested (desktop+mobile,
before/after) + Nathan screenshot-approved. 4 gate-caught issues fixed inline:
per-row count leak (Nathan), overflow-expand dedup (Codex), heading double-count
(Opus), and native Settings/Cron/Profile/Aux selectors dropping the overflow tail
(Codex CORE). Codex SAFE + Opus SHIP + 41 picker tests green.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-15 17:16:36 -07:00

134 lines
4.7 KiB
Python

"""Regression tests for /api/models/live backend TTL caching."""
import sys
import types
from urllib.parse import urlparse
def _install_provider_model_ids(monkeypatch, fn):
hermes_cli = types.ModuleType("hermes_cli")
hermes_cli.__path__ = []
models = types.ModuleType("hermes_cli.models")
models.provider_model_ids = fn
monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli)
monkeypatch.setitem(sys.modules, "hermes_cli.models", models)
def _patch_live_models_basics(monkeypatch, routes, profile="default"):
import api.config as config
import api.profiles as profiles
routes._clear_live_models_cache()
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
monkeypatch.setattr(config, "get_config", lambda: {"model": {"provider": "openai"}})
monkeypatch.setattr(config, "_resolve_provider_alias", lambda provider: provider)
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: profile)
def test_live_models_cache_hits_within_ttl(monkeypatch):
import api.routes as routes
calls = []
def provider_model_ids(provider):
calls.append(provider)
return ["openai/gpt-test"]
_install_provider_model_ids(monkeypatch, provider_model_ids)
_patch_live_models_basics(monkeypatch, routes)
parsed = urlparse("/api/models/live?provider=openai")
first = routes._handle_live_models(object(), parsed)
second = routes._handle_live_models(object(), parsed)
assert calls == ["openai"]
assert first == second
assert first["models"] == [{"id": "openai/gpt-test", "label": "GPT Test"}]
def test_live_models_cache_expires(monkeypatch):
import api.routes as routes
now = [1000.0]
calls = []
def provider_model_ids(provider):
calls.append(provider)
return [f"{provider}/model-{len(calls)}"]
_install_provider_model_ids(monkeypatch, provider_model_ids)
_patch_live_models_basics(monkeypatch, routes)
monkeypatch.setattr(routes.time, "monotonic", lambda: now[0])
parsed = urlparse("/api/models/live?provider=openai")
first = routes._handle_live_models(object(), parsed)
now[0] += routes._LIVE_MODELS_CACHE_TTL + 1
second = routes._handle_live_models(object(), parsed)
assert calls == ["openai", "openai"]
assert first["models"][0]["id"] == "openai/model-1"
assert second["models"][0]["id"] == "openai/model-2"
def test_live_models_cache_is_profile_scoped(monkeypatch):
import api.routes as routes
import api.profiles as profiles
active_profile = ["default"]
calls = []
def provider_model_ids(provider):
calls.append((active_profile[0], provider))
return [f"{provider}/{active_profile[0]}-model"]
_install_provider_model_ids(monkeypatch, provider_model_ids)
_patch_live_models_basics(monkeypatch, routes)
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: active_profile[0])
parsed = urlparse("/api/models/live?provider=openai")
default_payload = routes._handle_live_models(object(), parsed)
active_profile[0] = "research"
research_payload = routes._handle_live_models(object(), parsed)
again_payload = routes._handle_live_models(object(), parsed)
assert calls == [("default", "openai"), ("research", "openai")]
assert default_payload["models"][0]["id"] == "openai/default-model"
assert research_payload["models"][0]["id"] == "openai/research-model"
assert again_payload == research_payload
def test_live_models_cache_returns_deep_copies(monkeypatch):
import api.routes as routes
_install_provider_model_ids(monkeypatch, lambda provider: ["openai/gpt-test"])
_patch_live_models_basics(monkeypatch, routes)
parsed = urlparse("/api/models/live?provider=openai")
first = routes._handle_live_models(object(), parsed)
first["models"].clear()
first["provider"] = "mutated"
second = routes._handle_live_models(object(), parsed)
assert second["provider"] == "openai"
assert second["models"] == [{"id": "openai/gpt-test", "label": "GPT Test"}]
def test_live_models_endpoint_respects_picker_visibility_budget(monkeypatch):
import api.config as config
import api.routes as routes
_install_provider_model_ids(
monkeypatch,
lambda provider: [f"{provider}/model-{idx}" for idx in range(40)],
)
_patch_live_models_basics(monkeypatch, routes)
parsed = urlparse("/api/models/live?provider=openai")
payload = routes._handle_live_models(object(), parsed)
assert payload["count"] == config._MODEL_PICKER_VISIBLE_TARGET
assert [m["id"] for m in payload["models"]] == [
f"openai/model-{idx}" for idx in range(config._MODEL_PICKER_VISIBLE_TARGET)
]