test: reorganize test structure and add missing unit tests
Reorganize flat tests/ directory to mirror source code structure
(tools/, gateway/, hermes_cli/, integration/). Add 11 new test files
covering previously untested modules: registry, patch_parser,
fuzzy_match, todo_tool, approval, file_tools, gateway session/config/
delivery, and hermes_cli config/models. Total: 147 unit tests passing,
9 integration tests gated behind pytest marker.
2026-02-26 03:20:08 +03:00
|
|
|
"""Tests for the hermes_cli models module."""
|
|
|
|
|
|
2026-04-07 09:30:26 -07:00
|
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
|
|
2026-05-25 15:10:14 +10:00
|
|
|
from hermes_cli.nous_account import NousPortalAccountInfo
|
2026-04-07 02:17:14 -04:00
|
|
|
from hermes_cli.models import (
|
refactor: remove dead code — 1,784 lines across 77 files (#9180)
Deep scan with vulture, pyflakes, and manual cross-referencing identified:
- 41 dead functions/methods (zero callers in production)
- 7 production-dead functions (only test callers, tests deleted)
- 5 dead constants/variables
- ~35 unused imports across agent/, hermes_cli/, tools/, gateway/
Categories of dead code removed:
- Refactoring leftovers: _set_default_model, _setup_copilot_reasoning_selection,
rebuild_lookups, clear_session_context, get_logs_dir, clear_session
- Unused API surface: search_models_dev, get_pricing, skills_categories,
get_read_files_summary, clear_read_tracker, menu_labels, get_spinner_list
- Dead compatibility wrappers: schedule_cronjob, list_cronjobs, remove_cronjob
- Stale debug helpers: get_debug_session_info copies in 4 tool files
(centralized version in debug_helpers.py already exists)
- Dead gateway methods: send_emote, send_notice (matrix), send_reaction
(bluebubbles), _normalize_inbound_text (feishu), fetch_room_history
(matrix), _start_typing_indicator (signal), parse_feishu_post_content
- Dead constants: NOUS_API_BASE_URL, SKILLS_TOOL_DESCRIPTION,
FILE_TOOLS, VALID_ASPECT_RATIOS, MEMORY_DIR
- Unused UI code: _interactive_provider_selection,
_interactive_model_selection (superseded by prompt_toolkit picker)
Test suite verified: 609 tests covering affected files all pass.
Tests for removed functions deleted. Tests using removed utilities
(clear_read_tracker, MEMORY_DIR) updated to use internal APIs directly.
2026-04-13 16:32:04 -07:00
|
|
|
OPENROUTER_MODELS, fetch_openrouter_models, model_ids, detect_provider_for_model,
|
2026-04-07 02:17:14 -04:00
|
|
|
is_nous_free_tier, partition_nous_models_by_tier,
|
2026-04-10 03:03:35 -07:00
|
|
|
check_nous_free_tier, _FREE_TIER_CACHE_TTL,
|
fix(nous): surface Portal-flagged free models in picker even when curated list is stale (#24082)
Free-tier users were seeing 'No free models currently available.' in the
`hermes model` and post-login pickers even though qwen/qwen3.6-plus is
free on the Portal right now. Three independent breakages compounded:
1. The docs-hosted catalog manifest at website/static/api/model-catalog.json
was not regenerated when _PROVIDER_MODELS['nous'] was updated, so users
fetching the manifest got a list that didn't include qwen/qwen3.6-plus.
2. _resolve_nous_pricing_credentials() returned ('', '') on any auth blip,
collapsing get_pricing_for_provider('nous') to {} and making every
curated model fall through the free-tier filter as 'paid'.
3. Even with healthy pricing, the picker only ever showed models from the
in-repo curated list intersected with live pricing — a Portal-flagged
free model not yet in the curated list could never appear.
Changes:
- hermes_cli/models.py: new union_with_portal_free_recommendations() that
augments the curated list with Portal freeRecommendedModels entries
(with synthetic free pricing so partition keeps them). The Portal's
/api/nous/recommended-models endpoint is now the source of truth for
free-tier surfacing — old Hermes builds will see new free models
without a CLI release.
- hermes_cli/models.py: _resolve_nous_pricing_credentials() falls back to
the public inference base URL when runtime cred resolution fails.
The /v1/models endpoint exposes pricing without auth, so silently
returning {} just because a refresh token expired was wrong.
- hermes_cli/auth.py + hermes_cli/main.py: both free-tier picker call
sites call union_with_portal_free_recommendations() before partition.
- tests/hermes_cli/test_models.py: 7 tests covering union behaviour
(prepend, dedup, end-to-end with stale pricing, empty/missing/error
payloads, invalid entries).
- tests/hermes_cli/test_model_catalog.py: drift guard
TestManifestMatchesInRepoLists fails CI when _PROVIDER_MODELS['nous']
or OPENROUTER_MODELS is edited without re-running
scripts/build_model_catalog.py. Verified empirically that removing a
manifest entry triggers an assertion with an actionable error message.
Validation:
- 133/133 targeted tests pass (test_models, test_model_catalog,
test_auth_nous_provider).
- Live E2E against the real Portal:
- Stale curated list ['claude-opus','claude-sonnet','gpt-5.4'] (no
qwen) → after union: ['qwen/qwen3.6-plus', ...] →
partition(free_tier=True): selectable=['qwen/qwen3.6-plus'].
- Simulated expired refresh token → anon fetch returns 403 pricing
entries including qwen/qwen3.6-plus -> {prompt:0, completion:0}.
- ruff: clean.
2026-05-11 18:08:16 -07:00
|
|
|
union_with_portal_free_recommendations,
|
2026-05-12 15:16:17 -04:00
|
|
|
union_with_portal_paid_recommendations,
|
2026-04-07 02:17:14 -04:00
|
|
|
)
|
2026-04-07 09:30:26 -07:00
|
|
|
import hermes_cli.models as _models_mod
|
test: reorganize test structure and add missing unit tests
Reorganize flat tests/ directory to mirror source code structure
(tools/, gateway/, hermes_cli/, integration/). Add 11 new test files
covering previously untested modules: registry, patch_parser,
fuzzy_match, todo_tool, approval, file_tools, gateway session/config/
delivery, and hermes_cli config/models. Total: 147 unit tests passing,
9 integration tests gated behind pytest marker.
2026-02-26 03:20:08 +03:00
|
|
|
|
2026-04-09 15:11:58 -05:00
|
|
|
LIVE_OPENROUTER_MODELS = [
|
|
|
|
|
("anthropic/claude-opus-4.6", "recommended"),
|
2026-05-26 14:01:47 -07:00
|
|
|
("qwen/qwen3.7-max", ""),
|
2026-04-09 15:11:58 -05:00
|
|
|
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
|
|
|
|
|
]
|
|
|
|
|
|
test: reorganize test structure and add missing unit tests
Reorganize flat tests/ directory to mirror source code structure
(tools/, gateway/, hermes_cli/, integration/). Add 11 new test files
covering previously untested modules: registry, patch_parser,
fuzzy_match, todo_tool, approval, file_tools, gateway session/config/
delivery, and hermes_cli config/models. Total: 147 unit tests passing,
9 integration tests gated behind pytest marker.
2026-02-26 03:20:08 +03:00
|
|
|
|
2026-04-10 03:03:30 -07:00
|
|
|
|
test: reorganize test structure and add missing unit tests
Reorganize flat tests/ directory to mirror source code structure
(tools/, gateway/, hermes_cli/, integration/). Add 11 new test files
covering previously untested modules: registry, patch_parser,
fuzzy_match, todo_tool, approval, file_tools, gateway session/config/
delivery, and hermes_cli config/models. Total: 147 unit tests passing,
9 integration tests gated behind pytest marker.
2026-02-26 03:20:08 +03:00
|
|
|
class TestModelIds:
|
2026-02-26 00:53:57 -08:00
|
|
|
def test_returns_non_empty_list(self):
|
2026-04-09 15:11:58 -05:00
|
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
|
|
|
ids = model_ids()
|
test: reorganize test structure and add missing unit tests
Reorganize flat tests/ directory to mirror source code structure
(tools/, gateway/, hermes_cli/, integration/). Add 11 new test files
covering previously untested modules: registry, patch_parser,
fuzzy_match, todo_tool, approval, file_tools, gateway session/config/
delivery, and hermes_cli config/models. Total: 147 unit tests passing,
9 integration tests gated behind pytest marker.
2026-02-26 03:20:08 +03:00
|
|
|
assert isinstance(ids, list)
|
|
|
|
|
assert len(ids) > 0
|
|
|
|
|
|
2026-04-09 15:11:58 -05:00
|
|
|
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]
|
test: reorganize test structure and add missing unit tests
Reorganize flat tests/ directory to mirror source code structure
(tools/, gateway/, hermes_cli/, integration/). Add 11 new test files
covering previously untested modules: registry, patch_parser,
fuzzy_match, todo_tool, approval, file_tools, gateway session/config/
delivery, and hermes_cli config/models. Total: 147 unit tests passing,
9 integration tests gated behind pytest marker.
2026-02-26 03:20:08 +03:00
|
|
|
assert ids == expected
|
|
|
|
|
|
2026-02-26 00:53:57 -08:00
|
|
|
def test_all_ids_contain_provider_slash(self):
|
|
|
|
|
"""Model IDs should follow the provider/model format."""
|
2026-04-09 15:11:58 -05:00
|
|
|
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"
|
2026-02-26 00:53:57 -08:00
|
|
|
|
|
|
|
|
def test_no_duplicate_ids(self):
|
2026-04-09 15:11:58 -05:00
|
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
|
|
|
ids = model_ids()
|
2026-02-26 00:53:57 -08:00
|
|
|
assert len(ids) == len(set(ids)), "Duplicate model IDs found"
|
|
|
|
|
|
test: reorganize test structure and add missing unit tests
Reorganize flat tests/ directory to mirror source code structure
(tools/, gateway/, hermes_cli/, integration/). Add 11 new test files
covering previously untested modules: registry, patch_parser,
fuzzy_match, todo_tool, approval, file_tools, gateway session/config/
delivery, and hermes_cli config/models. Total: 147 unit tests passing,
9 integration tests gated behind pytest marker.
2026-02-26 03:20:08 +03:00
|
|
|
|
2026-02-26 00:53:57 -08:00
|
|
|
|
|
|
|
|
|
2026-04-10 03:03:30 -07:00
|
|
|
|
2026-02-26 00:53:57 -08:00
|
|
|
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)
|
|
|
|
|
|
2026-03-16 04:34:45 -07:00
|
|
|
|
2026-04-09 15:11:58 -05:00
|
|
|
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):
|
feat(models): add deepseek-v4-flash, trim variants, group curated lists by maker (#35659)
* feat(models): add deepseek-v4-flash to OpenRouter + Nous curated lists
deepseek/deepseek-v4-flash was already in the native deepseek provider
catalog but missing from the curated OpenRouter and Nous Portal picker
lists. Added it to both and regenerated the model-catalog.json manifest
(drift guard requires same-PR regeneration).
* refactor(models): trim redundant variants, group curated lists by maker
Remove claude-opus-4.7/4.6, gpt-5.4-nano, gpt-5.3-codex,
gemini-3-pro-image-preview, gemini-3.1-flash-lite-preview, grok-4.20,
and the older gemini-3-pro-preview (Nous). Reorder both OPENROUTER_MODELS
and _PROVIDER_MODELS[nous] into contiguous per-maker blocks with comment
headers. Regenerated model-catalog.json (openrouter 27, nous 20).
* feat(models): add gemini-3-pro-preview to OpenRouter + Nous curated lists
Adds google/gemini-3-pro-preview to both curated pickers (new on
OpenRouter, restored on Nous). Regenerated model-catalog.json
(openrouter 28, nous 21).
* test(models): use claude-opus-4.8 in OpenRouter fetch fixtures
The two TestFetchOpenRouterModels tests mocked a live OpenRouter
response with claude-opus-4.6 and relied on it surviving the curated-list
filter. Since 4.6 was removed from OPENROUTER_MODELS, those models got
filtered out and the recommended tag shifted. Swap the fixture to
claude-opus-4.8 (still curated, still first in the Anthropic block).
2026-05-30 20:57:01 -07:00
|
|
|
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"}}]}'
|
2026-04-09 15:11:58 -05:00
|
|
|
|
|
|
|
|
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
|
|
|
|
|
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
|
|
|
|
|
models = fetch_openrouter_models(force_refresh=True)
|
|
|
|
|
|
|
|
|
|
assert models == [
|
feat(models): add deepseek-v4-flash, trim variants, group curated lists by maker (#35659)
* feat(models): add deepseek-v4-flash to OpenRouter + Nous curated lists
deepseek/deepseek-v4-flash was already in the native deepseek provider
catalog but missing from the curated OpenRouter and Nous Portal picker
lists. Added it to both and regenerated the model-catalog.json manifest
(drift guard requires same-PR regeneration).
* refactor(models): trim redundant variants, group curated lists by maker
Remove claude-opus-4.7/4.6, gpt-5.4-nano, gpt-5.3-codex,
gemini-3-pro-image-preview, gemini-3.1-flash-lite-preview, grok-4.20,
and the older gemini-3-pro-preview (Nous). Reorder both OPENROUTER_MODELS
and _PROVIDER_MODELS[nous] into contiguous per-maker blocks with comment
headers. Regenerated model-catalog.json (openrouter 27, nous 20).
* feat(models): add gemini-3-pro-preview to OpenRouter + Nous curated lists
Adds google/gemini-3-pro-preview to both curated pickers (new on
OpenRouter, restored on Nous). Regenerated model-catalog.json
(openrouter 28, nous 21).
* test(models): use claude-opus-4.8 in OpenRouter fetch fixtures
The two TestFetchOpenRouterModels tests mocked a live OpenRouter
response with claude-opus-4.6 and relied on it surviving the curated-list
filter. Since 4.6 was removed from OPENROUTER_MODELS, those models got
filtered out and the recommended tag shifted. Swap the fixture to
claude-opus-4.8 (still curated, still first in the Anthropic block).
2026-05-30 20:57:01 -07:00
|
|
|
("anthropic/claude-opus-4.8", "recommended"),
|
2026-05-26 14:01:47 -07:00
|
|
|
("qwen/qwen3.7-max", ""),
|
2026-04-09 15:11:58 -05:00
|
|
|
("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)
|
|
|
|
|
with patch("hermes_cli.models.urllib.request.urlopen", side_effect=OSError("boom")):
|
|
|
|
|
models = fetch_openrouter_models(force_refresh=True)
|
|
|
|
|
|
|
|
|
|
assert models == OPENROUTER_MODELS
|
|
|
|
|
|
refactor(acp): validate method_id against advertised provider in authenticate() (#13468)
* feat(models): hide OpenRouter models that don't advertise tool support
Port from Kilo-Org/kilocode#9068.
hermes-agent is tool-calling-first — every provider path assumes the
model can invoke tools. Models whose OpenRouter supported_parameters
doesn't include 'tools' (e.g. image-only or completion-only models)
cannot be driven by the agent loop and fail at the first tool call.
Filter them out of fetch_openrouter_models() so they never appear in
the model picker (`hermes model`, setup wizard, /model slash command).
Permissive when the field is missing — OpenRouter-compatible gateways
(Nous Portal, private mirrors, older snapshots) don't always populate
supported_parameters. Treat missing as 'unknown → allow' rather than
silently emptying the picker on those gateways. Only hide models
whose supported_parameters is an explicit list that omits tools.
Tests cover: tools present → kept, tools absent → dropped, field
missing → kept, malformed non-list → kept, non-dict item → kept,
empty list → dropped.
* refactor(acp): validate method_id against advertised provider in authenticate()
Previously authenticate() accepted any method_id whenever the server had
provider credentials configured. This was not a vulnerability under the
personal-assistant trust model (ACP is stdio-only, local-trust — anything
that can reach the transport is already code-execution-equivalent to the
user), but it was sloppy API hygiene: the advertised auth_methods list
from initialize() was effectively ignored.
Now authenticate() only returns AuthenticateResponse when method_id
matches the currently-advertised provider (case-insensitive). Mismatched
or missing method_id returns None, consistent with the no-credentials
case.
Raised by xeloxa via GHSA-g5pf-8w9m-h72x. Declined as a CVE
(ACP transport is stdio, local-trust model), but the correctness fix is
worth having on its own.
2026-04-21 03:39:55 -07:00
|
|
|
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
|
2026-05-26 14:01:47 -07:00
|
|
|
# qwen3.7-max advertises tools → kept
|
refactor(acp): validate method_id against advertised provider in authenticate() (#13468)
* feat(models): hide OpenRouter models that don't advertise tool support
Port from Kilo-Org/kilocode#9068.
hermes-agent is tool-calling-first — every provider path assumes the
model can invoke tools. Models whose OpenRouter supported_parameters
doesn't include 'tools' (e.g. image-only or completion-only models)
cannot be driven by the agent loop and fail at the first tool call.
Filter them out of fetch_openrouter_models() so they never appear in
the model picker (`hermes model`, setup wizard, /model slash command).
Permissive when the field is missing — OpenRouter-compatible gateways
(Nous Portal, private mirrors, older snapshots) don't always populate
supported_parameters. Treat missing as 'unknown → allow' rather than
silently emptying the picker on those gateways. Only hide models
whose supported_parameters is an explicit list that omits tools.
Tests cover: tools present → kept, tools absent → dropped, field
missing → kept, malformed non-list → kept, non-dict item → kept,
empty list → dropped.
* refactor(acp): validate method_id against advertised provider in authenticate()
Previously authenticate() accepted any method_id whenever the server had
provider credentials configured. This was not a vulnerability under the
personal-assistant trust model (ACP is stdio-only, local-trust — anything
that can reach the transport is already code-execution-equivalent to the
user), but it was sloppy API hygiene: the advertised auth_methods list
from initialize() was effectively ignored.
Now authenticate() only returns AuthenticateResponse when method_id
matches the currently-advertised provider (case-insensitive). Mismatched
or missing method_id returns None, consistent with the no-credentials
case.
Raised by xeloxa via GHSA-g5pf-8w9m-h72x. Declined as a CVE
(ACP transport is stdio, local-trust model), but the correctness fix is
worth having on its own.
2026-04-21 03:39:55 -07:00
|
|
|
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"]},'
|
2026-05-26 14:01:47 -07:00
|
|
|
b'{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"},'
|
refactor(acp): validate method_id against advertised provider in authenticate() (#13468)
* feat(models): hide OpenRouter models that don't advertise tool support
Port from Kilo-Org/kilocode#9068.
hermes-agent is tool-calling-first — every provider path assumes the
model can invoke tools. Models whose OpenRouter supported_parameters
doesn't include 'tools' (e.g. image-only or completion-only models)
cannot be driven by the agent loop and fail at the first tool call.
Filter them out of fetch_openrouter_models() so they never appear in
the model picker (`hermes model`, setup wizard, /model slash command).
Permissive when the field is missing — OpenRouter-compatible gateways
(Nous Portal, private mirrors, older snapshots) don't always populate
supported_parameters. Treat missing as 'unknown → allow' rather than
silently emptying the picker on those gateways. Only hide models
whose supported_parameters is an explicit list that omits tools.
Tests cover: tools present → kept, tools absent → dropped, field
missing → kept, malformed non-list → kept, non-dict item → kept,
empty list → dropped.
* refactor(acp): validate method_id against advertised provider in authenticate()
Previously authenticate() accepted any method_id whenever the server had
provider credentials configured. This was not a vulnerability under the
personal-assistant trust model (ACP is stdio-only, local-trust — anything
that can reach the transport is already code-execution-equivalent to the
user), but it was sloppy API hygiene: the advertised auth_methods list
from initialize() was effectively ignored.
Now authenticate() only returns AuthenticateResponse when method_id
matches the currently-advertised provider (case-insensitive). Mismatched
or missing method_id returns None, consistent with the no-credentials
case.
Raised by xeloxa via GHSA-g5pf-8w9m-h72x. Declined as a CVE
(ACP transport is stdio, local-trust model), but the correctness fix is
worth having on its own.
2026-04-21 03:39:55 -07:00
|
|
|
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", ""),
|
2026-05-26 14:01:47 -07:00
|
|
|
("qwen/qwen3.7-max", ""),
|
refactor(acp): validate method_id against advertised provider in authenticate() (#13468)
* feat(models): hide OpenRouter models that don't advertise tool support
Port from Kilo-Org/kilocode#9068.
hermes-agent is tool-calling-first — every provider path assumes the
model can invoke tools. Models whose OpenRouter supported_parameters
doesn't include 'tools' (e.g. image-only or completion-only models)
cannot be driven by the agent loop and fail at the first tool call.
Filter them out of fetch_openrouter_models() so they never appear in
the model picker (`hermes model`, setup wizard, /model slash command).
Permissive when the field is missing — OpenRouter-compatible gateways
(Nous Portal, private mirrors, older snapshots) don't always populate
supported_parameters. Treat missing as 'unknown → allow' rather than
silently emptying the picker on those gateways. Only hide models
whose supported_parameters is an explicit list that omits tools.
Tests cover: tools present → kept, tools absent → dropped, field
missing → kept, malformed non-list → kept, non-dict item → kept,
empty list → dropped.
* refactor(acp): validate method_id against advertised provider in authenticate()
Previously authenticate() accepted any method_id whenever the server had
provider credentials configured. This was not a vulnerability under the
personal-assistant trust model (ACP is stdio-only, local-trust — anything
that can reach the transport is already code-execution-equivalent to the
user), but it was sloppy API hygiene: the advertised auth_methods list
from initialize() was effectively ignored.
Now authenticate() only returns AuthenticateResponse when method_id
matches the currently-advertised provider (case-insensitive). Mismatched
or missing method_id returns None, consistent with the no-credentials
case.
Raised by xeloxa via GHSA-g5pf-8w9m-h72x. Declined as a CVE
(ACP transport is stdio, local-trust model), but the correctness fix is
worth having on its own.
2026-04-21 03:39:55 -07:00
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
|
|
|
|
|
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
|
|
|
|
|
models = fetch_openrouter_models(force_refresh=True)
|
|
|
|
|
|
|
|
|
|
ids = [mid for mid, _ in models]
|
|
|
|
|
assert "anthropic/claude-opus-4.6" in ids
|
2026-05-26 14:01:47 -07:00
|
|
|
assert "qwen/qwen3.7-max" in ids
|
refactor(acp): validate method_id against advertised provider in authenticate() (#13468)
* feat(models): hide OpenRouter models that don't advertise tool support
Port from Kilo-Org/kilocode#9068.
hermes-agent is tool-calling-first — every provider path assumes the
model can invoke tools. Models whose OpenRouter supported_parameters
doesn't include 'tools' (e.g. image-only or completion-only models)
cannot be driven by the agent loop and fail at the first tool call.
Filter them out of fetch_openrouter_models() so they never appear in
the model picker (`hermes model`, setup wizard, /model slash command).
Permissive when the field is missing — OpenRouter-compatible gateways
(Nous Portal, private mirrors, older snapshots) don't always populate
supported_parameters. Treat missing as 'unknown → allow' rather than
silently emptying the picker on those gateways. Only hide models
whose supported_parameters is an explicit list that omits tools.
Tests cover: tools present → kept, tools absent → dropped, field
missing → kept, malformed non-list → kept, non-dict item → kept,
empty list → dropped.
* refactor(acp): validate method_id against advertised provider in authenticate()
Previously authenticate() accepted any method_id whenever the server had
provider credentials configured. This was not a vulnerability under the
personal-assistant trust model (ACP is stdio-only, local-trust — anything
that can reach the transport is already code-execution-equivalent to the
user), but it was sloppy API hygiene: the advertised auth_methods list
from initialize() was effectively ignored.
Now authenticate() only returns AuthenticateResponse when method_id
matches the currently-advertised provider (case-insensitive). Mismatched
or missing method_id returns None, consistent with the no-credentials
case.
Raised by xeloxa via GHSA-g5pf-8w9m-h72x. Declined as a CVE
(ACP transport is stdio, local-trust model), but the correctness fix is
worth having on its own.
2026-04-21 03:39:55 -07:00
|
|
|
# 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":['
|
feat(models): add deepseek-v4-flash, trim variants, group curated lists by maker (#35659)
* feat(models): add deepseek-v4-flash to OpenRouter + Nous curated lists
deepseek/deepseek-v4-flash was already in the native deepseek provider
catalog but missing from the curated OpenRouter and Nous Portal picker
lists. Added it to both and regenerated the model-catalog.json manifest
(drift guard requires same-PR regeneration).
* refactor(models): trim redundant variants, group curated lists by maker
Remove claude-opus-4.7/4.6, gpt-5.4-nano, gpt-5.3-codex,
gemini-3-pro-image-preview, gemini-3.1-flash-lite-preview, grok-4.20,
and the older gemini-3-pro-preview (Nous). Reorder both OPENROUTER_MODELS
and _PROVIDER_MODELS[nous] into contiguous per-maker blocks with comment
headers. Regenerated model-catalog.json (openrouter 27, nous 20).
* feat(models): add gemini-3-pro-preview to OpenRouter + Nous curated lists
Adds google/gemini-3-pro-preview to both curated pickers (new on
OpenRouter, restored on Nous). Regenerated model-catalog.json
(openrouter 28, nous 21).
* test(models): use claude-opus-4.8 in OpenRouter fetch fixtures
The two TestFetchOpenRouterModels tests mocked a live OpenRouter
response with claude-opus-4.6 and relied on it surviving the curated-list
filter. Since 4.6 was removed from OPENROUTER_MODELS, those models got
filtered out and the recommended tag shifted. Swap the fixture to
claude-opus-4.8 (still curated, still first in the Anthropic block).
2026-05-30 20:57:01 -07:00
|
|
|
b'{"id":"anthropic/claude-opus-4.8","pricing":{"prompt":"0.000015","completion":"0.000075"}},'
|
2026-05-26 14:01:47 -07:00
|
|
|
b'{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}}'
|
refactor(acp): validate method_id against advertised provider in authenticate() (#13468)
* feat(models): hide OpenRouter models that don't advertise tool support
Port from Kilo-Org/kilocode#9068.
hermes-agent is tool-calling-first — every provider path assumes the
model can invoke tools. Models whose OpenRouter supported_parameters
doesn't include 'tools' (e.g. image-only or completion-only models)
cannot be driven by the agent loop and fail at the first tool call.
Filter them out of fetch_openrouter_models() so they never appear in
the model picker (`hermes model`, setup wizard, /model slash command).
Permissive when the field is missing — OpenRouter-compatible gateways
(Nous Portal, private mirrors, older snapshots) don't always populate
supported_parameters. Treat missing as 'unknown → allow' rather than
silently emptying the picker on those gateways. Only hide models
whose supported_parameters is an explicit list that omits tools.
Tests cover: tools present → kept, tools absent → dropped, field
missing → kept, malformed non-list → kept, non-dict item → kept,
empty list → dropped.
* refactor(acp): validate method_id against advertised provider in authenticate()
Previously authenticate() accepted any method_id whenever the server had
provider credentials configured. This was not a vulnerability under the
personal-assistant trust model (ACP is stdio-only, local-trust — anything
that can reach the transport is already code-execution-equivalent to the
user), but it was sloppy API hygiene: the advertised auth_methods list
from initialize() was effectively ignored.
Now authenticate() only returns AuthenticateResponse when method_id
matches the currently-advertised provider (case-insensitive). Mismatched
or missing method_id returns None, consistent with the no-credentials
case.
Raised by xeloxa via GHSA-g5pf-8w9m-h72x. Declined as a CVE
(ACP transport is stdio, local-trust model), but the correctness fix is
worth having on its own.
2026-04-21 03:39:55 -07:00
|
|
|
b']}'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
|
|
|
|
|
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
|
|
|
|
|
models = fetch_openrouter_models(force_refresh=True)
|
|
|
|
|
|
|
|
|
|
ids = [mid for mid, _ in models]
|
feat(models): add deepseek-v4-flash, trim variants, group curated lists by maker (#35659)
* feat(models): add deepseek-v4-flash to OpenRouter + Nous curated lists
deepseek/deepseek-v4-flash was already in the native deepseek provider
catalog but missing from the curated OpenRouter and Nous Portal picker
lists. Added it to both and regenerated the model-catalog.json manifest
(drift guard requires same-PR regeneration).
* refactor(models): trim redundant variants, group curated lists by maker
Remove claude-opus-4.7/4.6, gpt-5.4-nano, gpt-5.3-codex,
gemini-3-pro-image-preview, gemini-3.1-flash-lite-preview, grok-4.20,
and the older gemini-3-pro-preview (Nous). Reorder both OPENROUTER_MODELS
and _PROVIDER_MODELS[nous] into contiguous per-maker blocks with comment
headers. Regenerated model-catalog.json (openrouter 27, nous 20).
* feat(models): add gemini-3-pro-preview to OpenRouter + Nous curated lists
Adds google/gemini-3-pro-preview to both curated pickers (new on
OpenRouter, restored on Nous). Regenerated model-catalog.json
(openrouter 28, nous 21).
* test(models): use claude-opus-4.8 in OpenRouter fetch fixtures
The two TestFetchOpenRouterModels tests mocked a live OpenRouter
response with claude-opus-4.6 and relied on it surviving the curated-list
filter. Since 4.6 was removed from OPENROUTER_MODELS, those models got
filtered out and the recommended tag shifted. Swap the fixture to
claude-opus-4.8 (still curated, still first in the Anthropic block).
2026-05-30 20:57:01 -07:00
|
|
|
assert "anthropic/claude-opus-4.8" in ids
|
2026-05-26 14:01:47 -07:00
|
|
|
assert "qwen/qwen3.7-max" in ids
|
refactor(acp): validate method_id against advertised provider in authenticate() (#13468)
* feat(models): hide OpenRouter models that don't advertise tool support
Port from Kilo-Org/kilocode#9068.
hermes-agent is tool-calling-first — every provider path assumes the
model can invoke tools. Models whose OpenRouter supported_parameters
doesn't include 'tools' (e.g. image-only or completion-only models)
cannot be driven by the agent loop and fail at the first tool call.
Filter them out of fetch_openrouter_models() so they never appear in
the model picker (`hermes model`, setup wizard, /model slash command).
Permissive when the field is missing — OpenRouter-compatible gateways
(Nous Portal, private mirrors, older snapshots) don't always populate
supported_parameters. Treat missing as 'unknown → allow' rather than
silently emptying the picker on those gateways. Only hide models
whose supported_parameters is an explicit list that omits tools.
Tests cover: tools present → kept, tools absent → dropped, field
missing → kept, malformed non-list → kept, non-dict item → kept,
empty list → dropped.
* refactor(acp): validate method_id against advertised provider in authenticate()
Previously authenticate() accepted any method_id whenever the server had
provider credentials configured. This was not a vulnerability under the
personal-assistant trust model (ACP is stdio-only, local-trust — anything
that can reach the transport is already code-execution-equivalent to the
user), but it was sloppy API hygiene: the advertised auth_methods list
from initialize() was effectively ignored.
Now authenticate() only returns AuthenticateResponse when method_id
matches the currently-advertised provider (case-insensitive). Mismatched
or missing method_id returns None, consistent with the no-credentials
case.
Raised by xeloxa via GHSA-g5pf-8w9m-h72x. Declined as a CVE
(ACP transport is stdio, local-trust model), but the correctness fix is
worth having on its own.
2026-04-21 03:39:55 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-04-09 15:11:58 -05:00
|
|
|
|
2026-03-16 04:34:45 -07:00
|
|
|
class TestFindOpenrouterSlug:
|
|
|
|
|
def test_exact_match(self):
|
|
|
|
|
from hermes_cli.models import _find_openrouter_slug
|
2026-04-09 15:11:58 -05:00
|
|
|
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"
|
2026-03-16 04:34:45 -07:00
|
|
|
|
|
|
|
|
def test_bare_name_match(self):
|
|
|
|
|
from hermes_cli.models import _find_openrouter_slug
|
2026-04-09 15:11:58 -05:00
|
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
|
|
|
result = _find_openrouter_slug("claude-opus-4.6")
|
2026-03-16 04:34:45 -07:00
|
|
|
assert result == "anthropic/claude-opus-4.6"
|
|
|
|
|
|
|
|
|
|
def test_case_insensitive(self):
|
|
|
|
|
from hermes_cli.models import _find_openrouter_slug
|
2026-04-09 15:11:58 -05:00
|
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
|
|
|
result = _find_openrouter_slug("Anthropic/Claude-Opus-4.6")
|
2026-03-16 04:34:45 -07:00
|
|
|
assert result is not None
|
|
|
|
|
|
|
|
|
|
def test_unknown_returns_none(self):
|
|
|
|
|
from hermes_cli.models import _find_openrouter_slug
|
2026-04-09 15:11:58 -05:00
|
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
|
|
|
assert _find_openrouter_slug("totally-fake-model-xyz") is None
|
2026-03-16 04:34:45 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDetectProviderForModel:
|
|
|
|
|
def test_anthropic_model_detected(self):
|
|
|
|
|
"""claude-opus-4-6 should resolve to anthropic provider."""
|
2026-04-09 15:11:58 -05:00
|
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
|
|
|
result = detect_provider_for_model("claude-opus-4-6", "openai-codex")
|
2026-03-16 04:34:45 -07:00
|
|
|
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
|
2026-05-17 02:29:41 -07:00
|
|
|
assert result[0] in {"deepseek", "openrouter"}
|
2026-03-16 04:34:45 -07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-04-25 14:13:02 -05:00
|
|
|
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")
|
|
|
|
|
|
2026-03-16 04:34:45 -07:00
|
|
|
def test_openrouter_slug_match(self):
|
|
|
|
|
"""Models in the OpenRouter catalog should be found."""
|
2026-04-09 15:11:58 -05:00
|
|
|
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")
|
2026-03-16 04:34:45 -07:00
|
|
|
assert result is not None
|
|
|
|
|
assert result[0] == "openrouter"
|
|
|
|
|
assert result[1] == "anthropic/claude-opus-4.6"
|
|
|
|
|
|
2026-04-02 09:36:24 -07:00
|
|
|
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)
|
2026-03-16 04:34:45 -07:00
|
|
|
"""Bare model names should get mapped to full OpenRouter slugs."""
|
2026-04-09 15:11:58 -05:00
|
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
|
|
|
result = detect_provider_for_model("claude-opus-4.6", "openai-codex")
|
2026-03-16 04:34:45 -07:00
|
|
|
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."""
|
2026-04-09 15:11:58 -05:00
|
|
|
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
|
2026-03-16 04:34:45 -07:00
|
|
|
|
|
|
|
|
def test_aggregator_not_suggested(self):
|
|
|
|
|
"""nous/openrouter should never be auto-suggested as target provider."""
|
2026-04-09 15:11:58 -05:00
|
|
|
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
|
|
|
|
|
result = detect_provider_for_model("claude-opus-4-6", "openai-codex")
|
2026-03-16 04:34:45 -07:00
|
|
|
assert result is not None
|
2026-05-17 02:29:41 -07:00
|
|
|
assert result[0] not in {"nous",} # nous has claude models but shouldn't be suggested
|
2026-04-07 02:17:14 -04:00
|
|
|
|
2026-06-24 18:51:49 +05:30
|
|
|
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"
|
|
|
|
|
|
2026-04-07 02:17:14 -04:00
|
|
|
|
|
|
|
|
class TestIsNousFreeTier:
|
|
|
|
|
"""Tests for is_nous_free_tier — account tier detection."""
|
|
|
|
|
|
2026-05-25 15:10:14 +10:00
|
|
|
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
|
|
|
|
|
|
2026-04-07 02:17:14 -04:00
|
|
|
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
|
2026-04-07 09:30:26 -07:00
|
|
|
|
|
|
|
|
|
fix(nous): surface Portal-flagged free models in picker even when curated list is stale (#24082)
Free-tier users were seeing 'No free models currently available.' in the
`hermes model` and post-login pickers even though qwen/qwen3.6-plus is
free on the Portal right now. Three independent breakages compounded:
1. The docs-hosted catalog manifest at website/static/api/model-catalog.json
was not regenerated when _PROVIDER_MODELS['nous'] was updated, so users
fetching the manifest got a list that didn't include qwen/qwen3.6-plus.
2. _resolve_nous_pricing_credentials() returned ('', '') on any auth blip,
collapsing get_pricing_for_provider('nous') to {} and making every
curated model fall through the free-tier filter as 'paid'.
3. Even with healthy pricing, the picker only ever showed models from the
in-repo curated list intersected with live pricing — a Portal-flagged
free model not yet in the curated list could never appear.
Changes:
- hermes_cli/models.py: new union_with_portal_free_recommendations() that
augments the curated list with Portal freeRecommendedModels entries
(with synthetic free pricing so partition keeps them). The Portal's
/api/nous/recommended-models endpoint is now the source of truth for
free-tier surfacing — old Hermes builds will see new free models
without a CLI release.
- hermes_cli/models.py: _resolve_nous_pricing_credentials() falls back to
the public inference base URL when runtime cred resolution fails.
The /v1/models endpoint exposes pricing without auth, so silently
returning {} just because a refresh token expired was wrong.
- hermes_cli/auth.py + hermes_cli/main.py: both free-tier picker call
sites call union_with_portal_free_recommendations() before partition.
- tests/hermes_cli/test_models.py: 7 tests covering union behaviour
(prepend, dedup, end-to-end with stale pricing, empty/missing/error
payloads, invalid entries).
- tests/hermes_cli/test_model_catalog.py: drift guard
TestManifestMatchesInRepoLists fails CI when _PROVIDER_MODELS['nous']
or OPENROUTER_MODELS is edited without re-running
scripts/build_model_catalog.py. Verified empirically that removing a
manifest entry triggers an assertion with an actionable error message.
Validation:
- 133/133 targeted tests pass (test_models, test_model_catalog,
test_auth_nous_provider).
- Live E2E against the real Portal:
- Stale curated list ['claude-opus','claude-sonnet','gpt-5.4'] (no
qwen) → after union: ['qwen/qwen3.6-plus', ...] →
partition(free_tier=True): selectable=['qwen/qwen3.6-plus'].
- Simulated expired refresh token → anon fetch returns 403 pricing
entries including qwen/qwen3.6-plus -> {prompt:0, completion:0}.
- ruff: clean.
2026-05-11 18:08:16 -07:00
|
|
|
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):
|
2026-06-04 19:47:53 -04:00
|
|
|
"""A Portal-advertised free model not in curated is appended + priced free."""
|
fix(nous): surface Portal-flagged free models in picker even when curated list is stale (#24082)
Free-tier users were seeing 'No free models currently available.' in the
`hermes model` and post-login pickers even though qwen/qwen3.6-plus is
free on the Portal right now. Three independent breakages compounded:
1. The docs-hosted catalog manifest at website/static/api/model-catalog.json
was not regenerated when _PROVIDER_MODELS['nous'] was updated, so users
fetching the manifest got a list that didn't include qwen/qwen3.6-plus.
2. _resolve_nous_pricing_credentials() returned ('', '') on any auth blip,
collapsing get_pricing_for_provider('nous') to {} and making every
curated model fall through the free-tier filter as 'paid'.
3. Even with healthy pricing, the picker only ever showed models from the
in-repo curated list intersected with live pricing — a Portal-flagged
free model not yet in the curated list could never appear.
Changes:
- hermes_cli/models.py: new union_with_portal_free_recommendations() that
augments the curated list with Portal freeRecommendedModels entries
(with synthetic free pricing so partition keeps them). The Portal's
/api/nous/recommended-models endpoint is now the source of truth for
free-tier surfacing — old Hermes builds will see new free models
without a CLI release.
- hermes_cli/models.py: _resolve_nous_pricing_credentials() falls back to
the public inference base URL when runtime cred resolution fails.
The /v1/models endpoint exposes pricing without auth, so silently
returning {} just because a refresh token expired was wrong.
- hermes_cli/auth.py + hermes_cli/main.py: both free-tier picker call
sites call union_with_portal_free_recommendations() before partition.
- tests/hermes_cli/test_models.py: 7 tests covering union behaviour
(prepend, dedup, end-to-end with stale pricing, empty/missing/error
payloads, invalid entries).
- tests/hermes_cli/test_model_catalog.py: drift guard
TestManifestMatchesInRepoLists fails CI when _PROVIDER_MODELS['nous']
or OPENROUTER_MODELS is edited without re-running
scripts/build_model_catalog.py. Verified empirically that removing a
manifest entry triggers an assertion with an actionable error message.
Validation:
- 133/133 targeted tests pass (test_models, test_model_catalog,
test_auth_nous_provider).
- Live E2E against the real Portal:
- Stale curated list ['claude-opus','claude-sonnet','gpt-5.4'] (no
qwen) → after union: ['qwen/qwen3.6-plus', ...] →
partition(free_tier=True): selectable=['qwen/qwen3.6-plus'].
- Simulated expired refresh token → anon fetch returns 403 pricing
entries including qwen/qwen3.6-plus -> {prompt:0, completion:0}.
- ruff: clean.
2026-05-11 18:08:16 -07:00
|
|
|
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, "")
|
|
|
|
|
|
2026-06-04 19:47:53 -04:00
|
|
|
# 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
|
fix(nous): surface Portal-flagged free models in picker even when curated list is stale (#24082)
Free-tier users were seeing 'No free models currently available.' in the
`hermes model` and post-login pickers even though qwen/qwen3.6-plus is
free on the Portal right now. Three independent breakages compounded:
1. The docs-hosted catalog manifest at website/static/api/model-catalog.json
was not regenerated when _PROVIDER_MODELS['nous'] was updated, so users
fetching the manifest got a list that didn't include qwen/qwen3.6-plus.
2. _resolve_nous_pricing_credentials() returned ('', '') on any auth blip,
collapsing get_pricing_for_provider('nous') to {} and making every
curated model fall through the free-tier filter as 'paid'.
3. Even with healthy pricing, the picker only ever showed models from the
in-repo curated list intersected with live pricing — a Portal-flagged
free model not yet in the curated list could never appear.
Changes:
- hermes_cli/models.py: new union_with_portal_free_recommendations() that
augments the curated list with Portal freeRecommendedModels entries
(with synthetic free pricing so partition keeps them). The Portal's
/api/nous/recommended-models endpoint is now the source of truth for
free-tier surfacing — old Hermes builds will see new free models
without a CLI release.
- hermes_cli/models.py: _resolve_nous_pricing_credentials() falls back to
the public inference base URL when runtime cred resolution fails.
The /v1/models endpoint exposes pricing without auth, so silently
returning {} just because a refresh token expired was wrong.
- hermes_cli/auth.py + hermes_cli/main.py: both free-tier picker call
sites call union_with_portal_free_recommendations() before partition.
- tests/hermes_cli/test_models.py: 7 tests covering union behaviour
(prepend, dedup, end-to-end with stale pricing, empty/missing/error
payloads, invalid entries).
- tests/hermes_cli/test_model_catalog.py: drift guard
TestManifestMatchesInRepoLists fails CI when _PROVIDER_MODELS['nous']
or OPENROUTER_MODELS is edited without re-running
scripts/build_model_catalog.py. Verified empirically that removing a
manifest entry triggers an assertion with an actionable error message.
Validation:
- 133/133 targeted tests pass (test_models, test_model_catalog,
test_auth_nous_provider).
- Live E2E against the real Portal:
- Stale curated list ['claude-opus','claude-sonnet','gpt-5.4'] (no
qwen) → after union: ['qwen/qwen3.6-plus', ...] →
partition(free_tier=True): selectable=['qwen/qwen3.6-plus'].
- Simulated expired refresh token → anon fetch returns 403 pricing
entries including qwen/qwen3.6-plus -> {prompt:0, completion:0}.
- ruff: clean.
2026-05-11 18:08:16 -07:00
|
|
|
# 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, "")
|
2026-06-04 19:47:53 -04:00
|
|
|
assert ids == ["a", "qwen/qwen3.6-plus"]
|
fix(nous): surface Portal-flagged free models in picker even when curated list is stale (#24082)
Free-tier users were seeing 'No free models currently available.' in the
`hermes model` and post-login pickers even though qwen/qwen3.6-plus is
free on the Portal right now. Three independent breakages compounded:
1. The docs-hosted catalog manifest at website/static/api/model-catalog.json
was not regenerated when _PROVIDER_MODELS['nous'] was updated, so users
fetching the manifest got a list that didn't include qwen/qwen3.6-plus.
2. _resolve_nous_pricing_credentials() returned ('', '') on any auth blip,
collapsing get_pricing_for_provider('nous') to {} and making every
curated model fall through the free-tier filter as 'paid'.
3. Even with healthy pricing, the picker only ever showed models from the
in-repo curated list intersected with live pricing — a Portal-flagged
free model not yet in the curated list could never appear.
Changes:
- hermes_cli/models.py: new union_with_portal_free_recommendations() that
augments the curated list with Portal freeRecommendedModels entries
(with synthetic free pricing so partition keeps them). The Portal's
/api/nous/recommended-models endpoint is now the source of truth for
free-tier surfacing — old Hermes builds will see new free models
without a CLI release.
- hermes_cli/models.py: _resolve_nous_pricing_credentials() falls back to
the public inference base URL when runtime cred resolution fails.
The /v1/models endpoint exposes pricing without auth, so silently
returning {} just because a refresh token expired was wrong.
- hermes_cli/auth.py + hermes_cli/main.py: both free-tier picker call
sites call union_with_portal_free_recommendations() before partition.
- tests/hermes_cli/test_models.py: 7 tests covering union behaviour
(prepend, dedup, end-to-end with stale pricing, empty/missing/error
payloads, invalid entries).
- tests/hermes_cli/test_model_catalog.py: drift guard
TestManifestMatchesInRepoLists fails CI when _PROVIDER_MODELS['nous']
or OPENROUTER_MODELS is edited without re-running
scripts/build_model_catalog.py. Verified empirically that removing a
manifest entry triggers an assertion with an actionable error message.
Validation:
- 133/133 targeted tests pass (test_models, test_model_catalog,
test_auth_nous_provider).
- Live E2E against the real Portal:
- Stale curated list ['claude-opus','claude-sonnet','gpt-5.4'] (no
qwen) → after union: ['qwen/qwen3.6-plus', ...] →
partition(free_tier=True): selectable=['qwen/qwen3.6-plus'].
- Simulated expired refresh token → anon fetch returns 403 pricing
entries including qwen/qwen3.6-plus -> {prompt:0, completion:0}.
- ruff: clean.
2026-05-11 18:08:16 -07:00
|
|
|
assert p["qwen/qwen3.6-plus"] == self._FREE
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 15:16:17 -04:00
|
|
|
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):
|
2026-06-04 19:47:53 -04:00
|
|
|
"""A Portal-advertised paid model not in curated is appended."""
|
2026-05-12 15:16:17 -04:00
|
|
|
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, "")
|
|
|
|
|
|
2026-06-04 19:47:53 -04:00
|
|
|
# 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
|
2026-05-12 15:16:17 -04:00
|
|
|
# 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, "")
|
2026-06-04 19:47:53 -04:00
|
|
|
assert ids == ["a", "openai/gpt-5.4"]
|
2026-05-12 15:16:17 -04:00
|
|
|
# No synthetic entry — pricing is untouched.
|
|
|
|
|
assert "openai/gpt-5.4" not in p
|
|
|
|
|
|
|
|
|
|
def test_preserves_relative_order_of_new_paid_models(self):
|
2026-06-04 19:47:53 -04:00
|
|
|
"""Multiple new paid models are appended in payload order, after curated."""
|
2026-05-12 15:16:17 -04:00
|
|
|
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 == [
|
2026-06-04 19:47:53 -04:00
|
|
|
"anthropic/claude-opus-4.6",
|
2026-05-12 15:16:17 -04:00
|
|
|
"openai/gpt-5.4",
|
|
|
|
|
"openai/gpt-5.5",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-04-10 03:03:35 -07:00
|
|
|
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
|
|
|
|
|
|
2026-05-25 15:10:14 +10:00
|
|
|
@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()
|
2026-04-10 03:03:35 -07:00
|
|
|
|
|
|
|
|
assert result1 is True
|
|
|
|
|
assert result2 is True
|
2026-05-25 15:10:14 +10:00
|
|
|
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
|
2026-04-10 03:03:35 -07:00
|
|
|
|
2026-05-25 15:10:14 +10:00
|
|
|
cached_result, cached_at = _models_mod._free_tier_cache
|
|
|
|
|
_models_mod._free_tier_cache = (cached_result, cached_at - _FREE_TIER_CACHE_TTL - 1)
|
2026-04-10 03:03:35 -07:00
|
|
|
|
2026-05-25 15:10:14 +10:00
|
|
|
result2 = check_nous_free_tier()
|
|
|
|
|
assert mock_account.call_count == 2
|
2026-04-10 03:03:35 -07:00
|
|
|
|
|
|
|
|
assert result1 is False
|
|
|
|
|
assert result2 is False
|
|
|
|
|
|
2026-05-25 15:10:14 +10:00
|
|
|
@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)
|
|
|
|
|
|
2026-04-10 03:03:35 -07:00
|
|
|
def test_cache_ttl_is_short(self):
|
|
|
|
|
"""TTL should be short enough to catch upgrades quickly (<=5 min)."""
|
|
|
|
|
assert _FREE_TIER_CACHE_TTL <= 300
|
feat(aux): use Portal /api/nous/recommended-models for auxiliary models
Wire the auxiliary client (compaction, vision, session search, web extract)
to the Nous Portal's curated recommended-models endpoint when running on
Nous Portal, with a TTL-cached fetch that mirrors how we pull /models for
pricing.
hermes_cli/models.py
- fetch_nous_recommended_models(portal_base_url, force_refresh=False)
10-minute TTL cache, keyed per portal URL (staging vs prod don't
collide). Public endpoint, no auth required. Returns {} on any
failure so callers always get a dict.
- get_nous_recommended_aux_model(vision, free_tier=None, ...)
Tier-aware pick from the payload:
- Paid tier → paidRecommended{Vision,Compaction}Model, falling back
to freeRecommended* when the paid field is null (common during
staged rollouts of new paid models).
- Free tier → freeRecommended* only, never leaks paid models.
When free_tier is None, auto-detects via the existing
check_nous_free_tier() helper (already cached 3 min against
/api/oauth/account). Detection errors default to paid so we never
silently downgrade a paying user.
agent/auxiliary_client.py — _try_nous()
- Replaces the hardcoded xiaomi/mimo free-tier branch with a single call
to get_nous_recommended_aux_model(vision=vision).
- Falls back to _NOUS_MODEL (google/gemini-3-flash-preview) when the
Portal is unreachable or returns a null recommendation.
- The Portal is now the source of truth for aux model selection; the
xiaomi allowlist we used to carry is effectively dead.
Tests (15 new)
- tests/hermes_cli/test_models.py::TestNousRecommendedModels
Fetch caching, per-portal keying, network failure, force_refresh;
paid-prefers-paid, paid-falls-to-free, free-never-leaks-paid,
auto-detect, detection-error → paid default, null/blank modelName
handling.
- tests/agent/test_auxiliary_client.py::TestNousAuxiliaryRefresh
_try_nous honors Portal recommendation for text + vision, falls
back to google/gemini-3-flash-preview on None or exception.
Behavior won't visibly change today — both tier recommendations currently
point at google/gemini-3-flash-preview — but the moment the Portal ships
a better paid recommendation, subscribers pick it up within 10 minutes
without a Hermes release.
2026-04-21 22:53:45 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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("urllib.request.urlopen", 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("urllib.request.urlopen", 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("urllib.request.urlopen", 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("urllib.request.urlopen", 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"
|
2026-06-24 19:23:53 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|