hermes-agent/tests/test_empty_model_fallback.py
Teknium b34e565957 feat(models): catalog-labeled silent default — GLM-5.2 marked "default": true in the model catalog
The remote model catalog (website/static/api/model-catalog.json) now labels
exactly one entry per provider block with "default": true — z-ai/glm-5.2 for
both OpenRouter and Nous Portal. That labeled entry is the model Hermes
silently lands on when the user never picked one, and it can be rotated by
editing the manifest alone: no release needed.

- model_catalog.py: get_default_model_from_cache() reads the label from the
  in-process/disk cache only — never triggers a network fetch, so hot
  resolution paths (agent build, gateway session setup) stay network-free.
- models.py: get_preferred_silent_default_model() resolves catalog label
  first, PREFERRED_SILENT_DEFAULT_MODEL constant second (offline/fresh
  install). _PROVIDER_SILENT_DEFAULT_OVERRIDES dict replaced by
  _SILENT_DEFAULT_PROVIDERS routing through the shared resolver.
  fetch_openrouter_models() preserves the "default" badge through live
  /v1/models refreshes so the picker shows it.
- scripts/build_model_catalog.py: generator emits the default label so
  regeneration can't drop it.
- website/docs/reference/model-catalog.md: schema documents the new field.
- Salvaged from PR #61141 (@HumphreySun98): bare-provider /model switches
  (/model nous) route through the cost-safe default instead of curated
  entry [0].
- tests: catalog-label precedence, constant fallback, stale-label fallback,
  cache-only (no network) guarantee, and a shipped-manifest contract test
  pinning the labeled entry to PREFERRED_SILENT_DEFAULT_MODEL.

E2E (temp HERMES_HOME): fresh-install constant fallback, shipped-manifest
label read, release-free rotation (relabeled cache -> new default across
models.py, tui_gateway, and gateway empty-model paths) all verified.
2026-07-15 00:10:31 -07:00

245 lines
10 KiB
Python

