Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 71 additions & 19 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import json
import os
import re
import urllib.parse
import urllib.request
import urllib.error
Expand Down Expand Up @@ -339,15 +340,15 @@ def _xai_curated_models() -> list[str]:
],
"anthropic": [
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-opus-4-5-20251101",
"claude-sonnet-4-5-20250929",
"claude-opus-4.8",
"claude-opus-4.7",
"claude-opus-4.6",
"claude-sonnet-4.6",
"claude-opus-4.5-20251101",
"claude-sonnet-4.5-20250929",
"claude-opus-4-20250514",
"claude-sonnet-4-20250514",
"claude-haiku-4-5-20251001",
"claude-haiku-4.5-20251001",
],
"deepseek": [
"deepseek-v4-pro",
Expand Down Expand Up @@ -1762,16 +1763,22 @@ def _provider_keys(provider: str) -> set[str]:
return {k for k in (key, normalized) if k}


def _model_lookup_key(model_id: str) -> str:
"""Normalize model IDs for catalog comparisons."""
return _anthropic_display_model_id(model_id).lower()


def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool:
lookup_key = _model_lookup_key(name_lower)
return any(
name_lower == model.lower()
lookup_key == _model_lookup_key(model)
for provider in providers
for model in _PROVIDER_MODELS.get(provider, [])
)


_AGGREGATOR_PROVIDERS = frozenset(
{"nous", "openrouter", "copilot", "kilocode"}
{"nous", "openrouter", "copilot", "kilocode", "opencode-go", "opencode-zen"}
)

# Subscription/OAuth providers whose catalogs RE-EXPOSE other vendors' models
Expand Down Expand Up @@ -1890,6 +1897,7 @@ def detect_static_provider_for_model(
current_provider == "custom"
or current_provider.startswith("custom:")
)
lookup_key = _model_lookup_key(name_lower)
for pid, models in _PROVIDER_MODELS.items():
if (
pid in current_keys
Expand All @@ -1899,8 +1907,20 @@ def detect_static_provider_for_model(
continue
if _is_custom_current:
continue
if any(name_lower == m.lower() for m in models):
return (pid, name)
for model in models:
model_lower = model.lower()
if lookup_key != _model_lookup_key(model_lower):
continue
if (
pid == "anthropic"
and "/" not in name_lower
and "." in name_lower
and name_lower == model_lower
):
# Bare display-form Claude IDs also appear on OpenRouter. Let the
# OpenRouter lookup below expand them to a full provider slug.
continue
return (pid, model if name_lower != model_lower else name)

# Borrow-list providers (re-expose other vendors' models) only after every
# native-vendor catalog, and only when one is the current provider.
Expand Down Expand Up @@ -1963,17 +1983,18 @@ def _find_openrouter_slug(model_name: str) -> Optional[str]:
name_lower = model_name.strip().lower()
if not name_lower:
return None
lookup_key = _model_lookup_key(name_lower)

# Exact match (already has provider/ prefix)
for mid in model_ids():
if name_lower == mid.lower():
if lookup_key == _model_lookup_key(mid):
return mid

# Try matching just the model part (after the /)
for mid in model_ids():
if "/" in mid:
_, model_part = mid.split("/", 1)
if name_lower == model_part.lower():
if lookup_key == _model_lookup_key(model_part):
return mid

return None
Expand Down Expand Up @@ -2047,6 +2068,32 @@ def _strip_vendor_prefix(model_id: str) -> str:
return raw


_CLAUDE_FAMILY_DECIMAL_RE = re.compile(
r"(?P<prefix>(?:^|/)claude-[a-z][a-z0-9]*-)(?P<major>\d+)-(?P<minor>\d{1,2})(?=(?:[-:]|$))",
re.IGNORECASE,
)
_CLAUDE_LEGACY_DECIMAL_RE = re.compile(
r"(?P<prefix>(?:^|/)claude-)(?P<major>\d+)-(?P<minor>\d{1,2})(?=-)",
re.IGNORECASE,
)


def _anthropic_display_model_id(model_id: str) -> str:
"""Use dot-versioned Claude IDs in picker/catalog output.

Anthropic's wire IDs use hyphens (``claude-sonnet-4-6``), but Hermes
displays Claude version numbers with decimals everywhere else. The
Anthropic adapter converts dots back to hyphens before API calls.
"""
value = str(model_id or "").strip()

def _dot_version(match: re.Match[str]) -> str:
return f"{match.group('prefix')}{match.group('major')}.{match.group('minor')}"

value = _CLAUDE_FAMILY_DECIMAL_RE.sub(_dot_version, value)
return _CLAUDE_LEGACY_DECIMAL_RE.sub(_dot_version, value)


def model_supports_fast_mode(model_id: Optional[str]) -> bool:
"""Return whether Hermes should expose the /fast toggle for this model."""
return _is_anthropic_fast_model(model_id) or _is_openai_fast_model(model_id)
Expand Down Expand Up @@ -2299,23 +2346,28 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
base_url=cfg_base_url or None,
api_key=cfg_api_key or None,
)
curated = [
_anthropic_display_model_id(model)
for model in _PROVIDER_MODELS.get("anthropic", [])
]
if live:
if cfg_base_url:
return live
return [_anthropic_display_model_id(model) for model in live]
# The live /v1/models dump lags newly-routed curated aliases
# (e.g. claude-fable-5, which is reachable on Anthropic before it
# is enumerated by the models endpoint). Surface curated entries
# first, then append any live-only models, so a fresh curated
# model never disappears just because the API hasn't listed it yet.
curated = list(_PROVIDER_MODELS.get("anthropic", []))
merged = list(curated)
merged_lower = {m.lower() for m in curated}
for m in live:
if m.lower() not in merged_lower:
merged.append(m)
merged_lower.add(m.lower())
display = _anthropic_display_model_id(m)
key = display.lower()
if key not in merged_lower:
merged.append(display)
merged_lower.add(key)
return merged
return list(_PROVIDER_MODELS.get("anthropic", []))
return curated
if normalized == "ollama-cloud":
live = fetch_ollama_cloud_models(force_refresh=force_refresh)
if live:
Expand Down
33 changes: 28 additions & 5 deletions tests/hermes_cli/test_anthropic_picker_curated.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ def test_anthropic_curated_alias_survives_when_live_omits_it():
"""A curated alias missing from /v1/models still surfaces (first)."""
curated = M._PROVIDER_MODELS["anthropic"]
assert "claude-fable-5" in curated # sanity: the alias is curated
assert "claude-opus-4.8" in curated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid snapshotting a specific curated catalog entry and spelling here (including the following negative assertion). AGENTS.md prohibits model-catalog change-detector tests; retain behavior-level conversion and deduplication assertions instead.

assert "claude-opus-4-8" not in curated

# Live catalog the API would actually return — no fable-5.
live = ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"]
Expand All @@ -34,19 +36,22 @@ def test_anthropic_curated_alias_survives_when_live_omits_it():
def test_anthropic_merge_dedupes_overlap_and_appends_live_only():
"""Models in both lists appear once; live-only models are appended."""
live = [
"claude-opus-4-8", # overlaps curated
"claude-sonnet-4-6", # overlaps curated
"claude-opus-4-8", # overlaps curated after display normalization
"claude-sonnet-4-6", # overlaps curated after display normalization
"claude-future-9-99", # live-only, not curated
]
with patch.object(M, "_fetch_anthropic_models", return_value=live):
result = M.provider_model_ids("anthropic")

# No duplicates introduced by the merge.
assert result.count("claude-opus-4-8") == 1
assert result.count("claude-opus-4.8") == 1
assert "claude-opus-4-8" not in result
assert "claude-sonnet-4.6" in result
assert "claude-sonnet-4-6" not in result
# Live-only entry is preserved (discovery still works for unknown models).
assert "claude-future-9-99" in result
assert "claude-future-9.99" in result
# Curated entries lead, live-only trails.
assert result.index("claude-fable-5") < result.index("claude-future-9-99")
assert result.index("claude-fable-5") < result.index("claude-future-9.99")


def test_anthropic_falls_back_to_curated_when_live_unavailable():
Expand All @@ -56,3 +61,21 @@ def test_anthropic_falls_back_to_curated_when_live_unavailable():

assert result == list(M._PROVIDER_MODELS["anthropic"])
assert "claude-fable-5" in result


def test_anthropic_live_models_use_decimal_display_versions():
"""Native Anthropic IDs are displayed with decimal Claude versions."""
live = [
"claude-opus-4-8",
"claude-sonnet-4-5-20250929",
"claude-3-5-sonnet-20241022",
"claude-opus-4-20250514",
]
with patch.object(M, "_fetch_anthropic_models", return_value=live):
result = M.provider_model_ids("anthropic")

assert "claude-opus-4.8" in result
assert "claude-sonnet-4.5-20250929" in result
assert "claude-3.5-sonnet-20241022" in result
# 4.0 date-stamped IDs do not have a minor version to decimalize.
assert "claude-opus-4-20250514" in result
2 changes: 1 addition & 1 deletion tests/hermes_cli/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ def test_aggregator_not_suggested(self):
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
assert result[0] not in {"nous", "opencode-go", "opencode-zen"}

def test_custom_provider_not_overridden_by_static_catalog(self):
"""When current provider is custom:*, a static-catalog match must NOT
Expand Down
Loading