diff --git a/hermes_cli/model_catalog.py b/hermes_cli/model_catalog.py index 029e2bcd72..2cb569ec4b 100644 --- a/hermes_cli/model_catalog.py +++ b/hermes_cli/model_catalog.py @@ -1,13 +1,9 @@ """Remote model catalog fetcher. -Pipeline -------- 1. ``get_catalog()`` — returns a parsed manifest dict. - Checks in-process cache -(invalidated by TTL). - Reads disk cache at ``~/.hermes/cache/model_catalog.json``. - Fetches the -master URL if disk cache is stale or missing. - On any fetch failure, keeps using the stale cache -(or empty dict). - -2. ``get_curated_openrouter_models()`` / ``get_curated_nous_models()`` — thin accessors returning -the shapes existing callers expect. Each falls back to the in-repo hardcoded list on any lookup -failure. +``get_catalog()`` returns the parsed manifest: in-process cache (TTL) → disk cache at +``~/.hermes/cache/model_catalog.json`` → master URL fetch; any fetch failure keeps the stale copy +(or ``{}``). ``get_curated_openrouter_models()`` / ``get_curated_nous_models()`` are thin accessors +whose callers fall back to the in-repo lists on ``None``. """ from __future__ import annotations @@ -26,43 +22,26 @@ from utils import atomic_replace logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - DEFAULT_CATALOG_URL = ( - "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json" -) -# Fallback fetch chain. The Docusaurus site is served through Vercel, which -# occasionally returns HTTP 403 + x-vercel-mitigated: challenge for non- -# browser clients (urllib, curl). When that happens the disk cache goes -# stale and new model releases never reach the picker. The raw GitHub URL -# is the same manifest published from the same repo and is not bot-gated, -# so we fall through to it whenever the primary URL fails. + "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json") +# The Docusaurus site sits behind Vercel, which occasionally 403s non-browser clients (bot +# challenge); the raw GitHub copy is the same manifest and is not bot-gated. DEFAULT_CATALOG_FALLBACK_URLS: tuple[str, ...] = ( "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/static/api/model-catalog.json", ) DEFAULT_TTL_MINUTES = 20 -# Legacy key. ``ttl_hours`` is honoured only when the user set it explicitly; -# the shipped default is ``ttl_minutes`` above. +# Legacy key, honoured only when the user set it explicitly; ``ttl_minutes`` is the shipped default. DEFAULT_TTL_HOURS = DEFAULT_TTL_MINUTES / 60.0 DEFAULT_FETCH_TIMEOUT = 8.0 SUPPORTED_SCHEMA_VERSION = 1 _HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}" -# In-process cache to avoid repeated disk + parse work across multiple -# calls within the same session. Invalidated by TTL against the disk file's -# mtime, so calling code never has to think about this. +# In-process cache, invalidated against the disk file's mtime and TTL. _catalog_cache: dict[str, Any] | None = None _catalog_cache_source_mtime: float = 0.0 -# --------------------------------------------------------------------------- -# Config -# --------------------------------------------------------------------------- - - def _load_catalog_config() -> dict[str, Any]: """Load the ``model_catalog`` config block with defaults filled in.""" try: @@ -70,16 +49,12 @@ def _load_catalog_config() -> dict[str, Any]: cfg = load_config() or {} except Exception: cfg = {} - raw = cfg.get("model_catalog") if not isinstance(raw, dict): raw = {} - # ``ttl_minutes`` is the shipped default (20). ``ttl_hours`` is the legacy - # key: honoured when a user set it explicitly and ``ttl_minutes`` is still - # at its default (load_config() deep-merges the default in, so "present" - # alone doesn't mean "user-set"), so old customized configs keep their - # chosen window. + # ``ttl_hours`` (legacy) is honoured only when ``ttl_minutes`` is still at its default — + # load_config() deep-merges the default in, so "present" alone doesn't mean "user-set". ttl_minutes = raw.get("ttl_minutes") try: ttl_minutes = float(ttl_minutes) if ttl_minutes not in (None, "") else DEFAULT_TTL_MINUTES @@ -97,31 +72,19 @@ def _load_catalog_config() -> dict[str, Any]: "enabled": bool(raw.get("enabled", True)), "url": str(raw.get("url") or DEFAULT_CATALOG_URL), "ttl_hours": ttl_minutes / 60.0, - "providers": raw.get("providers") if isinstance(raw.get("providers"), dict) else {}, - } + "providers": raw.get("providers") if isinstance(raw.get("providers"), dict) else {}} def _cache_path() -> Path: - """Return the disk cache path. Import lazily so tests can monkeypatch home.""" + """Disk cache path; imported lazily so tests can monkeypatch home.""" from hermes_constants import get_hermes_home return get_hermes_home() / "cache" / "model_catalog.json" -# --------------------------------------------------------------------------- -# Fetch + validate + cache -# --------------------------------------------------------------------------- - - def _fetch_manifest(url: str, timeout: float) -> dict[str, Any] | None: - """HTTP GET the manifest URL and return a parsed dict, or None on failure.""" + """HTTP GET the manifest URL and return a validated dict, or None on failure.""" try: - req = urllib.request.Request( - url, - headers={ - "Accept": "application/json", - "User-Agent": _HERMES_USER_AGENT, - }, - ) + req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": _HERMES_USER_AGENT}) with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc: @@ -130,25 +93,17 @@ def _fetch_manifest(url: str, timeout: float) -> dict[str, Any] | None: except Exception as exc: # pragma: no cover — defensive logger.info("model catalog fetch errored (%s): %s", url, exc) return None - if not _validate_manifest(data): logger.info("model catalog at %s failed schema validation", url) return None - return data def _fetch_manifest_with_fallback( - primary_url: str, - timeout: float, - fallback_urls: tuple[str, ...] = DEFAULT_CATALOG_FALLBACK_URLS, + primary_url: str, timeout: float, fallback_urls: tuple[str, ...] = DEFAULT_CATALOG_FALLBACK_URLS ) -> dict[str, Any] | None: - """Try ``primary_url`` first, then walk ``fallback_urls``. - - Returns the first manifest that fetches and validates, or None when every URL fails. Skips - fallback URLs identical to the primary so an operator who configured the catalog URL to point at - the raw GitHub copy doesn't double-fetch. - """ + """First manifest that fetches and validates from ``primary_url`` then ``fallback_urls`` (skipping + any equal to the primary so a raw-GitHub-configured operator doesn't double-fetch), or None.""" data = _fetch_manifest(primary_url, timeout) if data is not None: return data @@ -168,9 +123,7 @@ def _validate_manifest(data: Any) -> bool: return False version = data.get("version") if not isinstance(version, int) or version > SUPPORTED_SCHEMA_VERSION: - # Future schema version we don't understand — refuse rather than - # guess. Older schemas (version < 1) aren't supported either. - return False + return False # future schema we don't understand — refuse rather than guess providers = data.get("providers") if not isinstance(providers, dict): return False @@ -180,11 +133,8 @@ def _validate_manifest(data: Any) -> bool: models = pblock.get("models") if not isinstance(models, list): return False - for m in models: - if not isinstance(m, dict): - return False - if not isinstance(m.get("id"), str) or not m["id"].strip(): - return False + if not all(isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"].strip() for m in models): + return False return True @@ -193,16 +143,11 @@ def _read_disk_cache() -> tuple[dict[str, Any] | None, float]: path = _cache_path() try: mtime = path.stat().st_mtime - except (OSError, FileNotFoundError): - return (None, 0.0) - try: with open(path, encoding="utf-8") as fh: data = json.load(fh) except (OSError, json.JSONDecodeError): return (None, 0.0) - if not _validate_manifest(data): - return (None, 0.0) - return (data, mtime) + return (data, mtime) if _validate_manifest(data) else (None, 0.0) def _write_disk_cache(data: dict[str, Any]) -> None: @@ -218,9 +163,8 @@ def _write_disk_cache(data: dict[str, Any]) -> None: logger.info("model catalog cache write failed: %s", exc) -# Stale-while-revalidate machinery: at most one background manifest refresh -# in flight per process. The refreshed manifest lands on disk; the NEXT -# get_catalog() call picks it up via the mtime check. +# Stale-while-revalidate: at most one background manifest refresh in flight per process. The +# refreshed manifest lands on disk; the NEXT get_catalog() call picks it up via the mtime check. _catalog_swr_lock = threading.Lock() _catalog_swr_inflight = False @@ -248,73 +192,42 @@ def _spawn_catalog_swr_refresh(url: str) -> None: threading.Thread(target=_refresh, daemon=True, name="model-catalog-swr").start() -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- +def _remember(data: dict[str, Any], mtime: float) -> dict[str, Any]: + global _catalog_cache, _catalog_cache_source_mtime + _catalog_cache, _catalog_cache_source_mtime = data, mtime + return data def get_catalog(*, force_refresh: bool = False) -> dict[str, Any]: - """Return the parsed model catalog manifest, or an empty dict on failure. - - Callers should treat a missing provider/model as "use the in-repo fallback" — never raise from - this function so the CLI keeps working offline. - """ - global _catalog_cache, _catalog_cache_source_mtime - + """Parsed model catalog manifest, or ``{}`` on failure — never raises, so the CLI works offline + (callers treat a missing provider/model as "use the in-repo fallback").""" cfg = _load_catalog_config() if not cfg["enabled"]: return {} - ttl_seconds = max(0.0, cfg["ttl_hours"] * 3600.0) - disk_data, disk_mtime = _read_disk_cache() now = time.time() disk_fresh = disk_data is not None and (now - disk_mtime) < ttl_seconds - # In-process cache hit: disk hasn't changed since we loaded it and still fresh. - if ( - not force_refresh - and _catalog_cache is not None - and disk_data is not None - and disk_mtime == _catalog_cache_source_mtime - and disk_fresh - ): - return _catalog_cache - - # Disk is fresh enough — use it without a network hit. - if not force_refresh and disk_fresh and disk_data is not None: - _catalog_cache = disk_data - _catalog_cache_source_mtime = disk_mtime - return disk_data - - # Stale-while-revalidate: an expired disk copy is served immediately and - # refreshed off-thread, so interactive surfaces (the /model picker calls - # this via get_curated_nous_model_ids on every open) never block on the - # manifest fetch. Only a cold cache (no disk copy at all) still blocks. if not force_refresh and disk_data is not None: - _catalog_cache = disk_data - _catalog_cache_source_mtime = disk_mtime - _spawn_catalog_swr_refresh(cfg["url"]) - return disk_data + if disk_fresh and _catalog_cache is not None and disk_mtime == _catalog_cache_source_mtime: + return _catalog_cache + if not disk_fresh: + # Stale-while-revalidate: serve the expired disk copy now and refresh off-thread so the + # /model picker (which calls this on every open) never blocks on the manifest fetch. + # Only a cold cache (no disk copy at all) still blocks. + _spawn_catalog_swr_refresh(cfg["url"]) + return _remember(disk_data, disk_mtime) - # Need to (re)fetch. If it fails, fall back to any stale disk copy. fetched = _fetch_manifest_with_fallback(cfg["url"], DEFAULT_FETCH_TIMEOUT) if fetched is not None: _write_disk_cache(fetched) new_disk_data, new_mtime = _read_disk_cache() if new_disk_data is not None: - _catalog_cache = new_disk_data - _catalog_cache_source_mtime = new_mtime - return new_disk_data - _catalog_cache = fetched - _catalog_cache_source_mtime = now - return fetched - + return _remember(new_disk_data, new_mtime) + return _remember(fetched, now) if disk_data is not None: - _catalog_cache = disk_data - _catalog_cache_source_mtime = disk_mtime - return disk_data - + return _remember(disk_data, disk_mtime) return {} @@ -324,12 +237,9 @@ def refresh_interval_seconds() -> float: def refresh_catalogs() -> bool: - """Force-refresh every remote model catalog the picker reads from. - - Fetches the curated manifest, the OpenRouter live list (tool-support / free-pricing filter) and - the Nous Portal recommendations, writing each to its disk cache so the next ``/model`` open in - ANY process on this machine sees the new lists. Blocking; run it off the event loop. - """ + """Force-refresh every remote catalog the picker reads (manifest, OpenRouter live list, Nous Portal + recommendations), writing each disk cache so the next ``/model`` open in ANY process sees them. + Blocking; run it off the event loop.""" if not _load_catalog_config()["enabled"]: return False catalog = get_catalog(force_refresh=True) @@ -354,96 +264,56 @@ def _fetch_provider_override(provider: str) -> dict[str, Any] | None: override_url = provider_cfg.get("url") if not isinstance(override_url, str) or not override_url.strip(): return None - # Override fetches skip the disk cache because they're usually - # third-party self-hosted. Re-request on every call but with a short - # timeout so they don't block the picker. + # Overrides are usually third-party self-hosted: skip the disk cache, re-request every call. return _fetch_manifest(override_url.strip(), DEFAULT_FETCH_TIMEOUT) +def _block_of(manifest: dict[str, Any] | None, provider: str) -> dict[str, Any] | None: + block = (manifest or {}).get("providers", {}).get(provider) + return block if isinstance(block, dict) else None + + def _get_provider_block(provider: str) -> dict[str, Any] | None: """Return the provider's manifest block, respecting per-provider overrides.""" - override = _fetch_provider_override(provider) - if override is not None: - block = override.get("providers", {}).get(provider) - if isinstance(block, dict): - return block + return _block_of(_fetch_provider_override(provider), provider) or _block_of(get_catalog(), provider) - catalog = get_catalog() - if not catalog: - return None - block = catalog.get("providers", {}).get(provider) - return block if isinstance(block, dict) else None + +def _block_ids(block: dict[str, Any] | None) -> list[tuple[str, dict[str, Any]]]: + """``(id, entry)`` for every model entry of ``block`` with a non-empty id.""" + models = (block or {}).get("models", []) + return [(mid, m) for m in models if isinstance(m, dict) and (mid := str(m.get("id") or "").strip())] def get_curated_openrouter_models() -> list[tuple[str, str]] | None: - """Return OpenRouter's curated ``[(id, description), ...]`` from the manifest.""" - block = _get_provider_block("openrouter") - if not block: - return None - out: list[tuple[str, str]] = [] - for m in block.get("models", []): - mid = str(m.get("id") or "").strip() - if not mid: - continue - desc = str(m.get("description") or "") - out.append((mid, desc)) - return out or None + """OpenRouter's curated ``[(id, description), ...]`` from the manifest.""" + rows = _block_ids(_get_provider_block("openrouter")) + return [(mid, str(m.get("description") or "")) for mid, m in rows] or None def get_curated_nous_models() -> list[str] | None: - """Return Nous Portal's curated list of model ids from the manifest.""" - block = _get_provider_block("nous") - if not block: - return None - out: list[str] = [] - for m in block.get("models", []): - mid = str(m.get("id") or "").strip() - if mid: - out.append(mid) - return out or None + """Nous Portal's curated model ids from the manifest.""" + return [mid for mid, _ in _block_ids(_get_provider_block("nous"))] or None def _default_model_from_block(block: dict[str, Any] | None) -> str | None: - """Return the id of the model entry labeled ``"default": true``, or None.""" - if not isinstance(block, dict): - return None - for m in block.get("models", []): - if isinstance(m, dict) and m.get("default"): - mid = str(m.get("id") or "").strip() - if mid: - return mid - return None + """Id of the model entry labeled ``"default": true``, or None.""" + return next((mid for mid, m in _block_ids(block) if m.get("default")), None) def get_default_model_from_cache(provider: str) -> str | None: - """Return the catalog's labeled default model for ``provider`` — cache only. - - The manifest marks exactly one model entry per provider with ``"default": true``; that entry is - the model Hermes silently lands on when the user never picked one. - """ - if _catalog_cache is not None: - block = _catalog_cache.get("providers", {}).get(provider) - found = _default_model_from_block(block) - if found: - return found + """The manifest's labeled default for ``provider`` (the model Hermes silently lands on when the + user never picked one) — in-process then disk cache only, never a fetch.""" + found = _default_model_from_block(_block_of(_catalog_cache, provider)) if _catalog_cache is not None else None + if found: + return found disk_data, _mtime = _read_disk_cache() - if disk_data is not None: - block = disk_data.get("providers", {}).get(provider) - return _default_model_from_block(block) - return None + return _default_model_from_block(_block_of(disk_data, provider)) if disk_data is not None else None def seed_cache_from_checkout(project_root: "Path | str") -> bool: - """Overwrite the disk cache with the catalog shipped in a local checkout. - - ``hermes update`` pulls the latest repo, so the freshly-pulled ``website/static/api/model- - catalog.json`` IS the newest catalog — no network round-trip needed. Copying it straight over - the disk cache keeps the model picker current even when the remote manifest fetch is bot-gated - or the Portal hiccups. - - Reads the shipped manifest, validates it against the schema, and writes it to - ``~/.hermes/cache/model_catalog.json`` via the same atomic writer the network path uses. - """ + """Overwrite the disk cache with the checkout's ``website/static/api/model-catalog.json``. + After ``hermes update`` that file IS the newest catalog, so the picker stays current even when + the remote fetch is bot-gated. Validated, then written via the same atomic writer.""" src = Path(project_root) / "website" / "static" / "api" / "model-catalog.json" try: with open(src, encoding="utf-8") as fh: diff --git a/hermes_cli/model_cost_guard.py b/hermes_cli/model_cost_guard.py index 16d4f0fd51..9c28466ae6 100644 --- a/hermes_cli/model_cost_guard.py +++ b/hermes_cli/model_cost_guard.py @@ -28,10 +28,8 @@ class ExpensiveModelWarning: def _to_decimal(value: object) -> Optional[Decimal]: - if value is None: - return None try: - return Decimal(str(value)) + return None if value is None else Decimal(str(value)) except (InvalidOperation, ValueError): return None @@ -41,15 +39,10 @@ def _format_money(value: Optional[Decimal]) -> str: def _pricing_from_model_info( - model_info: Optional[ModelInfo], -) -> tuple[Optional[Decimal], Optional[Decimal], str]: + model_info: Optional[ModelInfo]) -> tuple[Optional[Decimal], Optional[Decimal], str]: if model_info is None or not model_info.has_cost_data(): return None, None, "" - return ( - _to_decimal(model_info.cost_input), - _to_decimal(model_info.cost_output), - "models.dev", - ) + return _to_decimal(model_info.cost_input), _to_decimal(model_info.cost_output), "models.dev" def _known_models_dev_provider(provider: Optional[str]) -> Optional[str]: @@ -58,23 +51,16 @@ def _known_models_dev_provider(provider: Optional[str]) -> Optional[str]: def _can_trust_model_info_pricing( - provider: Optional[str], - model_info: Optional[ModelInfo], -) -> bool: + provider: Optional[str], model_info: Optional[ModelInfo]) -> bool: expected_provider = _known_models_dev_provider(provider) if not expected_provider or model_info is None: return False - actual_provider = str(getattr(model_info, "provider_id", "") or "").strip().lower() return not actual_provider or actual_provider == expected_provider def _can_trust_pricing_lookup( - model_name: str, - *, - provider: Optional[str], - base_url: Optional[str], -) -> bool: + model_name: str, *, provider: Optional[str], base_url: Optional[str]) -> bool: try: from agent.usage_pricing import resolve_billing_route @@ -85,18 +71,11 @@ def _can_trust_pricing_lookup( def expensive_model_warning( - model_name: str, - *, - provider: Optional[str] = None, - base_url: Optional[str] = None, - api_key: Optional[str] = None, - model_info: Optional[ModelInfo] = None, + model_name: str, *, provider: Optional[str] = None, base_url: Optional[str] = None, + api_key: Optional[str] = None, model_info: Optional[ModelInfo] = None, ) -> Optional[ExpensiveModelWarning]: - """Return a warning payload when known pricing exceeds safety thresholds. - - The guard only triggers when pricing is known. Callers should use this after model resolution so - aliases and provider-specific model IDs have settled. - """ + """Warning payload when KNOWN pricing exceeds the safety thresholds (never fires on unknown + pricing). Call after model resolution so aliases / provider-specific ids have settled.""" model = (model_name or "").strip() if not model: return None @@ -104,7 +83,6 @@ def expensive_model_warning( input_cost: Optional[Decimal] = None output_cost: Optional[Decimal] = None source = "" - if _can_trust_model_info_pricing(provider, model_info): input_cost, output_cost, source = _pricing_from_model_info(model_info) @@ -115,9 +93,7 @@ def expensive_model_warning( try: from agent.models_dev import get_model_info - input_cost, output_cost, source = _pricing_from_model_info( - get_model_info(provider, model) - ) + input_cost, output_cost, source = _pricing_from_model_info(get_model_info(provider, model)) except Exception: pass @@ -125,12 +101,7 @@ def expensive_model_warning( try: from agent.usage_pricing import get_pricing_entry - entry = get_pricing_entry( - model, - provider=provider, - base_url=base_url, - api_key=api_key, - ) + entry = get_pricing_entry(model, provider=provider, base_url=base_url, api_key=api_key) except Exception: entry = None if entry is not None: @@ -139,7 +110,6 @@ def expensive_model_warning( source = entry.source is_known_gpt55_pro_confusion = model.lower() == GPT55_PRO_OPENROUTER_ID - over_input = input_cost is not None and input_cost > INPUT_COST_WARNING_THRESHOLD over_output = output_cost is not None and output_cost > OUTPUT_COST_WARNING_THRESHOLD if not over_input and not over_output and not is_known_gpt55_pro_confusion: @@ -151,8 +121,7 @@ def expensive_model_warning( f"{model} has known pricing above Hermes' safety threshold.", f"Input tokens: {_format_money(input_cost)}", f"Output tokens: {_format_money(output_cost)}", - "Threshold: more than $20/M input tokens or more than $100/M output tokens.", - ] + "Threshold: more than $20/M input tokens or more than $100/M output tokens."] if source: lines.append(f"Pricing source: {source}.") if is_known_gpt55_pro_confusion: @@ -160,10 +129,5 @@ def expensive_model_warning( lines.append("Confirm only if you intend to use this model.") return ExpensiveModelWarning( - model=model, - provider=(provider or "").strip(), - input_cost_per_million=input_cost, - output_cost_per_million=output_cost, - source=source or "unknown", - message="\n".join(lines), - ) + model=model, provider=(provider or "").strip(), input_cost_per_million=input_cost, + output_cost_per_million=output_cost, source=source or "unknown", message="\n".join(lines)) diff --git a/hermes_cli/model_data_policy_guard.py b/hermes_cli/model_data_policy_guard.py index 68c04d3e98..8735ff5a32 100644 --- a/hermes_cli/model_data_policy_guard.py +++ b/hermes_cli/model_data_policy_guard.py @@ -1,11 +1,8 @@ """Data-policy confirmation helpers for model selection surfaces. -Some inference tiers are cheap *because* the vendor trains future models on your prompts and -completions. Selecting one for the low price without realising the data trade-off is a real footgun. - -Why a static table (not a ProviderProfile hook): the guard runs inside core selection code -(``auth.py`` / ``web_server.py``), which never calls into the active provider profile for a -selection-time warning. +Some inference tiers are cheap *because* the vendor trains on your prompts. A static rule table (not +a ProviderProfile hook) because the guard runs inside core selection code (``auth.py`` / +``web_server.py``), which never calls into the active provider profile. """ from __future__ import annotations @@ -23,18 +20,12 @@ class DataTrainingWarning: message: str -# ── Rule table ──────────────────────────────────────────────────────────── -# Each rule: (human label, predicate over (model_lower, provider_lower), message). -# Extensible — new data-collection tiers from other vendors slot in here without -# touching the call sites. Predicates are intentionally conservative: match an -# explicit, vendor-documented id rather than guessing from price alone (price is -# only a corroborating signal and can change). +# Rule predicates are deliberately conservative: match an explicit, vendor-documented id rather +# than guessing from price (a corroborating signal that can change). def _is_meta_contributor(model_lower: str, provider_lower: str) -> bool: - # Meta Model API "contributor" tier (muse-spark-1.2-contributor and any - # future -contributor checkpoints). Match on the id suffix; do not require a - # specific provider id so it fires whether selected via the meta-ai plugin, - # a gateway, or a custom endpoint that serves the same model id. + # Meta "contributor" tier, matched on the id alone (no provider check) so it fires whether + # selected via the meta-ai plugin, a gateway, or a custom endpoint serving the same id. return model_lower.endswith("-contributor") or "contributor" in model_lower.split("-") @@ -71,27 +62,16 @@ def data_training_warning( provider: Optional[str] = None, base_url: Optional[str] = None, # noqa: ARG001 — reserved for host-scoped rules ) -> Optional[DataTrainingWarning]: - """Return a warning payload when *model_name* selects a data-training tier. - - Returns ``None`` when no rule matches (the common case). Callers should run this after model - resolution so aliases / provider-specific ids have settled, and surface ``.message`` as a - confirm prompt. - """ + """Warning payload when *model_name* selects a data-training tier, else ``None``. Call after model + resolution; surface ``.message`` as a confirm prompt.""" model = (model_name or "").strip() if not model: return None - model_lower = model.lower() - provider_lower = (provider or "").strip().lower() - + model_lower, provider_lower = model.lower(), (provider or "").strip().lower() for predicate, message in _RULES: try: if predicate(model_lower, provider_lower): - return DataTrainingWarning( - model=model, - provider=(provider or "").strip(), - message=message, - ) + return DataTrainingWarning(model=model, provider=(provider or "").strip(), message=message) except Exception: - # A misbehaving predicate must never break model selection. - continue + continue # a misbehaving predicate must never break model selection return None diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 0838cb207b..67636d2470 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -5,15 +5,8 @@ from __future__ import annotations import re from typing import Optional -# --------------------------------------------------------------------------- -# Vendor prefix mapping -# --------------------------------------------------------------------------- -# Maps the first hyphen-delimited token of a bare model name to the vendor -# slug used by aggregator APIs (OpenRouter, Nous, etc.). -# -# Example: "claude-sonnet-4.6" -> first token "claude" -> vendor "anthropic" -# -> aggregator slug: "anthropic/claude-sonnet-4.6" - +# First hyphen-delimited token of a bare model name → vendor slug used by aggregator APIs +# ("claude-sonnet-4.6" → "anthropic/claude-sonnet-4.6"). _VENDOR_PREFIXES: dict[str, str] = { "claude": "anthropic", "gpt": "openai", @@ -32,34 +25,23 @@ _VENDOR_PREFIXES: dict[str, str] = { "trinity": "arcee-ai", "nemotron": "nvidia", "llama": "meta-llama", - "step": "stepfun", - "trinity": "arcee-ai", -} + "step": "stepfun"} # Providers whose APIs consume vendor/model slugs. _AGGREGATOR_PROVIDERS: frozenset[str] = frozenset({ - "openrouter", - "nous", - "ai-gateway", - "kilocode", -}) + "openrouter", "nous", "ai-gateway", "kilocode"}) # Providers that want bare names with dots replaced by hyphens. _DOT_TO_HYPHEN_PROVIDERS: frozenset[str] = frozenset({ - "anthropic", -}) + "anthropic"}) # Providers that want bare names with dots preserved. _STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({ - "copilot", - "copilot-acp", - "openai-codex", -}) + "copilot", "copilot-acp", "openai-codex"}) # Providers whose native naming is authoritative -- pass through unchanged. _AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({ - "huggingface", -}) + "huggingface"}) # Direct providers that accept bare native names but should repair a matching # provider/ prefix when users copy the aggregator form into config.yaml. @@ -78,8 +60,7 @@ _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({ "nebius-token-factory", "custom", "gemini", - "xai", -}) + "xai"}) # Providers whose API serves ``vendor/model`` ids but whose endpoint can also # front arbitrary self-hosted models, so a bare name cannot be prefixed @@ -91,97 +72,43 @@ _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({ # Without this repair a bare ``nemotron-3-ultra-550b-a55b`` reaches the API # and returns a bare ``404 page not found`` that never names the model (#78796). _CATALOGUE_PREFIX_REPAIR_PROVIDERS: frozenset[str] = frozenset({ - "nvidia", -}) + "nvidia"}) -# Providers whose APIs require lowercase model IDs. Xiaomi's -# ``api.xiaomimimo.com`` rejects mixed-case names like ``MiMo-V2.5-Pro`` -# that users might copy from marketing docs — it only accepts -# ``mimo-v2.5-pro``. After stripping a matching provider prefix, these -# providers also get ``.lower()`` applied. +# Providers whose APIs require lowercase model IDs (Xiaomi rejects ``MiMo-V2.5-Pro`` copied from +# marketing docs; only ``mimo-v2.5-pro`` works). Applied after matching-prefix stripping. _LOWERCASE_MODEL_PROVIDERS: frozenset[str] = frozenset({ - "xiaomi", -}) + "xiaomi"}) -# --------------------------------------------------------------------------- -# DeepSeek special handling -# --------------------------------------------------------------------------- -# DeepSeek's direct API only accepts first-class V-series IDs after the -# 2026-07-24 cut-off. Legacy aliases and fuzzy names are remapped here so -# saved configs / picker leftovers cannot keep sending retired IDs. - -_DEEPSEEK_REASONER_KEYWORDS: frozenset[str] = frozenset({ - "reasoner", - "r1", - "think", - "reasoning", - "cot", -}) - -# Retired on 2026-07-24 15:59 UTC. Official docs: both aliases mapped to -# deepseek-v4-flash (chat = non-thinking, reasoner = thinking). Thinking -# mode itself is controlled by extra_body.thinking on the DeepSeek profile. +# DeepSeek's direct API only accepts first-class V-series IDs after the 2026-07-24 cut-off (HTTP 400 +# otherwise). Both retired aliases map to deepseek-v4-flash per the official docs (thinking mode is +# controlled by extra_body.thinking on the profile), so saved configs can't keep sending them. _DEEPSEEK_RETIRED_ALIASES: frozenset[str] = frozenset({ - "deepseek-chat", - "deepseek-reasoner", -}) + "deepseek-chat", "deepseek-reasoner"}) _DEEPSEEK_CANONICAL_MODELS: frozenset[str] = frozenset({ - "deepseek-v4-pro", # V4 Pro — first-class model ID - "deepseek-v4-flash", # V4 Flash — first-class model ID -}) + "deepseek-v4-pro", "deepseek-v4-flash"}) -# First-class V-series IDs (``deepseek-v4-pro``, ``deepseek-v4-flash``, -# future ``deepseek-v5-*``, dated variants like ``deepseek-v4-flash-20260423``). -# Verified empirically 2026-04-24: DeepSeek's Chat Completions API returns -# ``provider: DeepSeek`` / ``model: deepseek-v4-flash-20260423`` when called -# with ``model=deepseek/deepseek-v4-flash``, so these names are not aliases -# of ``deepseek-chat`` and must not be folded into it. +# First-class V-series IDs incl. future ``deepseek-v5-*`` and dated variants +# (``deepseek-v4-flash-20260423``): verified real model ids, NOT aliases of ``deepseek-chat``. _DEEPSEEK_V_SERIES_RE = re.compile(r"^deepseek-v\d+([-.].+)?$") def _normalize_for_deepseek(model_name: str) -> str: - """Map a model input to a DeepSeek-accepted identifier. - - Retired aliases ``deepseek-chat``/``deepseek-reasoner`` and known canonicals map as expected; - anything matching ``deepseek-v...`` passes through so future V-series ids work without a - release; reasoner keywords and everything else fall back to ``deepseek-v4-flash``. - """ + """Map a model input to a DeepSeek-accepted id: canonicals and ``deepseek-v…`` pass + through (future V-series work without a release); retired aliases and everything else become + ``deepseek-v4-flash``.""" bare = _strip_vendor_prefix(model_name).lower() - - # Retired aliases must rewrite — DeepSeek returns HTTP 400 after the - # 2026-07-24 cut-off if these IDs are sent on the wire. - if bare in _DEEPSEEK_RETIRED_ALIASES: - return "deepseek-v4-flash" - - if bare in _DEEPSEEK_CANONICAL_MODELS: + if bare in _DEEPSEEK_CANONICAL_MODELS or _DEEPSEEK_V_SERIES_RE.match(bare): return bare - - # V-series first-class IDs (v4-pro, v4-flash, future v5-*, dated variants) - if _DEEPSEEK_V_SERIES_RE.match(bare): - return bare - - # Check for reasoner-like keywords anywhere in the name - for keyword in _DEEPSEEK_REASONER_KEYWORDS: - if keyword in bare: - return "deepseek-v4-flash" - return "deepseek-v4-flash" -# --------------------------------------------------------------------------- -# Helper utilities -# --------------------------------------------------------------------------- - def _strip_vendor_prefix(model_name: str) -> str: """Remove a ``vendor/`` prefix if present.""" - if "/" in model_name: - return model_name.split("/", 1)[1] - return model_name + return model_name.split("/", 1)[1] if "/" in model_name else model_name def _dots_to_hyphens(model_name: str) -> str: - """Replace dots with hyphens in a model name.""" return model_name.replace(".", "-") @@ -199,108 +126,66 @@ def _normalize_provider_alias(provider_name: str) -> str: def _strip_matching_provider_prefix(model_name: str, target_provider: str) -> str: - """Strip ``provider/`` only when the prefix matches the target provider. - - Prevents arbitrary slash-bearing ids from being mangled on native providers while still - repairing config values like ``zai/glm-5.1`` for ``zai``. ``custom`` is a bucket, not a vendor: - an alias that merely resolves to it (e.g. ``ollama``) may be a real routing prefix required by a - proxy such as LiteLLM, so only a literal ``custom/`` prefix is treated as redundant. - """ + """Strip ``provider/`` only when the prefix matches the target provider, so arbitrary slash-bearing + ids aren't mangled while ``zai/glm-5.1`` is repaired for ``zai``. ``custom`` is a bucket, not a + vendor: an alias resolving to it (``ollama``) may be a real LiteLLM-style routing prefix, so only a + literal ``custom/`` prefix is redundant there.""" if "/" not in model_name: return model_name - prefix, remainder = model_name.split("/", 1) if not prefix.strip() or not remainder.strip(): return model_name - normalized_target = _normalize_provider_alias(target_provider) if normalized_target == "custom": - if prefix.strip().lower() == "custom": - return remainder.strip() - return model_name - + return remainder.strip() if prefix.strip().lower() == "custom" else model_name normalized_prefix = _normalize_provider_alias(prefix) - if normalized_prefix and normalized_prefix == normalized_target: - return remainder.strip() - return model_name + return remainder.strip() if normalized_prefix and normalized_prefix == normalized_target else model_name def detect_vendor(model_name: str) -> Optional[str]: - """Detect the vendor slug from a bare model name.""" + """Vendor slug from a bare model name: an existing ``vendor/`` prefix, the first hyphen token, + or a ``_VENDOR_PREFIXES`` key the name starts with (``qwen3.5-plus`` → ``qwen``).""" name = model_name.strip() if not name: return None - - # If there's already a vendor/ prefix, extract it if "/" in name: return name.split("/", 1)[0].lower() or None - name_lower = name.lower() - - # Try first hyphen-delimited token (exact match) first_token = name_lower.split("-")[0] if first_token in _VENDOR_PREFIXES: return _VENDOR_PREFIXES[first_token] - - # Handle patterns where the first token includes version digits, - # e.g. "qwen3.5-plus" -> first token "qwen3.5", but prefix is "qwen" - for prefix, vendor in _VENDOR_PREFIXES.items(): - if name_lower.startswith(prefix): - return vendor - - return None + return next((vendor for prefix, vendor in _VENDOR_PREFIXES.items() if name_lower.startswith(prefix)), None) def _prepend_vendor(model_name: str) -> str: - """Prepend the detected ``vendor/`` prefix if missing. - - For aggregators that require ``vendor/model``. Names already containing ``/`` or with no - detectable vendor are returned unchanged (the aggregator may still accept them). - """ + """Prepend the detected ``vendor/`` for aggregators; names with ``/`` or no detectable vendor + pass through (the aggregator may still accept them).""" if "/" in model_name: return model_name - vendor = detect_vendor(model_name) - if vendor: - return f"{vendor}/{model_name}" - return model_name + return f"{vendor}/{model_name}" if vendor else model_name def _repair_prefix_from_catalogue(model_name: str, provider: str) -> str: - """Restore a dropped ``vendor/`` prefix using the provider's catalogue. - - Unlike :func:`_prepend_vendor`, this never guesses from the model's name shape — it only repairs - a bare id that matches **exactly one** curated entry for this provider modulo the prefix. - """ + """Restore a dropped ``vendor/`` prefix only when the bare id matches **exactly one** curated + entry for this provider modulo the prefix — a lookup, never a guess from name shape.""" if "/" in model_name: return model_name try: from hermes_cli.models import _PROVIDER_MODELS except Exception: return model_name - - catalogue = _PROVIDER_MODELS.get(provider) or [] - # Compare against the catalogue's own suffix, tag included: a bare - # ``…:free`` id must resolve to the ``:free`` entry, not its paid sibling. + # Compare against the catalogue's own suffix, tag included: a bare ``…:free`` id must resolve to + # the ``:free`` entry, not its paid sibling. needle = model_name.strip().lower() - matches = { - entry - for entry in catalogue - if "/" in entry and entry.split("/", 1)[1].strip().lower() == needle - } - if len(matches) == 1: - return matches.pop() - return model_name + catalogue = _PROVIDER_MODELS.get(provider) or [] + matches = {e for e in catalogue if "/" in e and e.split("/", 1)[1].strip().lower() == needle} + return matches.pop() if len(matches) == 1 else model_name def suggest_prefixed_model_id(provider: str, model_name: str) -> Optional[str]: - """Return the prefixed catalogue id for a bare *model_name*, if unambiguous. - - Diagnostic counterpart to :func:`_repair_prefix_from_catalogue`, used to explain a provider's - content-free 404 when the configured id lost its ``vendor/`` prefix. Returns ``None`` when the - name already has a prefix, the provider has no catalogue, or nothing matches — so callers stay - silent rather than guess. - """ + """Prefixed catalogue id for a bare *model_name* if unambiguous, else ``None`` — the diagnostic + counterpart to :func:`_repair_prefix_from_catalogue` for explaining a content-free 404.""" name = (model_name or "").strip() if not name or "/" in name: return None @@ -312,59 +197,35 @@ def suggest_prefixed_model_id(provider: str, model_name: str) -> Optional[str]: return repaired if repaired != name else None -# --------------------------------------------------------------------------- -# Main normalisation entry point -# --------------------------------------------------------------------------- - def normalize_model_for_provider(model_input: str, target_provider: str) -> str: - """Translate a model name into the format the target provider's API expects. - - Primary entry point for model-name normalisation. Accepts bare, vendor-prefixed or native ids; - ``target_provider`` should already be normalised via ``normalize_provider()``. Never raises — - always returns a best-effort string. - """ + """Translate a model name (bare, vendor-prefixed or native) into what the target provider's API + expects. ``target_provider`` should already be canonical. Never raises.""" name = (model_input or "").strip() if not name: return name - provider = _normalize_provider_alias(target_provider) - # --- Aggregators: need vendor/model format --- if provider in _AGGREGATOR_PROVIDERS: return _prepend_vendor(name) - # --- OpenCode Zen / OpenCode Go: flat-namespace resellers. - # Their /v1/models API returns bare IDs only (no vendor prefix), and - # the inference endpoint rejects vendor-prefixed names with HTTP 401 - # "Model not supported". Strip ANY leading ``vendor/`` so config - # entries like ``minimax/minimax-m2.7`` or ``deepseek/deepseek-v4-flash`` - # — commonly copied from aggregator slugs into fallback_model lists — - # resolve to bare ``minimax-m2.7`` / ``deepseek-v4-flash`` the API - # actually serves. See PR reviewing opencode-go fallback 401s. --- + # OpenCode Zen / Go are flat-namespace resellers: /v1/models returns bare IDs and inference 401s + # vendor-prefixed names, so strip ANY leading ``vendor/`` (commonly copied from aggregator slugs). from hermes_cli.models import opencode_provider_family _oc_family = opencode_provider_family(provider) if _oc_family is not None: if "/" in name: - _, bare_after_slash = name.split("/", 1) - name = bare_after_slash.strip() or name + name = name.split("/", 1)[1].strip() or name if _oc_family == "opencode-zen" and name.lower().startswith("claude-"): return _dots_to_hyphens(name) return name - # --- Anthropic: strip matching provider prefix, dots -> hyphens --- if provider in _DOT_TO_HYPHEN_PROVIDERS: bare = _strip_matching_provider_prefix(name, provider) - if "/" in bare: - return bare - return _dots_to_hyphens(bare) + return bare if "/" in bare else _dots_to_hyphens(bare) - # --- Copilot / Copilot ACP: delegate to the Copilot-specific - # normalizer. It knows about the alias table (vendor-prefix - # stripping for Anthropic/OpenAI, dash-to-dot repair for Claude) - # and live-catalog lookups. Without this, vendor-prefixed or - # dash-notation Claude IDs survive to the Copilot API and hit - # HTTP 400 "model_not_supported". See issue #6879. + # Copilot's own normalizer knows the alias table (vendor stripping, dash-to-dot repair for Claude) + # and live-catalog lookups; without it dash-notation Claude ids hit HTTP 400 model_not_supported. if provider in {"copilot", "copilot-acp"}: try: from hermes_cli.models import normalize_copilot_model_id @@ -373,50 +234,25 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: if normalized: return normalized except Exception: - # Fall through to the generic strip-vendor behaviour below - # if the Copilot-specific path is unavailable for any reason. - pass + pass # fall through to the generic strip-vendor behaviour - # --- Copilot / Copilot ACP / openai-codex fallback: - # strip matching provider prefix, keep dots --- if provider in _STRIP_VENDOR_ONLY_PROVIDERS: stripped = _strip_matching_provider_prefix(name, provider) if stripped == name and name.startswith("openai/"): - # openai-codex maps openai/gpt-5.4 -> gpt-5.4 - return name.split("/", 1)[1] + return name.split("/", 1)[1] # openai-codex maps openai/gpt-5.4 -> gpt-5.4 return stripped - # --- DeepSeek: map to one of two canonical names --- if provider == "deepseek": bare = _strip_matching_provider_prefix(name, provider) - if "/" in bare: - return bare - return _normalize_for_deepseek(bare) + return bare if "/" in bare else _normalize_for_deepseek(bare) - # --- Direct providers: repair matching provider prefixes only --- if provider in _MATCHING_PREFIX_STRIP_PROVIDERS: result = _strip_matching_provider_prefix(name, provider) - # Some providers require lowercase model IDs (e.g. Xiaomi's API - # rejects "MiMo-V2.5-Pro" but accepts "mimo-v2.5-pro"). - if provider in _LOWERCASE_MODEL_PROVIDERS: - result = result.lower() - return result + return result.lower() if provider in _LOWERCASE_MODEL_PROVIDERS else result - # --- Catalogue-backed prefix repair: restore a dropped ``vendor/`` on a - # bare id that matches exactly one curated entry. Unknown names (a - # local NIM container, a proxied model) pass through untouched. --- + # Unknown names (a local NIM container, a proxied model) pass through untouched. if provider in _CATALOGUE_PREFIX_REPAIR_PROVIDERS: return _repair_prefix_from_catalogue(name, provider) - # --- Authoritative native providers: preserve user-facing slugs as-is --- - if provider in _AUTHORITATIVE_NATIVE_PROVIDERS: - return name - - # --- Custom & all others: pass through as-is --- + # Authoritative native providers, custom and all others: pass through as-is. return name - - -# --------------------------------------------------------------------------- -# Batch / convenience helpers -# --------------------------------------------------------------------------- - diff --git a/hermes_cli/model_search.py b/hermes_cli/model_search.py index 74bb8daa27..0c64835bc3 100644 --- a/hermes_cli/model_search.py +++ b/hermes_cli/model_search.py @@ -7,18 +7,12 @@ _MODEL_SEARCH_ALIASES: dict[str, tuple[str, ...]] = { "k3": ("kimi-k3", "kimi"), # OpenCode Zen serves the "Ox Alpha" stealth model under an opaque # preview slug; let users find it by its public codename. - "x-preview-f-free": ("ox-alpha", "ox"), -} + "x-preview-f-free": ("ox-alpha", "ox")} -# Lowercased wire id → canonical public slug it aliases. Used by picker -# dedup so a live bare id and its curated public slug (``k3`` / ``kimi-k3``) -# don't render as two rows for the same model. Derived from the FIRST alias -# entry, which by convention is the full public slug. +# Lowercased wire id → canonical public slug (the FIRST alias by convention), so picker dedup doesn't +# render a live bare id and its curated slug (``k3`` / ``kimi-k3``) as two rows. _MODEL_ALIAS_CANONICAL: dict[str, str] = { - wire_id: aliases[0].lower() - for wire_id, aliases in _MODEL_SEARCH_ALIASES.items() - if aliases -} + wire_id: aliases[0].lower() for wire_id, aliases in _MODEL_SEARCH_ALIASES.items() if aliases} def model_alias_canonical(model: str) -> str: @@ -28,14 +22,9 @@ def model_alias_canonical(model: str) -> str: def model_search_text(model: str) -> str: - """Return the haystack used for fuzzy/substring model search. - - Never changes the wire id passed to the provider. - """ + """Haystack for fuzzy/substring model search; never changes the wire id sent to the provider.""" mid = (model or "").strip() if not mid: return model or "" aliases = _MODEL_SEARCH_ALIASES.get(mid.lower()) - if not aliases: - return mid - return f"{mid} {' '.join(aliases)}" + return f"{mid} {' '.join(aliases)}" if aliases else mid diff --git a/hermes_cli/model_selection_guards.py b/hermes_cli/model_selection_guards.py index 8ce700f096..12be38c3c3 100644 --- a/hermes_cli/model_selection_guards.py +++ b/hermes_cli/model_selection_guards.py @@ -24,44 +24,28 @@ class SelectionWarning: def _wrap(kind: str, title: str, warning, model_name: str, provider: Optional[str]): - """Lift a raw guard payload into a :class:`SelectionWarning` (None passes through). - - Duck-typed access: tests (and future guard payloads) may supply objects carrying only - ``.message``. - """ + """Lift a raw guard payload into a :class:`SelectionWarning` (None passes through). Duck-typed: + payloads may carry only ``.message``.""" if warning is None: return None return SelectionWarning( - kind=kind, - title=title, - model=getattr(warning, "model", model_name), - provider=getattr(warning, "provider", provider or ""), - message=warning.message, - ) + kind=kind, title=title, model=getattr(warning, "model", model_name), + provider=getattr(warning, "provider", provider or ""), message=warning.message) def _cost_guard( - model_name: str, - provider: Optional[str], - base_url: Optional[str], - api_key: Optional[str], - model_info: Optional[ModelInfo], -) -> Optional[SelectionWarning]: + model_name: str, provider: Optional[str], base_url: Optional[str], api_key: Optional[str], + model_info: Optional[ModelInfo]) -> Optional[SelectionWarning]: from hermes_cli.model_cost_guard import expensive_model_warning warning = expensive_model_warning( - model_name, provider=provider, base_url=base_url, api_key=api_key, model_info=model_info - ) + model_name, provider=provider, base_url=base_url, api_key=api_key, model_info=model_info) return _wrap("cost", "Expensive Model Warning", warning, model_name, provider) def _data_policy_guard( - model_name: str, - provider: Optional[str], - base_url: Optional[str], - api_key: Optional[str], - model_info: Optional[ModelInfo], -) -> Optional[SelectionWarning]: + model_name: str, provider: Optional[str], base_url: Optional[str], api_key: Optional[str], + model_info: Optional[ModelInfo]) -> Optional[SelectionWarning]: from hermes_cli.model_data_policy_guard import data_training_warning warning = data_training_warning(model_name, provider=provider, base_url=base_url) @@ -74,22 +58,11 @@ _GUARDS = (_cost_guard, _data_policy_guard) def selection_warnings( - model_name: str, - *, - provider: Optional[str] = None, - base_url: Optional[str] = None, - api_key: Optional[str] = None, - model_info: Optional[ModelInfo] = None, - include_kinds: Optional[Iterable[str]] = None, -) -> List[SelectionWarning]: - """Run every registered selection guard and return the warnings that fired. - - Returns an empty list in the common case (no guard fired). Callers should run this after model - resolution so aliases / provider-specific ids have settled, then surface the messages as a - confirm step. ``include_kinds`` optionally restricts which guard kinds run (e.g. - - A misbehaving guard must never break model selection: individual guard exceptions are swallowed. - """ + model_name: str, *, provider: Optional[str] = None, base_url: Optional[str] = None, + api_key: Optional[str] = None, model_info: Optional[ModelInfo] = None, + include_kinds: Optional[Iterable[str]] = None) -> List[SelectionWarning]: + """Warnings from every registered guard (empty in the common case). ``include_kinds`` restricts + which kinds are returned. Guard exceptions are swallowed — never break model selection.""" wanted = set(include_kinds) if include_kinds is not None else None results: List[SelectionWarning] = [] for guard in _GUARDS: @@ -103,39 +76,22 @@ def selection_warnings( def combined_message(warnings: List[SelectionWarning]) -> str: - """Join multiple warnings into one confirm-prompt body. - - Used by surfaces with a single confirm dialog when more than one guard fires (rare) — one - prompt showing both blocks beats two sequential prompts. - """ + """One confirm-prompt body for several warnings (one prompt beats two sequential ones).""" return "\n\n".join(w.message for w in warnings) def combined_selection_warning( - model_name: str, - *, - provider: Optional[str] = None, - base_url: Optional[str] = None, - api_key: Optional[str] = None, - model_info: Optional[ModelInfo] = None, + model_name: str, *, provider: Optional[str] = None, base_url: Optional[str] = None, + api_key: Optional[str] = None, model_info: Optional[ModelInfo] = None, ) -> Optional[SelectionWarning]: - """Drop-in replacement for ``expensive_model_warning`` call sites. - - Returns ``None`` when no guard fired, the single :class:`SelectionWarning` when one fired, - or a merged ``kind="multiple"`` warning stacking every message — so surfaces rendering one - confirm dialog from ``warning.message`` can switch without reshaping control flow. - """ + """Drop-in for ``expensive_model_warning`` call sites: ``None``, the single warning, or a merged + ``kind="multiple"`` warning stacking every message.""" warnings = selection_warnings( - model_name, provider=provider, base_url=base_url, api_key=api_key, model_info=model_info - ) + model_name, provider=provider, base_url=base_url, api_key=api_key, model_info=model_info) if not warnings: return None if len(warnings) == 1: return warnings[0] return SelectionWarning( - kind="multiple", - title="Model Selection Warning", - model=warnings[0].model, - provider=warnings[0].provider, - message=combined_message(warnings), - ) + kind="multiple", title="Model Selection Warning", model=warnings[0].model, + provider=warnings[0].provider, message=combined_message(warnings)) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index e1971f08c4..189b25f390 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1,13 +1,9 @@ """Provider/model catalogs: discovery, caching, and identity helpers. -This is the origin module; cohesive clusters live in siblings and are re-imported here so +Origin module; cohesive clusters live in siblings and are re-imported here so ``hermes_cli.models.`` stays the stable import/monkeypatch surface: - -- ``models_catalog_static`` — curated tables, canonical provider registry, aliases (data only) -- ``models_reasoning_caps`` — per-model reasoning capabilities (OpenRouter / Nous catalogs) -- ``models_local`` — Ollama / LM Studio / Ollama Cloud probing -- ``models_pricing`` — live pricing + Nous policy filtering -- ``models_validate`` — ``validate_requested_model`` (the ``/model`` verdict ladder) +``models_catalog_static`` (curated tables, provider registry, aliases), ``models_reasoning_caps``, +``models_local`` (Ollama / LM Studio), ``models_pricing``, ``models_validate``. """ from __future__ import annotations @@ -51,61 +47,37 @@ from hermes_cli.models_catalog_static import ( # noqa: F401 (re-exported; test _PROVIDER_MODELS, _PROVIDER_RETIRED_ALIASES, _SILENT_DEFAULT_PROVIDERS, - _SLUG_TO_GROUP, - _XAI_CURATED_EXTRAS, - _XAI_STATIC_FALLBACK, - _XAI_TOP_MODEL, - _codex_curated_models, - _xai_curated_models, _xai_finalize_catalog, - _xai_merge_curated_extras, - _xai_promote_top, group_providers, - provider_group_for_slug, -) + provider_group_for_slug) from hermes_cli.models_reasoning_caps import ( # noqa: F401 (re-exported; tests patch hermes_cli.models.) _OPENROUTER_CATALOG_URL, - _REASONING_CAPS_DISK_TTL_SECONDS, - _fetch_reasoning_caps_catalog, - _hydrate_reasoning_caps_from_disk, - _load_reasoning_caps_disk, _read_reasoning_caps_disk, _reasoning_caps_disk_path, _save_reasoning_caps_disk, _seed_reasoning_caps, - _warm_reasoning_caps_async, nous_catalog_url, nous_model_reasoning_capabilities, openrouter_model_reasoning_capabilities, parse_openrouter_reasoning_capabilities, warm_nous_reasoning_caps_async, - warm_openrouter_reasoning_caps_async, -) + warm_openrouter_reasoning_caps_async) from hermes_cli.models_local import ( # noqa: F401 (re-exported; tests patch hermes_cli.models.) LMStudioLoadResult, - _OLLAMA_CLOUD_CACHE_TTL, - _OLLAMA_LOCAL_CACHE_MAX_ENTRIES, _OLLAMA_LOCAL_MODELS_CACHE, _OLLAMA_LOCAL_MODELS_CACHE_TTL, _OLLAMA_LOCAL_PROBE_FAILURE_CACHE, - _OLLAMA_LOCAL_PROBE_FAILURE_TTL, _OLLAMA_LOCAL_PROBE_REACHABLE, - _evict_related_ollama_cache_entries, _get_ollama_base_url, _get_ollama_native_headers, _get_ollama_request_headers, _lmstudio_fetch_raw_models, - _lmstudio_request_headers, _lmstudio_server_root, - _load_ollama_cloud_cache, _normalize_openai_base_url, - _ollama_cloud_cache_path, _ollama_local_catalog, _ollama_probe_cache_key, - _remember_ollama_cache, _root_for_ollama_native_api, _same_ollama_native_root, - _save_ollama_cloud_cache, _strip_ollama_cloud_suffix, ensure_lmstudio_model_loaded, fetch_lmstudio_models, @@ -115,33 +87,27 @@ from hermes_cli.models_local import ( # noqa: F401 (re-exported; tests patch h ollama_model_supports_thinking, probe_lmstudio_models, probe_ollama_local_models, - should_use_ollama_native_catalog, -) + should_use_ollama_native_catalog) from hermes_cli.models_pricing import ( # noqa: F401 (re-exported; tests patch hermes_cli.models.) - _DEFAULT_NOUS_INFERENCE_BASE, _FAILED_CATALOG_TTL_SECONDS, _NOUS_CATALOG_TTL_SECONDS, _NOUS_POLICY_APPEND_MAX, - _PRICING_AUTH_KEY_PREFIX, _cache_catalog, _cached_catalog, _fetch_deepinfra_pricing, _fetch_novita_pricing, - _fireworks_pricing_from_models_dev, _format_price_per_mtok, _pricing_auth_fingerprint, _pricing_cache, _pricing_cache_retry_after, _resolve_nous_pricing_credentials, - _resolve_openrouter_api_key, compute_sale_discount, fetch_ai_gateway_pricing, fetch_models_with_pricing, get_pricing_for_provider, nous_policy_allowed_ids, peek_cached_pricing, - restrict_to_nous_policy, -) + restrict_to_nous_policy) from hermes_cli.models_validate import validate_requested_model # noqa: F401 (re-exported) logger = logging.getLogger(__name__) @@ -161,6 +127,16 @@ def _urlopen_model_catalog_request(req: urllib.request.Request, *, timeout: floa return open_credentialed_url(req, timeout=timeout, ssl_context=ssl_context) +def _get_json( + url: str, *, timeout: float, headers: Optional[dict[str, str]] = None, opener=None, **open_kwargs: Any +) -> Any: + """GET ``url`` and parse the JSON body. ``opener`` defaults to the catalog opener (resolved at + call time so monkeypatching ``_urlopen_model_catalog_request`` still applies). Raises on failure.""" + req = urllib.request.Request(url, headers=headers or {}) + with (opener or _urlopen_model_catalog_request)(req, timeout=timeout, **open_kwargs) as resp: + return json.loads(resp.read().decode()) + + def _read_json_cache(path: Path, *, errors=Exception) -> Optional[dict]: """Load a JSON-object cache file; None when missing, unreadable, or not a dict.""" try: @@ -180,14 +156,20 @@ def _write_json_cache(path: Path, data: Any, **dump_kwargs: Any) -> None: atomic_json_write(path, data, **dump_kwargs) -def _custom_provider_ssl_context(base_url: str): - """Build an ``ssl.SSLContext`` from a custom provider's TLS settings. +def _merge_unique(primary: list[str], secondary: list[str], key=lambda m: str(m).lower()) -> list[str]: + """``primary`` verbatim, then ``secondary`` entries whose ``key`` is new (deduped as it goes).""" + merged, seen = list(primary), {key(m) for m in primary} + for m in secondary: + k = key(m) + if k not in seen: + seen.add(k) + merged.append(m) + return merged - Mirrors the httpx/requests TLS resolution so the urllib ``/models`` probe honors a - provider's ``ssl_ca_cert`` / ``ssl_verify`` instead of the process-wide - ``SSL_CERT_FILE``/certifi bundle. Returns None when no per-provider override applies, so the - caller keeps urllib's default policy. - """ + +def _custom_provider_ssl_context(base_url: str): + """``ssl.SSLContext`` honoring a custom provider's ``ssl_ca_cert`` / ``ssl_verify`` (mirrors the + httpx TLS resolution), or None so the urllib ``/models`` probe keeps the default policy.""" if not base_url: return None try: @@ -222,105 +204,76 @@ _ai_gateway_catalog_cache: list[tuple[str, str]] | None = None # --------------------------------------------------------------------------- -def _is_model_free(model_id: str, pricing: dict[str, dict[str, str]]) -> bool: - """Return True if *model_id* has zero-cost prompt AND completion pricing.""" - p = pricing.get(model_id) - if not p: +def _zero_priced(pricing: Any, keys: tuple[str, str], default: str) -> bool: + """True when both pricing fields parse to 0 (missing fields read as ``default``).""" + if not isinstance(pricing, dict): return False try: - return float(p.get("prompt", "1")) == 0 and float(p.get("completion", "1")) == 0 + return all(float(pricing.get(k, default)) == 0 for k in keys) except (TypeError, ValueError): return False -def partition_nous_models_by_tier( - model_ids: list[str], - pricing: dict[str, dict[str, str]], - free_tier: bool, -) -> tuple[list[str], list[str]]: - """Split Nous models into (selectable, unavailable) based on user tier. +def _is_model_free(model_id: str, pricing: dict[str, dict[str, str]]) -> bool: + """Return True if *model_id* has zero-cost prompt AND completion pricing.""" + return bool(pricing.get(model_id)) and _zero_priced(pricing.get(model_id), ("prompt", "completion"), "1") - For free-tier users: only free models are selectable; paid models are returned as unavailable - (shown grayed out in the menu). - """ + +def partition_nous_models_by_tier( + model_ids: list[str], pricing: dict[str, dict[str, str]], free_tier: bool +) -> tuple[list[str], list[str]]: + """Split Nous models into (selectable, unavailable): free-tier users may only select free models + (paid ones are returned as unavailable, shown grayed out).""" if not free_tier or not pricing: # no pricing → can't determine, show everything return (model_ids, []) - - selectable: list[str] = [] - unavailable: list[str] = [] - for mid in model_ids: - if _is_model_free(mid, pricing): - selectable.append(mid) - else: - unavailable.append(mid) - return (selectable, unavailable) + selectable = [mid for mid in model_ids if _is_model_free(mid, pricing)] + return (selectable, [mid for mid in model_ids if mid not in selectable]) def _union_with_portal_recommendations( - tier_key: str, - curated_ids: list[str], - pricing: dict[str, dict[str, str]], - portal_base_url: str, - *, - force_refresh: bool, - synthesize_free_pricing: bool, + tier_key: str, curated_ids: list[str], pricing: dict[str, dict[str, str]], portal_base_url: str, + *, force_refresh: bool, synthesize_free_pricing: bool, ) -> tuple[list[str], dict[str, dict[str, str]]]: """Append the Portal's ```` recommendations missing from ``curated_ids``. - In-repo curated models show first and Portal-only picks follow. Failures (network, parse, - missing field) are silent and degrade to returning the inputs unchanged — never block the - picker on a Portal-side hiccup. + Curated models show first, Portal-only picks follow. Failures (network, parse, missing field) + silently return the inputs unchanged — never block the picker on a Portal-side hiccup. """ try: payload = fetch_nous_recommended_models(portal_base_url, force_refresh=force_refresh) except Exception: - return (list(curated_ids), dict(pricing)) - + payload = None block = payload.get(tier_key) if isinstance(payload, dict) else None - if not isinstance(block, list) or not block: - return (list(curated_ids), dict(pricing)) - portal_ids = [name for entry in block if (name := _extract_model_name(entry))] + entries = block if isinstance(block, list) else [] + portal_ids = [name for entry in entries if (name := _extract_model_name(entry))] if not portal_ids: return (list(curated_ids), dict(pricing)) augmented_pricing = dict(pricing) if synthesize_free_pricing: for mid in portal_ids: - if mid not in augmented_pricing: - augmented_pricing[mid] = {"prompt": "0", "completion": "0"} - + augmented_pricing.setdefault(mid, {"prompt": "0", "completion": "0"}) seen = set(curated_ids) return (list(curated_ids) + [mid for mid in portal_ids if mid not in seen], augmented_pricing) def union_with_portal_free_recommendations( - curated_ids: list[str], - pricing: dict[str, dict[str, str]], - portal_base_url: str = "", - *, - force_refresh: bool = False, -) -> tuple[list[str], dict[str, dict[str, str]]]: - """Augment curated list + pricing with the Portal's ``freeRecommendedModels`` (Portal-only free - picks get a synthetic $0 pricing entry so tier partitioning sees them as free).""" + curated_ids: list[str], pricing: dict[str, dict[str, str]], portal_base_url: str = "", *, + force_refresh: bool = False) -> tuple[list[str], dict[str, dict[str, str]]]: + """Curated list + pricing plus the Portal's ``freeRecommendedModels``; Portal-only free picks get a + synthetic $0 pricing entry so tier partitioning sees them as free.""" return _union_with_portal_recommendations( "freeRecommendedModels", curated_ids, pricing, portal_base_url, - force_refresh=force_refresh, synthesize_free_pricing=True, - ) + force_refresh=force_refresh, synthesize_free_pricing=True) def union_with_portal_paid_recommendations( - curated_ids: list[str], - pricing: dict[str, dict[str, str]], - portal_base_url: str = "", - *, - force_refresh: bool = False, -) -> tuple[list[str], dict[str, dict[str, str]]]: - """Augment curated list with the Portal's ``paidRecommendedModels``. ``pricing`` is left - untouched — we deliberately do NOT synthesize pricing entries for paid models.""" + curated_ids: list[str], pricing: dict[str, dict[str, str]], portal_base_url: str = "", *, + force_refresh: bool = False) -> tuple[list[str], dict[str, dict[str, str]]]: + """Curated list plus the Portal's ``paidRecommendedModels``; ``pricing`` is deliberately left untouched.""" return _union_with_portal_recommendations( "paidRecommendedModels", curated_ids, pricing, portal_base_url, - force_refresh=force_refresh, synthesize_free_pricing=False, - ) + force_refresh=force_refresh, synthesize_free_pricing=False) # Free-tier detection cache — short so an account upgrade shows within minutes. @@ -329,38 +282,26 @@ _free_tier_cache: tuple[bool, float] | None = None # (result, timestamp) def check_nous_free_tier(*, force_fresh: bool = False) -> bool: - """Check if the current Nous Portal user is on a free (unpaid) tier. - - Results are cached for ``_FREE_TIER_CACHE_TTL`` seconds to avoid hitting the Portal API on every - call. The cache is short-lived so that an account upgrade is reflected within a few minutes. - - Returns True only when entitlement is known to be free. Unknown/error states return False so - this compatibility wrapper does not block users. - """ + """True only when the Nous Portal user is KNOWN to be free-tier (unknown/error → False so this + never blocks users). Cached ``_FREE_TIER_CACHE_TTL`` seconds so an upgrade shows within minutes.""" global _free_tier_cache now = time.monotonic() - if not force_fresh and _free_tier_cache is not None: - cached_result, cached_at = _free_tier_cache - if now - cached_at < _FREE_TIER_CACHE_TTL: - return cached_result - + if not force_fresh and _free_tier_cache is not None and now - _free_tier_cache[1] < _FREE_TIER_CACHE_TTL: + return _free_tier_cache[0] try: from hermes_cli.nous_account import get_nous_portal_account_info - account_info = get_nous_portal_account_info(force_fresh=force_fresh) - result = account_info.is_free_tier - _free_tier_cache = (result, now) - return result + result = get_nous_portal_account_info(force_fresh=force_fresh).is_free_tier except Exception: - _free_tier_cache = (False, now) - return False # default to paid on error — don't block users + result = False # default to paid on error — don't block users + _free_tier_cache = (result, now) + return result # --------------------------------------------------------------------------- -# Nous Portal recommended models — the Portal's curated paid/free suggestions plus dedicated -# compaction (aux) and vision picks, TTL-cached per process. Response fields we read: -# {paid,free}RecommendedModels: [{modelName, ...}], -# {paid,free}Recommended{Compaction,Vision}Model: {modelName, ...} | null +# Nous Portal recommended models — curated paid/free suggestions plus dedicated compaction (aux) +# and vision picks, TTL-cached per process. Fields read: {paid,free}RecommendedModels: +# [{modelName}], {paid,free}Recommended{Compaction,Vision}Model: {modelName} | null # --------------------------------------------------------------------------- NOUS_RECOMMENDED_MODELS_PATH = "/api/nous/recommended-models" @@ -370,31 +311,22 @@ _nous_recommended_cache: dict[str, tuple[dict[str, Any], float]] = {} def _nous_recommended_disk_path() -> "Path": - """Disk path for the persisted recommended-models cache.""" from hermes_constants import get_hermes_home return get_hermes_home() / "cache" / "nous_recommended_cache.json" def _read_nous_recommended_disk(base: str) -> dict[str, Any] | None: - """Return the last-known-good payload for ``base`` from disk, or None. - - The disk file is a JSON object keyed by portal base URL so staging and prod don't collide: - ``{"": {"data": {...}, "ts": }}``. - """ + """Last-known-good payload for ``base`` from the per-base disk map + ``{"": {"data": {...}, "ts": }}`` (staging and prod don't collide), or None.""" blob = _read_json_cache(_nous_recommended_disk_path(), errors=(OSError, json.JSONDecodeError)) entry = (blob or {}).get(base) - if not isinstance(entry, dict): - return None - data = entry.get("data") + data = entry.get("data") if isinstance(entry, dict) else None return data if isinstance(data, dict) and data else None def _write_nous_recommended_disk(base: str, data: dict[str, Any]) -> None: - """Persist ``data`` as the last-known-good payload for ``base``. - - Merges into any existing per-base map, then writes atomically. Failures are non-fatal (logged at - debug) — the in-process cache still works. - """ + """Merge ``data`` into the per-base disk map atomically; failures are debug-logged (the in-process + cache still works).""" if not data: return path = _nous_recommended_disk_path() @@ -407,43 +339,30 @@ def _write_nous_recommended_disk(base: str, data: dict[str, Any]) -> None: def fetch_nous_recommended_models( - portal_base_url: str = "", - timeout: float = 5.0, - *, - force_refresh: bool = False, + portal_base_url: str = "", timeout: float = 5.0, *, force_refresh: bool = False ) -> dict[str, Any]: - """Fetch the Nous Portal's curated recommended-models payload. + """Fetch the Portal's public ``/api/nous/recommended-models`` payload (no auth). - Hits ``/api/nous/recommended-models``. The endpoint is public — no auth is required. - Results are cached per portal URL for ``_NOUS_RECOMMENDED_CACHE_TTL`` seconds in process; pass - ``force_refresh=True`` to bypass the in-process cache. - - A successful live fetch is also persisted to a per-base disk cache - (``$HERMES_HOME/cache/nous_recommended_cache.json``) as last-known-good. Self-heals on the next - successful fetch. + Cached per portal URL for ``_NOUS_RECOMMENDED_CACHE_TTL`` seconds in process (``force_refresh`` + bypasses); a successful fetch is also persisted as last-known-good on disk, which serves a live + failure so a transient Portal hiccup doesn't drop the recommendations. """ base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/") now = time.monotonic() cached = _nous_recommended_cache.get(base) - if not force_refresh and cached is not None: - payload, cached_at = cached - if now - cached_at < _NOUS_RECOMMENDED_CACHE_TTL: - return payload - + if not force_refresh and cached is not None and now - cached[1] < _NOUS_RECOMMENDED_CACHE_TTL: + return cached[0] try: - req = urllib.request.Request(f"{base}{NOUS_RECOMMENDED_MODELS_PATH}", headers={"Accept": "application/json"}) - with _urlopen_model_catalog_request(req, timeout=timeout) as resp: - data = json.loads(resp.read().decode()) + data = _get_json( + f"{base}{NOUS_RECOMMENDED_MODELS_PATH}", timeout=timeout, headers={"Accept": "application/json"} + ) if not isinstance(data, dict): data = {} except Exception: data = {} - if data: - _write_nous_recommended_disk(base, data) # live succeeded — refresh both cache layers + _write_nous_recommended_disk(base, data) else: - # Live failed: last-known-good disk copy, so a transient Portal hiccup doesn't drop the - # recommendations entirely. data = _read_nous_recommended_disk(base) or data _nous_recommended_cache[base] = (data, now) return data @@ -463,57 +382,32 @@ def _resolve_nous_portal_url() -> str: def _extract_model_name(entry: Any) -> Optional[str]: """Pull the ``modelName`` field from a recommended-model entry, else None.""" - if not isinstance(entry, dict): - return None - model_name = entry.get("modelName") - if isinstance(model_name, str) and model_name.strip(): - return model_name.strip() - return None + model_name = entry.get("modelName") if isinstance(entry, dict) else None + return model_name.strip() if isinstance(model_name, str) and model_name.strip() else None def get_nous_recommended_aux_model( - *, - vision: bool = False, - free_tier: Optional[bool] = None, - portal_base_url: str = "", - force_refresh: bool = False, -) -> Optional[str]: - """Return the Portal's recommended model name for an auxiliary task. - - For paid-tier users we prefer the paid recommendation but gracefully fall back to the free - recommendation if the Portal returned ``null`` for the paid field (common during the staged - rollout of new paid models). - """ + *, vision: bool = False, free_tier: Optional[bool] = None, portal_base_url: str = "", + force_refresh: bool = False) -> Optional[str]: + """The Portal's recommended model for an auxiliary task: free tier → free pick only; paid tier → + paid pick, falling back to the free one when the Portal returned ``null`` (staged rollouts).""" base = portal_base_url or _resolve_nous_portal_url() payload = fetch_nous_recommended_models(base, force_refresh=force_refresh) if not payload: return None - if free_tier is None: try: free_tier = check_nous_free_tier() except Exception: - # On detection error assume paid — paid users see both fields anyway, so this is the - # safe default that maximises model quality. - free_tier = False - + free_tier = False # assume paid on detection error — paid users see both fields anyway kind = "Vision" if vision else "Compaction" - paid_key, free_key = f"paidRecommended{kind}Model", f"freeRecommended{kind}Model" - # free tier → free only; paid tier → paid, then free (if the paid field is null) - for key in ([free_key] if free_tier else [paid_key, free_key]): - name = _extract_model_name(payload.get(key)) - if name: - return name - return None + tiers = ("free",) if free_tier else ("paid", "free") + return next((n for t in tiers if (n := _extract_model_name(payload.get(f"{t}Recommended{kind}Model")))), None) def get_preferred_silent_default_model(provider: str = "openrouter") -> str: - """Return the silent-default model id — catalog label first, constant second. - - Reads the ``"default": true`` label from the cached remote catalog (never hits the network — - safe on hot resolution paths), falling back to :data:`PREFERRED_SILENT_DEFAULT_MODEL` when no - cached manifest exists or the provider block carries no label. - """ + """Silent-default model id: the cached remote catalog's ``"default": true`` label (never hits the + network — safe on hot paths), else :data:`PREFERRED_SILENT_DEFAULT_MODEL`.""" try: from hermes_cli.model_catalog import get_default_model_from_cache labeled = get_default_model_from_cache(provider) @@ -525,66 +419,35 @@ def get_preferred_silent_default_model(provider: str = "openrouter") -> str: def pick_silent_default_model(model_ids: list[str], provider: str = "openrouter") -> str: - """Pick the silent default from an available-models list. - - Returns the catalog-labeled default (see :func:`get_preferred_silent_default_model`) when the - list carries it, else the first entry, else "". Used by every surface that must choose a model - on the user's behalf without an interactive picker (GUI onboarding recommended-default, empty- - model runtime fallback). - """ + """Catalog-labeled default when ``model_ids`` carries it, else the first entry, else "". Used by + every surface that must choose a model without an interactive picker.""" preferred = get_preferred_silent_default_model(provider) - if preferred in model_ids: - return preferred - return model_ids[0] if model_ids else "" + return preferred if preferred in model_ids else (model_ids[0] if model_ids else "") def get_default_model_for_provider(provider: str) -> str: - """Return a cost-safe default model for a provider, or "" if unknown. - - Used as a NON-INTERACTIVE fallback when a provider is configured but no model was ever selected - (e.g. ``hermes auth add openai-codex`` without ``hermes model``, or a profile that sets - ``provider`` with no ``model``). - """ + """Cost-safe default model for a provider, or "" — the NON-INTERACTIVE fallback when a provider + is configured but no model was ever selected.""" models = _PROVIDER_MODELS.get(provider, []) if provider in _SILENT_DEFAULT_PROVIDERS: preferred = get_preferred_silent_default_model(provider) - # Trust the preferred default even when the provider has no static - # catalog (OpenRouter's picker list is fetched live; its curated - # snapshot carries the default). + # Trust the preferred default even without a static catalog (OpenRouter's picker list is + # fetched live; its curated snapshot carries the default). if preferred and (preferred in models or not models): return preferred return models[0] if models else "" def _openrouter_model_is_free(pricing: Any) -> bool: - """Return True when both prompt and completion pricing are zero.""" - if not isinstance(pricing, dict): - return False - try: - return float(pricing.get("prompt", "0")) == 0 and float(pricing.get("completion", "0")) == 0 - except (TypeError, ValueError): - return False + return _zero_priced(pricing, ("prompt", "completion"), "0") def _openrouter_model_supports_tools(item: Any) -> bool: - """Return True when the model's ``supported_parameters`` advertise tool calling. - - hermes-agent is tool-calling-first — every provider path assumes the model can invoke tools. - Models that don't advertise ``tools`` in their ``supported_parameters`` (e.g. image-only or - completion-only models) cannot be driven by the agent loop and would fail at the first tool - call. - - **Permissive when the field is missing.** Some OpenRouter-compatible gateways (Nous Portal, - private mirrors, older catalog snapshots) don't populate ``supported_parameters`` at all. Treat - that as "unknown capability → allow" so the picker doesn't silently empty for those users. - """ - if not isinstance(item, dict): - return True - params = item.get("supported_parameters") - if not isinstance(params, list): - # Field absent / malformed / None — be permissive. - return True - return "tools" in params + """True when ``supported_parameters`` advertises ``tools`` (hermes-agent is tool-calling-first). + Permissive when the field is absent/malformed: some OpenRouter-compatible gateways (Nous Portal, + private mirrors) don't populate it, and the picker must not silently empty for them.""" + params = item.get("supported_parameters") if isinstance(item, dict) else None + return "tools" in params if isinstance(params, list) else True # Reasoning-capability cache slots, one set per catalog (OpenRouter, Nous Portal). The logic @@ -605,17 +468,10 @@ from agent.reasoning_effort import clamp_effort as _clamp_effort def clamp_reasoning_effort_to_supported( - effort: Optional[str], - supported_efforts: Optional[list[str]], -) -> Optional[str]: - """Clamp a requested reasoning effort to a provider's supported levels. - - Thin wrapper over the canonical policy in :func:`agent.reasoning_effort.clamp_effort` (single - implementation for every transport and provider profile): keep a supported level verbatim, - otherwise nearest WEAKER supported level (never silently escalate cost), weakest supported level - when nothing weaker exists, pass through unknown supported-sets and bespoke level names - unchanged. - """ + effort: Optional[str], supported_efforts: Optional[list[str]]) -> Optional[str]: + """Thin wrapper over :func:`agent.reasoning_effort.clamp_effort`: keep a supported level verbatim, + else the nearest WEAKER supported level (never silently escalate cost), else the weakest; unknown + supported-sets and bespoke level names pass through unchanged.""" return _clamp_effort(effort, supported_efforts) @@ -623,39 +479,28 @@ def _fetch_live_catalog_index(url: str, timeout: float, opener) -> Optional[tupl """GET an OpenAI-style ``/models`` listing → ``(raw data array, {id: item})``, or None when the endpoint is unreachable or the payload has no ``data`` list.""" try: - req = urllib.request.Request(url, headers={"Accept": "application/json"}) - with opener(req, timeout=timeout) as resp: - payload = json.loads(resp.read().decode()) + payload = _get_json(url, timeout=timeout, headers={"Accept": "application/json"}, opener=opener) except Exception: return None live_items = payload.get("data", []) if not isinstance(live_items, list): return None - live_by_id: dict[str, dict[str, Any]] = {} - for item in live_items: - if not isinstance(item, dict): - continue - mid = str(item.get("id") or "").strip() - if not mid: - continue - live_by_id[mid] = item + live_by_id = { + mid: item for item in live_items if isinstance(item, dict) and (mid := str(item.get("id") or "").strip()) + } return live_items, live_by_id def fetch_openrouter_models( - timeout: float = 8.0, - *, - force_refresh: bool = False, -) -> list[tuple[str, str]]: + timeout: float = 8.0, *, force_refresh: bool = False) -> list[tuple[str, str]]: """Return the curated OpenRouter picker list, refreshed from the live catalog when possible.""" global _openrouter_catalog_cache if _openrouter_catalog_cache is not None and not force_refresh: return list(_openrouter_catalog_cache) - # Prefer the remotely-hosted catalog manifest; fall back to the in-repo snapshot when the - # manifest is unreachable. Both are curated lists that drive the picker; the OpenRouter live - # /v1/models filter (tool support, free pricing) is applied on top either way. + # Remote catalog manifest first, in-repo snapshot when unreachable; the live /v1/models filter + # (tool support, free pricing) is applied on top either way. try: from hermes_cli.model_catalog import get_curated_openrouter_models remote = get_curated_openrouter_models() @@ -668,8 +513,7 @@ def fetch_openrouter_models( return list(_openrouter_catalog_cache or fallback) live_items, live_by_id = live - # Free warm-up for the reasoning-capability cache: same payload the caps fetch would pull, so - # parse it once here and hot-path callers never need their own HTTP round-trip. + # Free warm-up for the reasoning-capability cache: same payload the caps fetch would pull. global _openrouter_reasoning_caps_cache seeded = _seed_reasoning_caps(_OPENROUTER_CATALOG_URL, live_items) if _openrouter_reasoning_caps_cache is None and seeded is not None: @@ -679,26 +523,19 @@ def fetch_openrouter_models( silent_default = get_preferred_silent_default_model("openrouter") for preferred_id, _ in fallback: live_item = live_by_id.get(preferred_id) - if live_item is None: - continue - # Hide models that don't advertise tool-calling support — hermes-agent requires it and - # selecting one fails at the first tool call. - if not _openrouter_model_supports_tools(live_item): + # Hide models without tool-calling support — selecting one fails at the first tool call. + if live_item is None or not _openrouter_model_supports_tools(live_item): continue if preferred_id == silent_default: - # Keep the silent-default badge through the live refresh so the picker shows which - # model Hermes lands on when none is selected. - desc = "default" + desc = "default" # keep the silent-default badge through the live refresh else: desc = "free" if _openrouter_model_is_free(live_item.get("pricing")) else "" curated.append((preferred_id, desc)) if not curated: return list(_openrouter_catalog_cache or fallback) - - first_id, first_desc = curated[0] - if not first_desc: - curated[0] = (first_id, "recommended") + if not curated[0][1]: + curated[0] = (curated[0][0], "recommended") _openrouter_catalog_cache = curated return list(curated) @@ -709,37 +546,22 @@ def model_ids(*, force_refresh: bool = False) -> list[str]: def get_curated_nous_model_ids() -> list[str]: - """Return the curated Nous Portal model-id list. - - Prefers the remotely-hosted catalog manifest (published under ``website/static/api/model- - catalog.json``); falls back to the in-repo snapshot in ``_PROVIDER_MODELS["nous"]`` when the - manifest is unreachable. Always returns a list (never None). - """ + """Curated Nous Portal model ids: the remote catalog manifest, else the in-repo + ``_PROVIDER_MODELS["nous"]`` snapshot. Always a list.""" try: from hermes_cli.model_catalog import get_curated_nous_models remote = get_curated_nous_models() except Exception: remote = None - if remote: - return list(remote) - return list(_PROVIDER_MODELS.get("nous", [])) + return list(remote or _PROVIDER_MODELS.get("nous", [])) def _ai_gateway_model_is_free(pricing: Any) -> bool: - """Return True if an AI Gateway model has $0 input AND output pricing.""" - if not isinstance(pricing, dict): - return False - try: - return float(pricing.get("input", "0")) == 0 and float(pricing.get("output", "0")) == 0 - except (TypeError, ValueError): - return False + return _zero_priced(pricing, ("input", "output"), "0") def fetch_ai_gateway_models( - timeout: float = 8.0, - *, - force_refresh: bool = False, -) -> list[tuple[str, str]]: + timeout: float = 8.0, *, force_refresh: bool = False) -> list[tuple[str, str]]: """Return the curated AI Gateway picker list, refreshed from the live catalog when possible.""" global _ai_gateway_catalog_cache @@ -754,35 +576,21 @@ def fetch_ai_gateway_models( return list(_ai_gateway_catalog_cache or fallback) _, live_by_id = live - curated: list[tuple[str, str]] = [] - for preferred_id, _ in fallback: - live_item = live_by_id.get(preferred_id) - if live_item is None: - continue - desc = "free" if _ai_gateway_model_is_free(live_item.get("pricing")) else "" - curated.append((preferred_id, desc)) - + curated = [ + (pid, "free" if _ai_gateway_model_is_free(live_by_id[pid].get("pricing")) else "") + for pid, _ in fallback if pid in live_by_id] if not curated: return list(_ai_gateway_catalog_cache or fallback) - # If the live catalog offers a free Moonshot model, auto-promote it to position #1 as - # "recommended" — dynamic discovery without a PR. + # A free Moonshot model in the live catalog is auto-promoted to #1 as "recommended". free_moonshot = next( - ( - mid - for mid, item in live_by_id.items() - if mid.startswith("moonshotai/") - and _ai_gateway_model_is_free(item.get("pricing")) - ), - None, - ) + (mid for mid, item in live_by_id.items() + if mid.startswith("moonshotai/") and _ai_gateway_model_is_free(item.get("pricing"))), + None) if free_moonshot: - curated = [(mid, desc) for mid, desc in curated if mid != free_moonshot] - curated.insert(0, (free_moonshot, "recommended")) + curated = [(free_moonshot, "recommended")] + [(mid, desc) for mid, desc in curated if mid != free_moonshot] else: - first_id, _ = curated[0] - curated[0] = (first_id, "recommended") - + curated[0] = (curated[0][0], "recommended") _ai_gateway_catalog_cache = curated return list(curated) @@ -800,6 +608,9 @@ def ai_gateway_model_ids(*, force_refresh: bool = False) -> list[str]: _KNOWN_PROVIDER_NAMES: set[str] = set(_PROVIDER_LABELS) | set(_PROVIDER_ALIASES) | {"openrouter", "custom"} +_CONFIG_ERRORS = (ImportError, OSError, RuntimeError, TypeError, ValueError, AttributeError) + + def _configured_custom_provider_ids() -> set[str]: """Return routable custom-provider IDs configured by the user.""" ids = {"custom"} @@ -810,15 +621,13 @@ def _configured_custom_provider_ids() -> set[str]: config = load_config() providers = config.get("providers", {}) if isinstance(providers, dict): - for key, entry in providers.items(): - if isinstance(entry, dict): - ids.add(custom_provider_slug(str(entry.get("name") or key), str(key))) + ids.update(custom_provider_slug(str(entry.get("name") or key), str(key)) + for key, entry in providers.items() if isinstance(entry, dict)) legacy = config.get("custom_providers", []) if isinstance(legacy, list): - for entry in legacy: - if isinstance(entry, dict): - ids.add(custom_provider_slug(str(entry.get("name") or ""))) - except (ImportError, OSError, RuntimeError, TypeError, ValueError, AttributeError): + ids.update( + custom_provider_slug(str(entry.get("name") or "")) for entry in legacy if isinstance(entry, dict)) + except _CONFIG_ERRORS: pass return ids @@ -838,12 +647,8 @@ def _provider_has_credentials(pid: str) -> bool: def list_available_providers() -> list[dict[str, str]]: - """Return info about all providers the user could use with ``provider:model``. - - Each dict has ``id``, ``label``, and ``aliases``, plus whether valid credentials are - configured. Derived from :data:`CANONICAL_PROVIDERS`, the single source of truth shared with - ``hermes model`` and ``/model``. - """ + """``{id, label, aliases, authenticated}`` for every provider usable with ``provider:model``, + derived from :data:`CANONICAL_PROVIDERS` (shared with ``hermes model`` and ``/model``).""" aliases_for: dict[str, list[str]] = {} for alias, canonical in _PROVIDER_ALIASES.items(): aliases_for.setdefault(canonical, []).append(alias) @@ -852,19 +657,13 @@ def list_available_providers() -> list[dict[str, str]]: "id": pid, "label": _PROVIDER_LABELS.get(pid, pid), "aliases": aliases_for.get(pid, []), - "authenticated": _provider_has_credentials(pid), - } - for pid in [p.slug for p in CANONICAL_PROVIDERS] + ["custom"] - ] + "authenticated": _provider_has_credentials(pid)} + for pid in [p.slug for p in CANONICAL_PROVIDERS] + ["custom"]] def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]: - """Parse ``/model`` input into ``(provider, model)``. - - The colon is only treated as a provider delimiter if the left side is a recognized provider name - or alias. This avoids misinterpreting model names that happen to contain colons (e.g. - ``anthropic/claude-3.5-sonnet:beta``). - """ + """Parse ``/model`` input into ``(provider, model)``. The colon is a provider delimiter only when + the left side is a known provider/alias, so ``anthropic/claude-3.5-sonnet:beta`` stays a model.""" stripped = raw.strip() colon = stripped.find(":") if colon > 0: @@ -890,9 +689,8 @@ def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]: def _get_custom_base_url() -> str: - """Get the custom endpoint base_url from config.yaml.""" - model_cfg = _get_model_config_dict() - return str(model_cfg.get("base_url", "")).strip() + """The custom endpoint ``model.base_url`` from config.yaml.""" + return str(_get_model_config_dict().get("base_url", "")).strip() def _get_provider_config_dict(provider: str) -> dict[str, Any]: @@ -902,13 +700,12 @@ def _get_provider_config_dict(provider: str) -> dict[str, Any]: return {} try: from hermes_cli.config import load_config - config = load_config() - providers_cfg = config.get("providers", {}) + providers_cfg = load_config().get("providers", {}) if isinstance(providers_cfg, dict): entry = providers_cfg.get(key) or providers_cfg.get(key.lower()) if isinstance(entry, dict): return entry - except (ImportError, OSError, RuntimeError, TypeError, ValueError, AttributeError): + except _CONFIG_ERRORS: pass return {} @@ -917,8 +714,7 @@ def _get_model_config_dict() -> dict[str, Any]: """Return the main model config mapping, or an empty dict.""" try: from hermes_cli.config import load_config - config = load_config() - model_cfg = config.get("model", {}) + model_cfg = load_config().get("model", {}) if isinstance(model_cfg, dict): return model_cfg except Exception: @@ -930,15 +726,12 @@ def _base_url_looks_like_anthropic_messages(base_url: str) -> bool: normalized = str(base_url or "").strip().lower().rstrip("/") if not normalized: return False - path = urllib.parse.urlparse(normalized).path.rstrip("/") - return path.endswith("/anthropic") or path.endswith("/anthropic/v1") + return urllib.parse.urlparse(normalized).path.rstrip("/").endswith(("/anthropic", "/anthropic/v1")) def _anthropic_models_url(base_url: Optional[str] = None) -> str: endpoint = str(base_url or "https://api.anthropic.com").strip().rstrip("/") - if endpoint.endswith("/v1"): - return endpoint + "/models" - return endpoint + "/v1/models" + return endpoint + ("/models" if endpoint.endswith("/v1") else "/v1/models") def _provider_keys(provider: str) -> set[str]: @@ -949,35 +742,25 @@ def _provider_keys(provider: str) -> set[str]: def _provider_catalog_names(provider: str) -> tuple[str, ...]: """Active picker models plus retired aliases recognized for detection.""" - active = tuple(_PROVIDER_MODELS.get(provider, [])) - retired = _PROVIDER_RETIRED_ALIASES.get(provider, ()) - return active + retired + return tuple(_PROVIDER_MODELS.get(provider, [])) + _PROVIDER_RETIRED_ALIASES.get(provider, ()) def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool: return any( name_lower == model.lower() for provider in providers - for model in _provider_catalog_names(provider) - ) + for model in _provider_catalog_names(provider)) def _openrouter_variant_base(model_id: str) -> Optional[str]: - """Return the base model id when ``model_id`` carries a recognized OpenRouter routing-variant - suffix (e.g. ``x-ai/grok-4:nitro`` → ``x-ai/grok-4``), else ``None``. - """ + """Base model id when ``model_id`` carries a recognized OpenRouter routing-variant suffix + (``x-ai/grok-4:nitro`` → ``x-ai/grok-4``), else ``None``.""" base, sep, suffix = (model_id or "").rpartition(":") - if not sep or not base: - return None - if suffix.lower() in _OPENROUTER_VARIANT_SUFFIXES: - return base - return None + return base if sep and base and suffix.lower() in _OPENROUTER_VARIANT_SUFFIXES else None def _resolve_static_model_alias( - name_lower: str, - current_keys: set[str], -) -> Optional[tuple[str, str]]: + name_lower: str, current_keys: set[str]) -> Optional[tuple[str, str]]: """Resolve short aliases (e.g. sonnet/opus) using static catalogs only.""" try: from hermes_cli.model_switch import MODEL_ALIASES @@ -988,61 +771,28 @@ def _resolve_static_model_alias( if identity is None: return None - vendor = identity.vendor - family = identity.family - def _match(provider: str) -> Optional[str]: - models = _PROVIDER_MODELS.get(provider, []) - if not models: - return None - prefix = ( - f"{vendor}/{family}" - if provider in _AGGREGATOR_PROVIDERS - else family - ).lower() - for model in models: - if model.lower().startswith(prefix): - return model - return None + prefix = f"{identity.vendor}/{identity.family}" if provider in _AGGREGATOR_PROVIDERS else identity.family + prefix = prefix.lower() + return next((m for m in _PROVIDER_MODELS.get(provider, []) if m.lower().startswith(prefix)), None) - for provider in current_keys: + # Current provider first, then native vendors, then aggregators / borrow-list providers the user + # is already on — so `sonnet` resolves to anthropic before any re-exposing provider. + skip = current_keys | _AGGREGATOR_PROVIDERS | _BORROWED_MODEL_PROVIDERS + candidates = [ + *current_keys, *(p for p in _PROVIDER_MODELS if p not in skip), + *(p for p in _AGGREGATOR_PROVIDERS if p in current_keys), + *(p for p in _BORROWED_MODEL_PROVIDERS if p in current_keys)] + for provider in candidates: if matched := _match(provider): return provider, matched - - for provider in _PROVIDER_MODELS: - if ( - provider in current_keys - or provider in _AGGREGATOR_PROVIDERS - or provider in _BORROWED_MODEL_PROVIDERS - ): - continue - if matched := _match(provider): - return provider, matched - - for provider in _AGGREGATOR_PROVIDERS: - if provider in current_keys and (matched := _match(provider)): - return provider, matched - - # Last resort: providers that re-expose other vendors' models. Only reached - # when no native-vendor catalog matched — so `sonnet` resolves to anthropic. - # None are currently defined (_BORROWED_MODEL_PROVIDERS is empty). - for provider in _BORROWED_MODEL_PROVIDERS: - if provider in current_keys and (matched := _match(provider)): - return provider, matched - return None def detect_static_provider_for_model( - model_name: str, - current_provider: str, -) -> Optional[tuple[str, str]]: - """Auto-detect a provider from static catalogs only. - - Returns ``(provider_id, model_name)``; the name may be remapped when a static alias or bare - provider name resolves to a catalog default. Returns ``None`` when no confident match is - found. - """ + model_name: str, current_provider: str) -> Optional[tuple[str, str]]: + """Auto-detect a provider from static catalogs only → ``(provider_id, model_name)`` (the name may + be remapped by a static alias or a bare provider name), or ``None`` without a confident match.""" name = (model_name or "").strip() if not name: return None @@ -1060,10 +810,8 @@ def detect_static_provider_for_model( if resolved_provider not in {"custom", "openrouter"}: default_models = _PROVIDER_MODELS.get(resolved_provider, []) if resolved_provider in _PROVIDER_LABELS and default_models and resolved_provider not in current_keys: - # Cost-safe default, not ``default_models[0]``: metered aggregators order their list - # most-capable-first, so [0] is the priciest flagship and `/model nous` would silently - # escalate to it — the footgun ``_SILENT_DEFAULT_PROVIDERS`` exists to prevent. Other - # providers still get ``models[0]``. + # Cost-safe default, not ``default_models[0]``: metered aggregators list most-capable-first, + # so [0] would silently escalate `/model nous` to the priciest flagship. return (resolved_provider, get_default_model_for_provider(resolved_provider) or default_models[0]) # A model in the current provider's own catalog never suggests switching. @@ -1090,12 +838,8 @@ def detect_static_provider_for_model( def _configured_provider_ids() -> set[str]: - """Provider ids defined in the user's config ``providers:`` block. - - Includes both top-level ids (``ollama``, ``nous``) and ``custom:*`` profile ids. Returns an - empty set when config is unreadable — callers treat that as "no user-defined providers" and fall - through to built-in catalogs only. - """ + """Provider ids (incl. ``custom:*``) from the user's ``providers:`` config block; empty when config + is unreadable (callers fall through to built-in catalogs).""" try: from hermes_cli.config import load_config @@ -1108,45 +852,27 @@ def _configured_provider_ids() -> set[str]: def _resolve_provider_prefix(model_name: str) -> Optional[tuple[str, str]]: - """Resolve an explicit ``vendor/model`` prefix to a configured provider. - - ``nous/deepseek-v4-pro`` or ``ollama/qwen3.5:4b`` should route to the named provider instead of - falling back to the configured default (which silently sends non-default models to the wrong - endpoint, #87189). - - Only vendors the user actually defined in their ``providers:`` config block (by raw name or - alias) are routed here. - """ + """Route an explicit ``vendor/model`` prefix (``nous/deepseek-v4-pro``, ``ollama/qwen3.5:4b``) to + a provider the user defined in ``providers:`` (by raw name or alias) instead of the default.""" if "/" not in model_name: return None vendor, model = model_name.split("/", 1) - vendor = vendor.strip().lower() - model = model.strip() + vendor, model = vendor.strip().lower(), model.strip() if not vendor or not model: return None configured = _configured_provider_ids() - if not configured: - return None - # A provider block the user explicitly named (``ollama:``) wins over the - # built-in alias table, which may canonicalize the same name elsewhere - # (``ollama`` → ``custom``) and route to the wrong endpoint. - if vendor in configured: - return (vendor, model) - canonical = _PROVIDER_ALIASES.get(vendor, vendor) - if canonical in configured: - return (canonical, model) + # An explicitly named provider block (``ollama:``) wins over the alias table, which may + # canonicalize the same name elsewhere (``ollama`` → ``custom``). + for candidate in (vendor, _PROVIDER_ALIASES.get(vendor, vendor)): + if candidate in configured: + return (candidate, model) return None def detect_provider_for_model( - model_name: str, - current_provider: str, -) -> Optional[tuple[str, str]]: - """Auto-detect the best provider for a model name. - - Priority: 0. Bare provider name → switch to that provider's default model 1. Direct provider - static catalog match 2. OpenRouter catalog match - """ + model_name: str, current_provider: str) -> Optional[tuple[str, str]]: + """Auto-detect the best provider for a model name: static catalogs (bare provider name → its + default; direct catalog match), then the OpenRouter catalog, then a configured ``vendor/`` prefix.""" name = (model_name or "").strip() if not name: return None @@ -1157,41 +883,33 @@ def detect_provider_for_model( if _model_in_provider_catalog(name.lower(), _provider_keys(current_provider)): return None - # Step 2: OpenRouter catalog (exact slug, then bare model part). + # OpenRouter catalog (exact slug, then bare model part). or_slug = _find_openrouter_slug(name) if or_slug: if current_provider != "openrouter" or or_slug != name: return ("openrouter", or_slug) return None # already on openrouter with matching name - # Step 3: explicit ``vendor/model`` prefix naming a configured provider. After the OpenRouter - # lookup so aggregator-native slugs (``deepseek/deepseek-chat``) keep their routing; only - # vendors from the user's ``providers:`` block route here. + # Explicit ``vendor/model`` prefix naming a configured provider — AFTER the OpenRouter lookup so + # aggregator-native slugs (``deepseek/deepseek-chat``) keep their routing. return _resolve_provider_prefix(name) def _find_openrouter_slug(model_name: str) -> Optional[str]: - """Find the full OpenRouter model slug for a bare or partial model name.""" + """Full OpenRouter slug for a bare or partial model name (exact slug first, then bare part).""" name_lower = model_name.strip().lower() if not name_lower: return None - ids = model_ids() - for mid in ids: # exact match (already has the provider/ prefix) - if name_lower == mid.lower(): - return mid - for mid in ids: # bare model part after the "/" - if "/" in mid and name_lower == mid.split("/", 1)[1].lower(): - return mid - return None + return ( + next((mid for mid in ids if name_lower == mid.lower()), None) + or next((mid for mid in ids if "/" in mid and name_lower == mid.split("/", 1)[1].lower()), None) + ) def normalize_provider(provider: Optional[str]) -> str: - """Normalize provider aliases to Hermes' canonical provider ids. - - ``"auto"`` passes through unchanged — use ``hermes_cli.auth.resolve_provider()`` to resolve - it to a concrete provider from credentials and environment. - """ + """Normalize provider aliases to canonical ids. ``"auto"`` passes through — use + ``hermes_cli.auth.resolve_provider()`` to resolve it from credentials.""" normalized = (provider or "openrouter").strip().lower() return _PROVIDER_ALIASES.get(normalized, normalized) @@ -1207,24 +925,16 @@ def provider_label(provider: Optional[str]) -> str: def _is_openai_fast_model(model_id: Optional[str]) -> bool: - """Return True if the model is an OpenAI flagship eligible for Priority Processing.""" - raw = _strip_vendor_prefix(str(model_id or "")) - base = raw.split(":")[0] - if not base: - return False - # Exclude Codex-series — they route through the Codex Responses API - # which doesn't accept service_tier. - if "codex" in base: - return False - return any(base.startswith(prefix) for prefix in _OPENAI_FAST_MODE_PREFIXES) + """OpenAI flagship eligible for Priority Processing. Codex-series excluded — the Codex Responses + API doesn't accept ``service_tier``.""" + base = _strip_vendor_prefix(str(model_id or "")).split(":")[0] + return bool(base) and "codex" not in base and base.startswith(tuple(_OPENAI_FAST_MODE_PREFIXES)) def _strip_vendor_prefix(model_id: str) -> str: - """Strip vendor/ prefix from a model ID (e.g. 'anthropic/claude-opus-4-6' -> 'claude-opus-4-6').""" + """Lowercase and strip a ``vendor/`` prefix (``anthropic/claude-opus-4-6`` → ``claude-opus-4-6``).""" raw = str(model_id or "").strip().lower() - if "/" in raw: - raw = raw.split("/", 1)[1] - return raw + return raw.split("/", 1)[1] if "/" in raw else raw def model_supports_fast_mode(model_id: Optional[str]) -> bool: @@ -1234,30 +944,21 @@ def model_supports_fast_mode(model_id: Optional[str]) -> bool: return ( _is_anthropic_fast_model(model_id) or _is_openai_fast_model(model_id) - or is_grok_46_family(str(model_id or "")) - ) + or is_grok_46_family(str(model_id or ""))) def _is_anthropic_fast_model(model_id: Optional[str]) -> bool: - """Return True if the model accepts the Anthropic Fast Mode ``speed`` param. - - Gates the *speed=fast request parameter* (Opus 4.8 / Opus 5, Claude API only) — deliberately - NOT a general "is this a fast model" check: Opus 4.7 hard-400s on the parameter, and dedicated - ``…-fast`` ids select fast inference via the model field and must not also get it. The - anthropic adapter additionally gates on native endpoints so proxies never see the beta header. - """ - raw = _strip_vendor_prefix(str(model_id or "")) - base = raw.split(":")[0] - if not base.startswith("claude-"): - return False - if "-fast" in base: + """Accepts the Anthropic Fast Mode ``speed`` param (Opus 4.8 / Opus 5 only) — deliberately NOT a + general "fast model" check: Opus 4.7 hard-400s on it, and dedicated ``…-fast`` ids select fast + inference via the model field and must not also get it.""" + base = _strip_vendor_prefix(str(model_id or "")).split(":")[0] + if not base.startswith("claude-") or "-fast" in base: return False return any(v in base for v in ("opus-4-8", "opus-4.8", "opus-5")) def _fast_mode_route_supported( - model_id: Optional[str], provider: Optional[str], base_url: Optional[str] -) -> bool: + model_id: Optional[str], provider: Optional[str], base_url: Optional[str]) -> bool: """Only the first-party endpoint that bills for fast mode may receive its params.""" from urllib.parse import urlparse @@ -1276,50 +977,30 @@ def _fast_mode_route_supported( def resolve_fast_mode_overrides( - model_id: Optional[str], - *, - provider: Optional[str] = None, - base_url: Optional[str] = None, + model_id: Optional[str], *, provider: Optional[str] = None, base_url: Optional[str] = None ) -> dict[str, Any] | None: - """Return request_overrides for fast/priority mode, or None if unsupported. - - Returns provider-appropriate overrides: - OpenAI models: ``{"service_tier": "priority"}`` - (Priority Processing) - Anthropic models: ``{"speed": "fast"}`` (Anthropic Fast Mode beta) - - Grok 4.6: ``{"service_tier": "priority"}`` (xAI Priority Processing) - - When ``provider``/``base_url`` are given the result is also gated on the route (see - ``_fast_mode_route_supported``) so proxies never see the params. This is the single fast-mode - gate for static ``/fast fast`` and the bounded ``auto``/``cold`` windows in ``agent.fast_mode``. - """ + """Fast/priority request_overrides — ``{"speed": "fast"}`` (Anthropic Fast Mode) or + ``{"service_tier": "priority"}`` (OpenAI / xAI Priority Processing) — or None if unsupported. + With ``provider``/``base_url`` the route is gated too (``_fast_mode_route_supported``) so proxies + never see the params. Single fast-mode gate for ``/fast`` and ``agent.fast_mode`` windows.""" if not model_supports_fast_mode(model_id): return None - if (provider or base_url) and not _fast_mode_route_supported( - model_id, provider, base_url - ): + if (provider or base_url) and not _fast_mode_route_supported(model_id, provider, base_url): return None - if _is_anthropic_fast_model(model_id): - return {"speed": "fast"} - return {"service_tier": "priority"} + return {"speed": "fast"} if _is_anthropic_fast_model(model_id) else {"service_tier": "priority"} def _first_exchangeable_copilot_token(raw_tokens) -> str: - """Exchange stored GitHub tokens in order; the first that validates AND exchanges wins. - - Trying every entry (instead of stopping at the first malformed one) keeps a later valid entry - reachable when an earlier one is unsupported. - """ + """Exchange stored GitHub tokens in order; the first that validates AND exchanges wins (every + entry is tried so a later valid token survives an earlier malformed one).""" from hermes_cli.copilot_auth import exchange_copilot_token, validate_copilot_token for raw in raw_tokens: raw = str(raw or "").strip() - if not raw: - continue - valid, _ = validate_copilot_token(raw) - if not valid: + if not raw or not validate_copilot_token(raw)[0]: continue try: - # exchange_copilot_token returns (api_token, expires_at, base_url). - api_token = exchange_copilot_token(raw)[0] + api_token = exchange_copilot_token(raw)[0] # (api_token, expires_at, base_url) except Exception: continue if api_token: @@ -1335,60 +1016,41 @@ def _copilot_cli_config_tokens() -> list[str]: return [] with open(cli_config, "r", encoding="utf-8", errors="ignore") as fh: raw_text = "\n".join( - line for line in fh.read().splitlines() - if not line.lstrip().startswith("//") - ) + line for line in fh.read().splitlines() if not line.lstrip().startswith("//")) data = json.loads(raw_text) if raw_text.strip() else {} tokens = data.get("copilotTokens") return list(tokens.values()) if isinstance(tokens, dict) else [] def _resolve_copilot_catalog_api_key() -> str: - """Best-effort GitHub token for fetching the Copilot model catalog. - - Resolution order: - 1. ``resolve_api_key_provider_credentials("copilot")`` — env vars (``COPILOT_GITHUB_TOKEN`` / - ``GH_TOKEN`` / ``GITHUB_TOKEN``) plus the ``gh auth token`` CLI fallback. - 2. ``read_credential_pool("copilot")`` — a token (a ``gho_*`` from device-code login, or a - fine-grained PAT) stored in ``auth.json`` under ``credential_pool.copilot[]``. - 3. ``~/.copilot/config.json`` ``copilotTokens`` — without it, a user whose ONLY credential is - the ACP CLI login sees the copilot-acp picker fall back to the stale curated list. - - Without (2)/(3), users without env-var credentials see the ``/model`` picker fall back to a - stale hardcoded list because the live catalog fetch silently 401s. - """ - try: - from hermes_cli.auth import resolve_api_key_provider_credentials - - api_key = str(resolve_api_key_provider_credentials("copilot").get("api_key") or "").strip() - if api_key: - return api_key - except Exception: - pass - try: + """Best-effort GitHub token for the Copilot catalog: env vars / ``gh auth token`` via + ``resolve_api_key_provider_credentials``, then ``auth.json`` ``credential_pool.copilot[]``, then + ``~/.copilot/config.json`` ``copilotTokens`` (the ACP CLI's own store). Without the latter two, + keyless users see the picker fall back to the stale curated list on a silent 401.""" + def _pool_token() -> str: from hermes_cli.auth import read_credential_pool - token = _first_exchangeable_copilot_token( - entry.get("access_token") for entry in read_credential_pool("copilot") if isinstance(entry, dict) - ) + return _first_exchangeable_copilot_token( + entry.get("access_token") for entry in read_credential_pool("copilot") if isinstance(entry, dict)) + + sources = ( + lambda: _api_key_credentials("copilot")[0], + _pool_token, + lambda: _first_exchangeable_copilot_token(_copilot_cli_config_tokens()), + ) + for source in sources: + try: + token = source() + except Exception: + continue if token: return token - except Exception: - pass - try: - return _first_exchangeable_copilot_token(_copilot_cli_config_tokens()) - except Exception: - return "" + return "" def _model_dedup_key(model_id: str) -> str: - """Case-insensitive dedup key that also folds picker-search aliases. - - Some providers serve one model under both a curated public slug and a bare live wire id - (Kimi lists ``k3`` while the curated catalog carries ``kimi-k3``). Folding through the - search-alias table keeps the curated-first merge from emitting both; the primary list's row - survives and selection sends whichever id it carries. - """ + """Case-insensitive dedup key folded through the picker-search alias table, so a bare live wire + id and its curated public slug (Kimi ``k3`` / ``kimi-k3``) don't both survive a merge.""" key = str(model_id).strip().lower() try: from hermes_cli.model_search import model_alias_canonical @@ -1398,56 +1060,30 @@ def _model_dedup_key(model_id: str) -> str: def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]: - """Merge curated list with fresh models.dev entries for a preferred provider. - - Returns models.dev entries first (in models.dev order), then any curated-only entries appended. - Preserves case for curated fallbacks (e.g. ``MiniMax-M2.7``) while trusting models.dev for newer - variants. - - If models.dev is unreachable or returns nothing, the curated list is returned unchanged — this - is the offline/CI fallback path. - """ + """models.dev entries first (their order), then curated-only extras, case-insensitively deduped + while preserving curated casing. Curated unchanged when models.dev is unreachable/empty.""" try: from agent.models_dev import list_agentic_models mdev = list_agentic_models(provider) except Exception: mdev = [] - if not mdev: return list(curated) - - # Case-insensitive dedup while preserving order and curated casing. - seen_lower: set[str] = set() - merged: list[str] = [] - for mid in mdev: - key = str(mid).lower() - if key in seen_lower: - continue - seen_lower.add(key) - merged.append(mid) - for mid in curated: - key = str(mid).lower() - if key in seen_lower: - continue - seen_lower.add(key) - merged.append(mid) - return merged + return _merge_unique(_merge_unique([], mdev), curated) def _openai_discovery_base_url(provider: str) -> str: - """Effective OpenAI endpoint for model discovery. - - Mirrors the runtime precedence so discovery probes the SAME endpoint inference uses: - ``$OPENAI_BASE_URL`` (explicit env override) → ``model.base_url`` from config.yaml when the - configured provider matches → the canonical default. - """ + """OpenAI endpoint for model discovery, mirroring runtime precedence so discovery probes the SAME + endpoint inference uses: ``$OPENAI_BASE_URL`` → config ``model.base_url`` (when the configured + provider matches) → the canonical default.""" env_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/") if env_raw: return env_raw try: model_cfg = _get_model_config_dict() cfg_provider = str(model_cfg.get("provider") or "").strip().lower() - if cfg_provider in ("openai", "openai-api") and normalize_provider(provider) == normalize_provider(cfg_provider): + same_provider = normalize_provider(provider) == normalize_provider(cfg_provider) + if cfg_provider in ("openai", "openai-api") and same_provider: cfg_url = str(model_cfg.get("base_url") or "").strip().rstrip("/") if cfg_url: return cfg_url @@ -1459,9 +1095,8 @@ def _openai_discovery_base_url(provider: str) -> str: def _codex_catalog(normalized: str, force_refresh: bool) -> list[str]: from hermes_cli.codex_models import get_codex_model_ids - # Pass the live OAuth access token so the picker matches whatever ChatGPT lists for this - # account right now; falls back to the hardcoded catalog without a token / when unreachable. - access_token = None + # Live OAuth token so the picker matches what ChatGPT lists for this account; hardcoded + # catalog without a token / when unreachable. try: from hermes_cli.auth import resolve_codex_runtime_credentials @@ -1478,9 +1113,7 @@ def _copilot_catalog(normalized: str, force_refresh: bool) -> Optional[list[str] return live except Exception: pass - if normalized == "copilot-acp": - return list(_PROVIDER_MODELS.get("copilot", [])) - return None + return list(_PROVIDER_MODELS.get("copilot", [])) if normalized == "copilot-acp" else None def _nous_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]]: @@ -1494,24 +1127,31 @@ def _nous_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]]: return live except Exception: pass - # Live failed (or no creds): the docs-hosted manifest — NOT the in-repo snapshot — so newly - # added Portal models still surface without a Hermes release. + # Live failed / no creds: the docs-hosted manifest — NOT the in-repo snapshot — so newly added + # Portal models still surface without a Hermes release. return get_curated_nous_model_ids() or None -def _api_key_provider_live(normalized: str, force_refresh: bool) -> Optional[list[str]]: - """Live /v1/models for a simple api-key provider (stepfun, gmi); None on any miss.""" +def _api_key_credentials(normalized: str) -> tuple[str, str]: + """``(api_key, base_url)`` from ``resolve_api_key_provider_credentials``; empty strings on any miss.""" try: from hermes_cli.auth import resolve_api_key_provider_credentials creds = resolve_api_key_provider_credentials(normalized) - api_key = str(creds.get("api_key") or "").strip() - base_url = str(creds.get("base_url") or "").strip() - if api_key and base_url: - return fetch_api_models(api_key, base_url) or None + return str(creds.get("api_key") or "").strip(), str(creds.get("base_url") or "").strip() except Exception: - pass - return None + return "", "" + + +def _api_key_provider_live(normalized: str, force_refresh: bool) -> Optional[list[str]]: + """Live /v1/models for a simple api-key provider (stepfun, gmi); None on any miss.""" + api_key, base_url = _api_key_credentials(normalized) + if not (api_key and base_url): + return None + try: + return fetch_api_models(api_key, base_url) or None + except Exception: + return None def _anthropic_catalog(normalized: str, force_refresh: bool) -> list[str]: @@ -1524,17 +1164,9 @@ def _anthropic_catalog(normalized: str, force_refresh: bool) -> list[str]: curated = list(_PROVIDER_MODELS.get("anthropic", [])) if not live: return curated - if cfg_base_url: - return live - # The live /v1/models dump lags newly-routed curated aliases (reachable before enumerated). - # Curated first, then live-only extras, so a fresh curated model never disappears. - 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()) - return merged + # The live /v1/models dump lags newly-routed curated aliases (reachable before enumerated): + # curated first, then live-only extras, so a fresh curated model never disappears. + return live if cfg_base_url else _merge_unique(curated, live) def _openai_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]]: @@ -1542,28 +1174,24 @@ def _openai_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]] if not api_key: return None base = _openai_discovery_base_url(normalized) - # Custom OpenAI-compatible endpoints may serve a small curated catalog — use it verbatim. - # Official OpenAI hosts (canonical AND data-residency regional, identical dump) return 120+ - # embeddings/whisper/tts/dall-e/moderation/legacy entries, so intersect with the curated - # agentic catalog there so ``/model`` matches ``hermes model``. + # Custom OpenAI-compatible endpoints serve a small curated catalog — use it verbatim. Official + # OpenAI hosts (canonical and data-residency regional) return 120+ embeddings/whisper/tts/… + # entries, so intersect with the curated agentic catalog so ``/model`` matches ``hermes model``. from hermes_cli.providers import is_official_openai_host - is_default_openai = is_official_openai_host(base) try: live = fetch_api_models(api_key, base) except Exception: - return None + live = None if not live: return None - if not is_default_openai: + if not is_official_openai_host(base): return live live_lower = {m.lower() for m in live} curated = list(_PROVIDER_MODELS.get(normalized, [])) - # Keep curated order; only surface curated models the account actually has access to. An - # account serving none of them (rare) falls back to curated so the picker still offers sane - # defaults. - filtered = [m for m in curated if m.lower() in live_lower] - return filtered or curated or live + # Curated order, only models the account has access to; an account serving none of them (rare) + # falls back to curated so the picker still offers sane defaults. + return [m for m in curated if m.lower() in live_lower] or curated or live def _custom_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]]: @@ -1576,15 +1204,13 @@ def _custom_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]] str(model_cfg.get("api_key", "") or "").strip() or os.getenv("CUSTOM_API_KEY", "") or os.getenv("OPENAI_API_KEY", "") - or os.getenv("OPENROUTER_API_KEY", "") - ) + or os.getenv("OPENROUTER_API_KEY", "")) api_mode = "anthropic_messages" if _base_url_looks_like_anthropic_messages(base_url) else None return fetch_api_models(api_key, base_url, api_mode=api_mode) or None def _bedrock_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]]: - # Live discovery keyed by the resolved AWS region so EU/AP users see eu.*/ap.* ids instead of - # the static us.* list. A hit skips the _MODELS_DEV_PREFERRED merge (bedrock isn't in it). + # Live discovery keyed by the resolved AWS region so EU/AP users see eu.*/ap.* ids. try: from agent.bedrock_adapter import bedrock_model_ids_or_none @@ -1594,10 +1220,8 @@ def _bedrock_catalog(normalized: str, force_refresh: bool) -> Optional[list[str] def _opencode_free_catalog(normalized: str, force_refresh: bool) -> list[str]: - # Keyless live catalog revalidated against the Zen relay every TTL. models.dev's - # cost.input==0 filter lags reality (a promo model kept "free" there after the relay began - # 401ing keyless requests), so filter the live /zen/v1/models dump to the anonymous-servable - # `*-free` tier ourselves; the curated floor only applies when the live fetch fails/is empty. + # Live keyless catalog filtered to the anonymous-servable `*-free` tier ourselves (models.dev's + # cost.input==0 lags reality); the curated floor applies only when the live fetch fails/is empty. return _fetch_opencode_free_models(force_refresh=force_refresh) or list(_PROVIDER_MODELS.get(normalized, [])) @@ -1613,72 +1237,45 @@ _PROVIDER_CATALOG_FETCHERS: dict[str, Any] = { "gmi": _api_key_provider_live, "anthropic": _anthropic_catalog, "ai-gateway": lambda normalized, force_refresh: _fetch_ai_gateway_models() or None, - # DeepInfra's generic /models mixes chat, image, video, speech and embedding models; the - # tagged catalog helper is the only safe source for the chat picker, including its - # empty/failure result. + # DeepInfra's generic /models mixes chat, image, video, speech and embedding models; the tagged + # catalog helper is the only safe source for the chat picker, including its empty/failure result. "deepinfra": lambda normalized, force_refresh: _fetch_deepinfra_models(force_refresh=force_refresh) or [], "ollama-cloud": lambda normalized, force_refresh: fetch_ollama_cloud_models(force_refresh=force_refresh) or None, "openai": _openai_catalog, "openai-api": _openai_catalog, "custom": _custom_catalog, "bedrock": _bedrock_catalog, - "opencode-free": _opencode_free_catalog, -} + "opencode-free": _opencode_free_catalog} def _profile_live_catalog(normalized: str) -> Optional[list[str]]: """Generic live fetch for any provider registered in providers/ with ``auth_type="api_key"``. - Live results are merged with the curated list so models the live endpoint omits (stale cache, - partial rollout) still appear. Most providers merge curated-first so the newest curated models - lead even when the live API lags; ``_LIVE_FIRST_PICKER_PROVIDERS`` (OpenCode Zen/Go, whose - live API is authoritative) merge live-first so stale curated entries stop polluting the top. - Plugin providers with no static entry use the profile's ``fallback_models`` as the curated - list so their agentic picks lead the picker (Fireworks lists an image model first). + Live results are merged with the curated list so models the live endpoint omits still appear: + curated-first by default so the newest curated models lead when the live API lags; + ``_LIVE_FIRST_PICKER_PROVIDERS`` (OpenCode Zen/Go, authoritative live API) live-first so stale + curated entries stop polluting the top. Plugin providers without a static entry use the + profile's ``fallback_models`` as the curated list (Fireworks lists an image model first). """ from providers import get_provider_profile - from hermes_cli.auth import resolve_api_key_provider_credentials profile = get_provider_profile(normalized) if not (profile and profile.auth_type == "api_key" and profile.base_url): return None - try: - creds = resolve_api_key_provider_credentials(normalized) - api_key = str(creds.get("api_key") or "").strip() - base_url = str(creds.get("base_url") or "").strip() - except Exception: - api_key, base_url = "", profile.base_url - if not base_url: - base_url = profile.base_url - if api_key: - live = profile.fetch_models(api_key=api_key, base_url=base_url or None) - if live: - curated = list(_PROVIDER_MODELS.get(normalized, [])) or list(profile.fallback_models or ()) - if not curated: - return live - if normalized in _LIVE_FIRST_PICKER_PROVIDERS: - primary, secondary = live, curated - else: - primary, secondary = curated, live - merged = list(primary) - merged_keys = {_model_dedup_key(m) for m in primary} - for m in secondary: - if _model_dedup_key(m) not in merged_keys: - merged.append(m) - merged_keys.add(_model_dedup_key(m)) - return merged - if profile.fallback_models: - return list(profile.fallback_models) - return None + api_key, base_url = _api_key_credentials(normalized) + live = profile.fetch_models(api_key=api_key, base_url=base_url or profile.base_url or None) if api_key else None + if not live: + return list(profile.fallback_models) if profile.fallback_models else None + curated = list(_PROVIDER_MODELS.get(normalized, [])) or list(profile.fallback_models or ()) + if not curated: + return live + primary, secondary = (live, curated) if normalized in _LIVE_FIRST_PICKER_PROVIDERS else (curated, live) + return _merge_unique(primary, secondary, key=_model_dedup_key) def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]: - """Return the best known model catalog for a provider. - - Tries live API endpoints where supported (Codex, Nous), falling back to static lists. For - providers in ``_MODELS_DEV_PREFERRED`` models.dev entries are merged on top of curated so - new platform models appear in ``/model`` without a Hermes release. - """ + """Best known model catalog for a provider: per-provider live fetchers, then the generic profile + fetch, then the static list (merged with models.dev for ``_MODELS_DEV_PREFERRED`` providers).""" requested = str(provider or "").strip().lower() if requested == "ollama": return _ollama_local_catalog(force_refresh) @@ -1689,38 +1286,32 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) models = fetcher(normalized, force_refresh) if models is not None: return models - try: models = _profile_live_catalog(normalized) - if models is not None: - return models except Exception: - pass + models = None + if models is not None: + return models curated_static = list(_PROVIDER_MODELS.get(normalized, [])) - if normalized in _MODELS_DEV_PREFERRED: - merged = _merge_with_models_dev(normalized, curated_static) - if normalized in {"xai", "xai-oauth"}: - return _xai_finalize_catalog(merged) - return merged - return curated_static + if normalized not in _MODELS_DEV_PREFERRED: + return curated_static + merged = _merge_with_models_dev(normalized, curated_static) + return _xai_finalize_catalog(merged) if normalized in {"xai", "xai-oauth"} else merged # --------------------------------------------------------------------------- -# Disk cache for provider_model_ids() — keeps /model picker fast. -# -# Without it every picker open re-fetches every authed provider's /v1/models (2+ s of serial -# round-trips). One JSON file at $HERMES_HOME/provider_models_cache.json; per-provider entries -# keyed by credential fingerprint (rotate OPENAI_API_KEY → entry invalidates); 1h TTL; -# `force_refresh=True` bypasses and overwrites on success; only NON-EMPTY results are cached so -# a transient failure is never pinned; any read/write error degrades silently to a live fetch. +# Disk cache for provider_model_ids() — keeps /model picker fast (otherwise every open re-fetches +# every authed provider's /v1/models). One JSON file at $HERMES_HOME/provider_models_cache.json; +# entries keyed by credential fingerprint (rotate OPENAI_API_KEY → entry invalidates); 1h TTL; +# only NON-EMPTY results are cached so a transient failure is never pinned; any read/write error +# degrades silently to a live fetch. # --------------------------------------------------------------------------- _PROVIDER_MODELS_CACHE_TTL = 3600 # 1h -# Stale-while-revalidate window: an expired-but-same-credentials entry is served IMMEDIATELY -# while a daemon thread refreshes the disk cache for the next open; beyond this bound the entry -# is too old to trust and the caller blocks on a live fetch. Catalogs change on release -# timescales, not hourly, so hour-old data beats stalling every picker surface. +# Stale-while-revalidate window: an expired same-credentials entry is served IMMEDIATELY while a +# daemon thread refreshes the disk cache; beyond this bound the caller blocks on a live fetch. +# Catalogs change on release timescales, so hour-old data beats stalling every picker surface. _PROVIDER_MODELS_STALE_SERVE_MAX = 7 * 24 * 3600 # 7d # Cache keys with a background SWR refresh in flight — dedupes concurrent refreshes. @@ -1743,16 +1334,10 @@ def _ollama_native_probe_reachable() -> bool: def _spawn_swr_refresh(cache_key: str, refresh_fn=None) -> None: - """Kick a background refresh of *cache_key*'s model-id cache entry. - - Fire-and-forget daemon thread; at most one in flight per cache key. Failures are swallowed — the - stale entry stays served until a later refresh succeeds (same degradation the blocking path - already had). - - ``refresh_fn`` (no-args, returns the fresh cache-entry dict or ``None``) lets non-slug keys - (``custom:`` entries from :func:`cached_fetch_api_models`) reuse the same inflight- - dedupe and thread scaffolding. - """ + """Fire-and-forget daemon refresh of *cache_key*'s cache entry, at most one in flight per key. + Failures are swallowed — the stale entry stays served until a later refresh succeeds. + ``refresh_fn`` (no-args → fresh entry dict or None) lets ``custom:`` keys from + :func:`cached_fetch_api_models` reuse the same inflight-dedupe scaffolding.""" with _swr_refresh_lock: if cache_key in _swr_refresh_inflight: return @@ -1760,28 +1345,22 @@ def _spawn_swr_refresh(cache_key: str, refresh_fn=None) -> None: def _default_refresh(): live = provider_model_ids(cache_key, force_refresh=True) - if not live and cache_key == "ollama" and _ollama_native_probe_reachable(): - return _cache_entry(_credential_fingerprint(cache_key), []) - if not live: - return None - return _cache_entry(_credential_fingerprint(cache_key), live) + if live or (cache_key == "ollama" and _ollama_native_probe_reachable()): + return _cache_entry(_credential_fingerprint(cache_key), live or []) + return None def _refresh() -> None: try: entry = (refresh_fn or _default_refresh)() if entry: - cache = _load_provider_models_cache() - cache[cache_key] = entry - _save_provider_models_cache(cache) + _store_cache_entry(cache_key, entry) except Exception: logger.debug("SWR refresh failed for %s", cache_key, exc_info=True) finally: with _swr_refresh_lock: _swr_refresh_inflight.discard(cache_key) - threading.Thread( - target=_refresh, daemon=True, name=f"model-cache-swr-{cache_key}" - ).start() + threading.Thread(target=_refresh, daemon=True, name=f"model-cache-swr-{cache_key}").start() def _provider_models_cache_path() -> Path: @@ -1790,44 +1369,31 @@ def _provider_models_cache_path() -> Path: def _credential_fingerprint(provider: str) -> str: - """Short hash of the credentials ``provider_model_ids(provider)`` would see right now. - - Rotating any relevant env var invalidates that provider's cache entry: the api-key and base- - url env vars from ``PROVIDER_REGISTRY`` are hashed. OAuth-backed providers keep tokens in - ``auth.json`` and external credential files, so those files' mtimes are folded in too — re- - auth busts the cache without parsing every file shape. - """ + """Short hash of the credentials ``provider_model_ids(provider)`` would see right now: api-key / + base-url env vars from ``PROVIDER_REGISTRY`` plus the mtimes of ``auth.json`` and external + credential files (OAuth re-auth busts the cache without parsing every file shape).""" import hashlib - import os as _os - parts: list[str] = [] - - # Keyless providers have no credential to fingerprint: the catalog is - # served anonymously, so nothing the user rotates (env vars, auth files, - # base URLs) should invalidate the cached entry. A stable fingerprint keeps - # the SWR disk cache alive across unrelated re-auths and only busts on TTL - # expiry — matching how the live catalog genuinely changes. + # Keyless providers serve the catalog anonymously: nothing the user rotates should invalidate + # the entry, so a stable fingerprint keeps the SWR cache alive and busts only on TTL expiry. if (provider or "").strip().lower() in _KEYLESS_STABLE_CACHE_PROVIDERS: return "keyless:" + (provider or "").strip().lower() - # Env vars from PROVIDER_REGISTRY for this slug + parts: list[str] = [] try: from hermes_cli.auth import PROVIDER_REGISTRY pcfg = PROVIDER_REGISTRY.get(provider) if pcfg is not None: for ev in getattr(pcfg, "api_key_env_vars", ()) or (): - parts.append(f"{ev}={_os.environ.get(ev, '')}") + parts.append(f"{ev}={os.environ.get(ev, '')}") bev = getattr(pcfg, "base_url_env_var", "") or "" if bev: - parts.append(f"{bev}={_os.environ.get(bev, '')}") + parts.append(f"{bev}={os.environ.get(bev, '')}") except Exception: pass - # Effective configured endpoint: config.yaml's model.base_url changes the - # endpoint discovery probes (data-residency hosts) without touching any - # env var, so it must change the fingerprint too or `hermes config set - # model.base_url ...` keeps serving the previous endpoint's cached - # catalog until TTL expiry. + # config.yaml's model.base_url changes the endpoint discovery probes (data-residency hosts) + # without touching any env var, so it must change the fingerprint too. if provider in ("openai", "openai-api"): try: parts.append(f"effective_base={_openai_discovery_base_url(provider)}") @@ -1835,31 +1401,27 @@ def _credential_fingerprint(provider: str) -> str: pass if provider == "ollama": - parts.append(f"OLLAMA_HOST={_os.environ.get('OLLAMA_HOST', '')}") provider_cfg = _get_provider_config_dict("ollama") - parts.append( - "providers.ollama.base_url=" - f"{provider_cfg.get('base_url', '') or provider_cfg.get('api', '') or provider_cfg.get('url', '')}" - ) - parts.append(f"providers.ollama.api_key={provider_cfg.get('api_key', '')}") key_env = provider_cfg.get("key_env") or provider_cfg.get("api_key_env") or "" - parts.append(f"providers.ollama.key_env={key_env}") - if key_env: - parts.append(f"{key_env}={_os.environ.get(str(key_env), '')}") model_cfg = _get_model_config_dict() - parts.append( - "model.provider=" - f"{model_cfg.get('provider', '')}|model.base_url={model_cfg.get('base_url', '')}" - ) - parts.append( + parts += [ + f"OLLAMA_HOST={os.environ.get('OLLAMA_HOST', '')}", + "providers.ollama.base_url=" + f"{provider_cfg.get('base_url', '') or provider_cfg.get('api', '') or provider_cfg.get('url', '')}", + f"providers.ollama.api_key={provider_cfg.get('api_key', '')}", + f"providers.ollama.key_env={key_env}", + ] + if key_env: + parts.append(f"{key_env}={os.environ.get(str(key_env), '')}") + parts += [ + f"model.provider={model_cfg.get('provider', '')}|model.base_url={model_cfg.get('base_url', '')}", "providers.ollama.extra_headers=" - + json.dumps(provider_cfg.get("extra_headers", {}), sort_keys=True, default=str) - ) + + json.dumps(provider_cfg.get("extra_headers", {}), sort_keys=True, default=str), + ] - # OAuth / external credential-file mtimes that change on re-auth. def _mtime_part(label: str, path) -> None: try: - parts.append(f"{label}@{_os.stat(path).st_mtime_ns}") + parts.append(f"{label}@{os.stat(path).st_mtime_ns}") except FileNotFoundError: parts.append(f"{label}@missing") except Exception: @@ -1873,7 +1435,7 @@ def _credential_fingerprint(provider: str) -> str: pass for rel in ("~/.codex/auth.json", "~/.claude/.credentials.json", "~/.config/github-copilot/hosts.json", "~/.minimax/credentials.json"): - path = _os.path.expanduser(rel) + path = os.path.expanduser(rel) _mtime_part(path, path) blob = "|".join(parts).encode("utf-8", errors="replace") @@ -1901,42 +1463,44 @@ def _save_provider_models_cache(data: dict) -> None: pass -def update_provider_cache_entry(provider: str, models: list[str]) -> None: - """Thread-safe single-entry update of the provider-models disk cache. +def _store_cache_entry(cache_key: str, entry: dict, cache: Optional[dict] = None) -> None: + """Write one row into the disk cache (reloading the latest state unless ``cache`` is given).""" + if cache is None: + cache = _load_provider_models_cache() + cache[cache_key] = entry + _save_provider_models_cache(cache) - Used by parallel prefetch workers so concurrent fetches don't clobber each other's writes via - read-modify-write races on the shared JSON file. Each worker loads the latest cache state under - the lock, writes its own entry, and saves — best-effort, silent on any error. - """ + +def update_provider_cache_entry(provider: str, models: list[str]) -> None: + """Thread-safe single-entry update for parallel prefetch workers: load-modify-save under a lock + so concurrent fetches don't clobber each other's rows. Best-effort, silent on any error.""" try: normalized = normalize_provider(provider) or (provider or "") if not normalized or not models: return fp = _credential_fingerprint(normalized) with _cache_write_lock: - cache = _load_provider_models_cache() - cache[normalized] = _cache_entry(fp, models) - _save_provider_models_cache(cache) + _store_cache_entry(normalized, _cache_entry(fp, models)) except Exception: pass -def cached_provider_model_ids( - provider: Optional[str], - *, - force_refresh: bool = False, - ttl_seconds: int = _PROVIDER_MODELS_CACHE_TTL, -) -> list[str]: - """Disk-cached wrapper around :func:`provider_model_ids`. - - Hits the cache when fresh; otherwise calls the live function and persists a non-empty result. - Always returns a list (never None). - """ +def _normalized_cache_slug(provider: Optional[str]) -> str: + """``ollama`` stays a raw slug (its alias would canonicalize to ``custom``); everything else normalizes.""" requested = str(provider or "").strip().lower() - normalized = requested if requested == "ollama" else (normalize_provider(provider) or (provider or "")) + return requested if requested == "ollama" else (normalize_provider(provider) or (provider or "")) + + +def cached_provider_model_ids( + provider: Optional[str], *, force_refresh: bool = False, + ttl_seconds: int = _PROVIDER_MODELS_CACHE_TTL) -> list[str]: + """Disk-cached :func:`provider_model_ids`: fresh cache hit, else live fetch persisting a non-empty + result. Always returns a list.""" + normalized = _normalized_cache_slug(provider) if not normalized: return [] - if normalized == "ollama": + is_ollama = normalized == "ollama" + if is_ollama: ttl_seconds = min(ttl_seconds, _OLLAMA_LOCAL_MODELS_CACHE_TTL) cache = _load_provider_models_cache() @@ -1944,66 +1508,45 @@ def cached_provider_model_ids( entry = cache.get(normalized) now = time.time() - allow_empty_ollama = normalized == "ollama" - if not force_refresh and _cache_entry_valid(entry, fp, allow_empty=allow_empty_ollama): + if not force_refresh and _cache_entry_valid(entry, fp, allow_empty=is_ollama): age = now - entry["at"] if age < ttl_seconds: return list(entry["models"]) - # Empty native catalogs are authoritative only for the short native - # TTL. Re-probe after expiry so newly pulled models become visible; - # do not serve an empty row through the generic stale window. + # Empty native catalogs are authoritative only for the short native TTL — never served + # through the stale window. Non-empty stale rows are served immediately (SWR) so picker + # opens never block on serial /v1/models round-trips. if entry["models"] and age < _PROVIDER_MODELS_STALE_SERVE_MAX: - # Stale-while-revalidate: serve the expired entry immediately so - # interactive picker opens never block on serial /v1/models - # round-trips; refresh the cache off-thread for the next open. _spawn_swr_refresh(normalized) return list(entry["models"]) - # Cache miss / stale / forced refresh — call the live path. live = provider_model_ids(normalized, force_refresh=force_refresh) if live: - cache[normalized] = _cache_entry(fp, live, now) - _save_provider_models_cache(cache) + _store_cache_entry(normalized, _cache_entry(fp, live, now), cache) return list(live) - if normalized == "ollama": + if is_ollama: if _ollama_native_probe_reachable(): - # A reachable empty native catalog is authoritative for the short - # native TTL; do not resurrect a stale disk catalog. - cache[normalized] = _cache_entry(fp, [], now) - _save_provider_models_cache(cache) + # A reachable empty native catalog is authoritative; do not resurrect a stale disk catalog. + _store_cache_entry(normalized, _cache_entry(fp, [], now), cache) return [] - - # A failed/non-native probe is not authoritative. Preserve a stale - # catalog rather than blanking the picker during a transient outage. - if ( - isinstance(entry, dict) - and entry.get("fp") == fp - and isinstance(entry.get("models"), list) - and entry["models"] - ): + # A failed/non-native probe is not authoritative: keep a stale catalog rather than blanking + # the picker during a transient outage. + same_creds = isinstance(entry, dict) and entry.get("fp") == fp + if same_creds and isinstance(entry.get("models"), list) and entry["models"]: return list(entry["models"]) return [] - - # Live fetch returned nothing. If we have a stale entry with the - # SAME fingerprint, prefer it over an empty result — stale data - # beats no data when the network is flaky. + # Live returned nothing: a stale same-fingerprint entry beats an empty result. if _cache_entry_valid(entry, fp): return list(entry["models"]) - return list(live or []) + return [] def clear_provider_models_cache(provider: Optional[str] = None) -> None: - """Drop a single provider's cache entry, or wipe the whole cache. - - ``provider=None`` wipes everything; otherwise only that provider's entry is removed. Used by - ``/model --refresh`` and ``hermes model --refresh``. - """ + """Drop one provider's cache entry, or wipe the whole cache (``provider=None``). Used by + ``/model --refresh`` and ``hermes model --refresh``.""" try: - # Native Ollama tags are keyed by root URL rather than provider slug. - # A targeted refresh for a custom local-Ollama endpoint cannot identify - # the right root from the provider name alone, so clear this small - # in-process cache on every explicit provider-cache refresh. + # Native Ollama tags are keyed by root URL, not provider slug — a targeted refresh can't + # identify the root from the name alone, so clear this small in-process cache every time. _OLLAMA_LOCAL_MODELS_CACHE.clear() _OLLAMA_LOCAL_PROBE_FAILURE_CACHE.clear() _OLLAMA_LOCAL_PROBE_REACHABLE.clear() @@ -2013,8 +1556,7 @@ def clear_provider_models_cache(provider: Optional[str] = None) -> None: path.unlink() return cache = _load_provider_models_cache() - requested = str(provider or "").strip().lower() - normalized = requested if requested == "ollama" else (normalize_provider(provider) or provider or "") + normalized = _normalized_cache_slug(provider) if normalized in cache: del cache[normalized] _save_provider_models_cache(cache) @@ -2023,44 +1565,29 @@ def clear_provider_models_cache(provider: Optional[str] = None) -> None: def _resolve_anthropic_pool_catalog_credentials() -> tuple[str, str]: - """Return a read-only API-key pool credential for model discovery. - - ``resolve_anthropic_token()`` intentionally ignores ``api_key`` pool entries because its runtime - contract is OAuth-oriented. - """ + """Read-only API-key pool credential for model discovery (``resolve_anthropic_token()`` ignores + ``api_key`` pool entries — its runtime contract is OAuth-oriented).""" try: from agent.credential_pool import AUTH_TYPE_API_KEY from hermes_cli.auth import read_credential_pool for entry in read_credential_pool("anthropic"): - if not isinstance(entry, dict): - continue - if entry.get("auth_type") != AUTH_TYPE_API_KEY: + if not isinstance(entry, dict) or entry.get("auth_type") != AUTH_TYPE_API_KEY: continue token = str(entry.get("access_token") or "").strip() - if not token: - continue - endpoint = str( - entry.get("base_url") or entry.get("inference_base_url") or "" - ).strip() - return token, endpoint + if token: + return token, str(entry.get("base_url") or entry.get("inference_base_url") or "").strip() except Exception: pass return "", "" def _fetch_anthropic_models( - timeout: float = 5.0, - *, - base_url: Optional[str] = None, - api_key: Optional[str] = None, + timeout: float = 5.0, *, base_url: Optional[str] = None, api_key: Optional[str] = None ) -> Optional[list[str]]: - """Fetch available models from the Anthropic /v1/models endpoint. - - Uses resolve_anthropic_token() to find credentials (env vars, OAuth, or Claude Code auto- - discovery) unless api_key is provided explicitly. If those sources are empty, a read-only API- - key credential_pool entry is used. Returns sorted model IDs or None. - """ + """Sorted model ids from the Anthropic /v1/models endpoint, or None. Credentials: explicit + ``api_key``, else ``resolve_anthropic_token()`` (env / OAuth / Claude Code), else a read-only + API-key credential_pool entry.""" try: from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token except ImportError: @@ -2069,8 +1596,8 @@ def _fetch_anthropic_models( resolved_base_url = base_url token = (api_key or "").strip() or resolve_anthropic_token() if not token: - # A pool credential and its endpoint are one security boundary. Never - # pair the selected pool key with a caller-provided model endpoint. + # A pool credential and its endpoint are one security boundary — never pair the pool key + # with a caller-provided endpoint. token, resolved_base_url = _resolve_anthropic_pool_catalog_credentials() if not token: return None @@ -2084,21 +1611,13 @@ def _fetch_anthropic_models( else: headers["x-api-key"] = token - def _do_request(h: dict[str, str]): - req = urllib.request.Request( - _anthropic_models_url(resolved_base_url), - headers=h, - ) - with _urlopen_model_catalog_request(req, timeout=timeout) as resp: - return json.loads(resp.read().decode()) - + url = _anthropic_models_url(resolved_base_url) try: try: - data = _do_request(headers) + data = _get_json(url, timeout=timeout, headers=headers) except urllib.error.HTTPError as http_err: - # Reactive recovery for OAuth subscriptions that 400 the 1M context beta ("long context - # beta is not yet available for this subscription"): retry once without it; re-raise - # anything else so the outer except logs it. + # OAuth subscriptions that 400 the 1M context beta ("long context beta is not yet + # available for this subscription"): retry once without it; re-raise anything else. if not (is_oauth and http_err.code == 400): raise try: @@ -2110,7 +1629,7 @@ def _fetch_anthropic_models( headers["anthropic-beta"] = ",".join( [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS) ) - data = _do_request(headers) + data = _get_json(url, timeout=timeout, headers=headers) models = [m["id"] for m in data.get("data", []) if m.get("id")] # opus, then sonnet, then haiku; alphabetical within tier. return sorted(models, key=lambda m: ("opus" not in m, "sonnet" not in m, "haiku" not in m, m)) @@ -2134,38 +1653,28 @@ def copilot_default_headers(*, is_agent_turn: bool = True) -> dict[str, str]: "Editor-Version": COPILOT_EDITOR_VERSION, "User-Agent": "HermesAgent/1.0", "Openai-Intent": "conversation-edits", - "x-initiator": "agent" if is_agent_turn else "user", - } + "x-initiator": "agent" if is_agent_turn else "user"} + + +_COPILOT_CHAT_ENDPOINTS = {"/chat/completions", "/responses", "/v1/messages"} def _copilot_catalog_item_is_text_model( - item: dict[str, Any], *, ignore_picker_flag: bool = False -) -> bool: - model_id = str(item.get("id") or "").strip() - if not model_id: + item: dict[str, Any], *, ignore_picker_flag: bool = False) -> bool: + if not str(item.get("id") or "").strip(): return False - if not ignore_picker_flag and item.get("model_picker_enabled") is False: return False - capabilities = item.get("capabilities") if isinstance(capabilities, dict): model_type = str(capabilities.get("type") or "").strip().lower() if model_type and model_type != "chat": return False - supported_endpoints = item.get("supported_endpoints") if isinstance(supported_endpoints, list): - normalized_endpoints = { - str(endpoint).strip() - for endpoint in supported_endpoints - if str(endpoint).strip() - } - if normalized_endpoints and not normalized_endpoints.intersection( - {"/chat/completions", "/responses", "/v1/messages"} - ): + endpoints = {e for endpoint in supported_endpoints if (e := str(endpoint).strip())} + if endpoints and not endpoints & _COPILOT_CHAT_ENDPOINTS: return False - return True @@ -2174,20 +1683,19 @@ def _copilot_text_models(items: list[dict[str, Any]], *, ignore_picker_flag: boo models: list[dict[str, Any]] = [] seen_ids: set[str] = set() for item in items: - if not _copilot_catalog_item_is_text_model(item, ignore_picker_flag=ignore_picker_flag): - continue model_id = str(item.get("id") or "").strip() - if not model_id or model_id in seen_ids: + if model_id in seen_ids: + continue + if not _copilot_catalog_item_is_text_model(item, ignore_picker_flag=ignore_picker_flag): continue seen_ids.add(model_id) models.append(item) return models -# Short-TTL cache of the filtered GitHub Copilot /models catalog: the picker path and the -# context/normalize helpers all ask for it in one process. Keyed by the api_key of the successful -# fetch so a credential swap never serves the previous account's catalog; monotonic clock so -# wall-clock adjustments can't extend the TTL; lock-free (a race at worst duplicates one fetch). +# Short-TTL cache of the filtered GitHub Copilot /models catalog (picker + context/normalize helpers +# share it). Keyed by the api_key of the successful fetch so a credential swap never serves the +# previous account's catalog; monotonic clock; lock-free (a race at worst duplicates one fetch). _github_model_catalog_cache: Optional[list[dict[str, Any]]] = None _github_model_catalog_cache_key: Optional[str] = None _github_model_catalog_cache_time: float = 0.0 @@ -2195,8 +1703,7 @@ _GITHUB_MODEL_CATALOG_CACHE_TTL = 300 # 5 minutes def fetch_github_model_catalog( - api_key: Optional[str] = None, timeout: float = 5.0 -) -> Optional[list[dict[str, Any]]]: + api_key: Optional[str] = None, timeout: float = 5.0) -> Optional[list[dict[str, Any]]]: """Fetch the live GitHub Copilot model catalog for this account.""" global _github_model_catalog_cache, _github_model_catalog_cache_key global _github_model_catalog_cache_time @@ -2206,9 +1713,7 @@ def fetch_github_model_catalog( and _github_model_catalog_cache_key == api_key and (time.monotonic() - _github_model_catalog_cache_time) < _GITHUB_MODEL_CATALOG_CACHE_TTL ): - # Deep copy: catalog items are dicts, and a shallow copy would let - # callers mutate the cached entries in place. - return copy.deepcopy(_github_model_catalog_cache) + return copy.deepcopy(_github_model_catalog_cache) # deep: callers must not mutate cached dicts attempts: list[dict[str, str]] = [] if api_key: @@ -2216,30 +1721,26 @@ def fetch_github_model_catalog( attempts.append(copilot_default_headers()) for headers in attempts: - req = urllib.request.Request(COPILOT_MODELS_URL, headers=headers) try: - with _urlopen_model_catalog_request(req, timeout=timeout) as resp: - data = json.loads(resp.read().decode()) - items = _payload_items(data) - models = _copilot_text_models(items) - if not models and items: - # GitHub has been observed returning ``model_picker_enabled: false`` for EVERY - # model on some accounts/token types, which would strand the picker on the - # stale curated fallback. The flag is a display hint, not an availability - # contract — when honoring it empties the catalog, retry without it - # (chat/endpoint checks still apply, so non-chat rows stay excluded). - models = _copilot_text_models(items, ignore_picker_flag=True) - if models: - _github_model_catalog_cache = copy.deepcopy(models) - _github_model_catalog_cache_key = api_key - _github_model_catalog_cache_time = time.monotonic() - return models + items = _payload_items(_get_json(COPILOT_MODELS_URL, timeout=timeout, headers=headers)) except Exception: continue + models = _copilot_text_models(items) + if not models and items: + # GitHub has been observed returning ``model_picker_enabled: false`` for EVERY model on + # some accounts, which would strand the picker on the stale curated fallback. The flag + # is a display hint, not an availability contract — retry without it (chat/endpoint + # checks still apply). + models = _copilot_text_models(items, ignore_picker_flag=True) + if models: + _github_model_catalog_cache = copy.deepcopy(models) + _github_model_catalog_cache_key = api_key + _github_model_catalog_cache_time = time.monotonic() + return models return None -# ─── Copilot catalog context-window helpers ───────────────────────────────── +# ─── Copilot catalog context-window helpers ─── # Module-level cache: {model_id: max_prompt_tokens} _copilot_context_cache: dict[str, int] = {} @@ -2248,15 +1749,12 @@ _COPILOT_CONTEXT_CACHE_TTL = 3600 # 1 hour def get_copilot_model_context(model_id: str, api_key: Optional[str] = None) -> Optional[int]: - """Look up max_prompt_tokens for a Copilot model from the live /models API. - - Results are cached in-process for 1 hour to avoid repeated API calls. Returns the token limit or - None if not found. - """ + """``max_prompt_tokens`` for a Copilot model from the live /models API (cached in-process 1h; a + miss on a fresh cache does not re-fetch), or None.""" global _copilot_context_cache, _copilot_context_cache_time if _copilot_context_cache and (time.time() - _copilot_context_cache_time < _COPILOT_CONTEXT_CACHE_TTL): - return _copilot_context_cache.get(model_id) # fresh cache: a miss does not re-fetch + return _copilot_context_cache.get(model_id) catalog = fetch_github_model_catalog(api_key=api_key) if not catalog: @@ -2273,36 +1771,26 @@ def get_copilot_model_context(model_id: str, api_key: Optional[str] = None) -> O def _is_github_models_base_url(base_url: Optional[str]) -> bool: - normalized = (base_url or "").strip().rstrip("/").lower() - return ( - normalized.startswith(COPILOT_BASE_URL) - or normalized.startswith("https://models.github.ai/inference") - or normalized.startswith("https://models.inference.ai.azure.com") + return (base_url or "").strip().rstrip("/").lower().startswith( + (COPILOT_BASE_URL, "https://models.github.ai/inference", "https://models.inference.ai.azure.com") ) def _fetch_github_models(api_key: Optional[str] = None, timeout: float = 5.0) -> Optional[list[str]]: catalog = fetch_github_model_catalog(api_key=api_key, timeout=timeout) - if not catalog: - return None - return [item.get("id", "") for item in catalog if item.get("id")] + return [item.get("id", "") for item in catalog if item.get("id")] if catalog else None def _copilot_catalog_ids( - catalog: Optional[list[dict[str, Any]]] = None, - api_key: Optional[str] = None, -) -> set[str]: + catalog: Optional[list[dict[str, Any]]] = None, api_key: Optional[str] = None) -> set[str]: if catalog is None and api_key: catalog = fetch_github_model_catalog(api_key=api_key) return {mid for item in (catalog or []) if (mid := str(item.get("id") or "").strip())} def normalize_copilot_model_id( - model_id: Optional[str], - *, - catalog: Optional[list[dict[str, Any]]] = None, - api_key: Optional[str] = None, -) -> str: + model_id: Optional[str], *, catalog: Optional[list[dict[str, Any]]] = None, + api_key: Optional[str] = None) -> str: raw = str(model_id or "").strip() if not raw: return "" @@ -2344,70 +1832,45 @@ def _github_reasoning_efforts_for_model_id(model_id: str) -> list[str]: def _should_use_copilot_responses_api(model_id: str) -> bool: - """Decide whether a Copilot model should use the Responses API. - - Replicates opencode's ``shouldUseCopilotResponsesApi``: GPT-5+ models use the Responses API - except ``gpt-5-mini``; all non-GPT models (Claude, Gemini, ...) use Chat Completions. - """ - import re - + """opencode's ``shouldUseCopilotResponsesApi``: GPT-5+ uses the Responses API except + ``gpt-5-mini``; non-GPT models (Claude, Gemini, ...) use Chat Completions.""" match = re.match(r"^gpt-(\d+)", model_id) - if not match: - return False - major = int(match.group(1)) - return major >= 5 and not model_id.startswith("gpt-5-mini") + return bool(match) and int(match.group(1)) >= 5 and not model_id.startswith("gpt-5-mini") def copilot_model_api_mode( - model_id: Optional[str], - *, - catalog: Optional[list[dict[str, Any]]] = None, - api_key: Optional[str] = None, -) -> str: - """Determine the API mode for a Copilot model. - - Uses the model ID pattern (matching opencode's approach) as the primary signal. Falls back to - the catalog's ``supported_endpoints`` only for models not covered by the pattern check. - """ + model_id: Optional[str], *, catalog: Optional[list[dict[str, Any]]] = None, + api_key: Optional[str] = None) -> str: + """API mode for a Copilot model from the id pattern (opencode's approach). Copilot's Claude models + go through its OpenAI-compatible chat endpoint, not the native Anthropic adapter: the catalog may + advertise /v1/messages but the Copilot token/header scheme lives in the OpenAI client path.""" if catalog is None and api_key: # fetch once so normalize + endpoint check share it catalog = fetch_github_model_catalog(api_key=api_key) normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key) if normalized and _should_use_copilot_responses_api(normalized): return "codex_responses" - # Copilot's Claude models go through its OpenAI-compatible chat endpoint, not Hermes' native - # Anthropic adapter: the catalog may advertise /v1/messages, but the Copilot token/header - # scheme lives in the OpenAI client path, so anthropic_messages would send the wrong wire shape. return "chat_completions" def azure_foundry_model_api_mode(model_name: Optional[str]) -> Optional[str]: - """Infer Azure Foundry api_mode from a deployment/model name. + """``"codex_responses"`` for families that only accept the Responses API on Azure Foundry (GPT-5.x + incl. gpt-5-mini, codex, o1/o3/o4), else None. Any ``vendor/`` prefix is stripped first.""" + raw = str(model_name or "").strip().lower().rsplit("/", 1)[-1] + return "codex_responses" if raw and raw.startswith(tuple(_AZURE_FOUNDRY_RESPONSES_PREFIXES)) else None - Returns ``"codex_responses"`` when the model name matches a family that only accepts the - Responses API on Azure Foundry (GPT-5.x, codex, o1/o3/o4 reasoning models). - """ - raw = str(model_name or "").strip().lower() - if not raw: - return None - # Strip any vendor/ prefix copied from OpenRouter / Copilot. Unlike Copilot, Azure Foundry - # deploys the whole gpt-5 family (incl. gpt-5-mini) on Responses — no exception carved here. - raw = raw.rsplit("/", 1)[-1] - return "codex_responses" if raw.startswith(tuple(_AZURE_FOUNDRY_RESPONSES_PREFIXES)) else None + +_OPENCODE_FAMILIES = ("opencode-free", "opencode-go", "opencode-zen") def opencode_provider_family(provider_id: Optional[str]) -> Optional[str]: - """Resolve a provider id to its OpenCode family, or None. - - ``opencode-go`` is checked before ``opencode-zen`` but the two slugs are not prefixes of each - other, so order is cosmetic. - """ + """Resolve a provider id (canonical or prefixed) to its OpenCode family, or None.""" raw = str(provider_id or "").strip().lower() if not raw: return None canonical = normalize_provider(provider_id) - if canonical in {"opencode-zen", "opencode-go", "opencode-free"}: + if canonical in _OPENCODE_FAMILIES: return canonical - return next((f for f in ("opencode-free", "opencode-go", "opencode-zen") if raw.startswith(f)), None) + return next((f for f in _OPENCODE_FAMILIES if raw.startswith(f)), None) def normalize_opencode_model_id(provider_id: Optional[str], model_id: Optional[str]) -> str: @@ -2416,13 +1879,9 @@ def normalize_opencode_model_id(provider_id: Optional[str], model_id: Optional[s current = str(model_id or "").strip() if not current or family is None: return current - - prefix = f"{provider_id}/" if provider_id else f"{family}/" - if current.lower().startswith(prefix.lower()): - return current[len(prefix):] - fallback_prefix = f"{family}/" - if current.lower().startswith(fallback_prefix.lower()): - return current[len(fallback_prefix):] + for prefix in (f"{provider_id or family}/", f"{family}/"): + if current.lower().startswith(prefix.lower()): + return current[len(prefix):] return current @@ -2445,12 +1904,9 @@ _OPENCODE_FREE_LIVE_MEMO_TTL = 300.0 # 5 min; SWR disk cache handles the rest def opencode_zen_free_headers() -> dict: - """Client default_headers for anonymous OpenCode Zen free-tier requests. - - ``Authorization: ""`` overrides the OpenAI SDK's ``Bearer `` header so the placeholder - key never reaches the wire — the Zen relay accepts anonymous requests for free models but 401s - any unknown bearer. Attribution headers mirror the opencode provider profile. - """ + """Client default_headers for anonymous Zen free-tier requests. ``Authorization: ""`` overrides the + OpenAI SDK's ``Bearer `` so the placeholder never reaches the wire (the relay 401s any + unknown bearer). Attribution headers mirror the opencode provider profile.""" try: from hermes_cli import __version__ as _v except Exception: @@ -2459,32 +1915,22 @@ def opencode_zen_free_headers() -> dict: "Authorization": "", "HTTP-Referer": "https://hermes-agent.nousresearch.com", "X-Title": "Hermes Agent", - "User-Agent": f"HermesAgent/{_v}", - } + "User-Agent": f"HermesAgent/{_v}"} def _fetch_opencode_free_models( - timeout: float = 8.0, *, force_refresh: bool = False -) -> Optional[list[str]]: - """Fetch the live keyless OpenCode Free catalog from the Zen relay. - - The Zen ``/models`` dump also lists paid/subscription IDs (e.g. Go ``ox-alpha-free`` is KEYED - despite the suffix), so a bare ``*-free`` suffix filter is not safe on its own — this mirrors - the existing ``opencode_zen_free_runtime`` contract, which uses membership in the verified - keyless catalog as the routing criterion. - """ - import urllib.request - + timeout: float = 8.0, *, force_refresh: bool = False) -> Optional[list[str]]: + """Live keyless OpenCode Free catalog from the Zen relay, filtered to the anonymous-servable + ``*-free`` tier minus known keyed twins (Go ``ox-alpha-free`` is KEYED despite the suffix) — the + same membership criterion ``opencode_zen_free_runtime`` routes on.""" from hermes_cli.urllib_security import open_credentialed_url now = time.time() - if not force_refresh: - memo = _opencode_free_live_memo - if memo is not None and now - memo[0] < _OPENCODE_FREE_LIVE_MEMO_TTL: - return list(memo[1]) if memo[1] else None + memo = _opencode_free_live_memo + if not force_refresh and memo is not None and now - memo[0] < _OPENCODE_FREE_LIVE_MEMO_TTL: + return list(memo[1]) if memo[1] else None - url = f"{_OPENCODE_ZEN_FREE_BASE_URL.rstrip('/')}/models" - req = urllib.request.Request(url) + req = urllib.request.Request(f"{_OPENCODE_ZEN_FREE_BASE_URL.rstrip('/')}/models") req.add_header("Accept", "application/json") for k, v in opencode_zen_free_headers().items(): if k.lower() != "authorization": # never send a bearer keylessly @@ -2496,16 +1942,12 @@ def _fetch_opencode_free_models( except Exception: _set_opencode_free_live_memo(None) return None - ids = [m["id"] for m in items if isinstance(m, dict) and isinstance(m.get("id"), str)] - # Filter to the anonymous-servable free tier. The Zen dump can contain - # keyed/Go IDs; only the verified free set belongs in the keyless picker. live_free = [ - mid - for mid in ids - if mid.lower().endswith("-free") - and mid.lower() not in _OPENCODE_FREE_KEYED_SUFFIX_MODELS + m["id"] for m in items + if isinstance(m, dict) and isinstance(m.get("id"), str) + and m["id"].lower().endswith("-free") and m["id"].lower() not in _OPENCODE_FREE_KEYED_SUFFIX_MODELS ] - result = live_free if live_free else None + result = live_free or None _set_opencode_free_live_memo(result) return result @@ -2516,12 +1958,9 @@ def _set_opencode_free_live_memo(ids: Optional[list[str]]) -> None: def _opencode_free_known_model_slugs() -> set[str]: - """Lowercased keyless free-tier slugs known right now — WITHOUT network I/O. - - Union of the static ``_PROVIDER_MODELS["opencode-free"]`` floor, the in-process live memo, and - the SWR disk-cache entry. Used by the ``opencode_zen_free_runtime`` healing path, which runs - during model resolution and must never block on a live fetch. - """ + """Lowercased keyless free-tier slugs known right now WITHOUT network I/O: static floor ∪ live + memo ∪ SWR disk-cache entry. The ``opencode_zen_free_runtime`` healing path runs during model + resolution and must never block on a fetch.""" known = {m.lower() for m in _PROVIDER_MODELS.get("opencode-free", [])} memo = _opencode_free_live_memo if memo is not None and memo[1]: @@ -2535,38 +1974,26 @@ def _opencode_free_known_model_slugs() -> set[str]: def opencode_zen_free_runtime(provider_id: Optional[str], model_id: Optional[str]) -> Optional[dict]: - """Keyless runtime entry for an OpenCode Zen free-tier model, or None. - - - ``provider_id`` is ``opencode-free`` (the dedicated keyless provider — EVERY model on it - routes anonymously; that is the provider's contract), or - ``provider_id`` is any other - OpenCode-family provider and ``model_id`` is in the VERIFIED keyless catalog - (``_PROVIDER_MODELS["opencode-free"]``) — heals a free-model selection made under opencode- - zen/opencode-go, whose keys the free tier rejects. - - Membership means the union of the cached LIVE keyless catalog (in-process memo / SWR disk cache - — never a blocking fetch on this hot path) and the static floor, so a newly-live free model - heals without a release. - """ + """Keyless runtime entry for an OpenCode Zen free-tier model, or None. Fires when ``provider_id`` + is ``opencode-free`` (EVERY model on it routes anonymously) or when any other OpenCode-family + provider selected a model in the known keyless catalog (static floor ∪ cached live catalog — + never a blocking fetch), healing a free-model pick made under Zen/Go whose keys the free tier + rejects.""" family = opencode_provider_family(provider_id) if family is None: return None - if family != "opencode-free": - bare = normalize_opencode_model_id(provider_id, model_id).strip().lower() - if bare not in _opencode_free_known_model_slugs(): - return None normalized = normalize_opencode_model_id(provider_id, model_id) + if family != "opencode-free" and normalized.strip().lower() not in _opencode_free_known_model_slugs(): + return None api_mode = opencode_model_api_mode("opencode-zen", normalized) - base_url = normalize_opencode_base_url( - "opencode-zen", api_mode, _OPENCODE_ZEN_FREE_BASE_URL - ) + base_url = normalize_opencode_base_url("opencode-zen", api_mode, _OPENCODE_ZEN_FREE_BASE_URL) return { "provider": family, "api_mode": api_mode, "base_url": base_url, "api_key": OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER, "default_headers": opencode_zen_free_headers(), - "source": "opencode-zen-free-keyless", - } + "source": "opencode-zen-free-keyless"} # Per-family (model-id prefix → api_mode) routing from OpenCode's published Zen/Go endpoint @@ -2576,14 +2003,10 @@ def opencode_zen_free_runtime(provider_id: Optional[str], model_id: Optional[str _OPENCODE_API_MODE_PREFIXES: dict[str, tuple[tuple[tuple[str, ...], str], ...]] = { "opencode-go": ( (("gpt-", "grok-", "muse-spark"), "codex_responses"), - (("minimax-", "qwen"), "anthropic_messages"), - ), + (("minimax-", "qwen"), "anthropic_messages")), "opencode-zen": ( - (("claude-",), "anthropic_messages"), - (("gpt-", "grok-", "muse-spark"), "codex_responses"), - (("qwen",), "anthropic_messages"), - ), -} + (("claude-",), "anthropic_messages"), (("gpt-", "grok-", "muse-spark"), "codex_responses"), + (("qwen",), "anthropic_messages"))} def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) -> str: @@ -2600,45 +2023,28 @@ def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) def normalize_opencode_base_url( - provider_id: Optional[str], api_mode: Optional[str], base_url: Optional[str] -) -> str: - """Normalize an OpenCode Zen / Go base URL for the target API mode. - - Crucially this must be SYMMETRIC. The stripped URL gets persisted to config (``model.base_url``) - by the TUI/desktop and gateway after switching into an anthropic-routed model (e.g. minimax-m2.7 - on Go). - - Only opencode.ai-hosted URLs are re-suffixed; custom proxy overrides via ``OPENCODE_*_BASE_URL`` - are left alone unless they already carry ``/v1``. - """ + provider_id: Optional[str], api_mode: Optional[str], base_url: Optional[str]) -> str: + """Normalize an OpenCode Zen / Go base URL for the API mode. Must be SYMMETRIC: the anthropic- + stripped URL gets persisted to ``model.base_url`` after switching into an anthropic-routed model, + and chat/codex modes heal it by re-adding ``/v1`` — but only on opencode.ai hosts, so custom + ``OPENCODE_*_BASE_URL`` proxies are left alone.""" url = str(base_url or "").strip().rstrip("/") - if not url: + if not url or opencode_provider_family(provider_id) is None: return url - if opencode_provider_family(provider_id) is None: - return url - if api_mode == "anthropic_messages": return re.sub(r"/v1$", "", url) - - # chat_completions / codex_responses: ensure the /v1 suffix is present on - # official opencode.ai hosts (heals a persisted anthropic-stripped URL). if url.endswith("/v1"): return url try: host = urllib.parse.urlparse(url).netloc.lower() except Exception: host = "" - if host == "opencode.ai" or host.endswith(".opencode.ai"): - return url + "/v1" - return url + return url + "/v1" if host == "opencode.ai" or host.endswith(".opencode.ai") else url def github_model_reasoning_efforts( - model_id: Optional[str], - *, - catalog: Optional[list[dict[str, Any]]] = None, - api_key: Optional[str] = None, -) -> list[str]: + model_id: Optional[str], *, catalog: Optional[list[dict[str, Any]]] = None, + api_key: Optional[str] = None) -> list[str]: """Return supported reasoning-effort levels for a Copilot-visible model.""" normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key) if not normalized: @@ -2647,58 +2053,47 @@ def github_model_reasoning_efforts( if catalog is None and api_key: catalog = fetch_github_model_catalog(api_key=api_key) catalog_entry = next((item for item in catalog if item.get("id") == normalized), None) if catalog else None - if catalog_entry is not None: capabilities = catalog_entry.get("capabilities") if isinstance(capabilities, dict): # Structured catalog: the advertised list is authoritative (empty when absent). supports = capabilities.get("supports") efforts = supports.get("reasoning_effort") if isinstance(supports, dict) else None - if isinstance(efforts, list): - return list(dict.fromkeys(e for effort in efforts if (e := str(effort).strip().lower()))) - return [] + if not isinstance(efforts, list): + return [] + return list(dict.fromkeys(e for effort in efforts if (e := str(effort).strip().lower()))) # Legacy list-shaped capabilities: only a "reasoning" tag unlocks the pattern defaults. if "reasoning" not in {str(c).strip().lower() for c in catalog_entry.get("capabilities", [])}: return [] - return _github_reasoning_efforts_for_model_id(str(model_id or normalized)) -def _probe_result(models, probed_url, resolved_base_url, suggested_base_url=None, used_fallback=False) -> dict[str, Any]: +def _probe_result( + models, probed_url, resolved_base_url, suggested_base_url=None, used_fallback=False +) -> dict[str, Any]: return { "models": models, "probed_url": probed_url, "resolved_base_url": resolved_base_url, "suggested_base_url": suggested_base_url, - "used_fallback": used_fallback, - } + "used_fallback": used_fallback} def probe_api_models( - api_key: Optional[str], - base_url: Optional[str], - timeout: float = 5.0, - api_mode: Optional[str] = None, - request_headers: Optional[dict[str, str]] = None, + api_key: Optional[str], base_url: Optional[str], timeout: float = 5.0, + api_mode: Optional[str] = None, request_headers: Optional[dict[str, str]] = None, ) -> dict[str, Any]: """Probe a ``/models`` endpoint with light URL heuristics (``base`` then ``base±/v1``). - - For ``anthropic_messages`` mode, sends ``x-api-key`` and ``anthropic-version`` headers - instead of ``Authorization: Bearer``; the response shape (``data[].id``) is identical so one - parser serves both. ``models`` is None when no candidate answered. - """ + ``anthropic_messages`` mode sends ``x-api-key`` + ``anthropic-version`` instead of a bearer; the + ``data[].id`` response shape is identical. ``models`` is None when no candidate answered.""" normalized = (base_url or "").strip().rstrip("/") if not normalized: return _probe_result(None, None, "") - if _is_github_models_base_url(normalized): - return _probe_result(_fetch_github_models(api_key=api_key, timeout=timeout), COPILOT_MODELS_URL, COPILOT_BASE_URL) - - if normalized.endswith("/v1"): - alternate_base = normalized[:-3].rstrip("/") - else: - alternate_base = normalized + "/v1" + models = _fetch_github_models(api_key=api_key, timeout=timeout) + return _probe_result(models, COPILOT_MODELS_URL, COPILOT_BASE_URL) + alternate_base = normalized[:-3].rstrip("/") if normalized.endswith("/v1") else normalized + "/v1" candidates: list[tuple[str, bool]] = [(normalized, False)] if alternate_base and alternate_base != normalized: candidates.append((alternate_base, True)) @@ -2715,70 +2110,51 @@ def probe_api_models( if normalized.startswith(COPILOT_BASE_URL): headers.update(copilot_default_headers()) if isinstance(request_headers, dict): - # Per-provider custom headers can contain auth/proxy secrets. Merge last so - # endpoint-specific config wins, and never log the values. + # Per-provider custom headers can contain secrets: merge last so endpoint config wins; never log. from hermes_cli.config import normalize_extra_headers headers.update(normalize_extra_headers(request_headers)) - # Only thread ssl_context when a per-provider TLS override applies; public/unconfigured - # endpoints keep the original 2-arg call so existing call-seam mocks stay valid. - _open_kwargs: dict[str, Any] = {"timeout": timeout} + # Only thread ssl_context when a per-provider TLS override applies; public endpoints keep the + # original 2-arg call so existing call-seam mocks stay valid. + _open_kwargs: dict[str, Any] = {} _ssl_context = _custom_provider_ssl_context(normalized) if _ssl_context is not None: _open_kwargs["ssl_context"] = _ssl_context for candidate_base, is_fallback in candidates: url = candidate_base.rstrip("/") + "/models" tried.append(url) - req = urllib.request.Request(url, headers=headers) try: - with _urlopen_model_catalog_request(req, **_open_kwargs) as resp: - data = json.loads(resp.read().decode()) - return _probe_result( - [m.get("id", "") for m in data.get("data", [])], - url, - candidate_base.rstrip("/"), - alternate_base if alternate_base != candidate_base else normalized, - is_fallback, - ) + data = _get_json(url, timeout=timeout, headers=headers, **_open_kwargs) except Exception: continue - + return _probe_result( + [m.get("id", "") for m in data.get("data", [])], url, candidate_base.rstrip("/"), + alternate_base if alternate_base != candidate_base else normalized, is_fallback) return _probe_result( - None, - tried[0] if tried else normalized.rstrip("/") + "/models", - normalized, - alternate_base if alternate_base != normalized else None, - ) + None, tried[0] if tried else normalized.rstrip("/") + "/models", normalized, + alternate_base if alternate_base != normalized else None) -# Legacy filter — used when an item has no surface tag (rolling out -# 2026-05). Once every model returned by the catalog endpoint carries an -# explicit surface tag (``chat``/``embed``/``image-gen``/``tts``/``stt``) -# the regex path becomes unreachable and can be removed. +# Legacy id-regex filter for items with no surface tag; unreachable (deletable) once every catalog +# entry carries an explicit ``chat``/``embed``/``image-gen``/``tts``/``stt`` tag. _DEEPINFRA_EXCLUDE_RE = re.compile( r"(?i)(embed|rerank|whisper|stable-diffusion|flux|sdxl|" - r"tts|bark|speech|image-gen|clip|vit-|dpt-)", -) + r"tts|bark|speech|image-gen|clip|vit-|dpt-)") -# Surface tags announce *what kind of model* this is. When none of these -# are present on a catalog entry, the tags array only carries capability -# tags (``reasoning``, ``vision``, ``prompt_cache``, …) and we have to -# fall back to id-regex inference for the chat surface. +# Surface tags say *what kind of model* this is. Absent all of them, the tags array only carries +# capability tags (``reasoning``, ``vision``, …) and the chat surface falls back to id-regex inference. _DEEPINFRA_SURFACE_TAGS: frozenset[str] = frozenset({ - "chat", "embed", "image-gen", "tts", "stt", "video-gen", -}) + "chat", "embed", "image-gen", "tts", "stt", "video-gen"}) _DEEPINFRA_DEFAULT_BASE_URL = "https://api.deepinfra.com/v1/openai" _DEEPINFRA_MODELS_QUERY = "filter=true&sort_by=hermes" -# Full tagged catalog (parsed ``data`` list) keyed by base URL; every surface filter (chat / -# image-gen / tts / stt) reads it so one round-trip serves the whole process. +# Full tagged catalog keyed by base URL; every surface filter reads it so one round-trip serves all. _deepinfra_catalog_cache: dict[str, list[dict]] = {} -# Negative cache: monotonic time of the last failed fetch per base URL. Without it an -# unreachable catalog makes every surface helper re-attempt a blocking fetch that eats the full -# timeout — several sequential stalls in one operation. Short TTL so connectivity can recover. +# Negative cache (monotonic time of the last failed fetch per base URL) so an unreachable catalog +# doesn't make every surface helper eat the full timeout in turn. Short TTL so connectivity recovers. _deepinfra_catalog_neg_cache: dict[str, float] = {} _DEEPINFRA_CATALOG_NEG_TTL = 60.0 # seconds @@ -2791,16 +2167,9 @@ def _deepinfra_catalog_url() -> tuple[str, str]: def _fetch_deepinfra_catalog( - *, - timeout: float = 5.0, - force_refresh: bool = False, -) -> Optional[list[dict]]: - """Fetch the raw DeepInfra catalog list with module-level caching. - - The endpoint serves chat, embed, image-gen, TTS and STT models in one response. Auth is - optional but a Bearer token is attached when available so user-scoped catalogs (private - fine-tunes) show. - """ + *, timeout: float = 5.0, force_refresh: bool = False) -> Optional[list[dict]]: + """Raw DeepInfra catalog list (chat, embed, image-gen, TTS, STT in one response), cached per base + URL. A Bearer token is attached when available so user-scoped catalogs (private fine-tunes) show.""" cache_key, url = _deepinfra_catalog_url() if not force_refresh: if cache_key in _deepinfra_catalog_cache: @@ -2813,49 +2182,33 @@ def _fetch_deepinfra_catalog( api_key = os.getenv("DEEPINFRA_API_KEY", "").strip() if api_key: headers["Authorization"] = f"Bearer {api_key}" - - req = urllib.request.Request(url, headers=headers) try: - with _urlopen_model_catalog_request(req, timeout=timeout) as resp: - payload = json.loads(resp.read().decode()) + payload = _get_json(url, timeout=timeout, headers=headers) except Exception: _deepinfra_catalog_neg_cache[cache_key] = time.monotonic() return None - data = payload.get("data") if not isinstance(data, list): _deepinfra_catalog_neg_cache[cache_key] = time.monotonic() return None - _deepinfra_catalog_cache[cache_key] = data _deepinfra_catalog_neg_cache.pop(cache_key, None) return data def _fetch_deepinfra_models_by_tag( - tag: str, - *, - timeout: float = 5.0, - force_refresh: bool = False, -) -> Optional[list[dict]]: - """Return DeepInfra models whose ``metadata.tags`` includes *tag*. - - Each item is ``{"id", "metadata"}`` so callers can inspect context length, pricing and - units. For the chat surface, items with no ``tags`` field fall through to the legacy name- - regex exclusion so this keeps working while the tag rollout is in flight. Returns ``None`` - on network failure. - """ + tag: str, *, timeout: float = 5.0, force_refresh: bool = False) -> Optional[list[dict]]: + """DeepInfra ``{"id", "metadata"}`` items whose ``metadata.tags`` includes *tag*. Items with no + surface tag fall through to the legacy id-regex exclusion (chat surface only — embed/image-gen/ + tts/stt cannot be inferred from an id). ``None`` on network failure.""" data = _fetch_deepinfra_catalog(timeout=timeout, force_refresh=force_refresh) if data is None: return None - matched: list[dict] = [] for item in data: mid = item.get("id") raw_metadata = item.get("metadata") - # ``metadata is None`` is a stub without pricing/context — listed but not served. Skip - # those for every surface. - if not mid or raw_metadata is None: + if not mid or raw_metadata is None: # metadata None = listed-but-not-served stub continue metadata = raw_metadata if isinstance(raw_metadata, dict) else {} raw_tags = metadata.get("tags") @@ -2863,8 +2216,6 @@ def _fetch_deepinfra_models_by_tag( if any(t in _DEEPINFRA_SURFACE_TAGS for t in tags): hit = tag in tags else: - # Surface-tag rollout incomplete — id-regex inference, meaningful only for the chat - # surface (embed/image-gen/tts/stt cannot be inferred from an id alone). hit = tag == "chat" and not _DEEPINFRA_EXCLUDE_RE.search(mid) if hit: matched.append({"id": mid, "metadata": metadata}) @@ -2872,20 +2223,11 @@ def _fetch_deepinfra_models_by_tag( def _fetch_deepinfra_models( - timeout: float = 5.0, - *, - force_refresh: bool = False, -) -> Optional[list[str]]: - """Return DeepInfra chat-model ids (tag-aware, regex fallback). - - Thin wrapper over :func:`_fetch_deepinfra_models_by_tag` so historical callers in - :func:`provider_model_ids` keep their string-list contract. Returns ``None`` on network failure, - an empty list if the catalog contains no chat-tagged ids (which would itself be surprising). - """ + timeout: float = 5.0, *, force_refresh: bool = False) -> Optional[list[str]]: + """DeepInfra chat-model ids (string-list contract for :func:`provider_model_ids`); ``None`` on + network failure or when no chat-tagged id exists.""" items = _fetch_deepinfra_models_by_tag("chat", timeout=timeout, force_refresh=force_refresh) - if items is None: - return None - return [item["id"] for item in items] or None + return ([item["id"] for item in items] or None) if items is not None else None def deepinfra_model_ids(tag: str, *, force_refresh: bool = False) -> list[str]: @@ -2895,11 +2237,7 @@ def deepinfra_model_ids(tag: str, *, force_refresh: bool = False) -> list[str]: def deepinfra_base_url(section: Optional[dict] = None) -> str: - """Resolve the DeepInfra OpenAI-compatible base URL, normalized. - - Precedence: config-section ``base_url`` → ``DEEPINFRA_BASE_URL`` env → default. Always stripped - with any trailing slash removed. - """ + """DeepInfra base URL: config-section ``base_url`` → ``DEEPINFRA_BASE_URL`` env → default; stripped.""" candidate = section.get("base_url") if isinstance(section, dict) else None value = candidate or os.getenv("DEEPINFRA_BASE_URL") or _DEEPINFRA_DEFAULT_BASE_URL return str(value).strip().rstrip("/") @@ -2915,139 +2253,95 @@ def _fetch_ai_gateway_models(timeout: float = 5.0) -> Optional[list[str]]: from hermes_constants import AI_GATEWAY_BASE_URL base_url = AI_GATEWAY_BASE_URL - url = base_url.rstrip("/") + "/models" - headers: dict[str, str] = { - "Authorization": f"Bearer {api_key}", - "User-Agent": _HERMES_USER_AGENT, - } - req = urllib.request.Request(url, headers=headers) + headers = {"Authorization": f"Bearer {api_key}", "User-Agent": _HERMES_USER_AGENT} try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - data = json.loads(resp.read().decode()) - return [ - m["id"] for m in data.get("data", []) - if m.get("id") and m.get("type") == "language" and "tool-use" in (m.get("tags") or []) - ] + url = base_url.rstrip("/") + "/models" + data = _get_json(url, timeout=timeout, headers=headers, opener=urllib.request.urlopen) + return [ + m["id"] for m in data.get("data", []) + if m.get("id") and m.get("type") == "language" and "tool-use" in (m.get("tags") or [])] except Exception: return None def fetch_api_models( - api_key: Optional[str], - base_url: Optional[str], - timeout: float = 5.0, - api_mode: Optional[str] = None, - headers: Optional[dict[str, str]] = None, + api_key: Optional[str], base_url: Optional[str], timeout: float = 5.0, + api_mode: Optional[str] = None, headers: Optional[dict[str, str]] = None, ) -> Optional[list[str]]: """Fetch the list of available model IDs from the provider's ``/models`` endpoint.""" - return probe_api_models(api_key, base_url, timeout=timeout, api_mode=api_mode, request_headers=headers).get("models") + result = probe_api_models(api_key, base_url, timeout=timeout, api_mode=api_mode, request_headers=headers) + return result.get("models") def _custom_endpoint_fingerprint( - api_key: Optional[str], - api_mode: Optional[str], - headers: Optional[dict[str, str]], -) -> str: - """Fingerprint the credentials/wire-shape used to probe a custom endpoint. - - Custom OpenAI-compatible endpoints have no ``PROVIDER_REGISTRY`` slug to key off (unlike - ``_credential_fingerprint``), so this hashes exactly the values callers pass to - :func:`fetch_api_models`: a rotated ``api_key``, a changed ``api_mode``, or an edited - ``extra_headers`` block each bust the cache entry on their own. - """ + api_key: Optional[str], api_mode: Optional[str], headers: Optional[dict[str, str]]) -> str: + """Custom endpoints have no ``PROVIDER_REGISTRY`` slug, so hash exactly what callers pass to + :func:`fetch_api_models`: a rotated ``api_key``, changed ``api_mode`` or edited ``extra_headers`` + each bust the cache entry. blake2b for the same CodeQL rationale as ``_credential_fingerprint``.""" import hashlib - blob = "|".join(( - api_key or "", - api_mode or "", - json.dumps(headers or {}, sort_keys=True), - )).encode("utf-8", errors="replace") - # blake2b for cache-key fingerprinting only, same rationale as - # _credential_fingerprint (avoids CodeQL's sha256-over-secrets rule). - return hashlib.blake2b(blob, digest_size=8).hexdigest() + blob = "|".join((api_key or "", api_mode or "", json.dumps(headers or {}, sort_keys=True))) + return hashlib.blake2b(blob.encode("utf-8", errors="replace"), digest_size=8).hexdigest() def _cache_entry_valid( - entry: Any, - fp: str, - *, - allow_empty: bool = False, -) -> "TypeGuard[dict[str, Any]]": - """True when *entry* is a well-formed cache row for fingerprint *fp*. - - Requires a numeric ``at`` so corrupt disk state (hand-edited JSON with ``"at": "yesterday"`` or - ``null``) degrades to a cache miss / live fetch instead of raising out of the wrapper. Empty - model lists are valid only for callers that explicitly opt into an authoritative empty catalog. - """ + entry: Any, fp: str, *, allow_empty: bool = False) -> "TypeGuard[dict[str, Any]]": + """Well-formed cache row for fingerprint *fp*. Requires a numeric ``at`` so corrupt disk state + degrades to a cache miss instead of raising; empty model lists are valid only when the caller + opts into an authoritative empty catalog.""" return ( isinstance(entry, dict) and entry.get("fp") == fp and isinstance(entry.get("models"), list) and (allow_empty or bool(entry["models"])) and isinstance(entry.get("at"), (int, float)) - and not isinstance(entry.get("at"), bool) - ) + and not isinstance(entry.get("at"), bool)) def cached_fetch_api_models( - api_key: Optional[str], - base_url: Optional[str], - *, - timeout: float = 5.0, - api_mode: Optional[str] = None, - headers: Optional[dict[str, str]] = None, - force_refresh: bool = False, - cache_only: bool = False, - ttl_seconds: int = _PROVIDER_MODELS_CACHE_TTL, -) -> Optional[list[str]]: - """Disk-cached wrapper around :func:`fetch_api_models` for custom endpoints. - - Callers that deliberately skip live probing for latency reasons (GUI picker opens, which must - not block on a stopped local endpoint) use this so a warm catalog still reaches the picker - instead of collapsing to the config-declared subset. - """ - normalized_url = str(base_url or "").strip().rstrip("/").lower() - if not normalized_url: - if cache_only: - return None - # Nothing to key the cache on — live call so callers keep fetch_api_models' own behavior. + api_key: Optional[str], base_url: Optional[str], *, timeout: float = 5.0, + api_mode: Optional[str] = None, headers: Optional[dict[str, str]] = None, + force_refresh: bool = False, cache_only: bool = False, + ttl_seconds: int = _PROVIDER_MODELS_CACHE_TTL) -> Optional[list[str]]: + """Disk-cached :func:`fetch_api_models` for custom endpoints. ``cache_only`` callers (GUI picker + opens that must not block on a stopped local endpoint) still get a warm catalog instead of + collapsing to the config-declared subset.""" + def _live(): return fetch_api_models(api_key, base_url, timeout=timeout, api_mode=api_mode, headers=headers) + normalized_url = str(base_url or "").strip().rstrip("/").lower() + if not normalized_url: # nothing to key the cache on + return None if cache_only else _live() + cache_key = f"custom:{normalized_url}" fp = _custom_endpoint_fingerprint(api_key, api_mode, headers) cache = _load_provider_models_cache() entry = cache.get(cache_key) now = time.time() + valid = not force_refresh and _cache_entry_valid(entry, fp) if cache_only: - # Same trust window as the stale-while-revalidate tier below, minus the revalidation: - # anything older is a miss so the caller falls back to its configured list. - if force_refresh or not _cache_entry_valid(entry, fp) or now - entry["at"] >= _PROVIDER_MODELS_STALE_SERVE_MAX: - return None - return list(entry["models"]) + # Same trust window as the SWR tier below, minus the revalidation. + return list(entry["models"]) if valid and now - entry["at"] < _PROVIDER_MODELS_STALE_SERVE_MAX else None - if not force_refresh and _cache_entry_valid(entry, fp): + if valid: age = now - entry["at"] if age < ttl_seconds: return list(entry["models"]) if age < _PROVIDER_MODELS_STALE_SERVE_MAX: - # Stale-while-revalidate: serve the expired entry immediately so picker opens never - # block on a live /v1/models round-trip; refresh off-thread for the next open. + # Stale-while-revalidate: serve now, refresh off-thread for the next open. def _refresh_custom(): - live = fetch_api_models(api_key, base_url, timeout=timeout, api_mode=api_mode, headers=headers) + live = _live() return _cache_entry(fp, live) if live else None _spawn_swr_refresh(cache_key, _refresh_custom) return list(entry["models"]) - live = fetch_api_models(api_key, base_url, timeout=timeout, api_mode=api_mode, headers=headers) + live = _live() if live: - cache[cache_key] = _cache_entry(fp, live, now) - _save_provider_models_cache(cache) + _store_cache_entry(cache_key, _cache_entry(fp, live, now), cache) return list(live) - - # Live fetch returned nothing (offline endpoint, timeout, auth hiccup). - # A stale same-fingerprint entry beats an empty result. + # Live returned nothing (offline, timeout, auth hiccup): a stale same-fingerprint entry beats it. if _cache_entry_valid(entry, fp): return list(entry["models"]) return live