- 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
1203 lines
46 KiB
Python
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)
|