"""Tests for empty model fallback — when provider is configured but model is missing."""
from unittest.mock import patch
class TestGetDefaultModelForProvider:
"""Unit tests for hermes_cli.models.get_default_model_for_provider."""
def test_known_provider_returns_first_model(self):
from hermes_cli.models import get_default_model_for_provider
result = get_default_model_for_provider("openai-codex")
# Should return first model from _PROVIDER_MODELS["openai-codex"]
assert result
assert isinstance(result, str)
def test_openrouter_returns_preferred_silent_default(self):
"""OpenRouter has no static catalog (live fetch), but the silent
default must still resolve — to the cost-safe preferred model, never
the curated list's Anthropic flagship (claude-fable-5)."""
from hermes_cli.models import (
PREFERRED_SILENT_DEFAULT_MODEL,
get_default_model_for_provider,
)
result = get_default_model_for_provider("openrouter")
assert result == PREFERRED_SILENT_DEFAULT_MODEL
assert "claude" not in result.lower()
def test_unknown_provider_returns_empty(self):
from hermes_cli.models import get_default_model_for_provider
assert get_default_model_for_provider("nonexistent-provider") == ""
def test_custom_provider_returns_empty(self):
"""Custom provider has no model catalog — should return empty."""
from hermes_cli.models import get_default_model_for_provider
# Custom providers don't have entries in _PROVIDER_MODELS
assert get_default_model_for_provider("some-random-custom") == ""
def test_nous_silent_default_is_not_the_expensive_flagship(self):
"""Nous Portal is a metered aggregator whose curated list is ordered
most-capable-first, so entry [0] is the priciest flagship
(anthropic/claude-fable-5). The silent fallback (provider set, no model)
must NOT escalate to it — otherwise an unconfigured profile silently
bills the most expensive model. Regression for the billing footgun.
"""
from hermes_cli.models import (
_PROVIDER_MODELS,
get_default_model_for_provider,
get_preferred_silent_default_model,
)
result = get_default_model_for_provider("nous")
assert result, "nous must resolve to a usable default model"
assert "opus" not in result.lower(), (
f"silent default escalated to an expensive flagship: {result!r}"
)
assert "claude" not in result.lower(), (
f"silent default escalated to an expensive flagship: {result!r}"
)
assert result != _PROVIDER_MODELS["nous"][0], (
"silent default must not be the most-capable/priciest catalog entry"
)
# The default must resolve through the catalog-label helper and point
# at a model that actually exists in the curated catalog.
assert result == get_preferred_silent_default_model("nous")
assert result in _PROVIDER_MODELS["nous"]
def test_catalog_label_overrides_constant(self):
"""A ``"default": true`` label in the cached catalog manifest wins over
the in-repo constant, so maintainers can rotate the silent default
without shipping a release."""
from unittest.mock import patch
from hermes_cli import models as models_mod
with patch(
"hermes_cli.model_catalog.get_default_model_from_cache",
return_value="qwen/qwen3.7-plus",
):
assert (
models_mod.get_preferred_silent_default_model("nous")
== "qwen/qwen3.7-plus"
)
# nous catalog carries qwen3.7-plus, so the full resolver follows.
assert (
models_mod.get_default_model_for_provider("nous")
== "qwen/qwen3.7-plus"
)
def test_no_catalog_cache_falls_back_to_constant(self):
"""With no cached manifest (fresh install / offline), the in-repo
constant is the silent default."""
from unittest.mock import patch
from hermes_cli import models as models_mod
with patch(
"hermes_cli.model_catalog.get_default_model_from_cache",
return_value=None,
):
assert (
models_mod.get_preferred_silent_default_model("openrouter")
== models_mod.PREFERRED_SILENT_DEFAULT_MODEL
)
def test_stale_label_not_in_catalog_falls_back(self):
"""If the labeled default model is no longer in the provider's curated
catalog, fall back to entry [0] rather than returning an absent id."""
from unittest.mock import patch
from hermes_cli import models as models_mod
with patch(
"hermes_cli.model_catalog.get_default_model_from_cache",
return_value="does-not-exist-model",
):
result = models_mod.get_default_model_for_provider("nous")
assert result == models_mod._PROVIDER_MODELS["nous"][0]
class TestDetectStaticProviderCostSafeDefault:
"""detect_static_provider_for_model must apply the same cost-safe default
as get_default_model_for_provider when a bare provider name is typed as a
model (e.g. ``/model nous``)."""
def test_bare_nous_does_not_escalate_to_flagship(self):
from hermes_cli.models import (
_PROVIDER_MODELS,
get_default_model_for_provider,
detect_static_provider_for_model,
)
result = detect_static_provider_for_model("nous", "openrouter")
assert result is not None
provider, model = result
assert provider == "nous"
# Must match the cost-safe silent default, NOT the priciest catalog
# entry [0]. Regression: this path returned _PROVIDER_MODELS["nous"][0]
# directly, re-introducing the billing footgun on the interactive
# ``/model nous`` path.
assert model == get_default_model_for_provider("nous")
assert "opus" not in model.lower()
assert model != _PROVIDER_MODELS["nous"][0]
def test_provider_without_override_still_uses_first_model(self):
"""Providers outside _SILENT_DEFAULT_PROVIDERS are unchanged."""
from hermes_cli.models import (
_PROVIDER_MODELS,
_SILENT_DEFAULT_PROVIDERS,
detect_static_provider_for_model,
)
for provider in ("anthropic", "xai"):
if provider in _SILENT_DEFAULT_PROVIDERS:
continue
result = detect_static_provider_for_model(provider, "openrouter")
assert result is not None
assert result[1] == _PROVIDER_MODELS[provider][0]
class TestGatewayEmptyModelFallback:
"""Test that _resolve_session_agent_runtime fills in empty model from provider catalog."""
def test_empty_model_filled_from_provider(self):
"""When config has no model but provider is openai-codex, use first codex model."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner._session_model_overrides = {}
# Mock _resolve_gateway_model to return empty string
# Mock _resolve_runtime_agent_kwargs to return openai-codex provider
with patch("gateway.run._resolve_gateway_model", return_value=""), \
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={
"provider": "openai-codex",
"api_key": "test-key",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_mode": "codex_responses",
}):
model, kwargs = runner._resolve_session_agent_runtime()
# Model should have been filled in from provider catalog
assert model, "Model should not be empty when provider is known"
assert isinstance(model, str)
assert kwargs["provider"] == "openai-codex"
def test_nonempty_model_not_overridden(self):
"""When config has a model set, don't override it."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner._session_model_overrides = {}
with patch("gateway.run._resolve_gateway_model", return_value="gpt-5.4"), \
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={
"provider": "openai-codex",
"api_key": "test-key",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_mode": "codex_responses",
}):
model, kwargs = runner._resolve_session_agent_runtime()
assert model == "gpt-5.4", "Explicit model should not be overridden"
def test_empty_model_no_provider_stays_empty(self):
"""When both model and provider are empty, model stays empty."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner._session_model_overrides = {}
with patch("gateway.run._resolve_gateway_model", return_value=""), \
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={
"provider": "",
"api_key": "test-key",
"base_url": "https://example.com",
"api_mode": "chat_completions",
}):
model, kwargs = runner._resolve_session_agent_runtime()
# Can't fill in a default without knowing the provider
assert model == ""
class TestResolveGatewayModel:
"""Test _resolve_gateway_model reads model from config correctly."""
def test_returns_default_key(self):
from gateway.run import _resolve_gateway_model
assert _resolve_gateway_model({"model": {"default": "gpt-5.4"}}) == "gpt-5.4"
def test_returns_model_key_fallback(self):
from gateway.run import _resolve_gateway_model
assert _resolve_gateway_model({"model": {"model": "gpt-5.4"}}) == "gpt-5.4"
def test_returns_empty_when_missing(self):
from gateway.run import _resolve_gateway_model
assert _resolve_gateway_model({"model": {}}) == ""
def test_returns_empty_when_no_model_section(self):
from gateway.run import _resolve_gateway_model
assert _resolve_gateway_model({}) == ""
def test_string_model_config(self):
from gateway.run import _resolve_gateway_model
assert _resolve_gateway_model({"model": "my-model"}) == "my-model"