diff --git a/hermes_cli/models.py b/hermes_cli/models.py index ba42bd6891..665647fd8f 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1,4 +1,14 @@ -"""Canonical model catalogs and lightweight validation helpers.""" +"""Provider/model catalogs: discovery, caching, and identity helpers. + +This is the 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) +""" from __future__ import annotations @@ -783,16 +793,11 @@ def ai_gateway_model_ids(*, force_refresh: bool = False) -> list[str]: # --------------------------------------------------------------------------- -# Pricing helpers — fetch live pricing from OpenRouter-compatible /v1/models +# Provider identity: ``provider:model`` parsing, auto-detection, labels # --------------------------------------------------------------------------- - -# All provider IDs and aliases that are valid for the provider:model syntax. -_KNOWN_PROVIDER_NAMES: set[str] = ( - set(_PROVIDER_LABELS.keys()) - | set(_PROVIDER_ALIASES.keys()) - | {"openrouter", "custom"} -) +# All provider IDs and aliases valid on the left of the ``provider:model`` syntax. +_KNOWN_PROVIDER_NAMES: set[str] = set(_PROVIDER_LABELS) | set(_PROVIDER_ALIASES) | {"openrouter", "custom"} def _configured_custom_provider_ids() -> set[str]: @@ -817,6 +822,21 @@ def _configured_custom_provider_ids() -> set[str]: pass return ids + +def _provider_has_credentials(pid: str) -> bool: + try: + from hermes_cli.auth import get_auth_status, has_usable_secret + + if pid == "custom": + return bool((_get_custom_base_url() or "").strip()) + if pid == "openrouter": + return has_usable_secret(os.getenv("OPENROUTER_API_KEY", "")) + status = get_auth_status(pid) + return bool(status.get("logged_in") or status.get("configured")) + except Exception: + return False + + def list_available_providers() -> list[dict[str, str]]: """Return info about all providers the user could use with ``provider:model``. @@ -824,39 +844,18 @@ def list_available_providers() -> list[dict[str, str]]: configured. Derived from :data:`CANONICAL_PROVIDERS`, the single source of truth shared with ``hermes model`` and ``/model``. """ - # Derive display order from canonical list + custom - provider_order = [p.slug for p in CANONICAL_PROVIDERS] + ["custom"] - - # Build reverse alias map aliases_for: dict[str, list[str]] = {} for alias, canonical in _PROVIDER_ALIASES.items(): aliases_for.setdefault(canonical, []).append(alias) - - result = [] - for pid in provider_order: - label = _PROVIDER_LABELS.get(pid, pid) - alias_list = aliases_for.get(pid, []) - # Check if this provider has credentials available - has_creds = False - try: - from hermes_cli.auth import get_auth_status, has_usable_secret - if pid == "custom": - custom_base_url = _get_custom_base_url() or "" - has_creds = bool(custom_base_url.strip()) - elif pid == "openrouter": - has_creds = has_usable_secret(os.getenv("OPENROUTER_API_KEY", "")) - else: - status = get_auth_status(pid) - has_creds = bool(status.get("logged_in") or status.get("configured")) - except Exception: - pass - result.append({ + return [ + { "id": pid, - "label": label, - "aliases": alias_list, - "authenticated": has_creds, - }) - return result + "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"] + ] def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]: @@ -873,27 +872,19 @@ def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]: model_part = stripped[colon + 1:].strip() if provider_part and model_part and provider_part in _KNOWN_PROVIDER_NAMES: if provider_part == "custom": + # Longest configured ``custom:`` id that prefixes the input wins. lowered = stripped.lower() - for custom_id in sorted( - _configured_custom_provider_ids() - {"custom"}, - key=len, - reverse=True, - ): - prefix = f"{custom_id.lower()}:" - if lowered.startswith(prefix): + for custom_id in sorted(_configured_custom_provider_ids() - {"custom"}, key=len, reverse=True): + if lowered.startswith(f"{custom_id.lower()}:"): return custom_id, stripped[len(custom_id) + 1 :].strip() - # Support custom:name:model triple syntax for named custom - # providers. ``custom:local:qwen`` → ("custom:local", "qwen"). - # Single colon ``custom:qwen`` → ("custom", "qwen") as before. - if provider_part == "custom" and ":" in model_part: - second_colon = model_part.find(":") - custom_name = model_part[:second_colon].strip() - actual_model = model_part[second_colon + 1:].strip() - if custom_name and actual_model: - custom_id = f"custom:{custom_name.lower()}" - if custom_id in _configured_custom_provider_ids(): - return (custom_id, actual_model) - return ("custom", model_part) + # ``custom:local:qwen`` → ("custom:local", "qwen") for a configured named provider; + # single-colon ``custom:qwen`` → ("custom", "qwen") as before. + if ":" in model_part: + custom_name, actual_model = (part.strip() for part in model_part.split(":", 1)) + if custom_name and actual_model: + if f"custom:{custom_name.lower()}" in _configured_custom_provider_ids(): + return (f"custom:{custom_name.lower()}", actual_model) + return ("custom", model_part) return (normalize_provider(provider_part), model_part) return (current_provider, stripped) @@ -2870,29 +2861,22 @@ def _fetch_deepinfra_models_by_tag( matched: list[dict] = [] for item in data: mid = item.get("id") - if not mid: - continue - # ``metadata is None`` means DeepInfra returns a stub without - # pricing/context — typically a model that's listed but not - # served. Skip those for every surface. raw_metadata = item.get("metadata") - if raw_metadata is None: + # ``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: continue metadata = raw_metadata if isinstance(raw_metadata, dict) else {} raw_tags = metadata.get("tags") tags = raw_tags if isinstance(raw_tags, list) else [] - has_surface_tag = any(t in _DEEPINFRA_SURFACE_TAGS for t in tags) - - if has_surface_tag: - if tag in tags: - matched.append({"id": mid, "metadata": metadata}) - continue - # Surface-tag rollout incomplete — fall back to id-regex inference. - # Only meaningful for the chat surface; embed/image-gen/tts/stt - # cannot be safely inferred from an id alone. - if tag == "chat" and not _DEEPINFRA_EXCLUDE_RE.search(mid): + 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}) - return matched @@ -2907,9 +2891,7 @@ def _fetch_deepinfra_models( :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). """ - items = _fetch_deepinfra_models_by_tag( - "chat", timeout=timeout, force_refresh=force_refresh - ) + 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 @@ -2952,11 +2934,8 @@ def _fetch_ai_gateway_models(timeout: float = 5.0) -> Optional[list[str]]: 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 []) + 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 @@ -2970,13 +2949,7 @@ def fetch_api_models( 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") + return probe_api_models(api_key, base_url, timeout=timeout, api_mode=api_mode, request_headers=headers).get("models") def _custom_endpoint_fingerprint( @@ -3046,11 +3019,8 @@ def cached_fetch_api_models( if not normalized_url: if cache_only: return None - # No base_url means nothing to key the cache on — fall through to a - # live call so callers keep getting fetch_api_models' own behavior. - return fetch_api_models( - api_key, base_url, timeout=timeout, api_mode=api_mode, headers=headers - ) + # Nothing to key the cache on — live call so callers keep fetch_api_models' own behavior. + return fetch_api_models(api_key, base_url, timeout=timeout, api_mode=api_mode, headers=headers) cache_key = f"custom:{normalized_url}" fp = _custom_endpoint_fingerprint(api_key, api_mode, headers) @@ -3059,13 +3029,9 @@ def cached_fetch_api_models( now = time.time() if cache_only: - # Same trust window as the stale-while-revalidate tier below, minus - # the revalidation: an entry this side of the bound is good enough to - # render, and anything older is treated as a miss so the caller falls - # back to its configured list rather than showing a stale catalog. - if force_refresh or not _cache_entry_valid(entry, fp): - return None - if now - entry["at"] >= _PROVIDER_MODELS_STALE_SERVE_MAX: + # 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"]) @@ -3074,27 +3040,18 @@ def cached_fetch_api_models( 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 - # (#72762's stall class, which a plain TTL would reintroduce an - # hour into the session); refresh off-thread for the next open. + # 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. def _refresh_custom(): - live = fetch_api_models( - api_key, base_url, - timeout=timeout, api_mode=api_mode, headers=headers, - ) - if not live: - return None - return {"fp": fp, "at": time.time(), "models": list(live)} + live = fetch_api_models(api_key, base_url, timeout=timeout, api_mode=api_mode, headers=headers) + 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 = fetch_api_models(api_key, base_url, timeout=timeout, api_mode=api_mode, headers=headers) if live: - cache[cache_key] = {"fp": fp, "at": now, "models": list(live)} + cache[cache_key] = _cache_entry(fp, live, now) _save_provider_models_cache(cache) return list(live) @@ -3105,8 +3062,3 @@ def cached_fetch_api_models( return live -# --------------------------------------------------------------------------- -# Ollama Cloud — merged model discovery with disk cache -# --------------------------------------------------------------------------- - - diff --git a/hermes_cli/models_catalog_static.py b/hermes_cli/models_catalog_static.py index 5f0ccebd5a..a4460bd04c 100644 --- a/hermes_cli/models_catalog_static.py +++ b/hermes_cli/models_catalog_static.py @@ -1,4 +1,7 @@ -"""Static provider/model catalog tables: curated per-provider model lists, canonical provider registry, display groups, alias maps. +"""Static provider/model catalog tables. + +Curated per-provider model lists, the canonical provider registry, display groups, and the +alias maps. Data only — no network. Split out of ``hermes_cli.models``; every moved name is re-imported there, so ``hermes_cli.models.`` keeps resolving (and monkeypatching) as before. diff --git a/hermes_cli/models_local.py b/hermes_cli/models_local.py index 564b90826f..609edd3831 100644 --- a/hermes_cli/models_local.py +++ b/hermes_cli/models_local.py @@ -1,4 +1,8 @@ -"""Local / self-hosted model servers: Ollama (native /api/tags probe, headers, base-url resolution), LM Studio (/api/v1/models, load-on-demand), Ollama Cloud (merged live + models.dev catalog with disk cache). +"""Local / self-hosted model servers. + +Ollama (native ``/api/tags`` probe, request headers, base-url resolution), LM Studio +(``/api/v1/models``, load-on-demand), and Ollama Cloud (live + models.dev merged catalog with a +disk cache). Split out of ``hermes_cli.models``; every moved name is re-imported there, so ``hermes_cli.models.`` keeps resolving (and monkeypatching) as before. diff --git a/hermes_cli/models_pricing.py b/hermes_cli/models_pricing.py index 27714ed2e5..e2e0bfff07 100644 --- a/hermes_cli/models_pricing.py +++ b/hermes_cli/models_pricing.py @@ -1,4 +1,8 @@ -"""Live model pricing: OpenRouter-compatible /v1/models pricing fetch + per-endpoint/credential cache, Nous Portal sale chrome and org-policy filtering, Vercel AI Gateway / Novita / Fireworks / DeepInfra pricing adapters. +"""Live model pricing. + +OpenRouter-compatible ``/v1/models`` pricing fetch with a per-endpoint/per-credential cache, +Nous Portal sale chrome and org-policy filtering, and the Vercel AI Gateway / Novita / Fireworks / +DeepInfra pricing adapters. Split out of ``hermes_cli.models``; every moved name is re-imported there, so ``hermes_cli.models.`` keeps resolving (and monkeypatching) as before.