mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
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.
978 lines
44 KiB
Python
978 lines
44 KiB
Python
"""Tests for the hermes_cli models module."""
|
|
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
from hermes_cli.nous_account import NousPortalAccountInfo
|
|
from hermes_cli.models import (
|
|
OPENROUTER_MODELS, fetch_openrouter_models, model_ids, detect_provider_for_model,
|
|
is_nous_free_tier, partition_nous_models_by_tier,
|
|
check_nous_free_tier, _FREE_TIER_CACHE_TTL,
|
|
union_with_portal_free_recommendations,
|
|
union_with_portal_paid_recommendations,
|
|
)
|
|
import hermes_cli.models as _models_mod
|
|
|
|
LIVE_OPENROUTER_MODELS = [
|
|
("anthropic/claude-opus-4.6", "recommended"),
|
|
("qwen/qwen3.7-max", ""),
|
|
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
|
|
]
|
|
|
|
|
|
|
|
class TestModelIds:
|
|
def test_returns_non_empty_list(self):
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
ids = model_ids()
|
|
assert isinstance(ids, list)
|
|
assert len(ids) > 0
|
|
|
|
def test_ids_match_fetched_catalog(self):
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
ids = model_ids()
|
|
expected = [mid for mid, _ in LIVE_OPENROUTER_MODELS]
|
|
assert ids == expected
|
|
|
|
def test_all_ids_contain_provider_slash(self):
|
|
"""Model IDs should follow the provider/model format."""
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
for mid in model_ids():
|
|
assert "/" in mid, f"Model ID '{mid}' missing provider/ prefix"
|
|
|
|
def test_no_duplicate_ids(self):
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
ids = model_ids()
|
|
assert len(ids) == len(set(ids)), "Duplicate model IDs found"
|
|
|
|
|
|
|
|
|
|
|
|
class TestOpenRouterModels:
|
|
def test_structure_is_list_of_tuples(self):
|
|
for entry in OPENROUTER_MODELS:
|
|
assert isinstance(entry, tuple) and len(entry) == 2
|
|
mid, desc = entry
|
|
assert isinstance(mid, str) and len(mid) > 0
|
|
assert isinstance(desc, str)
|
|
|
|
|
|
class TestFetchOpenRouterModels:
|
|
def test_live_fetch_recomputes_free_tags(self, monkeypatch):
|
|
class _Resp:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def read(self):
|
|
return b'{"data":[{"id":"anthropic/claude-opus-4.8","pricing":{"prompt":"0.000015","completion":"0.000075"}},{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}},{"id":"nvidia/nemotron-3-super-120b-a12b:free","pricing":{"prompt":"0","completion":"0"}}]}'
|
|
|
|
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
|
|
with patch("hermes_cli.models._urlopen_model_catalog_request", return_value=_Resp()):
|
|
models = fetch_openrouter_models(force_refresh=True)
|
|
|
|
assert models == [
|
|
("anthropic/claude-opus-4.8", "recommended"),
|
|
("qwen/qwen3.7-max", ""),
|
|
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
|
|
]
|
|
|
|
|
|
def test_falls_back_to_static_snapshot_on_fetch_failure(self, monkeypatch):
|
|
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
|
|
# Pin the remote manifest out too — otherwise the fallback silently
|
|
# depends on whatever the deployed catalog currently contains.
|
|
with patch("hermes_cli.model_catalog.get_curated_openrouter_models", return_value=None), \
|
|
patch("hermes_cli.models._urlopen_model_catalog_request", side_effect=OSError("boom")):
|
|
models = fetch_openrouter_models(force_refresh=True)
|
|
|
|
assert models == OPENROUTER_MODELS
|
|
|
|
def test_filters_out_models_without_tool_support(self, monkeypatch):
|
|
"""Models whose supported_parameters omits 'tools' must not appear in the picker.
|
|
|
|
hermes-agent is tool-calling-first — surfacing a non-tool model leads to
|
|
immediate runtime failures when the user selects it. Ported from
|
|
Kilo-Org/kilocode#9068.
|
|
"""
|
|
class _Resp:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def read(self):
|
|
# opus-4.6 advertises tools → kept
|
|
# nano-image has explicit supported_parameters that OMITS tools → dropped
|
|
# qwen3.7-max advertises tools → kept
|
|
return (
|
|
b'{"data":['
|
|
b'{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"},'
|
|
b'"supported_parameters":["temperature","tools","tool_choice"]},'
|
|
b'{"id":"google/gemini-3-pro-image-preview","pricing":{"prompt":"0.00001","completion":"0.00003"},'
|
|
b'"supported_parameters":["temperature","response_format"]},'
|
|
b'{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"},'
|
|
b'"supported_parameters":["tools","temperature"]}'
|
|
b']}'
|
|
)
|
|
|
|
# Include the image-only id in the curated list so it has a chance to be surfaced.
|
|
monkeypatch.setattr(
|
|
_models_mod,
|
|
"OPENROUTER_MODELS",
|
|
[
|
|
("anthropic/claude-opus-4.6", ""),
|
|
("google/gemini-3-pro-image-preview", ""),
|
|
("qwen/qwen3.7-max", ""),
|
|
],
|
|
)
|
|
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
|
|
with (
|
|
patch("hermes_cli.model_catalog.get_curated_openrouter_models", return_value=[]),
|
|
patch("hermes_cli.models._urlopen_model_catalog_request", return_value=_Resp()),
|
|
):
|
|
models = fetch_openrouter_models(force_refresh=True)
|
|
|
|
ids = [mid for mid, _ in models]
|
|
assert "anthropic/claude-opus-4.6" in ids
|
|
assert "qwen/qwen3.7-max" in ids
|
|
# Image-only model advertised supported_parameters WITHOUT tools → must be dropped.
|
|
assert "google/gemini-3-pro-image-preview" not in ids
|
|
|
|
def test_permissive_when_supported_parameters_missing(self, monkeypatch):
|
|
"""Models missing the supported_parameters field keep appearing in the picker.
|
|
|
|
Some OpenRouter-compatible gateways (Nous Portal, private mirrors, older
|
|
catalog snapshots) don't populate supported_parameters. Treating missing
|
|
as 'unknown → allow' prevents the picker from silently emptying on
|
|
those gateways.
|
|
"""
|
|
class _Resp:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
def read(self):
|
|
# No supported_parameters field at all on either entry.
|
|
return (
|
|
b'{"data":['
|
|
b'{"id":"anthropic/claude-opus-4.8","pricing":{"prompt":"0.000015","completion":"0.000075"}},'
|
|
b'{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}}'
|
|
b']}'
|
|
)
|
|
|
|
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
|
|
with patch("hermes_cli.models._urlopen_model_catalog_request", return_value=_Resp()):
|
|
models = fetch_openrouter_models(force_refresh=True)
|
|
|
|
ids = [mid for mid, _ in models]
|
|
assert "anthropic/claude-opus-4.8" in ids
|
|
assert "qwen/qwen3.7-max" in ids
|
|
|
|
|
|
class TestOpenRouterToolSupportHelper:
|
|
"""Unit tests for _openrouter_model_supports_tools (Kilo port #9068)."""
|
|
|
|
def test_tools_in_supported_parameters(self):
|
|
from hermes_cli.models import _openrouter_model_supports_tools
|
|
assert _openrouter_model_supports_tools(
|
|
{"id": "x", "supported_parameters": ["temperature", "tools"]}
|
|
) is True
|
|
|
|
def test_tools_missing_from_supported_parameters(self):
|
|
from hermes_cli.models import _openrouter_model_supports_tools
|
|
assert _openrouter_model_supports_tools(
|
|
{"id": "x", "supported_parameters": ["temperature", "response_format"]}
|
|
) is False
|
|
|
|
def test_supported_parameters_absent_is_permissive(self):
|
|
"""Missing field → allow (so older / non-OR gateways still work)."""
|
|
from hermes_cli.models import _openrouter_model_supports_tools
|
|
assert _openrouter_model_supports_tools({"id": "x"}) is True
|
|
|
|
def test_supported_parameters_none_is_permissive(self):
|
|
from hermes_cli.models import _openrouter_model_supports_tools
|
|
assert _openrouter_model_supports_tools({"id": "x", "supported_parameters": None}) is True
|
|
|
|
def test_supported_parameters_malformed_is_permissive(self):
|
|
"""Malformed (non-list) value → allow rather than silently drop."""
|
|
from hermes_cli.models import _openrouter_model_supports_tools
|
|
assert _openrouter_model_supports_tools(
|
|
{"id": "x", "supported_parameters": "tools,temperature"}
|
|
) is True
|
|
|
|
def test_non_dict_item_is_permissive(self):
|
|
from hermes_cli.models import _openrouter_model_supports_tools
|
|
assert _openrouter_model_supports_tools(None) is True
|
|
assert _openrouter_model_supports_tools("anthropic/claude-opus-4.6") is True
|
|
|
|
def test_empty_supported_parameters_list_drops_model(self):
|
|
"""Explicit empty list → no tools → drop."""
|
|
from hermes_cli.models import _openrouter_model_supports_tools
|
|
assert _openrouter_model_supports_tools(
|
|
{"id": "x", "supported_parameters": []}
|
|
) is False
|
|
|
|
|
|
class TestFindOpenrouterSlug:
|
|
def test_exact_match(self):
|
|
from hermes_cli.models import _find_openrouter_slug
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
assert _find_openrouter_slug("anthropic/claude-opus-4.6") == "anthropic/claude-opus-4.6"
|
|
|
|
def test_bare_name_match(self):
|
|
from hermes_cli.models import _find_openrouter_slug
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
result = _find_openrouter_slug("claude-opus-4.6")
|
|
assert result == "anthropic/claude-opus-4.6"
|
|
|
|
def test_case_insensitive(self):
|
|
from hermes_cli.models import _find_openrouter_slug
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
result = _find_openrouter_slug("Anthropic/Claude-Opus-4.6")
|
|
assert result is not None
|
|
|
|
def test_unknown_returns_none(self):
|
|
from hermes_cli.models import _find_openrouter_slug
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
assert _find_openrouter_slug("totally-fake-model-xyz") is None
|
|
|
|
|
|
class TestDetectProviderForModel:
|
|
def test_anthropic_model_detected(self):
|
|
"""claude-opus-4-6 should resolve to anthropic provider."""
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
result = detect_provider_for_model("claude-opus-4-6", "openai-codex")
|
|
assert result is not None
|
|
assert result[0] == "anthropic"
|
|
|
|
def test_deepseek_model_detected(self):
|
|
"""deepseek-chat should resolve to deepseek provider."""
|
|
result = detect_provider_for_model("deepseek-chat", "openai-codex")
|
|
assert result is not None
|
|
# Provider is deepseek (direct) or openrouter (fallback) depending on creds
|
|
assert result[0] in {"deepseek", "openrouter"}
|
|
|
|
def test_current_provider_model_returns_none(self):
|
|
"""Models belonging to the current provider should not trigger a switch."""
|
|
assert detect_provider_for_model("gpt-5.3-codex", "openai-codex") is None
|
|
|
|
def test_short_alias_resolves_to_static_model(self):
|
|
"""Short aliases (e.g. sonnet) should resolve without network lookups."""
|
|
with patch(
|
|
"hermes_cli.models.fetch_openrouter_models",
|
|
side_effect=AssertionError("network lookup should not run"),
|
|
):
|
|
result = detect_provider_for_model("sonnet", "auto")
|
|
assert result is not None
|
|
assert result[0] == "anthropic"
|
|
assert result[1].startswith("claude-sonnet")
|
|
|
|
def test_openrouter_slug_match(self):
|
|
"""Models in the OpenRouter catalog should be found."""
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
result = detect_provider_for_model("anthropic/claude-opus-4.6", "openai-codex")
|
|
assert result is not None
|
|
assert result[0] == "openrouter"
|
|
assert result[1] == "anthropic/claude-opus-4.6"
|
|
|
|
def test_bare_name_gets_openrouter_slug(self, monkeypatch):
|
|
for env_var in (
|
|
"ANTHROPIC_API_KEY",
|
|
"ANTHROPIC_TOKEN",
|
|
"CLAUDE_CODE_TOKEN",
|
|
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
):
|
|
monkeypatch.delenv(env_var, raising=False)
|
|
"""Bare model names should get mapped to full OpenRouter slugs."""
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
result = detect_provider_for_model("claude-opus-4.6", "openai-codex")
|
|
assert result is not None
|
|
# Should find it on OpenRouter with full slug
|
|
assert result[1] == "anthropic/claude-opus-4.6"
|
|
|
|
def test_unknown_model_returns_none(self):
|
|
"""Completely unknown model names should return None."""
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
assert detect_provider_for_model("nonexistent-model-xyz", "openai-codex") is None
|
|
|
|
def test_aggregator_not_suggested(self):
|
|
"""nous/openrouter should never be auto-suggested as target provider."""
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
result = detect_provider_for_model("claude-opus-4-6", "openai-codex")
|
|
assert result is not None
|
|
assert result[0] not in {"nous",} # nous has claude models but shouldn't be suggested
|
|
|
|
def test_custom_provider_not_overridden_by_static_catalog(self):
|
|
"""When current provider is custom:*, a static-catalog match must NOT
|
|
override it — otherwise a model served by the user's own endpoint gets
|
|
misattributed to a native provider, rewriting model.provider (#48305).
|
|
|
|
`gpt-5.4` is in the static openai catalog; with current=custom:foo,
|
|
detection must return None instead of switching to openai.
|
|
"""
|
|
assert detect_provider_for_model("gpt-5.4", "custom:foo") is None
|
|
|
|
def test_bare_custom_provider_not_overridden_by_static_catalog(self):
|
|
"""Same protection for the bare 'custom' provider."""
|
|
assert detect_provider_for_model("gpt-5.4", "custom") is None
|
|
|
|
def test_non_custom_provider_detection_unaffected(self):
|
|
"""The custom-provider guard must NOT change detection for non-custom
|
|
current providers — a static-catalog model still routes normally."""
|
|
result = detect_provider_for_model("gpt-5.4", "openrouter")
|
|
assert result is not None and result[0] == "openai"
|
|
|
|
|
|
class TestIsNousFreeTier:
|
|
"""Tests for is_nous_free_tier — account tier detection."""
|
|
|
|
def test_paid_service_access_allowed_true_is_not_free(self):
|
|
assert is_nous_free_tier({"paid_service_access": {"allowed": True}}) is False
|
|
|
|
def test_paid_service_access_allowed_false_is_free(self):
|
|
assert is_nous_free_tier({"paid_service_access": {"allowed": False}}) is True
|
|
|
|
def test_paid_service_access_paid_access_fallback(self):
|
|
assert is_nous_free_tier({"paid_service_access": {"paid_access": False}}) is True
|
|
|
|
def test_paid_plus_tier(self):
|
|
assert is_nous_free_tier({"subscription": {"plan": "Plus", "tier": 2, "monthly_charge": 20}}) is False
|
|
|
|
def test_free_tier_by_charge(self):
|
|
assert is_nous_free_tier({"subscription": {"plan": "Free", "tier": 0, "monthly_charge": 0}}) is True
|
|
|
|
def test_no_charge_field_not_free(self):
|
|
"""Missing monthly_charge defaults to not-free (don't block users)."""
|
|
assert is_nous_free_tier({"subscription": {"plan": "Free", "tier": 0}}) is False
|
|
|
|
def test_plan_name_alone_not_free(self):
|
|
"""Plan name alone is not enough — monthly_charge is required."""
|
|
assert is_nous_free_tier({"subscription": {"plan": "free"}}) is False
|
|
|
|
def test_empty_subscription_not_free(self):
|
|
"""Empty subscription dict defaults to not-free (don't block users)."""
|
|
assert is_nous_free_tier({"subscription": {}}) is False
|
|
|
|
def test_no_subscription_not_free(self):
|
|
"""Missing subscription key returns False."""
|
|
assert is_nous_free_tier({}) is False
|
|
|
|
def test_empty_response_not_free(self):
|
|
"""Completely empty response defaults to not-free."""
|
|
assert is_nous_free_tier({}) is False
|
|
|
|
|
|
class TestPartitionNousModelsByTier:
|
|
"""Tests for partition_nous_models_by_tier — free vs paid tier model split."""
|
|
|
|
_PAID = {"prompt": "0.000003", "completion": "0.000015"}
|
|
_FREE = {"prompt": "0", "completion": "0"}
|
|
|
|
def test_paid_tier_all_selectable(self):
|
|
"""Paid users get all models as selectable, none unavailable."""
|
|
models = ["anthropic/claude-opus-4.6", "xiaomi/mimo-v2-pro"]
|
|
pricing = {"anthropic/claude-opus-4.6": self._PAID, "xiaomi/mimo-v2-pro": self._FREE}
|
|
sel, unav = partition_nous_models_by_tier(models, pricing, free_tier=False)
|
|
assert sel == models
|
|
assert unav == []
|
|
|
|
def test_free_tier_splits_correctly(self):
|
|
"""Free users see only free models; paid ones are unavailable."""
|
|
models = ["anthropic/claude-opus-4.6", "xiaomi/mimo-v2-pro", "openai/gpt-5.4"]
|
|
pricing = {
|
|
"anthropic/claude-opus-4.6": self._PAID,
|
|
"xiaomi/mimo-v2-pro": self._FREE,
|
|
"openai/gpt-5.4": self._PAID,
|
|
}
|
|
sel, unav = partition_nous_models_by_tier(models, pricing, free_tier=True)
|
|
assert sel == ["xiaomi/mimo-v2-pro"]
|
|
assert unav == ["anthropic/claude-opus-4.6", "openai/gpt-5.4"]
|
|
|
|
def test_no_pricing_returns_all(self):
|
|
"""Without pricing data, all models are selectable."""
|
|
models = ["anthropic/claude-opus-4.6", "openai/gpt-5.4"]
|
|
sel, unav = partition_nous_models_by_tier(models, {}, free_tier=True)
|
|
assert sel == models
|
|
assert unav == []
|
|
|
|
def test_all_free_models(self):
|
|
"""When all models are free, free-tier users can select all."""
|
|
models = ["xiaomi/mimo-v2-pro", "xiaomi/mimo-v2-omni"]
|
|
pricing = {m: self._FREE for m in models}
|
|
sel, unav = partition_nous_models_by_tier(models, pricing, free_tier=True)
|
|
assert sel == models
|
|
assert unav == []
|
|
|
|
def test_all_paid_models(self):
|
|
"""When all models are paid, free-tier users have none selectable."""
|
|
models = ["anthropic/claude-opus-4.6", "openai/gpt-5.4"]
|
|
pricing = {m: self._PAID for m in models}
|
|
sel, unav = partition_nous_models_by_tier(models, pricing, free_tier=True)
|
|
assert sel == []
|
|
assert unav == models
|
|
|
|
|
|
class TestUnionWithPortalFreeRecommendations:
|
|
"""Tests for union_with_portal_free_recommendations.
|
|
|
|
The Portal's freeRecommendedModels endpoint is the source of truth for
|
|
what's free *right now* — the in-repo curated list and docs-hosted
|
|
manifest can lag. This helper guarantees the picker still surfaces
|
|
Portal-flagged free models even when the rest of the catalog is stale.
|
|
"""
|
|
|
|
_PAID = {"prompt": "0.000003", "completion": "0.000015"}
|
|
_FREE = {"prompt": "0", "completion": "0"}
|
|
|
|
def _payload(self, free_models: list[str]) -> dict:
|
|
return {
|
|
"freeRecommendedModels": [
|
|
{"modelName": mid, "displayName": mid} for mid in free_models
|
|
],
|
|
}
|
|
|
|
def test_adds_portal_free_model_missing_from_curated(self):
|
|
"""A Portal-advertised free model not in curated is appended + priced free."""
|
|
curated = ["anthropic/claude-opus-4.6"]
|
|
pricing = {"anthropic/claude-opus-4.6": self._PAID}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value=self._payload(["qwen/qwen3.6-plus"]),
|
|
):
|
|
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
|
|
|
# Curated ("HA") models stay first; Portal-only picks follow.
|
|
assert ids[0] == "anthropic/claude-opus-4.6"
|
|
assert ids[-1] == "qwen/qwen3.6-plus" # appended
|
|
# Synthetic free pricing entry created
|
|
assert p["qwen/qwen3.6-plus"] == self._FREE
|
|
# Existing pricing untouched
|
|
assert p["anthropic/claude-opus-4.6"] == self._PAID
|
|
|
|
def test_does_not_duplicate_curated_entries(self):
|
|
"""A Portal free model already in curated is not duplicated."""
|
|
curated = ["qwen/qwen3.6-plus", "anthropic/claude-opus-4.6"]
|
|
pricing = {
|
|
"qwen/qwen3.6-plus": self._FREE,
|
|
"anthropic/claude-opus-4.6": self._PAID,
|
|
}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value=self._payload(["qwen/qwen3.6-plus"]),
|
|
):
|
|
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
|
|
|
assert ids == curated
|
|
assert p == pricing
|
|
|
|
def test_then_partition_keeps_portal_free_model(self):
|
|
"""End-to-end: Portal-flagged free model survives partition."""
|
|
# Simulate the broken-state-before-this-fix: in-repo curated list
|
|
# contains qwen/qwen3.6-plus (because new builds shipped it) but
|
|
# live pricing endpoint hasn't published its zero-cost entry yet.
|
|
# The Portal's freeRecommendedModels still flags it as free.
|
|
curated = ["qwen/qwen3.6-plus", "anthropic/claude-opus-4.6"]
|
|
pricing = {"anthropic/claude-opus-4.6": self._PAID} # qwen missing!
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value=self._payload(["qwen/qwen3.6-plus"]),
|
|
):
|
|
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
|
sel, unav = partition_nous_models_by_tier(ids, p, free_tier=True)
|
|
assert "qwen/qwen3.6-plus" in sel
|
|
assert "anthropic/claude-opus-4.6" in unav
|
|
|
|
def test_empty_payload_returns_inputs_unchanged(self):
|
|
"""Empty Portal response leaves curated + pricing untouched."""
|
|
curated = ["a", "b"]
|
|
pricing = {"a": self._PAID}
|
|
with patch("hermes_cli.models.fetch_nous_recommended_models", return_value={}):
|
|
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
|
assert ids == curated
|
|
assert p == pricing
|
|
|
|
def test_missing_freeRecommendedModels_key(self):
|
|
"""Portal payload without freeRecommendedModels degrades gracefully."""
|
|
curated = ["a"]
|
|
pricing = {"a": self._PAID}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value={"paidRecommendedModels": [{"modelName": "x"}]},
|
|
):
|
|
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
|
assert ids == curated
|
|
assert p == pricing
|
|
|
|
def test_fetch_failure_returns_inputs(self):
|
|
"""Network failures don't blow up the picker."""
|
|
curated = ["a"]
|
|
pricing = {"a": self._PAID}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
side_effect=RuntimeError("network down"),
|
|
):
|
|
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
|
assert ids == curated
|
|
assert p == pricing
|
|
|
|
def test_invalid_entries_skipped(self):
|
|
"""Non-dict / missing-modelName entries are filtered out."""
|
|
curated = ["a"]
|
|
pricing = {"a": self._PAID}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value={
|
|
"freeRecommendedModels": [
|
|
"not-a-dict",
|
|
{"displayName": "no-modelName"},
|
|
{"modelName": ""},
|
|
{"modelName": "qwen/qwen3.6-plus"},
|
|
]
|
|
},
|
|
):
|
|
ids, p = union_with_portal_free_recommendations(curated, pricing, "")
|
|
assert ids == ["a", "qwen/qwen3.6-plus"]
|
|
assert p["qwen/qwen3.6-plus"] == self._FREE
|
|
|
|
|
|
class TestUnionWithPortalPaidRecommendations:
|
|
"""Tests for union_with_portal_paid_recommendations.
|
|
|
|
Mirror of TestUnionWithPortalFreeRecommendations: the Portal's
|
|
paidRecommendedModels endpoint is the source of truth for what's a
|
|
blessed paid model *right now*. The in-repo curated list and
|
|
docs-hosted manifest can lag — this helper guarantees newly-launched
|
|
paid models surface in the picker for paid-tier users without a CLI
|
|
release.
|
|
"""
|
|
|
|
_PAID = {"prompt": "0.000003", "completion": "0.000015"}
|
|
_FREE = {"prompt": "0", "completion": "0"}
|
|
|
|
def _payload(self, paid_models: list[str]) -> dict:
|
|
return {
|
|
"paidRecommendedModels": [
|
|
{"modelName": mid, "displayName": mid} for mid in paid_models
|
|
],
|
|
}
|
|
|
|
def test_adds_portal_paid_model_missing_from_curated(self):
|
|
"""A Portal-advertised paid model not in curated is appended."""
|
|
curated = ["anthropic/claude-opus-4.6"]
|
|
pricing = {"anthropic/claude-opus-4.6": self._PAID}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value=self._payload(["openai/gpt-5.4"]),
|
|
):
|
|
ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
|
|
|
|
# Curated ("HA") models stay first; Portal-only picks follow.
|
|
assert ids[0] == "anthropic/claude-opus-4.6"
|
|
assert ids[-1] == "openai/gpt-5.4" # appended
|
|
# Existing pricing untouched
|
|
assert p["anthropic/claude-opus-4.6"] == self._PAID
|
|
|
|
def test_does_not_synthesize_pricing_for_paid_models(self):
|
|
"""Paid recommendations missing from live pricing get no synthetic entry.
|
|
|
|
Synthesizing zero pricing (like the free helper does) would mislead
|
|
:func:`partition_nous_models_by_tier` into treating them as free;
|
|
synthesizing a non-zero placeholder would lie to the user. The
|
|
right thing is to leave pricing absent so the picker shows a blank
|
|
column until the live pricing endpoint catches up.
|
|
"""
|
|
curated = ["anthropic/claude-opus-4.6"]
|
|
pricing = {"anthropic/claude-opus-4.6": self._PAID}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value=self._payload(["openai/gpt-5.4"]),
|
|
):
|
|
_, p = union_with_portal_paid_recommendations(curated, pricing, "")
|
|
|
|
assert "openai/gpt-5.4" not in p
|
|
assert p["anthropic/claude-opus-4.6"] == self._PAID
|
|
|
|
def test_does_not_duplicate_curated_entries(self):
|
|
"""A Portal paid model already in curated is not duplicated."""
|
|
curated = ["openai/gpt-5.4", "anthropic/claude-opus-4.6"]
|
|
pricing = {
|
|
"openai/gpt-5.4": self._PAID,
|
|
"anthropic/claude-opus-4.6": self._PAID,
|
|
}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value=self._payload(["openai/gpt-5.4"]),
|
|
):
|
|
ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
|
|
|
|
assert ids == curated
|
|
assert p == pricing
|
|
|
|
def test_empty_payload_returns_inputs_unchanged(self):
|
|
"""Empty Portal response leaves curated + pricing untouched."""
|
|
curated = ["a", "b"]
|
|
pricing = {"a": self._PAID}
|
|
with patch("hermes_cli.models.fetch_nous_recommended_models", return_value={}):
|
|
ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
|
|
assert ids == curated
|
|
assert p == pricing
|
|
|
|
def test_missing_paidRecommendedModels_key(self):
|
|
"""Portal payload without paidRecommendedModels degrades gracefully."""
|
|
curated = ["a"]
|
|
pricing = {"a": self._PAID}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value={"freeRecommendedModels": [{"modelName": "x"}]},
|
|
):
|
|
ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
|
|
assert ids == curated
|
|
assert p == pricing
|
|
|
|
def test_fetch_failure_returns_inputs(self):
|
|
"""Network failures don't blow up the picker."""
|
|
curated = ["a"]
|
|
pricing = {"a": self._PAID}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
side_effect=RuntimeError("network down"),
|
|
):
|
|
ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
|
|
assert ids == curated
|
|
assert p == pricing
|
|
|
|
def test_invalid_entries_skipped(self):
|
|
"""Non-dict / missing-modelName entries are filtered out."""
|
|
curated = ["a"]
|
|
pricing = {"a": self._PAID}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value={
|
|
"paidRecommendedModels": [
|
|
"not-a-dict",
|
|
{"displayName": "no-modelName"},
|
|
{"modelName": ""},
|
|
{"modelName": "openai/gpt-5.4"},
|
|
]
|
|
},
|
|
):
|
|
ids, p = union_with_portal_paid_recommendations(curated, pricing, "")
|
|
assert ids == ["a", "openai/gpt-5.4"]
|
|
# No synthetic entry — pricing is untouched.
|
|
assert "openai/gpt-5.4" not in p
|
|
|
|
def test_preserves_relative_order_of_new_paid_models(self):
|
|
"""Multiple new paid models are appended in payload order, after curated."""
|
|
curated = ["anthropic/claude-opus-4.6"]
|
|
pricing = {"anthropic/claude-opus-4.6": self._PAID}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value=self._payload(["openai/gpt-5.4", "openai/gpt-5.5"]),
|
|
):
|
|
ids, _ = union_with_portal_paid_recommendations(curated, pricing, "")
|
|
assert ids == [
|
|
"anthropic/claude-opus-4.6",
|
|
"openai/gpt-5.4",
|
|
"openai/gpt-5.5",
|
|
]
|
|
|
|
|
|
class TestCheckNousFreeTierCache:
|
|
"""Tests for the TTL cache on check_nous_free_tier()."""
|
|
|
|
def setup_method(self):
|
|
_models_mod._free_tier_cache = None
|
|
|
|
def teardown_method(self):
|
|
_models_mod._free_tier_cache = None
|
|
|
|
@patch("hermes_cli.nous_account.get_nous_portal_account_info")
|
|
def test_result_is_cached(self, mock_account):
|
|
"""Second call within TTL returns cached result without account lookup."""
|
|
mock_account.return_value = NousPortalAccountInfo(
|
|
logged_in=True,
|
|
source="jwt",
|
|
fresh=False,
|
|
paid_service_access=False,
|
|
)
|
|
result1 = check_nous_free_tier()
|
|
result2 = check_nous_free_tier()
|
|
|
|
assert result1 is True
|
|
assert result2 is True
|
|
assert mock_account.call_count == 1
|
|
|
|
@patch("hermes_cli.nous_account.get_nous_portal_account_info")
|
|
def test_cache_expires_after_ttl(self, mock_account):
|
|
"""After TTL expires, account info is resolved again."""
|
|
mock_account.return_value = NousPortalAccountInfo(
|
|
logged_in=True,
|
|
source="jwt",
|
|
fresh=False,
|
|
paid_service_access=True,
|
|
)
|
|
result1 = check_nous_free_tier()
|
|
assert mock_account.call_count == 1
|
|
|
|
cached_result, cached_at = _models_mod._free_tier_cache
|
|
_models_mod._free_tier_cache = (cached_result, cached_at - _FREE_TIER_CACHE_TTL - 1)
|
|
|
|
result2 = check_nous_free_tier()
|
|
assert mock_account.call_count == 2
|
|
|
|
assert result1 is False
|
|
assert result2 is False
|
|
|
|
@patch("hermes_cli.nous_account.get_nous_portal_account_info")
|
|
def test_force_fresh_bypasses_cache(self, mock_account):
|
|
mock_account.return_value = NousPortalAccountInfo(
|
|
logged_in=True,
|
|
source="account_api",
|
|
fresh=True,
|
|
paid_service_access=True,
|
|
)
|
|
|
|
assert check_nous_free_tier() is False
|
|
assert check_nous_free_tier(force_fresh=True) is False
|
|
|
|
assert mock_account.call_count == 2
|
|
mock_account.assert_called_with(force_fresh=True)
|
|
|
|
def test_cache_ttl_is_short(self):
|
|
"""TTL should be short enough to catch upgrades quickly (<=5 min)."""
|
|
assert _FREE_TIER_CACHE_TTL <= 300
|
|
|
|
|
|
class TestNousRecommendedModels:
|
|
"""Tests for fetch_nous_recommended_models + get_nous_recommended_aux_model."""
|
|
|
|
_SAMPLE_PAYLOAD = {
|
|
"paidRecommendedModels": [],
|
|
"freeRecommendedModels": [],
|
|
"paidRecommendedCompactionModel": None,
|
|
"paidRecommendedVisionModel": None,
|
|
"freeRecommendedCompactionModel": {
|
|
"modelName": "google/gemini-3-flash-preview",
|
|
"displayName": "Google: Gemini 3 Flash Preview",
|
|
},
|
|
"freeRecommendedVisionModel": {
|
|
"modelName": "google/gemini-3-flash-preview",
|
|
"displayName": "Google: Gemini 3 Flash Preview",
|
|
},
|
|
}
|
|
|
|
def setup_method(self):
|
|
_models_mod._nous_recommended_cache.clear()
|
|
|
|
def teardown_method(self):
|
|
_models_mod._nous_recommended_cache.clear()
|
|
|
|
def _mock_urlopen(self, payload):
|
|
"""Return a context-manager mock mimicking urllib.request.urlopen()."""
|
|
import json as _json
|
|
response = MagicMock()
|
|
response.read.return_value = _json.dumps(payload).encode()
|
|
cm = MagicMock()
|
|
cm.__enter__.return_value = response
|
|
cm.__exit__.return_value = False
|
|
return cm
|
|
|
|
def test_fetch_caches_per_portal_url(self):
|
|
from hermes_cli.models import fetch_nous_recommended_models
|
|
mock_cm = self._mock_urlopen(self._SAMPLE_PAYLOAD)
|
|
with patch("hermes_cli.models._urlopen_model_catalog_request", return_value=mock_cm) as mock_urlopen:
|
|
a = fetch_nous_recommended_models("https://portal.example.com")
|
|
b = fetch_nous_recommended_models("https://portal.example.com")
|
|
assert a == self._SAMPLE_PAYLOAD
|
|
assert b == self._SAMPLE_PAYLOAD
|
|
assert mock_urlopen.call_count == 1 # second call served from cache
|
|
|
|
def test_fetch_cache_is_keyed_per_portal(self):
|
|
from hermes_cli.models import fetch_nous_recommended_models
|
|
mock_cm = self._mock_urlopen(self._SAMPLE_PAYLOAD)
|
|
with patch("hermes_cli.models._urlopen_model_catalog_request", return_value=mock_cm) as mock_urlopen:
|
|
fetch_nous_recommended_models("https://portal.example.com")
|
|
fetch_nous_recommended_models("https://portal.staging-nousresearch.com")
|
|
assert mock_urlopen.call_count == 2 # different portals → separate fetches
|
|
|
|
def test_fetch_returns_empty_on_network_failure(self):
|
|
from hermes_cli.models import fetch_nous_recommended_models
|
|
with patch("hermes_cli.models._urlopen_model_catalog_request", side_effect=OSError("boom")):
|
|
result = fetch_nous_recommended_models("https://portal.example.com")
|
|
assert result == {}
|
|
|
|
def test_fetch_force_refresh_bypasses_cache(self):
|
|
from hermes_cli.models import fetch_nous_recommended_models
|
|
mock_cm = self._mock_urlopen(self._SAMPLE_PAYLOAD)
|
|
with patch("hermes_cli.models._urlopen_model_catalog_request", return_value=mock_cm) as mock_urlopen:
|
|
fetch_nous_recommended_models("https://portal.example.com")
|
|
fetch_nous_recommended_models("https://portal.example.com", force_refresh=True)
|
|
assert mock_urlopen.call_count == 2
|
|
|
|
def test_get_aux_model_returns_vision_recommendation(self):
|
|
from hermes_cli.models import get_nous_recommended_aux_model
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value=self._SAMPLE_PAYLOAD,
|
|
):
|
|
# Free tier → free vision recommendation.
|
|
model = get_nous_recommended_aux_model(vision=True, free_tier=True)
|
|
assert model == "google/gemini-3-flash-preview"
|
|
|
|
def test_get_aux_model_returns_compaction_recommendation(self):
|
|
from hermes_cli.models import get_nous_recommended_aux_model
|
|
payload = dict(self._SAMPLE_PAYLOAD)
|
|
payload["freeRecommendedCompactionModel"] = {"modelName": "minimax/minimax-m2.7"}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value=payload,
|
|
):
|
|
model = get_nous_recommended_aux_model(vision=False, free_tier=True)
|
|
assert model == "minimax/minimax-m2.7"
|
|
|
|
def test_get_aux_model_returns_none_when_field_null(self):
|
|
from hermes_cli.models import get_nous_recommended_aux_model
|
|
payload = dict(self._SAMPLE_PAYLOAD)
|
|
payload["freeRecommendedCompactionModel"] = None
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value=payload,
|
|
):
|
|
model = get_nous_recommended_aux_model(vision=False, free_tier=True)
|
|
assert model is None
|
|
|
|
def test_get_aux_model_returns_none_on_empty_payload(self):
|
|
from hermes_cli.models import get_nous_recommended_aux_model
|
|
with patch("hermes_cli.models.fetch_nous_recommended_models", return_value={}):
|
|
assert get_nous_recommended_aux_model(vision=False, free_tier=True) is None
|
|
assert get_nous_recommended_aux_model(vision=True, free_tier=False) is None
|
|
|
|
def test_get_aux_model_returns_none_when_modelname_blank(self):
|
|
from hermes_cli.models import get_nous_recommended_aux_model
|
|
payload = {"freeRecommendedCompactionModel": {"modelName": " "}}
|
|
with patch(
|
|
"hermes_cli.models.fetch_nous_recommended_models",
|
|
return_value=payload,
|
|
):
|
|
assert get_nous_recommended_aux_model(vision=False, free_tier=True) is None
|
|
|
|
def test_paid_tier_prefers_paid_recommendation(self):
|
|
"""Paid-tier users should get the paid model when it's populated."""
|
|
from hermes_cli.models import get_nous_recommended_aux_model
|
|
payload = {
|
|
"paidRecommendedCompactionModel": {"modelName": "anthropic/claude-opus-4.7"},
|
|
"freeRecommendedCompactionModel": {"modelName": "google/gemini-3-flash-preview"},
|
|
"paidRecommendedVisionModel": {"modelName": "openai/gpt-5.4"},
|
|
"freeRecommendedVisionModel": {"modelName": "google/gemini-3-flash-preview"},
|
|
}
|
|
with patch("hermes_cli.models.fetch_nous_recommended_models", return_value=payload):
|
|
text = get_nous_recommended_aux_model(vision=False, free_tier=False)
|
|
vision = get_nous_recommended_aux_model(vision=True, free_tier=False)
|
|
assert text == "anthropic/claude-opus-4.7"
|
|
assert vision == "openai/gpt-5.4"
|
|
|
|
def test_paid_tier_falls_back_to_free_when_paid_is_null(self):
|
|
"""If the Portal returns null for the paid field, fall back to free."""
|
|
from hermes_cli.models import get_nous_recommended_aux_model
|
|
payload = {
|
|
"paidRecommendedCompactionModel": None,
|
|
"freeRecommendedCompactionModel": {"modelName": "google/gemini-3-flash-preview"},
|
|
"paidRecommendedVisionModel": None,
|
|
"freeRecommendedVisionModel": {"modelName": "google/gemini-3-flash-preview"},
|
|
}
|
|
with patch("hermes_cli.models.fetch_nous_recommended_models", return_value=payload):
|
|
text = get_nous_recommended_aux_model(vision=False, free_tier=False)
|
|
vision = get_nous_recommended_aux_model(vision=True, free_tier=False)
|
|
assert text == "google/gemini-3-flash-preview"
|
|
assert vision == "google/gemini-3-flash-preview"
|
|
|
|
def test_free_tier_never_uses_paid_recommendation(self):
|
|
"""Free-tier users must not get paid-only recommendations."""
|
|
from hermes_cli.models import get_nous_recommended_aux_model
|
|
payload = {
|
|
"paidRecommendedCompactionModel": {"modelName": "anthropic/claude-opus-4.7"},
|
|
"freeRecommendedCompactionModel": None, # no free recommendation
|
|
}
|
|
with patch("hermes_cli.models.fetch_nous_recommended_models", return_value=payload):
|
|
model = get_nous_recommended_aux_model(vision=False, free_tier=True)
|
|
# Free tier must return None — never leak the paid model.
|
|
assert model is None
|
|
|
|
def test_auto_detects_tier_when_not_supplied(self):
|
|
"""Default behaviour: call check_nous_free_tier() to pick the tier."""
|
|
from hermes_cli.models import get_nous_recommended_aux_model
|
|
payload = {
|
|
"paidRecommendedCompactionModel": {"modelName": "paid-model"},
|
|
"freeRecommendedCompactionModel": {"modelName": "free-model"},
|
|
}
|
|
with (
|
|
patch("hermes_cli.models.fetch_nous_recommended_models", return_value=payload),
|
|
patch("hermes_cli.models.check_nous_free_tier", return_value=True),
|
|
):
|
|
assert get_nous_recommended_aux_model(vision=False) == "free-model"
|
|
with (
|
|
patch("hermes_cli.models.fetch_nous_recommended_models", return_value=payload),
|
|
patch("hermes_cli.models.check_nous_free_tier", return_value=False),
|
|
):
|
|
assert get_nous_recommended_aux_model(vision=False) == "paid-model"
|
|
|
|
def test_tier_detection_error_defaults_to_paid(self):
|
|
"""If tier detection raises, assume paid so we don't downgrade silently."""
|
|
from hermes_cli.models import get_nous_recommended_aux_model
|
|
payload = {
|
|
"paidRecommendedCompactionModel": {"modelName": "paid-model"},
|
|
"freeRecommendedCompactionModel": {"modelName": "free-model"},
|
|
}
|
|
with (
|
|
patch("hermes_cli.models.fetch_nous_recommended_models", return_value=payload),
|
|
patch("hermes_cli.models.check_nous_free_tier", side_effect=RuntimeError("boom")),
|
|
):
|
|
assert get_nous_recommended_aux_model(vision=False) == "paid-model"
|
|
|
|
|
|
class TestCodexSoftAcceptPlausibilityGate:
|
|
"""#45006 kernel (b): the openai-codex / xai-oauth hidden-model soft-accept
|
|
(#16172 / #19729) must only accept slugs that plausibly belong to that
|
|
provider's family. An undeclared, unrelated typed name (e.g. a local model
|
|
name) must be REJECTED with actionable --provider guidance instead of being
|
|
fake-accepted as a hidden Codex/Grok model (which would 400 on the next turn
|
|
and mislabel the provider as 'OpenAI Codex')."""
|
|
|
|
def test_unrelated_name_rejected_on_openai_codex(self):
|
|
from hermes_cli.models import validate_requested_model
|
|
r = validate_requested_model("qwen3.5-4b", "openai-codex")
|
|
assert r["accepted"] is False
|
|
assert r["persist"] is False
|
|
assert "--provider" in (r["message"] or "")
|
|
|
|
def test_unrelated_name_rejected_on_xai_oauth(self):
|
|
from hermes_cli.models import validate_requested_model
|
|
r = validate_requested_model("llama-3.1-8b", "xai-oauth")
|
|
assert r["accepted"] is False
|
|
assert "--provider" in (r["message"] or "")
|
|
|
|
def test_family_shaped_hidden_slug_still_soft_accepted_codex(self):
|
|
"""#16172 intent preserved: a gpt-/codex-shaped unknown slug is still
|
|
soft-accepted (entitlement-gated hidden models)."""
|
|
from hermes_cli.models import validate_requested_model
|
|
r = validate_requested_model("gpt-5.9-codex-hidden", "openai-codex")
|
|
assert r["accepted"] is True
|
|
assert r["recognized"] is False
|
|
|
|
def test_family_shaped_hidden_slug_still_soft_accepted_xai(self):
|
|
from hermes_cli.models import validate_requested_model
|
|
r = validate_requested_model("grok-9-hidden", "xai-oauth")
|
|
assert r["accepted"] is True
|
|
assert r["recognized"] is False
|
|
|
|
def test_real_catalog_model_unaffected(self):
|
|
from hermes_cli.models import validate_requested_model
|
|
r = validate_requested_model("gpt-5.5", "openai-codex")
|
|
assert r["accepted"] is True
|
|
assert r["recognized"] is True
|