Files
hermes-agent/agent/models_dev.py
Teknium 77743eac8a refactor(agent/models): compact pricing snapshot, billing/subscription views, reasoning helpers
- usage_pricing: _snap() builder for official-docs pricing entries (table values identical, verified by dump), shared source/version dicts, drop dead DEFAULT_PRICING
- models_dev: _registry_models/_iter_model_entries/_extract_limit helpers replace repeated registry walking; drop dead ModelInfo.format_cost
- billing_view/subscription_view: OrgRoleCapability mixin replaces duplicated is_admin/can_change_plan; shared fetch_portal_state/parse_org_fields
- reasoning_effort/timeouts/summaries, thinking_timeout_guidance, portal_tags: dispatch tables and compacted comment essays; drop dead CODEX_RESPONSES_EFFORTS alias and _match_any
2026-09-02 13:52:51 -07:00

1203 lines
46 KiB
Python

"""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.<provider>.<model_id>`` is an explicit override
# that always wins over the catalog for the fields it sets (partial patch).
# ``model_overrides.<provider>._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)