"""Models.dev registry integration — primary database for providers and models. Fetches https://models.dev/api.json (4000+ models, 100+ providers): provider metadata (name, base URL, env vars, docs) and model metadata (context window, max output, cost/M tokens, capabilities, modalities, knowledge cutoff, open-weights flag, family, deprecation status). Resolution order: in-memory cache (fresh, or stale served immediately while one background daemon thread refreshes) → disk cache (~/.hermes/models_dev_cache.json, any age) → network, only when no cache exists at all. Failed refreshes back off for 5 minutes process-wide. Invariants: - **ETag conditional GET**: refreshes send ``If-None-Match`` whenever a servable registry is held; a 304 re-confirms the cache without re-downloading ~2 MB. The ETag sidecar is persisted with the cache body. - **No network on hot paths**: resolution, picker, and resume paths pass ``allow_network=False`` and never perform network I/O. - **Corrupt-cache rejection**: a disk cache that fails to parse, is not a dict, or is empty is quarantined with a warning rather than served as ``{}``. - **Mirror URL override**: ``models_dev.url`` in config.yaml. Other modules should import the dataclasses and query functions from here rather than parsing the raw JSON themselves. """ import json import logging import re import threading import time from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from utils import atomic_json_write import requests logger = logging.getLogger(__name__) MODELS_DEV_URL = "https://models.dev/api.json" _MODELS_DEV_CACHE_TTL = 4 * 3600 # 4 hours — ETag conditional GET makes refresh cheap _MODELS_DEV_RETRY_DELAY = 300 # 5 minutes after a failed refresh # In-memory cache _models_dev_cache: Dict[str, Any] = {} _models_dev_cache_time: float = 0 _models_dev_retry_after: float = 0 _models_dev_fetch_lock = threading.Lock() _models_dev_refresh_lock = threading.Lock() _models_dev_refresh_in_flight = False # --------------------------------------------------------------------------- # Dataclasses # --------------------------------------------------------------------------- @dataclass class ModelInfo: """Full metadata for a single model from models.dev.""" id: str name: str family: str provider_id: str # models.dev provider ID (e.g. "anthropic") # Capabilities reasoning: bool = False tool_call: bool = False attachment: bool = False # supports image/file attachments (vision) temperature: bool = False structured_output: bool = False open_weights: bool = False # Modalities input_modalities: Tuple[str, ...] = () # ("text", "image", "pdf", ...) output_modalities: Tuple[str, ...] = () # Limits context_window: int = 0 max_output: int = 0 max_input: Optional[int] = None # Cost (per million tokens, USD) cost_input: float = 0.0 cost_output: float = 0.0 cost_cache_read: Optional[float] = None cost_cache_write: Optional[float] = None # Metadata knowledge_cutoff: str = "" release_date: str = "" status: str = "" # "alpha", "beta", "deprecated", or "" interleaved: Any = False # True or {"field": "reasoning_content"} def has_cost_data(self) -> bool: return self.cost_input > 0 or self.cost_output > 0 def supports_vision(self) -> bool: return self.attachment or "image" in self.input_modalities def supports_pdf(self) -> bool: return "pdf" in self.input_modalities def supports_audio_input(self) -> bool: return "audio" in self.input_modalities def format_capabilities(self) -> str: """Human-readable capabilities, e.g. 'reasoning, tools, vision, PDF'.""" flags = ( (self.reasoning, "reasoning"), (self.tool_call, "tools"), (self.supports_vision(), "vision"), (self.supports_pdf(), "PDF"), (self.supports_audio_input(), "audio"), (self.structured_output, "structured output"), (self.open_weights, "open weights"), ) caps = [label for on, label in flags if on] return ", ".join(caps) if caps else "basic" @dataclass class ProviderInfo: """Full metadata for a provider from models.dev.""" id: str # models.dev provider ID name: str # display name env: Tuple[str, ...] # env var names for API key api: str # base URL doc: str = "" # documentation URL model_count: int = 0 @dataclass class ModelCapabilities: """Structured capability metadata for a model from models.dev.""" supports_tools: bool = True supports_vision: bool = False supports_reasoning: bool = False context_window: int = 200000 max_output_tokens: int = 8192 model_family: str = "" # --------------------------------------------------------------------------- # Provider ID mapping: Hermes ↔ models.dev # --------------------------------------------------------------------------- # Hermes provider names → models.dev provider IDs PROVIDER_TO_MODELS_DEV: Dict[str, str] = { "openrouter": "openrouter", "novita": "novita-ai", "anthropic": "anthropic", "openai": "openai", "openai-codex": "openai", "zai": "zai", "kimi": "kimi-for-coding", "kimi-coding": "kimi-for-coding", "moonshot": "kimi-for-coding", "stepfun": "stepfun", "kimi-coding-cn": "kimi-for-coding", "minimax": "minimax", "minimax-oauth": "minimax", "minimax-cn": "minimax-cn", "deepseek": "deepseek", "alibaba": "alibaba", "qwen-oauth": "alibaba", "copilot": "github-copilot", "ai-gateway": "vercel", "opencode-zen": "opencode", "opencode-go": "opencode-go", "kilocode": "kilo", "fireworks": "fireworks-ai", "huggingface": "huggingface", "gemini": "google", "google": "google", "xai": "xai", "xai-oauth": "xai", # OAuth is a transport path for the same xAI catalog "xiaomi": "xiaomi", "nvidia": "nvidia", # Meta Model API (Muse Spark, api.meta.ai): models.dev keys it "meta", the # Hermes provider is "meta-ai"; both aliases are needed or muse-spark-* # falls back to the generic 256K default instead of its true 1M window. "meta-ai": "meta", "meta": "meta", "groq": "groq", "mistral": "mistral", "togetherai": "togetherai", "perplexity": "perplexity", "cohere": "cohere", "ollama-cloud": "ollama-cloud", } # Reverse mapping: models.dev id → Hermes ids (built lazily; many-to-one). _MODELS_DEV_TO_PROVIDER: Optional[Dict[str, List[str]]] = None def _models_dev_to_hermes_ids(mdev_id: str) -> List[str]: """Return the Hermes provider ids that map to *mdev_id* (may be []).""" global _MODELS_DEV_TO_PROVIDER if _MODELS_DEV_TO_PROVIDER is None: reverse: Dict[str, List[str]] = {} for hermes_id, mapped in PROVIDER_TO_MODELS_DEV.items(): reverse.setdefault(mapped, []).append(hermes_id) _MODELS_DEV_TO_PROVIDER = reverse return _MODELS_DEV_TO_PROVIDER.get(mdev_id, []) # --------------------------------------------------------------------------- # Disk cache + ETag sidecar # --------------------------------------------------------------------------- def _get_cache_path() -> Path: from hermes_constants import get_hermes_home return get_hermes_home() / "models_dev_cache.json" def _get_etag_path() -> Path: from hermes_constants import get_hermes_home return get_hermes_home() / "models_dev_cache.etag" def _load_etag() -> str: """Last-known ETag from disk, or "" if missing.""" try: etag_path = _get_etag_path() if etag_path.exists(): return etag_path.read_text(encoding="utf-8").strip() except Exception as e: logger.debug("Failed to load models.dev ETag: %s", e) return "" def _save_etag(etag: str) -> None: try: from utils import atomic_write_text etag_path = _get_etag_path() etag_path.parent.mkdir(parents=True, exist_ok=True) atomic_write_text(etag_path, etag) except Exception as e: logger.debug("Failed to save models.dev ETag: %s", e) def _clear_etag() -> None: """Delete the ETag sidecar so the next fetch is unconditional. Called when the registry the ETag vouches for is gone or unusable: an If-None-Match without a servable cache invites a 304 that leaves the process with no data at all. """ try: _get_etag_path().unlink(missing_ok=True) except Exception as e: logger.debug("Failed to clear models.dev ETag: %s", e) def _get_models_dev_url() -> str: """The models.dev API URL, honoring the ``models_dev.url`` config override (mirrors / self-hosted copies).""" try: from hermes_cli.config import cfg_get, load_config_readonly cfg = load_config_readonly() url = cfg_get(cfg, "models_dev", "url", default="") if isinstance(url, str) and url.strip(): return url.strip() except Exception: pass # Module global (not a captured constant) so code/tests that patch # MODELS_DEV_URL keep working. return MODELS_DEV_URL def _validate_registry(data: Any) -> bool: """True if *data* is a non-empty dict suitable for serving.""" return isinstance(data, dict) and len(data) > 0 def _load_disk_cache() -> Dict[str, Any]: """Load the disk cache; a corrupt/empty one is quarantined with a warning so it never masquerades as ``{}`` and breaks provider/model resolution.""" try: cache_path = _get_cache_path() if cache_path.exists(): with open(cache_path, encoding="utf-8") as f: data = json.load(f) if not _validate_registry(data): logger.warning( "models.dev disk cache is corrupt or empty; " "quarantining (will refetch from network)" ) _quarantine_corrupt_cache(cache_path) return {} return data except Exception as e: logger.warning( "Failed to load models.dev disk cache; quarantining: %s", e ) try: _quarantine_corrupt_cache(_get_cache_path()) except Exception: pass return {} def _quarantine_corrupt_cache(cache_path: Path) -> None: """Rename a rejected cache aside and drop its ETag sidecar. Renaming makes the rejection a one-time event — otherwise every hot-path call that finds the in-memory cache empty re-parses the corrupt file and re-warns until a network fetch succeeds. The sidecar vouches for a registry we no longer hold, so it goes too. """ try: cache_path.rename(cache_path.with_suffix(".json.corrupt")) except Exception as e: logger.debug("Could not quarantine corrupt models.dev cache: %s", e) _clear_etag() def _disk_cache_age_seconds() -> Optional[float]: """Age of the disk cache file in seconds, or None if missing/unreadable (or mtime in the future from clock skew — treated as unknown freshness so callers fall through to the network rather than trusting it forever).""" try: cache_path = _get_cache_path() if not cache_path.exists(): return None age = time.time() - cache_path.stat().st_mtime return age if age >= 0 else None except Exception as e: logger.debug("Failed to stat models.dev disk cache: %s", e) return None def _save_disk_cache(data: Dict[str, Any], etag: str = "") -> None: """Save the registry atomically, plus the ETag sidecar when non-empty.""" try: atomic_json_write(_get_cache_path(), data, indent=None, separators=(",", ":")) except Exception as e: logger.debug("Failed to save models.dev disk cache: %s", e) if etag: _save_etag(etag) # --------------------------------------------------------------------------- # Network refresh (all state mutation happens under _models_dev_fetch_lock) # --------------------------------------------------------------------------- class _NotModified(Exception): """Server returned 304 Not Modified — existing cache is still valid.""" def _fetch_models_dev_from_network( *, conditional: bool = False ) -> Tuple[Dict[str, Any], str]: """Fetch the live registry; returns ``(registry, etag)`` (etag "" if none). ``conditional`` sends ``If-None-Match`` with the sidecar's ETag and raises ``_NotModified`` on 304. Pass True ONLY while holding ``_models_dev_fetch_lock`` AND a servable registry — a conditional request without one invites a 304 that leaves the process with no data (formerly a permanent empty-registry loop when the sidecar outlived a corrupt cache). Raises on network errors and on an empty/invalid payload. """ url = _get_models_dev_url() headers: Dict[str, str] = {} if conditional: etag = _load_etag() if etag: headers["If-None-Match"] = etag # (connect, read) timeout: 5 s connect fails fast on blackholed hosts # instead of stalling the first turn; 10 s read tolerates a slow registry. response = requests.get(url, headers=headers, timeout=(5, 10)) if response.status_code == 304: raise _NotModified() response.raise_for_status() data = response.json() if not _validate_registry(data): raise ValueError("models.dev returned an empty or invalid registry") return data, response.headers.get("ETag", "") def _mark_stale_cache_grace() -> None: """Give stale cache data a 5-minute in-memory grace before retrying refresh. Only ever moves the timestamp forward, so a background refresh that completed between the caller's staleness check and this call keeps its fresh timestamp. """ global _models_dev_cache_time grace_time = time.time() - _MODELS_DEV_CACHE_TTL + _MODELS_DEV_RETRY_DELAY if grace_time > _models_dev_cache_time: _models_dev_cache_time = grace_time def _commit_registry(data: Dict[str, Any], *, etag: str = "", where: str) -> None: """Persist a fetched registry: disk + in-mem + clear backoff. Callers hold ``_models_dev_fetch_lock`` so a failing refresh on one path can never stomp state a succeeding refresh on the other just committed. """ global _models_dev_cache, _models_dev_cache_time, _models_dev_retry_after _save_disk_cache(data, etag) _models_dev_cache = data _models_dev_cache_time = time.time() _models_dev_retry_after = 0 logger.debug( "Refreshed models.dev registry (%s): %d providers, %d total models", where, len(data), sum(len(p.get("models", {})) for p in data.values() if isinstance(p, dict)), ) def _confirm_cache_not_modified(*, where: str) -> None: """After a 304: clear backoff and re-mark the held cache fresh (disk is untouched — only the freshness marker advances). Caller holds the lock.""" global _models_dev_cache_time, _models_dev_retry_after if not _models_dev_cache: # A 304 with no registry held should be unreachable (conditional GETs # require a servable cache) but previously caused a permanent # empty-registry loop: drop the sidecar and arm the normal backoff # rather than marking {} "fresh". _clear_etag() _models_dev_retry_after = time.time() + _MODELS_DEV_RETRY_DELAY logger.warning( "models.dev returned 304 but no cached registry is held (%s); " "cleared ETag sidecar, will refetch unconditionally", where, ) return _models_dev_cache_time = time.time() _models_dev_retry_after = 0 logger.debug( "models.dev registry unchanged (304 Not Modified, %s); " "cache re-confirmed fresh", where, ) def _note_refresh_failure(exc: Exception, *, where: str) -> None: """Arm the process-wide 5-minute backoff. Caller holds the lock.""" global _models_dev_retry_after _models_dev_retry_after = time.time() + _MODELS_DEV_RETRY_DELAY logger.debug( "models.dev refresh failed (%s); retry suppressed for %ds: %s", where, _MODELS_DEV_RETRY_DELAY, exc, ) def _background_refresh_models_dev() -> None: """Best-effort refresh after serving stale cache data.""" global _models_dev_refresh_in_flight try: # Fetch INSIDE the lock, symmetric with the foreground path: the # conditional-GET inputs (memory cache + sidecar) can't be mutated # mid-fetch by a concurrent force_refresh and the two paths can't # double-download. Hot-path callers never touch this lock. with _models_dev_fetch_lock: data, etag = _fetch_models_dev_from_network( conditional=bool(_models_dev_cache) ) _commit_registry(data, etag=etag, where="background") except _NotModified: with _models_dev_fetch_lock: _confirm_cache_not_modified(where="background") except Exception as e: with _models_dev_fetch_lock: _note_refresh_failure(e, where="background") finally: with _models_dev_refresh_lock: _models_dev_refresh_in_flight = False def _start_background_refresh_models_dev() -> None: """Start one daemon refresh worker if none is running and the failure backoff has elapsed.""" global _models_dev_refresh_in_flight if time.time() < _models_dev_retry_after: return with _models_dev_refresh_lock: if _models_dev_refresh_in_flight: return _models_dev_refresh_in_flight = True thread = threading.Thread( target=_background_refresh_models_dev, name="models-dev-refresh", daemon=True, ) try: thread.start() except Exception as e: # Thread/fd exhaustion: clear the flag so refresh isn't disabled for # the rest of the process. Callers still get stale data. with _models_dev_refresh_lock: _models_dev_refresh_in_flight = False logger.debug("Failed to start models.dev refresh thread: %s", e) def fetch_models_dev( force_refresh: bool = False, *, allow_network: bool = True ) -> Dict[str, Any]: """Fetch the models.dev registry (dict keyed by provider ID; {} on failure). Cache hierarchy when ``force_refresh=False``: 1. Fresh in-memory cache → return. 2. Stale in-memory cache → return it and refresh in one background daemon thread. Callers never block on the network while any cache exists; models.dev only changes when providers add models, so stale data beats a foreground timeout. 3. Disk cache (any age) → populate in-mem and return; a stale one triggers the same background refresh. Corrupt/empty is rejected. 4. No cache → singleflight foreground network fetch, saved to disk+mem. 5. Any failed refresh suppresses automatic refreshes for 5 minutes. ``force_refresh=True`` (``hermes config refresh``) bypasses the cache fast paths and the backoff, falling back to cached data only if the call fails. ``allow_network=False`` returns any memory/disk cache regardless of age and never makes a request — for latency-sensitive paths (gateway route-identity checks, vision routing, context-length lookup). Network requests use ETag conditional GET when a servable registry is held (a cold ``force_refresh`` hydrates memory from disk first); a 304 re-confirms the cache without re-downloading ~2 MB. """ global _models_dev_cache, _models_dev_cache_time, _models_dev_retry_after if not allow_network: if _models_dev_cache: return _models_dev_cache disk_data = _load_disk_cache() if disk_data: _models_dev_cache = disk_data disk_age = _disk_cache_age_seconds() _models_dev_cache_time = ( time.time() - disk_age if disk_age is not None else 0 ) return _models_dev_cache # Stage 1: fresh in-memory cache — the hot path, no I/O. if ( not force_refresh and _models_dev_cache and (time.time() - _models_dev_cache_time) < _MODELS_DEV_CACHE_TTL ): return _models_dev_cache # Stage 2: stale in-memory cache beats blocking on the network. if not force_refresh and _models_dev_cache: _mark_stale_cache_grace() _start_background_refresh_models_dev() logger.debug( "Using stale in-memory models.dev cache; refreshing in background" ) return _models_dev_cache # Stage 3: disk cache (cold-start only). A stale disk cache is deliberately # usable so resolution doesn't hang when models.dev is unreachable. if not force_refresh: disk_age = _disk_cache_age_seconds() if disk_age is not None: disk_data = _load_disk_cache() if disk_data: _models_dev_cache = disk_data if disk_age < _MODELS_DEV_CACHE_TTL: # Anchor the in-mem TTL to the file's age so an aging # cache isn't extended by another full TTL. _models_dev_cache_time = time.time() - disk_age logger.debug( "Loaded models.dev from fresh disk cache " "(%d providers, age=%.0fs)", len(disk_data), disk_age, ) else: _mark_stale_cache_grace() _start_background_refresh_models_dev() logger.debug( "Using stale models.dev disk cache (age=%.0fs); " "refreshing in background", disk_age, ) return _models_dev_cache # Process-wide backoff: don't make every caller retry an unreachable # endpoint while no usable cache exists. if not force_refresh and time.time() < _models_dev_retry_after: return _models_dev_cache # Stage 4: singleflight foreground fetch. Recheck state under the lock — # another caller may have refreshed or armed the backoff while we waited. with _models_dev_fetch_lock: now = time.time() if not force_refresh and (_models_dev_cache or now < _models_dev_retry_after): return _models_dev_cache # Cold force_refresh: stages 1-3 were skipped, so hydrate memory from # disk first so the conditional GET fires and a 304 can re-confirm it. if force_refresh and not _models_dev_cache: disk = _load_disk_cache() if disk: _models_dev_cache = disk _models_dev_cache_time = 0 # servable but not fresh try: data, etag = _fetch_models_dev_from_network( conditional=bool(_models_dev_cache) ) _commit_registry(data, etag=etag, where="foreground") return data except _NotModified: _confirm_cache_not_modified(where="foreground") return _models_dev_cache except Exception as e: _note_refresh_failure(e, where="foreground") # Stage 5: network failed — serve any stale memory/disk cache. Freshness # stays expired; the retry-after timestamp gates the next attempt. if not _models_dev_cache: _models_dev_cache = _load_disk_cache() _models_dev_cache_time = 0 if _models_dev_cache: logger.debug( "Loaded stale models.dev disk cache (%d providers)", len(_models_dev_cache), ) return _models_dev_cache # --------------------------------------------------------------------------- # Catalog access helpers # --------------------------------------------------------------------------- def _fetch_registry(allow_network: bool) -> Dict[str, Any]: # Keep the zero-argument call on the allow_network path: dozens of test # sites monkeypatch fetch_models_dev with zero-arg lambdas. return fetch_models_dev() if allow_network else fetch_models_dev(allow_network=False) def _registry_models(mdev_id: str, *, allow_network: bool) -> Optional[Dict[str, Any]]: """The ``models`` dict of a models.dev provider entry, or None.""" provider_data = _fetch_registry(allow_network).get(mdev_id) if not isinstance(provider_data, dict): return None models = provider_data.get("models", {}) return models if isinstance(models, dict) else None def _get_provider_models( provider: str, *, allow_network: bool = False ) -> Optional[Dict[str, Any]]: """Resolve a Hermes provider ID to its models dict, or None if unknown. ``allow_network`` defaults to False — called from hot paths (vision/image routing, capability checks) that must never block. """ mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider) if not mdev_provider_id: return None return _registry_models(mdev_provider_id, allow_network=allow_network) def _iter_model_entries(models: Dict[str, Any], model: str, *, suffix_fallback: bool = True): """Yield ``(model_id, entry)`` candidates: exact, case-insensitive, then (optionally) ``:cloud``/``-cloud`` suffixed forms. The suffix fallback exists because some providers (e.g. ollama-cloud) store ``kimi-k2.6:cloud`` while the live API returns the bare name; without it context lookup falls through to stale OpenRouter metadata and trips the 64k minimum-context guard. Every consumer shares this order so "is this model in the catalog" means the same thing everywhere — a suffix-keyed catalog model must count as KNOWN for ``model_overrides`` fill-gap ``_default`` semantics. """ entry = models.get(model) if isinstance(entry, dict): yield model, entry model_lower = model.lower() for mid, mdata in models.items(): if mid.lower() == model_lower and isinstance(mdata, dict): yield mid, mdata if not suffix_fallback: return for suffix in (":cloud", "-cloud"): entry = models.get(model + suffix) if isinstance(entry, dict): yield model + suffix, entry suffixed_lower = model_lower + suffix for mid, mdata in models.items(): if mid.lower() == suffixed_lower and isinstance(mdata, dict): yield mid, mdata def _find_model_entry(models: Dict[str, Any], model: str) -> Optional[Dict[str, Any]]: """First catalog entry for *model* (exact, case-insensitive, suffix), or None.""" for _mid, entry in _iter_model_entries(models, model): return entry return None def _extract_limit(entry: Any, key: str) -> Optional[int]: """Positive int ``entry.limit[key]`` or None (audio/image models have context=0).""" if not isinstance(entry, dict): return None limit = entry.get("limit") if not isinstance(limit, dict): return None value = limit.get(key) if isinstance(value, (int, float)) and value > 0: return int(value) return None def _extract_context(entry: Dict[str, Any]) -> Optional[int]: """Context length from a models.dev model entry, or None if invalid/zero.""" return _extract_limit(entry, "context") def lookup_models_dev_context( provider: str, model: str, *, allow_network: bool = False ) -> Optional[int]: """Context window in tokens for provider+model, or None if not found. An EXPLICIT ``model_overrides`` entry wins over the catalog; ``_default`` entries fill the gap only when the catalog has no answer (the supported self-unblock path for models with wrong/missing context in models.dev). Catalog entries with context=0 are skipped in favour of later candidates. ``allow_network`` defaults to False — this runs every conversation turn and must never block; pass True only from explicit refresh flows. """ override_ctx = _override_context_window(provider, model) if override_ctx is not None: return override_ctx models = _get_provider_models(provider, allow_network=allow_network) if models is not None: for _mid, entry in _iter_model_entries(models, model): ctx = _extract_context(entry) if ctx: return ctx return _default_override_context(provider) # --------------------------------------------------------------------------- # Per-model metadata overrides (config.yaml → model_overrides) # --------------------------------------------------------------------------- # # Canonical override schema (the ONLY key space consumers accept): # context_window, max_output_tokens, supports_tools, supports_vision, # supports_reasoning, model_family # # Resolution: ``model_overrides..`` is an explicit override # that always wins over the catalog for the fields it sets (partial patch). # ``model_overrides.._default`` / ``model_overrides._default`` are # FILL-GAP defaults: they apply ONLY to models the catalog does not know (the # self-unblock path for custom/local/new models) and never displace catalog # data — a ``_default: {context_window: 128000}`` cannot clamp every # catalog-known model of a provider. # # Provider keys accept the Hermes provider id or the models.dev provider id. # Model ids match exactly, then case-insensitively (mirroring catalog lookup). _OVERRIDE_WARNED_KEYS: set = set() # Safe defaults for models absent from the catalog (tools on, vision/reasoning # off, 200K context); shared by get_model_capabilities and get_model_info so # the two unknown-model paths agree. _UNKNOWN_MODEL_BASE: Dict[str, Any] = { "limit": {"context": 200000, "output": 8192}, "tool_call": True, } def _load_model_overrides() -> Dict[str, Any]: """The ``model_overrides`` config section ({} on any failure). Deliberately not memoized: ``load_config_readonly()`` is already (mtime, size)-cached upstream, and an ``id(cfg)``-keyed layer here can serve stale overrides after a reload when CPython reuses the dict address. """ try: from hermes_cli.config import cfg_get, load_config_readonly raw = cfg_get(load_config_readonly(), "model_overrides", default={}) return raw if isinstance(raw, dict) else {} except Exception: return {} def _provider_override_section(provider: str) -> Optional[Dict[str, Any]]: """Override section for *provider* (keyed by Hermes OR models.dev id), or None.""" overrides = _load_model_overrides() if not overrides: return None provider_key = (provider or "").strip() if not provider_key: return None candidates = [provider_key] mapped = PROVIDER_TO_MODELS_DEV.get(provider_key) if mapped and mapped != provider_key: candidates.append(mapped) # Reverse: caller passed a models.dev id, config keyed by Hermes id. for hermes_id in _models_dev_to_hermes_ids(provider_key): if hermes_id != provider_key: candidates.append(hermes_id) for key in candidates: section = overrides.get(key) if isinstance(section, dict): return section return None def _explicit_model_override(provider: str, model: str) -> Optional[Dict[str, Any]]: """Explicit per-provider+model override dict (exact, then case-insensitive skipping the ``_default`` sentinel), or None.""" model_key = (model or "").strip() if not model_key: return None section = _provider_override_section(provider) if section is None: return None entry = section.get(model_key) if isinstance(entry, dict): return entry model_lower = model_key.lower() for mid, mdata in section.items(): if mid == "_default": continue if mid.lower() == model_lower and isinstance(mdata, dict): return mdata return None def _default_model_override(provider: str) -> Optional[Dict[str, Any]]: """Fill-gap ``_default`` override: per-provider first, then global; or None.""" section = _provider_override_section(provider) if section is not None: default = section.get("_default") if isinstance(default, dict): return default global_default = _load_model_overrides().get("_default") if isinstance(global_default, dict): return global_default return None def _override_for( provider: str, model: str, *, catalog_hit: bool ) -> Optional[Dict[str, Any]]: """Explicit override if any; else the ``_default`` only on a catalog miss.""" explicit = _explicit_model_override(provider, model) if explicit is not None: return explicit if catalog_hit: return None return _default_model_override(provider) def _override_int(override: Dict[str, Any], key: str) -> Optional[int]: """Coerce an override field to a positive int, warning once on garbage.""" raw = override.get(key) if raw is None: return None try: value = int(raw) if value > 0: return value except (TypeError, ValueError): pass warn_key = (key, repr(raw)) if warn_key not in _OVERRIDE_WARNED_KEYS: _OVERRIDE_WARNED_KEYS.add(warn_key) logger.warning( "model_overrides: ignoring invalid %s value %r " "(expected a positive integer)", key, raw, ) return None def _override_context_window(provider: str, model: str) -> Optional[int]: """EXPLICITLY overridden context_window, or None. Explicit-only on purpose: this runs early in the resolution chain (agent/model_metadata.py, before custom_providers and live probes) where a ``_default`` must not preempt more specific sources; fill-gap defaults apply later in ``lookup_models_dev_context`` once the catalog has missed. """ ov = _explicit_model_override(provider, model) return _override_int(ov, "context_window") if ov is not None else None def _default_override_context(provider: str) -> Optional[int]: """Fill-gap context from a ``_default`` override, for catalog misses.""" default = _default_model_override(provider) return _override_int(default, "context_window") if default is not None else None def _override_to_catalog_shape( override: Dict[str, Any], ) -> Tuple[Dict[str, Any], Optional[bool]]: """Translate canonical override keys into a models.dev-shaped patch. Consumers read the raw catalog shape (``limit.context``, ``tool_call``, ...) while users write ONE canonical schema, so this boundary translates. Returns ``(patch, vision)`` — vision is out-of-band because it maps onto the ``modalities.input`` list rather than a scalar field. """ patch: Dict[str, Any] = {} limit: Dict[str, Any] = {} ctx = _override_int(override, "context_window") if ctx is not None: limit["context"] = ctx out = _override_int(override, "max_output_tokens") if out is not None: limit["output"] = out if limit: patch["limit"] = limit if "supports_tools" in override: patch["tool_call"] = bool(override["supports_tools"]) if "supports_reasoning" in override: patch["reasoning"] = bool(override["supports_reasoning"]) vision: Optional[bool] = None if "supports_vision" in override: vision = bool(override["supports_vision"]) patch["attachment"] = vision if "model_family" in override: patch["family"] = str(override["model_family"] or "") return patch, vision def _merge_catalog_entry_with_override( raw: Dict[str, Any], override: Dict[str, Any] ) -> Dict[str, Any]: """Patch a catalog entry with a canonical-schema override. Sub-dicts (``limit``, ``modalities``) are merged, not clobbered — setting only ``context_window`` must not wipe the catalog's ``limit.output``.""" shaped, vision_override = _override_to_catalog_shape(override) merged = dict(raw) limit_patch = shaped.pop("limit", None) if limit_patch: base_limit = raw.get("limit") base_limit = dict(base_limit) if isinstance(base_limit, dict) else {} base_limit.update(limit_patch) merged["limit"] = base_limit if vision_override is not None: base_mods = raw.get("modalities") base_mods = dict(base_mods) if isinstance(base_mods, dict) else {} input_mods = base_mods.get("input") input_mods = list(input_mods) if isinstance(input_mods, list) else [] if vision_override and "image" not in input_mods: input_mods.append("image") elif not vision_override and "image" in input_mods: input_mods.remove("image") base_mods["input"] = input_mods merged["modalities"] = base_mods merged.update(shaped) return merged # --------------------------------------------------------------------------- # Model capability metadata # --------------------------------------------------------------------------- def _entry_supports_vision(entry: Dict[str, Any]) -> bool: """Prefer explicit ``modalities.input`` (the older ``attachment`` flag can be stale or too broad for image routing); fall back to it only when the input modalities are absent/invalid.""" input_mods = entry.get("modalities", {}) input_mods = input_mods.get("input") if isinstance(input_mods, dict) else None if isinstance(input_mods, list): return "image" in input_mods return bool(entry.get("attachment", False)) def get_model_capabilities( provider: str, model: str, *, allow_network: bool = False ) -> Optional[ModelCapabilities]: """Capability metadata from the models.dev cache, or None if unresolvable. EXPLICIT ``model_overrides`` entries patch catalog values for the fields they set; ``_default`` entries fill the gap only for models the catalog does not know. Unspecified fields fall through to the catalog value, or to safe defaults (tools on, vision/reasoning off, 200K/8K) when absent. ``allow_network`` defaults to False — vision/image routing is a hot path. """ models = _get_provider_models(provider, allow_network=allow_network) entry = _find_model_entry(models, model) if models is not None else None # Select the override AFTER the catalog lookup: _default only fills misses. override = _override_for(provider, model, catalog_hit=entry is not None) if entry is None and override is None: return None raw = entry if entry is not None else _UNKNOWN_MODEL_BASE if override is not None: raw = _merge_catalog_entry_with_override(raw, override) return ModelCapabilities( supports_tools=bool(raw.get("tool_call", False)), supports_vision=_entry_supports_vision(raw), supports_reasoning=bool(raw.get("reasoning", False)), context_window=_extract_limit(raw, "context") or 200000, max_output_tokens=_extract_limit(raw, "output") or 8192, model_family=raw.get("family", "") or "", ) def list_provider_models( provider: str, *, allow_network: bool = True ) -> List[str]: """All model IDs for a provider ([] if unknown). ``allow_network`` defaults to True: the model picker is interactive and a fresh catalog is worth a short wait.""" from hermes_cli.models import normalize_provider provider = normalize_provider(provider) or provider models = _get_provider_models(provider, allow_network=allow_network) if models is None: return [] return [ mid for mid in models.keys() if not _should_hide_from_provider_catalog(provider, mid) ] # Non-agentic or noise models (TTS, embedding, dated preview snapshots, # live/streaming-only, image-only). _NOISE_PATTERNS: re.Pattern = re.compile( r"-tts\b|embedding|live-|-(preview|exp)-\d{2,4}[-_]|" r"-image\b|-image-preview\b|-customtools\b", re.IGNORECASE, ) # Hidden from the Gemini catalogs surfaced in setup/model selection (capability # metadata stays available for direct/manual use). _GOOGLE_HIDDEN_MODELS = frozenset({ # Low-TPM Gemma models that trip Google input-token quota walls under # agent-style traffic despite advertising large context windows. "gemma-4-31b-it", "gemma-4-26b-it", "gemma-4-26b-a4b-it", "gemma-3-1b", "gemma-3-1b-it", "gemma-3-2b", "gemma-3-2b-it", "gemma-3-4b", "gemma-3-4b-it", "gemma-3-12b", "gemma-3-12b-it", "gemma-3-27b", "gemma-3-27b-it", # Stale/retired Google slugs that 404 on the current endpoints. "gemini-1.5-flash", "gemini-1.5-pro", "gemini-1.5-flash-8b", "gemini-2.0-flash", "gemini-2.0-flash-lite", }) def _should_hide_from_provider_catalog(provider: str, model_id: str) -> bool: provider_lower = (provider or "").strip().lower() model_lower = (model_id or "").strip().lower() return provider_lower in {"gemini", "google"} and model_lower in _GOOGLE_HIDDEN_MODELS def list_agentic_models( provider: str, *, allow_network: bool = True ) -> List[str]: """Model IDs suitable for agentic use: tool_call=True, minus hidden and noise models. [] on any failure. ``allow_network`` defaults to True (called from interactive model selection).""" models = _get_provider_models(provider, allow_network=allow_network) if models is None: return [] return [ mid for mid, entry in models.items() if isinstance(entry, dict) and not _should_hide_from_provider_catalog(provider, mid) and entry.get("tool_call", False) and not _NOISE_PATTERNS.search(mid) ] # --------------------------------------------------------------------------- # Rich dataclass constructors + queries # --------------------------------------------------------------------------- def _parse_model_info(model_id: str, raw: Dict[str, Any], provider_id: str) -> ModelInfo: """Convert a raw models.dev model entry dict into a ModelInfo dataclass.""" cost = raw.get("cost") or {} if not isinstance(cost, dict): cost = {} modalities = raw.get("modalities") or {} if not isinstance(modalities, dict): modalities = {} input_mods = modalities.get("input") or [] output_mods = modalities.get("output") or [] def _cost(key: str) -> Optional[float]: return float(cost[key]) if key in cost and cost[key] is not None else None return ModelInfo( id=model_id, name=raw.get("name", "") or model_id, family=raw.get("family", "") or "", provider_id=provider_id, reasoning=bool(raw.get("reasoning", False)), tool_call=bool(raw.get("tool_call", False)), attachment=bool(raw.get("attachment", False)), temperature=bool(raw.get("temperature", False)), structured_output=bool(raw.get("structured_output", False)), open_weights=bool(raw.get("open_weights", False)), input_modalities=tuple(input_mods) if isinstance(input_mods, list) else (), output_modalities=tuple(output_mods) if isinstance(output_mods, list) else (), context_window=_extract_limit(raw, "context") or 0, max_output=_extract_limit(raw, "output") or 0, max_input=_extract_limit(raw, "input"), cost_input=float(cost.get("input", 0) or 0), cost_output=float(cost.get("output", 0) or 0), cost_cache_read=_cost("cache_read"), cost_cache_write=_cost("cache_write"), knowledge_cutoff=raw.get("knowledge", "") or "", release_date=raw.get("release_date", "") or "", status=raw.get("status", "") or "", interleaved=raw.get("interleaved", False), ) def _parse_provider_info(provider_id: str, raw: Dict[str, Any]) -> ProviderInfo: """Convert a raw models.dev provider entry dict into a ProviderInfo.""" env = raw.get("env") or [] models = raw.get("models") or {} return ProviderInfo( id=provider_id, name=raw.get("name", "") or provider_id, env=tuple(env) if isinstance(env, list) else (), api=raw.get("api", "") or "", doc=raw.get("doc", "") or "", model_count=len(models) if isinstance(models, dict) else 0, ) def get_provider_info( provider_id: str, *, allow_network: bool = True ) -> Optional[ProviderInfo]: """Provider metadata by Hermes or models.dev ID, or None if not cataloged. ``allow_network`` defaults to True — the primary caller is interactive setup (``resolve_provider_full``). Hot-path callers pass False. """ mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id) raw = _fetch_registry(allow_network).get(mdev_id) if not isinstance(raw, dict): return None return _parse_provider_info(mdev_id, raw) def get_model_info( provider_id: str, model_id: str, *, allow_network: bool = False ) -> Optional[ModelInfo]: """Full model metadata by Hermes or models.dev provider ID (exact match, then case-insensitive), or None if not found. ``model_overrides`` use the same canonical schema as every other consumer and are translated into the catalog shape here with sub-dicts merged, not clobbered. EXPLICIT entries patch known catalog models; ``_default`` entries fill the gap only for models the catalog does not know. ``allow_network`` defaults to False — cost guard and inventory are hot paths. """ mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id) def _resolve(mid: str, raw: Dict[str, Any], *, catalog_hit: bool) -> Optional[ModelInfo]: override = _override_for(provider_id, model_id, catalog_hit=catalog_hit) if override is not None: raw = _merge_catalog_entry_with_override(raw, override) elif not catalog_hit: return None return _parse_model_info(mid, raw, mdev_id) models = _registry_models(mdev_id, allow_network=allow_network) if models is not None: for mid, raw in _iter_model_entries(models, model_id, suffix_fallback=False): return _resolve(mid, raw, catalog_hit=True) # Not in catalog — an override (explicit or _default) may still provide it. return _resolve(model_id, _UNKNOWN_MODEL_BASE, catalog_hit=False)