4879 lines
187 KiB
Python
4879 lines
187 KiB
Python
"""Canonical model catalogs and lightweight validation helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import http.client
|
|
import logging
|
|
import os
|
|
import re
|
|
import threading
|
|
import urllib.parse
|
|
import urllib.request
|
|
import urllib.error
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, NamedTuple, Optional, TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import TypeGuard
|
|
|
|
from hermes_cli import __version__ as _HERMES_VERSION
|
|
from hermes_cli.urllib_security import open_credentialed_url, url_origin
|
|
from hermes_cli.models_catalog_static import ( # noqa: F401 (re-exported; tests patch hermes_cli.models.<name>)
|
|
CANONICAL_PROVIDERS,
|
|
OPENROUTER_MODELS,
|
|
PREFERRED_SILENT_DEFAULT_MODEL,
|
|
PROVIDER_GROUPS,
|
|
ProviderEntry,
|
|
VERCEL_AI_GATEWAY_MODELS,
|
|
_AGGREGATOR_PROVIDERS,
|
|
_AZURE_FOUNDRY_RESPONSES_PREFIXES,
|
|
_BORROWED_MODEL_PROVIDERS,
|
|
_COPILOT_MODEL_ALIASES,
|
|
_KEYLESS_STABLE_CACHE_PROVIDERS,
|
|
_LIVE_FIRST_PICKER_PROVIDERS,
|
|
_MODELS_DEV_PREFERRED,
|
|
_OPENAI_FAST_MODE_PREFIXES,
|
|
_OPENROUTER_VARIANT_SUFFIXES,
|
|
_PROVIDER_ALIASES,
|
|
_PROVIDER_LABELS,
|
|
_PROVIDER_MODELS,
|
|
_PROVIDER_RETIRED_ALIASES,
|
|
_SILENT_DEFAULT_PROVIDERS,
|
|
_SLUG_TO_GROUP,
|
|
_XAI_CURATED_EXTRAS,
|
|
_XAI_STATIC_FALLBACK,
|
|
_XAI_TOP_MODEL,
|
|
_codex_curated_models,
|
|
_xai_curated_models,
|
|
_xai_finalize_catalog,
|
|
_xai_merge_curated_extras,
|
|
_xai_promote_top,
|
|
group_providers,
|
|
provider_group_for_slug,
|
|
)
|
|
from hermes_cli.models_reasoning_caps import ( # noqa: F401 (re-exported; tests patch hermes_cli.models.<name>)
|
|
_OPENROUTER_CATALOG_URL,
|
|
_REASONING_CAPS_DISK_TTL_SECONDS,
|
|
_fetch_reasoning_caps_catalog,
|
|
_hydrate_reasoning_caps_from_disk,
|
|
_load_reasoning_caps_disk,
|
|
_read_reasoning_caps_disk,
|
|
_reasoning_caps_disk_path,
|
|
_save_reasoning_caps_disk,
|
|
_seed_reasoning_caps,
|
|
_warm_reasoning_caps_async,
|
|
nous_catalog_url,
|
|
nous_model_reasoning_capabilities,
|
|
openrouter_model_reasoning_capabilities,
|
|
parse_openrouter_reasoning_capabilities,
|
|
warm_nous_reasoning_caps_async,
|
|
warm_openrouter_reasoning_caps_async,
|
|
)
|
|
from hermes_cli.models_validate import validate_requested_model # noqa: F401 (re-exported)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Identify ourselves so endpoints fronted by Cloudflare's Browser Integrity
|
|
# Check (error 1010) don't reject the default ``Python-urllib/*`` signature.
|
|
_HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}"
|
|
|
|
COPILOT_BASE_URL = "https://api.githubcopilot.com"
|
|
COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models"
|
|
COPILOT_EDITOR_VERSION = "vscode/1.104.1"
|
|
COPILOT_REASONING_EFFORTS_GPT5 = ["minimal", "low", "medium", "high"]
|
|
COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
|
|
|
|
def _urlopen_model_catalog_request(req: urllib.request.Request, *, timeout: float, ssl_context=None):
|
|
"""Open catalog requests without forwarding headers across origins."""
|
|
return open_credentialed_url(req, timeout=timeout, ssl_context=ssl_context)
|
|
|
|
|
|
def _custom_provider_ssl_context(base_url: str):
|
|
"""Build an ``ssl.SSLContext`` from a custom provider's TLS settings.
|
|
|
|
Mirrors the httpx/requests TLS resolution so the urllib ``/models`` probe honors a
|
|
provider's ``ssl_ca_cert`` / ``ssl_verify`` instead of the process-wide
|
|
``SSL_CERT_FILE``/certifi bundle. Returns None when no per-provider override applies, so the
|
|
caller keeps urllib's default policy.
|
|
"""
|
|
if not base_url:
|
|
return None
|
|
try:
|
|
from hermes_cli.config import get_custom_provider_tls_settings
|
|
|
|
tls = get_custom_provider_tls_settings(base_url)
|
|
if not tls:
|
|
return None
|
|
import ssl
|
|
|
|
if tls.get("ssl_verify") is False:
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
return ctx
|
|
ca = tls.get("ssl_ca_cert")
|
|
if isinstance(ca, str) and ca and os.path.isfile(ca):
|
|
return ssl.create_default_context(cafile=ca)
|
|
except Exception:
|
|
return None # never break discovery on a TLS-config lookup
|
|
return None
|
|
|
|
|
|
_openrouter_catalog_cache: list[tuple[str, str]] | None = None
|
|
|
|
|
|
_ai_gateway_catalog_cache: list[tuple[str, str]] | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Nous Portal free-model helper
|
|
# ---------------------------------------------------------------------------
|
|
# The Nous Portal models endpoint is the source of truth for which models
|
|
# are currently offered (free or paid). We trust whatever it returns and
|
|
# surface it to users as-is — no local allowlist filtering.
|
|
|
|
|
|
def _is_model_free(model_id: str, pricing: dict[str, dict[str, str]]) -> bool:
|
|
"""Return True if *model_id* has zero-cost prompt AND completion pricing."""
|
|
p = pricing.get(model_id)
|
|
if not p:
|
|
return False
|
|
try:
|
|
return float(p.get("prompt", "1")) == 0 and float(p.get("completion", "1")) == 0
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def partition_nous_models_by_tier(
|
|
model_ids: list[str],
|
|
pricing: dict[str, dict[str, str]],
|
|
free_tier: bool,
|
|
) -> tuple[list[str], list[str]]:
|
|
"""Split Nous models into (selectable, unavailable) based on user tier.
|
|
|
|
For free-tier users: only free models are selectable; paid models are returned as unavailable
|
|
(shown grayed out in the menu).
|
|
"""
|
|
if not free_tier:
|
|
return (model_ids, [])
|
|
|
|
if not pricing:
|
|
return (model_ids, []) # can't determine, show everything
|
|
|
|
selectable: list[str] = []
|
|
unavailable: list[str] = []
|
|
for mid in model_ids:
|
|
if _is_model_free(mid, pricing):
|
|
selectable.append(mid)
|
|
else:
|
|
unavailable.append(mid)
|
|
return (selectable, unavailable)
|
|
|
|
|
|
def union_with_portal_free_recommendations(
|
|
curated_ids: list[str],
|
|
pricing: dict[str, dict[str, str]],
|
|
portal_base_url: str = "",
|
|
*,
|
|
force_refresh: bool = False,
|
|
) -> tuple[list[str], dict[str, dict[str, str]]]:
|
|
"""Augment curated list + pricing with the Portal's ``freeRecommendedModels``.
|
|
|
|
* Portal free recommendations missing from ``curated_ids`` are appended after the curated list
|
|
(so the in-repo curated models show first and Portal-only picks follow).
|
|
"""
|
|
try:
|
|
payload = fetch_nous_recommended_models(
|
|
portal_base_url, force_refresh=force_refresh
|
|
)
|
|
except Exception:
|
|
return (list(curated_ids), dict(pricing))
|
|
|
|
free_block = payload.get("freeRecommendedModels") if isinstance(payload, dict) else None
|
|
if not isinstance(free_block, list) or not free_block:
|
|
return (list(curated_ids), dict(pricing))
|
|
|
|
portal_free_ids: list[str] = []
|
|
for entry in free_block:
|
|
name = _extract_model_name(entry)
|
|
if name:
|
|
portal_free_ids.append(name)
|
|
if not portal_free_ids:
|
|
return (list(curated_ids), dict(pricing))
|
|
|
|
augmented_pricing = dict(pricing)
|
|
free_synthetic = {"prompt": "0", "completion": "0"}
|
|
for mid in portal_free_ids:
|
|
if mid not in augmented_pricing:
|
|
augmented_pricing[mid] = dict(free_synthetic)
|
|
|
|
augmented_ids = list(curated_ids)
|
|
seen = set(augmented_ids)
|
|
# Append Portal free recommendations that aren't already curated, so the
|
|
# in-repo curated ("HA") models show first and Portal-only picks follow.
|
|
new_ones = [mid for mid in portal_free_ids if mid not in seen]
|
|
if new_ones:
|
|
augmented_ids = augmented_ids + new_ones
|
|
|
|
return (augmented_ids, augmented_pricing)
|
|
|
|
|
|
def union_with_portal_paid_recommendations(
|
|
curated_ids: list[str],
|
|
pricing: dict[str, dict[str, str]],
|
|
portal_base_url: str = "",
|
|
*,
|
|
force_refresh: bool = False,
|
|
) -> tuple[list[str], dict[str, dict[str, str]]]:
|
|
"""Augment curated list with the Portal's ``paidRecommendedModels``.
|
|
|
|
* Portal paid recommendations missing from ``curated_ids`` are appended after the curated list
|
|
(so the in-repo curated models show first and Portal-only picks follow). * ``pricing`` is left
|
|
untouched — we deliberately do NOT synthesize pricing entries for paid models.
|
|
|
|
Failures (network, parse, missing field) are silent and degrade to returning the inputs
|
|
unchanged — never block the picker on a Portal-side hiccup.
|
|
"""
|
|
try:
|
|
payload = fetch_nous_recommended_models(
|
|
portal_base_url, force_refresh=force_refresh
|
|
)
|
|
except Exception:
|
|
return (list(curated_ids), dict(pricing))
|
|
|
|
paid_block = payload.get("paidRecommendedModels") if isinstance(payload, dict) else None
|
|
if not isinstance(paid_block, list) or not paid_block:
|
|
return (list(curated_ids), dict(pricing))
|
|
|
|
portal_paid_ids: list[str] = []
|
|
for entry in paid_block:
|
|
name = _extract_model_name(entry)
|
|
if name:
|
|
portal_paid_ids.append(name)
|
|
if not portal_paid_ids:
|
|
return (list(curated_ids), dict(pricing))
|
|
|
|
augmented_ids = list(curated_ids)
|
|
seen = set(augmented_ids)
|
|
# Append Portal paid recommendations that aren't already curated, so the
|
|
# in-repo curated ("HA") models show first and Portal-only picks follow.
|
|
new_ones = [mid for mid in portal_paid_ids if mid not in seen]
|
|
if new_ones:
|
|
augmented_ids = augmented_ids + new_ones
|
|
|
|
return (augmented_ids, dict(pricing))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TTL cache for free-tier detection — avoids repeated API calls within a
|
|
# session while still picking up upgrades quickly.
|
|
# ---------------------------------------------------------------------------
|
|
_FREE_TIER_CACHE_TTL: int = 180 # seconds (3 minutes)
|
|
_free_tier_cache: tuple[bool, float] | None = None # (result, timestamp)
|
|
|
|
|
|
def check_nous_free_tier(*, force_fresh: bool = False) -> bool:
|
|
"""Check if the current Nous Portal user is on a free (unpaid) tier.
|
|
|
|
Results are cached for ``_FREE_TIER_CACHE_TTL`` seconds to avoid hitting the Portal API on every
|
|
call. The cache is short-lived so that an account upgrade is reflected within a few minutes.
|
|
|
|
Returns True only when entitlement is known to be free. Unknown/error states return False so
|
|
this compatibility wrapper does not block users.
|
|
"""
|
|
global _free_tier_cache
|
|
now = time.monotonic()
|
|
if not force_fresh and _free_tier_cache is not None:
|
|
cached_result, cached_at = _free_tier_cache
|
|
if now - cached_at < _FREE_TIER_CACHE_TTL:
|
|
return cached_result
|
|
|
|
try:
|
|
from hermes_cli.nous_account import get_nous_portal_account_info
|
|
|
|
account_info = get_nous_portal_account_info(force_fresh=force_fresh)
|
|
result = account_info.is_free_tier
|
|
_free_tier_cache = (result, now)
|
|
return result
|
|
except Exception:
|
|
_free_tier_cache = (False, now)
|
|
return False # default to paid on error — don't block users
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Nous Portal recommended models
|
|
#
|
|
# The Portal publishes a curated list of suggested models (separated into
|
|
# paid and free tiers) plus dedicated recommendations for compaction (text
|
|
# summarisation / auxiliary) and vision tasks. We fetch it once per process
|
|
# with a TTL cache so callers can ask "what's the best aux model right now?"
|
|
# without hitting the network on every lookup.
|
|
#
|
|
# Shape of the response (fields we care about):
|
|
# {
|
|
# "paidRecommendedModels": [ {modelName, ...}, ... ],
|
|
# "freeRecommendedModels": [ {modelName, ...}, ... ],
|
|
# "paidRecommendedCompactionModel": {modelName, ...} | null,
|
|
# "paidRecommendedVisionModel": {modelName, ...} | null,
|
|
# "freeRecommendedCompactionModel": {modelName, ...} | null,
|
|
# "freeRecommendedVisionModel": {modelName, ...} | null,
|
|
# }
|
|
# ---------------------------------------------------------------------------
|
|
|
|
NOUS_RECOMMENDED_MODELS_PATH = "/api/nous/recommended-models"
|
|
_NOUS_RECOMMENDED_CACHE_TTL: int = 600 # seconds (10 minutes)
|
|
# (result_dict, timestamp) keyed by portal_base_url so staging vs prod don't collide.
|
|
_nous_recommended_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
|
|
|
|
|
def _nous_recommended_disk_path() -> "Path":
|
|
"""Disk path for the persisted recommended-models cache."""
|
|
from hermes_constants import get_hermes_home
|
|
return get_hermes_home() / "cache" / "nous_recommended_cache.json"
|
|
|
|
|
|
def _read_nous_recommended_disk(base: str) -> dict[str, Any] | None:
|
|
"""Return the last-known-good payload for ``base`` from disk, or None.
|
|
|
|
The disk file is a JSON object keyed by portal base URL so staging and prod don't collide:
|
|
``{"<base>": {"data": {...}, "ts": <epoch_seconds>}}``.
|
|
"""
|
|
try:
|
|
with open(_nous_recommended_disk_path(), encoding="utf-8") as fh:
|
|
blob = json.load(fh)
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
if not isinstance(blob, dict):
|
|
return None
|
|
entry = blob.get(base)
|
|
if not isinstance(entry, dict):
|
|
return None
|
|
data = entry.get("data")
|
|
return data if isinstance(data, dict) and data else None
|
|
|
|
|
|
def _write_nous_recommended_disk(base: str, data: dict[str, Any]) -> None:
|
|
"""Persist ``data`` as the last-known-good payload for ``base``.
|
|
|
|
Merges into any existing per-base map, then writes atomically. Failures are non-fatal (logged at
|
|
debug) — the in-process cache still works.
|
|
"""
|
|
if not data:
|
|
return
|
|
path = _nous_recommended_disk_path()
|
|
try:
|
|
try:
|
|
with open(path, encoding="utf-8") as fh:
|
|
blob = json.load(fh)
|
|
if not isinstance(blob, dict):
|
|
blob = {}
|
|
except (OSError, json.JSONDecodeError):
|
|
blob = {}
|
|
blob[base] = {"data": data, "ts": time.time()}
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
with open(tmp, "w", encoding="utf-8") as fh:
|
|
json.dump(blob, fh, indent=2)
|
|
fh.write("\n")
|
|
os.replace(tmp, path)
|
|
except OSError as exc:
|
|
import logging
|
|
logging.getLogger(__name__).debug(
|
|
"nous recommended-models disk cache write failed: %s", exc
|
|
)
|
|
|
|
|
|
def fetch_nous_recommended_models(
|
|
portal_base_url: str = "",
|
|
timeout: float = 5.0,
|
|
*,
|
|
force_refresh: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Fetch the Nous Portal's curated recommended-models payload.
|
|
|
|
Hits ``<portal>/api/nous/recommended-models``. The endpoint is public — no auth is required.
|
|
Results are cached per portal URL for ``_NOUS_RECOMMENDED_CACHE_TTL`` seconds in process; pass
|
|
``force_refresh=True`` to bypass the in-process cache.
|
|
|
|
A successful live fetch is also persisted to a per-base disk cache
|
|
(``$HERMES_HOME/cache/nous_recommended_cache.json``) as last-known-good. Self-heals on the next
|
|
successful fetch.
|
|
"""
|
|
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
|
|
now = time.monotonic()
|
|
cached = _nous_recommended_cache.get(base)
|
|
if not force_refresh and cached is not None:
|
|
payload, cached_at = cached
|
|
if now - cached_at < _NOUS_RECOMMENDED_CACHE_TTL:
|
|
return payload
|
|
|
|
url = f"{base}{NOUS_RECOMMENDED_MODELS_PATH}"
|
|
try:
|
|
req = urllib.request.Request(
|
|
url,
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
with _urlopen_model_catalog_request(req, timeout=timeout) as resp:
|
|
data = json.loads(resp.read().decode())
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
except Exception:
|
|
data = {}
|
|
|
|
if data:
|
|
# Live fetch succeeded — refresh both cache layers.
|
|
_nous_recommended_cache[base] = (data, now)
|
|
_write_nous_recommended_disk(base, data)
|
|
return data
|
|
|
|
# Live fetch failed. Fall back to the last-known-good disk copy so a
|
|
# transient Portal hiccup doesn't drop the recommendations entirely.
|
|
disk = _read_nous_recommended_disk(base)
|
|
if disk:
|
|
_nous_recommended_cache[base] = (disk, now)
|
|
return disk
|
|
|
|
_nous_recommended_cache[base] = (data, now)
|
|
return data
|
|
|
|
|
|
def _resolve_nous_portal_url() -> str:
|
|
"""Best-effort lookup of the Portal base URL the user is authed against."""
|
|
try:
|
|
from hermes_cli.auth import (
|
|
DEFAULT_NOUS_PORTAL_URL,
|
|
get_provider_auth_state,
|
|
)
|
|
state = get_provider_auth_state("nous") or {}
|
|
portal = str(state.get("portal_base_url") or "").strip()
|
|
if portal:
|
|
return portal.rstrip("/")
|
|
return str(DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
|
except Exception:
|
|
return "https://portal.nousresearch.com"
|
|
|
|
|
|
def _extract_model_name(entry: Any) -> Optional[str]:
|
|
"""Pull the ``modelName`` field from a recommended-model entry, else None."""
|
|
if not isinstance(entry, dict):
|
|
return None
|
|
model_name = entry.get("modelName")
|
|
if isinstance(model_name, str) and model_name.strip():
|
|
return model_name.strip()
|
|
return None
|
|
|
|
|
|
def get_nous_recommended_aux_model(
|
|
*,
|
|
vision: bool = False,
|
|
free_tier: Optional[bool] = None,
|
|
portal_base_url: str = "",
|
|
force_refresh: bool = False,
|
|
) -> Optional[str]:
|
|
"""Return the Portal's recommended model name for an auxiliary task.
|
|
|
|
For paid-tier users we prefer the paid recommendation but gracefully fall back to the free
|
|
recommendation if the Portal returned ``null`` for the paid field (common during the staged
|
|
rollout of new paid models).
|
|
"""
|
|
base = portal_base_url or _resolve_nous_portal_url()
|
|
payload = fetch_nous_recommended_models(base, force_refresh=force_refresh)
|
|
if not payload:
|
|
return None
|
|
|
|
if free_tier is None:
|
|
try:
|
|
free_tier = check_nous_free_tier()
|
|
except Exception:
|
|
# On any detection error, assume paid — paid users see both fields
|
|
# anyway so this is a safe default that maximises model quality.
|
|
free_tier = False
|
|
|
|
if vision:
|
|
paid_key, free_key = "paidRecommendedVisionModel", "freeRecommendedVisionModel"
|
|
else:
|
|
paid_key, free_key = "paidRecommendedCompactionModel", "freeRecommendedCompactionModel"
|
|
|
|
# Preference order:
|
|
# free tier → free only
|
|
# paid tier → paid, then free (if paid field is null)
|
|
candidates = [free_key] if free_tier else [paid_key, free_key]
|
|
for key in candidates:
|
|
name = _extract_model_name(payload.get(key))
|
|
if name:
|
|
return name
|
|
return None
|
|
|
|
|
|
def get_preferred_silent_default_model(provider: str = "openrouter") -> str:
|
|
"""Return the silent-default model id — catalog label first, constant second.
|
|
|
|
Reads the ``"default": true`` label from the cached remote catalog (never hits the network —
|
|
safe on hot resolution paths), falling back to :data:`PREFERRED_SILENT_DEFAULT_MODEL` when no
|
|
cached manifest exists or the provider block carries no label.
|
|
"""
|
|
try:
|
|
from hermes_cli.model_catalog import get_default_model_from_cache
|
|
labeled = get_default_model_from_cache(provider)
|
|
if labeled:
|
|
return labeled
|
|
except Exception:
|
|
pass
|
|
return PREFERRED_SILENT_DEFAULT_MODEL
|
|
|
|
|
|
def pick_silent_default_model(model_ids: list[str], provider: str = "openrouter") -> str:
|
|
"""Pick the silent default from an available-models list.
|
|
|
|
Returns the catalog-labeled default (see :func:`get_preferred_silent_default_model`) when the
|
|
list carries it, else the first entry, else "". Used by every surface that must choose a model
|
|
on the user's behalf without an interactive picker (GUI onboarding recommended-default, empty-
|
|
model runtime fallback).
|
|
"""
|
|
preferred = get_preferred_silent_default_model(provider)
|
|
if preferred in model_ids:
|
|
return preferred
|
|
return model_ids[0] if model_ids else ""
|
|
|
|
|
|
def get_default_model_for_provider(provider: str) -> str:
|
|
"""Return a cost-safe default model for a provider, or "" if unknown.
|
|
|
|
Used as a NON-INTERACTIVE fallback when a provider is configured but no model was ever selected
|
|
(e.g. ``hermes auth add openai-codex`` without ``hermes model``, or a profile that sets
|
|
``provider`` with no ``model``).
|
|
"""
|
|
models = _PROVIDER_MODELS.get(provider, [])
|
|
if provider in _SILENT_DEFAULT_PROVIDERS:
|
|
preferred = get_preferred_silent_default_model(provider)
|
|
# Trust the preferred default even when the provider has no static
|
|
# catalog (OpenRouter's picker list is fetched live; its curated
|
|
# snapshot carries the default).
|
|
if preferred and (preferred in models or not models):
|
|
return preferred
|
|
return models[0] if models else ""
|
|
|
|
|
|
def _openrouter_model_is_free(pricing: Any) -> bool:
|
|
"""Return True when both prompt and completion pricing are zero."""
|
|
if not isinstance(pricing, dict):
|
|
return False
|
|
try:
|
|
return float(pricing.get("prompt", "0")) == 0 and float(pricing.get("completion", "0")) == 0
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def _openrouter_model_supports_tools(item: Any) -> bool:
|
|
"""Return True when the model's ``supported_parameters`` advertise tool calling.
|
|
|
|
hermes-agent is tool-calling-first — every provider path assumes the model can invoke tools.
|
|
Models that don't advertise ``tools`` in their ``supported_parameters`` (e.g. image-only or
|
|
completion-only models) cannot be driven by the agent loop and would fail at the first tool
|
|
call.
|
|
|
|
**Permissive when the field is missing.** Some OpenRouter-compatible gateways (Nous Portal,
|
|
private mirrors, older catalog snapshots) don't populate ``supported_parameters`` at all. Treat
|
|
that as "unknown capability → allow" so the picker doesn't silently empty for those users.
|
|
"""
|
|
if not isinstance(item, dict):
|
|
return True
|
|
params = item.get("supported_parameters")
|
|
if not isinstance(params, list):
|
|
# Field absent / malformed / None — be permissive.
|
|
return True
|
|
return "tools" in params
|
|
|
|
|
|
# Reasoning-capability cache slots, one set per catalog (OpenRouter, Nous Portal). The logic
|
|
# lives in models_reasoning_caps and reads/writes these by name so tests can reset them here.
|
|
# ``*_cache``: model id → parsed caps for the process lifetime; ``*_failed_at``: monotonic time
|
|
# of the last failed fetch (60s re-fetch suppression); the flags are once-per-process guards.
|
|
_openrouter_reasoning_caps_cache: dict[str, Optional[dict[str, Any]]] | None = None
|
|
_openrouter_reasoning_caps_failed_at: float | None = None
|
|
_openrouter_caps_disk_checked = False
|
|
_openrouter_caps_warm_started = False
|
|
_nous_reasoning_caps_cache: dict[str, Optional[dict[str, Any]]] | None = None
|
|
_nous_reasoning_caps_failed_at: float | None = None
|
|
_nous_caps_disk_checked = False
|
|
_nous_caps_warm_started = False
|
|
|
|
|
|
from agent.reasoning_effort import clamp_effort as _clamp_effort
|
|
|
|
|
|
def clamp_reasoning_effort_to_supported(
|
|
effort: Optional[str],
|
|
supported_efforts: Optional[list[str]],
|
|
) -> Optional[str]:
|
|
"""Clamp a requested reasoning effort to a provider's supported levels.
|
|
|
|
Thin wrapper over the canonical policy in :func:`agent.reasoning_effort.clamp_effort` (single
|
|
implementation for every transport and provider profile): keep a supported level verbatim,
|
|
otherwise nearest WEAKER supported level (never silently escalate cost), weakest supported level
|
|
when nothing weaker exists, pass through unknown supported-sets and bespoke level names
|
|
unchanged.
|
|
"""
|
|
return _clamp_effort(effort, supported_efforts)
|
|
|
|
|
|
def fetch_openrouter_models(
|
|
timeout: float = 8.0,
|
|
*,
|
|
force_refresh: bool = False,
|
|
) -> list[tuple[str, str]]:
|
|
"""Return the curated OpenRouter picker list, refreshed from the live catalog when possible."""
|
|
global _openrouter_catalog_cache
|
|
|
|
if _openrouter_catalog_cache is not None and not force_refresh:
|
|
return list(_openrouter_catalog_cache)
|
|
|
|
# Prefer the remotely-hosted catalog manifest; fall back to the in-repo
|
|
# snapshot when the manifest is unreachable. Both are curated lists that
|
|
# drive the picker; the OpenRouter live /v1/models filter (tool support,
|
|
# free pricing) is applied on top either way.
|
|
try:
|
|
from hermes_cli.model_catalog import get_curated_openrouter_models
|
|
remote = get_curated_openrouter_models()
|
|
except Exception:
|
|
remote = None
|
|
fallback = list(remote) if remote else list(OPENROUTER_MODELS)
|
|
preferred_ids = [mid for mid, _ in fallback]
|
|
|
|
try:
|
|
req = urllib.request.Request(
|
|
_OPENROUTER_CATALOG_URL,
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
with _urlopen_model_catalog_request(req, timeout=timeout) as resp:
|
|
payload = json.loads(resp.read().decode())
|
|
except Exception:
|
|
return list(_openrouter_catalog_cache or fallback)
|
|
|
|
live_items = payload.get("data", [])
|
|
if not isinstance(live_items, list):
|
|
return list(_openrouter_catalog_cache or fallback)
|
|
|
|
live_by_id: dict[str, dict[str, Any]] = {}
|
|
for item in live_items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
mid = str(item.get("id") or "").strip()
|
|
if not mid:
|
|
continue
|
|
live_by_id[mid] = item
|
|
|
|
# Free warm-up for the reasoning-capability cache: this is the same payload
|
|
# _fetch_openrouter_reasoning_caps would fetch, so parse it once here and
|
|
# hot-path callers (openrouter_model_reasoning_capabilities) never need
|
|
# their own HTTP round-trip.
|
|
global _openrouter_reasoning_caps_cache
|
|
seeded = _seed_reasoning_caps(_OPENROUTER_CATALOG_URL, live_items)
|
|
if _openrouter_reasoning_caps_cache is None and seeded is not None:
|
|
_openrouter_reasoning_caps_cache = seeded
|
|
|
|
curated: list[tuple[str, str]] = []
|
|
silent_default = get_preferred_silent_default_model("openrouter")
|
|
for preferred_id in preferred_ids:
|
|
live_item = live_by_id.get(preferred_id)
|
|
if live_item is None:
|
|
continue
|
|
# Hide models that don't advertise tool-calling support — hermes-agent
|
|
# requires it and surfacing them leads to immediate runtime failures
|
|
# when the user selects them. Ported from Kilo-Org/kilocode#9068.
|
|
if not _openrouter_model_supports_tools(live_item):
|
|
continue
|
|
if preferred_id == silent_default:
|
|
# Keep the silent-default badge through the live refresh so the
|
|
# picker shows which model Hermes lands on when none is selected.
|
|
desc = "default"
|
|
else:
|
|
desc = "free" if _openrouter_model_is_free(live_item.get("pricing")) else ""
|
|
curated.append((preferred_id, desc))
|
|
|
|
if not curated:
|
|
return list(_openrouter_catalog_cache or fallback)
|
|
|
|
first_id, first_desc = curated[0]
|
|
if not first_desc:
|
|
curated[0] = (first_id, "recommended")
|
|
_openrouter_catalog_cache = curated
|
|
return list(curated)
|
|
|
|
|
|
def model_ids(*, force_refresh: bool = False) -> list[str]:
|
|
"""Return just the OpenRouter model-id strings."""
|
|
return [mid for mid, _ in fetch_openrouter_models(force_refresh=force_refresh)]
|
|
|
|
|
|
def get_curated_nous_model_ids() -> list[str]:
|
|
"""Return the curated Nous Portal model-id list.
|
|
|
|
Prefers the remotely-hosted catalog manifest (published under ``website/static/api/model-
|
|
catalog.json``); falls back to the in-repo snapshot in ``_PROVIDER_MODELS["nous"]`` when the
|
|
manifest is unreachable. Always returns a list (never None).
|
|
"""
|
|
try:
|
|
from hermes_cli.model_catalog import get_curated_nous_models
|
|
remote = get_curated_nous_models()
|
|
except Exception:
|
|
remote = None
|
|
if remote:
|
|
return list(remote)
|
|
return list(_PROVIDER_MODELS.get("nous", []))
|
|
|
|
|
|
def _ai_gateway_model_is_free(pricing: Any) -> bool:
|
|
"""Return True if an AI Gateway model has $0 input AND output pricing."""
|
|
if not isinstance(pricing, dict):
|
|
return False
|
|
try:
|
|
return float(pricing.get("input", "0")) == 0 and float(pricing.get("output", "0")) == 0
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def fetch_ai_gateway_models(
|
|
timeout: float = 8.0,
|
|
*,
|
|
force_refresh: bool = False,
|
|
) -> list[tuple[str, str]]:
|
|
"""Return the curated AI Gateway picker list, refreshed from the live catalog when possible."""
|
|
global _ai_gateway_catalog_cache
|
|
|
|
if _ai_gateway_catalog_cache is not None and not force_refresh:
|
|
return list(_ai_gateway_catalog_cache)
|
|
|
|
from hermes_constants import AI_GATEWAY_BASE_URL
|
|
|
|
fallback = list(VERCEL_AI_GATEWAY_MODELS)
|
|
preferred_ids = [mid for mid, _ in fallback]
|
|
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"{AI_GATEWAY_BASE_URL.rstrip('/')}/models",
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
payload = json.loads(resp.read().decode())
|
|
except Exception:
|
|
return list(_ai_gateway_catalog_cache or fallback)
|
|
|
|
live_items = payload.get("data", [])
|
|
if not isinstance(live_items, list):
|
|
return list(_ai_gateway_catalog_cache or fallback)
|
|
|
|
live_by_id: dict[str, dict[str, Any]] = {}
|
|
for item in live_items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
mid = str(item.get("id") or "").strip()
|
|
if not mid:
|
|
continue
|
|
live_by_id[mid] = item
|
|
|
|
curated: list[tuple[str, str]] = []
|
|
for preferred_id in preferred_ids:
|
|
live_item = live_by_id.get(preferred_id)
|
|
if live_item is None:
|
|
continue
|
|
desc = "free" if _ai_gateway_model_is_free(live_item.get("pricing")) else ""
|
|
curated.append((preferred_id, desc))
|
|
|
|
if not curated:
|
|
return list(_ai_gateway_catalog_cache or fallback)
|
|
|
|
# If the live catalog offers a free Moonshot model, auto-promote it to
|
|
# position #1 as "recommended" — dynamic discovery without a PR.
|
|
free_moonshot = next(
|
|
(
|
|
mid
|
|
for mid, item in live_by_id.items()
|
|
if mid.startswith("moonshotai/")
|
|
and _ai_gateway_model_is_free(item.get("pricing"))
|
|
),
|
|
None,
|
|
)
|
|
if free_moonshot:
|
|
curated = [(mid, desc) for mid, desc in curated if mid != free_moonshot]
|
|
curated.insert(0, (free_moonshot, "recommended"))
|
|
else:
|
|
first_id, _ = curated[0]
|
|
curated[0] = (first_id, "recommended")
|
|
|
|
_ai_gateway_catalog_cache = curated
|
|
return list(curated)
|
|
|
|
|
|
def ai_gateway_model_ids(*, force_refresh: bool = False) -> list[str]:
|
|
"""Return just the AI Gateway model-id strings."""
|
|
return [mid for mid, _ in fetch_ai_gateway_models(force_refresh=force_refresh)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pricing helpers — fetch live pricing from OpenRouter-compatible /v1/models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Cache: maps model_id → {"prompt": str, "completion": str} per endpoint
|
|
_pricing_cache: dict[str, dict[str, dict[str, str]]] = {}
|
|
|
|
# A failed fetch caches its empty result too, so an unreachable endpoint isn't
|
|
# re-dialed on every call — but only until this deadline. Cached forever, one
|
|
# bad moment (a blip during startup, a key that hadn't been written yet) turns
|
|
# into no live model discovery for the life of the process, and the processes
|
|
# that read this most are the ones that run for weeks: the gateway, the desktop
|
|
# backend. Every caller falls back to a curated list meanwhile, so the cost of
|
|
# the stale entry is silent and invisible.
|
|
_FAILED_CATALOG_TTL_SECONDS = 120.0
|
|
_pricing_cache_retry_after: dict[str, float] = {}
|
|
|
|
|
|
def _cached_catalog(cache_key: str) -> Optional[dict[str, dict[str, Any]]]:
|
|
"""The cached catalog for *cache_key*, or None to go fetch it."""
|
|
cached = _pricing_cache.get(cache_key)
|
|
if cached is None:
|
|
return None
|
|
retry_after = _pricing_cache_retry_after.get(cache_key)
|
|
if retry_after is not None and time.monotonic() >= retry_after:
|
|
_pricing_cache.pop(cache_key, None)
|
|
_pricing_cache_retry_after.pop(cache_key, None)
|
|
return None
|
|
return cached
|
|
|
|
|
|
def _cache_catalog(
|
|
cache_key: str,
|
|
result: dict[str, dict[str, Any]],
|
|
ttl_seconds: Optional[float] = None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""Cache a catalog result, giving an empty one an expiry.
|
|
|
|
*ttl_seconds* expires a non-empty result too. Only a catalog whose contents depend on server-
|
|
side state the client cannot observe needs it — an org's model policy can change while a long-
|
|
lived process holds the entry.
|
|
"""
|
|
_pricing_cache[cache_key] = result
|
|
if result:
|
|
if ttl_seconds:
|
|
_pricing_cache_retry_after[cache_key] = time.monotonic() + ttl_seconds
|
|
else:
|
|
_pricing_cache_retry_after.pop(cache_key, None)
|
|
else:
|
|
_pricing_cache_retry_after[cache_key] = (
|
|
time.monotonic() + _FAILED_CATALOG_TTL_SECONDS
|
|
)
|
|
return result
|
|
|
|
|
|
# NUL cannot appear in a URL, so this cannot collide with a real base URL.
|
|
_PRICING_AUTH_KEY_PREFIX = "\x00auth:"
|
|
|
|
|
|
def _pricing_auth_fingerprint(api_key: str | None) -> str:
|
|
"""Key suffix identifying the credential a catalog was read with.
|
|
|
|
A governed endpoint answers each token with the catalog its org may reach, so two credentials
|
|
cannot share an entry. blake2b for cache-key fingerprinting only, same rationale as
|
|
:func:`_custom_endpoint_fingerprint`.
|
|
"""
|
|
if not api_key:
|
|
return ""
|
|
import hashlib
|
|
|
|
digest = hashlib.blake2b(api_key.encode("utf-8", errors="replace"), digest_size=8)
|
|
return _PRICING_AUTH_KEY_PREFIX + digest.hexdigest()
|
|
|
|
|
|
def peek_cached_pricing(base_url: str) -> dict[str, dict[str, Any]]:
|
|
"""Pricing already cached for *base_url*, or ``{}``. Never fetches.
|
|
|
|
Accepts a ``/v1``-suffixed URL as well as the pre-``/v1`` root the fetchers key on, and
|
|
prefers an authenticated catalog. Scans rather than rebuilding a key because callers hold no
|
|
credential — newest first, skipping expired entries, so a rotated credential does not keep
|
|
answering from the catalog its predecessor read.
|
|
"""
|
|
root = (base_url or "").rstrip("/")
|
|
if root.endswith("/v1"):
|
|
root = root[:-3].rstrip("/")
|
|
authed_prefix = root + _PRICING_AUTH_KEY_PREFIX
|
|
for key in reversed(list(_pricing_cache)):
|
|
if key.startswith(authed_prefix):
|
|
cached = _cached_catalog(key)
|
|
if cached:
|
|
return cached
|
|
return _cached_catalog(root) or {}
|
|
|
|
|
|
def _format_price_per_mtok(per_token_str: str) -> str:
|
|
"""Convert a per-token price string to a human-friendly $/Mtok string.
|
|
|
|
Always uses 2 decimal places so that prices align vertically when right-justified in a column
|
|
(the decimal point stays in the same position).
|
|
|
|
Sub-cent prices (e.g. deep-discount cache-hit promos) extend precision instead of collapsing to
|
|
"$0.00": the smallest decimal place that makes the value non-zero is found, then one extra digit
|
|
is kept and trailing zeros trimmed.
|
|
"""
|
|
try:
|
|
val = float(per_token_str)
|
|
except (TypeError, ValueError):
|
|
return "?"
|
|
if val == 0:
|
|
return "free"
|
|
per_m = val * 1_000_000
|
|
text = f"{per_m:.2f}"
|
|
if per_m < 0.01:
|
|
# Non-zero price below one cent per Mtok — widen precision until the
|
|
# value shows, keep one extra significant digit, trim trailing zeros.
|
|
prec = 3
|
|
while prec < 12 and round(per_m, prec) == 0:
|
|
prec += 1
|
|
text = f"{per_m:.{min(prec + 1, 12)}f}".rstrip("0").rstrip(".")
|
|
return f"${text}"
|
|
|
|
|
|
def compute_sale_discount(
|
|
prompt: str,
|
|
completion: str,
|
|
original: Any,
|
|
) -> tuple[int, str, str] | None:
|
|
"""Derive sale chrome from gateway ``pricing.original`` when cheaper.
|
|
|
|
Nous Portal-only feature: callers gate on the provider; this helper only sees ``original``
|
|
because the Nous fetch path opted in via ``include_sale_original=True``.
|
|
|
|
Returns ``(discount_percent, was_prompt_raw, was_completion_raw)`` only when ``original`` is a
|
|
dict and the current prompt (fallback: completion) rate is strictly below the corresponding
|
|
original.
|
|
"""
|
|
def _finite(raw: Any) -> float | None:
|
|
try:
|
|
n = float(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return n if n > 0 and n == n else None # n == n rejects NaN
|
|
|
|
def _nonneg(raw: Any) -> float | None:
|
|
try:
|
|
n = float(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return n if n >= 0 and n == n else None
|
|
|
|
orig_dict = original if isinstance(original, dict) else {}
|
|
was_prompt = orig_dict.get("prompt")
|
|
was_completion = orig_dict.get("completion")
|
|
|
|
# Free / $0 models: flat 100% off, with "was" prices only when the
|
|
# gateway actually served an original (e.g. a :free sibling); a
|
|
# natively-free model (stealth/ox-alpha) gets bare "-100%" chrome.
|
|
cur_prompt_any = _nonneg(prompt) if prompt not in (None, "") else None
|
|
cur_comp_any = _nonneg(completion) if completion not in (None, "") else None
|
|
if cur_prompt_any == 0 and cur_comp_any in (0, None):
|
|
return (
|
|
100,
|
|
str(was_prompt) if was_prompt not in (None, "") else "",
|
|
str(was_completion) if was_completion not in (None, "") else "",
|
|
)
|
|
|
|
if not isinstance(original, dict):
|
|
return None
|
|
|
|
if was_prompt in (None, "") and was_completion in (None, ""):
|
|
return None
|
|
|
|
cur_prompt = _finite(prompt) if prompt not in (None, "") else None
|
|
orig_prompt = _finite(was_prompt) if was_prompt not in (None, "") else None
|
|
if cur_prompt is not None and orig_prompt is not None and cur_prompt < orig_prompt:
|
|
pct = int(round((1.0 - (cur_prompt / orig_prompt)) * 100))
|
|
if pct < 1:
|
|
return None
|
|
return (
|
|
pct,
|
|
str(was_prompt),
|
|
str(was_completion) if was_completion not in (None, "") else "",
|
|
)
|
|
|
|
cur_comp = _finite(completion) if completion not in (None, "") else None
|
|
orig_comp = _finite(was_completion) if was_completion not in (None, "") else None
|
|
if cur_comp is not None and orig_comp is not None and cur_comp < orig_comp:
|
|
pct = int(round((1.0 - (cur_comp / orig_comp)) * 100))
|
|
if pct < 1:
|
|
return None
|
|
return (
|
|
pct,
|
|
str(was_prompt) if was_prompt not in (None, "") else "",
|
|
str(was_completion),
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def fetch_models_with_pricing(
|
|
api_key: str | None = None,
|
|
base_url: str = "https://openrouter.ai/api",
|
|
timeout: float = 8.0,
|
|
*,
|
|
force_refresh: bool = False,
|
|
include_sale_original: bool = False,
|
|
cache_ttl_seconds: Optional[float] = None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""Fetch ``/v1/models`` and return ``{model_id: {prompt, completion, ...}}``.
|
|
|
|
Results are cached per *base_url* and per credential, so repeated calls are free and one
|
|
caller's catalog never answers another's read. Works with any OpenRouter-compatible endpoint
|
|
(OpenRouter, Nous Portal).
|
|
|
|
When *include_sale_original* is true (Nous Portal only) and the gateway advertises a global
|
|
discount under ``pricing.original``, those pre-discount rates are copied through as a nested
|
|
``original`` dict so pickers can show sale chrome.
|
|
"""
|
|
url_root = (base_url or "").rstrip("/")
|
|
cache_key = url_root + _pricing_auth_fingerprint(api_key)
|
|
if not force_refresh:
|
|
cached = _cached_catalog(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
url = url_root + "/v1/models"
|
|
headers: dict[str, str] = {
|
|
"Accept": "application/json",
|
|
"User-Agent": _HERMES_USER_AGENT,
|
|
}
|
|
if api_key:
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
|
|
try:
|
|
req = urllib.request.Request(url, headers=headers)
|
|
with _urlopen_model_catalog_request(req, timeout=timeout) as resp:
|
|
payload = json.loads(resp.read().decode())
|
|
except Exception:
|
|
return _cache_catalog(cache_key, {})
|
|
|
|
# Same document the reasoning-capability fetch would pull, and every
|
|
# picker/pricing surface goes through here — mirror it so a later hot-path
|
|
# lookup (and the next process) has an answer without its own round-trip.
|
|
_seed_reasoning_caps(url, payload.get("data"))
|
|
|
|
result: dict[str, dict[str, Any]] = {}
|
|
for item in payload.get("data", []):
|
|
mid = item.get("id")
|
|
pricing = item.get("pricing")
|
|
if mid and isinstance(pricing, dict):
|
|
entry: dict[str, Any] = {
|
|
"prompt": str(pricing.get("prompt", "")),
|
|
"completion": str(pricing.get("completion", "")),
|
|
}
|
|
if pricing.get("input_cache_read"):
|
|
entry["input_cache_read"] = str(pricing["input_cache_read"])
|
|
if pricing.get("input_cache_write"):
|
|
entry["input_cache_write"] = str(pricing["input_cache_write"])
|
|
# Sale chrome is Nous Portal-only. Never copy pricing.original for
|
|
# OpenRouter / other OpenAI-compatible catalogs.
|
|
if include_sale_original:
|
|
original = pricing.get("original")
|
|
if isinstance(original, dict):
|
|
orig_entry: dict[str, str] = {}
|
|
for key in (
|
|
"prompt",
|
|
"completion",
|
|
"input_cache_read",
|
|
"input_cache_write",
|
|
):
|
|
if original.get(key) not in (None, ""):
|
|
orig_entry[key] = str(original[key])
|
|
if orig_entry.get("prompt") or orig_entry.get("completion"):
|
|
entry["original"] = orig_entry
|
|
result[mid] = entry
|
|
|
|
return _cache_catalog(cache_key, result, cache_ttl_seconds)
|
|
|
|
|
|
def fetch_ai_gateway_pricing(
|
|
timeout: float = 8.0,
|
|
*,
|
|
force_refresh: bool = False,
|
|
) -> dict[str, dict[str, str]]:
|
|
"""Fetch Vercel AI Gateway /v1/models and return hermes-shaped pricing.
|
|
|
|
Vercel uses ``input`` / ``output`` field names; hermes's picker expects ``prompt`` /
|
|
``completion``. This translates. Cache read/write field names already match.
|
|
"""
|
|
from hermes_constants import AI_GATEWAY_BASE_URL
|
|
|
|
cache_key = AI_GATEWAY_BASE_URL.rstrip("/")
|
|
if not force_refresh:
|
|
cached = _cached_catalog(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"{cache_key}/models",
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
payload = json.loads(resp.read().decode())
|
|
except Exception:
|
|
return _cache_catalog(cache_key, {})
|
|
|
|
result: dict[str, dict[str, str]] = {}
|
|
for item in payload.get("data", []):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
mid = item.get("id")
|
|
pricing = item.get("pricing")
|
|
if not (mid and isinstance(pricing, dict)):
|
|
continue
|
|
entry: dict[str, str] = {
|
|
"prompt": str(pricing.get("input", "")),
|
|
"completion": str(pricing.get("output", "")),
|
|
}
|
|
if pricing.get("input_cache_read"):
|
|
entry["input_cache_read"] = str(pricing["input_cache_read"])
|
|
if pricing.get("input_cache_write"):
|
|
entry["input_cache_write"] = str(pricing["input_cache_write"])
|
|
result[mid] = entry
|
|
|
|
return _cache_catalog(cache_key, result)
|
|
|
|
|
|
def _resolve_openrouter_api_key() -> str:
|
|
"""Best-effort OpenRouter API key for pricing fetch."""
|
|
return os.getenv("OPENROUTER_API_KEY", "").strip()
|
|
|
|
|
|
_DEFAULT_NOUS_INFERENCE_BASE = "https://inference-api.nousresearch.com"
|
|
|
|
|
|
def _resolve_nous_pricing_credentials() -> tuple[str, str]:
|
|
"""Return ``(api_key, base_url)`` for Nous Portal pricing.
|
|
|
|
Base URL precedence (mirrors runtime credential resolution): 1. ``NOUS_INFERENCE_BASE_URL`` env
|
|
override (staging / preview) 2. Resolved runtime credential ``base_url`` 3. Production default
|
|
|
|
Without (1), a staging profile's sale ``pricing.original`` never reaches the pickers — the
|
|
anonymous fallback would hit prod, which has no ``original`` field.
|
|
"""
|
|
env_base = None
|
|
try:
|
|
from hermes_cli.auth import _nous_inference_env_override
|
|
|
|
env_base = _nous_inference_env_override()
|
|
except Exception:
|
|
env_base = None
|
|
|
|
api_key = ""
|
|
creds_base = ""
|
|
try:
|
|
from hermes_cli.auth import resolve_nous_runtime_credentials
|
|
|
|
creds = resolve_nous_runtime_credentials()
|
|
if creds:
|
|
api_key = creds.get("api_key", "") or ""
|
|
creds_base = (creds.get("base_url", "") or "").strip()
|
|
except Exception:
|
|
pass
|
|
|
|
base_url = (env_base or creds_base or _DEFAULT_NOUS_INFERENCE_BASE).rstrip("/")
|
|
# Credential bases arrive with or without the ``/v1`` suffix. Callers
|
|
# append their own path, so hand back the bare origin.
|
|
if base_url.endswith("/v1"):
|
|
base_url = base_url[:-3]
|
|
return (api_key, base_url)
|
|
|
|
|
|
def nous_policy_allowed_ids(*, force_refresh: bool = False) -> Optional[set[str]]:
|
|
"""The Nous model ids the caller's org may reach, or ``None`` to not filter.
|
|
|
|
The gateway omits policy-blocked rows from an authenticated ``GET /v1/models``, so that
|
|
response's keys are the reachable set.
|
|
|
|
``None`` means "leave the caller's list alone", for the three states that cannot support
|
|
narrowing one: no policy (or a token too old to say), an anonymous read whose catalog is
|
|
unfiltered, and an empty read, which is a fetch failure rather than an org that may reach
|
|
nothing.
|
|
"""
|
|
try:
|
|
from hermes_cli.nous_account import nous_policy_present
|
|
|
|
if nous_policy_present() is not True:
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
api_key, base_url = _resolve_nous_pricing_credentials()
|
|
if not api_key or not base_url:
|
|
return None
|
|
|
|
# Same arguments as get_pricing_for_provider's nous branch, so a caller
|
|
# asking for pricing too shares this entry instead of paying for a second
|
|
# request.
|
|
pricing = fetch_models_with_pricing(
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
force_refresh=force_refresh,
|
|
include_sale_original=True,
|
|
cache_ttl_seconds=_NOUS_CATALOG_TTL_SECONDS,
|
|
)
|
|
return set(pricing) or None
|
|
|
|
|
|
# Past this size an allowed set reads as a whole catalog rather than an
|
|
# allowlist, and is not worth showing in place of an empty picker.
|
|
_NOUS_POLICY_APPEND_MAX = 64
|
|
|
|
# How long a Nous catalog stays trusted. Its contents depend on the org's
|
|
# policy, which an admin can change at any time and the client cannot observe,
|
|
# so a long-lived process must re-ask instead of holding the first answer for
|
|
# its whole life. Other providers' catalogs carry no such state and keep the
|
|
# default no-expiry caching.
|
|
_NOUS_CATALOG_TTL_SECONDS = 300.0
|
|
|
|
|
|
def restrict_to_nous_policy(
|
|
model_ids: list[str],
|
|
allowed: Optional[set[str]],
|
|
*,
|
|
rescue_empty: bool = False,
|
|
) -> list[str]:
|
|
"""*model_ids* narrowed to *allowed*, preserving the caller's order.
|
|
|
|
A ``:free`` sibling is kept when its base model is reachable, mirroring the gateway, which
|
|
admits a row when any of its requestable ids passes. Prefer over-listing: that costs a 403 from
|
|
the authoritative gate, while hiding a row the gate would serve is unrecoverable from the
|
|
client.
|
|
"""
|
|
if not allowed:
|
|
return list(model_ids)
|
|
kept = [
|
|
mid
|
|
for mid in model_ids
|
|
if mid in allowed or mid.split(":", 1)[0] in allowed
|
|
]
|
|
|
|
# An allowlist can name only models the curated manifest lacks, leaving an
|
|
# empty picker — worse than no filter, since the models the org may use are
|
|
# the ones dropped. Opt-in per list: an already-empty list (a paid tier's
|
|
# gated models) means "nothing to gate", not "nothing survived".
|
|
if rescue_empty and not kept and len(allowed) <= _NOUS_POLICY_APPEND_MAX:
|
|
return sorted(allowed)
|
|
return kept
|
|
|
|
|
|
def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
|
|
"""Return live pricing for providers that support it (openrouter, nous, ai-gateway, novita)."""
|
|
normalized = normalize_provider(provider)
|
|
if normalized == "openrouter":
|
|
return fetch_models_with_pricing(
|
|
api_key=_resolve_openrouter_api_key(),
|
|
base_url="https://openrouter.ai/api",
|
|
force_refresh=force_refresh,
|
|
)
|
|
if normalized == "ai-gateway":
|
|
return fetch_ai_gateway_pricing(force_refresh=force_refresh)
|
|
if normalized == "novita":
|
|
return _fetch_novita_pricing(force_refresh=force_refresh)
|
|
if normalized == "deepinfra":
|
|
return _fetch_deepinfra_pricing(force_refresh=force_refresh)
|
|
if normalized == "fireworks":
|
|
return _fireworks_pricing_from_models_dev(force_refresh=force_refresh)
|
|
if normalized == "nous":
|
|
api_key, base_url = _resolve_nous_pricing_credentials()
|
|
if base_url:
|
|
return fetch_models_with_pricing(
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
force_refresh=force_refresh,
|
|
# Sale chrome (pricing.original) is Nous Portal-only.
|
|
include_sale_original=True,
|
|
cache_ttl_seconds=_NOUS_CATALOG_TTL_SECONDS,
|
|
)
|
|
return {}
|
|
|
|
|
|
def _fireworks_pricing_from_models_dev(
|
|
*,
|
|
force_refresh: bool = False,
|
|
) -> dict[str, dict[str, str]]:
|
|
"""Derive Fireworks picker pricing from the models.dev registry cache.
|
|
|
|
No dedicated network fetch: ``fetch_models_dev()`` already maintains an in-memory + disk cache
|
|
(1h TTL) that every picker surface shares, so this is a pure dict transform on the picker path —
|
|
no added latency and no per-render network call.
|
|
"""
|
|
cache_key = "models.dev/fireworks"
|
|
if not force_refresh:
|
|
cached = _cached_catalog(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
result: dict[str, dict[str, str]] = {}
|
|
try:
|
|
from agent.models_dev import _get_provider_models
|
|
|
|
models = _get_provider_models("fireworks") or {}
|
|
for mid, entry in models.items():
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
cost = entry.get("cost")
|
|
if not isinstance(cost, dict):
|
|
continue
|
|
inp = cost.get("input")
|
|
out = cost.get("output")
|
|
if inp is None and out is None:
|
|
continue
|
|
row: dict[str, str] = {
|
|
"prompt": str(float(inp or 0) / 1_000_000),
|
|
"completion": str(float(out or 0) / 1_000_000),
|
|
}
|
|
cache_read = cost.get("cache_read")
|
|
if cache_read:
|
|
row["input_cache_read"] = str(float(cache_read) / 1_000_000)
|
|
result[str(mid)] = row
|
|
except Exception:
|
|
result = {}
|
|
|
|
return _cache_catalog(cache_key, result)
|
|
|
|
|
|
def _fetch_novita_pricing(
|
|
timeout: float = 8.0,
|
|
*,
|
|
force_refresh: bool = False,
|
|
) -> dict[str, dict[str, str]]:
|
|
"""Fetch pricing from NovitaAI /v1/models.
|
|
|
|
NovitaAI reports per-million-token prices in units of 0.0001 USD; they are converted to the
|
|
per-token strings the shared pricing formatter expects. Results are cached in
|
|
``_pricing_cache`` keyed on the resolved base URL so menu renders don't re-hit the network.
|
|
"""
|
|
api_key = os.getenv("NOVITA_API_KEY", "").strip()
|
|
if not api_key:
|
|
return {}
|
|
|
|
base_url = os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1"
|
|
cache_key = base_url.rstrip("/")
|
|
if not force_refresh:
|
|
cached = _cached_catalog(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
url = cache_key + "/models"
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Accept": "application/json",
|
|
"User-Agent": _HERMES_USER_AGENT,
|
|
}
|
|
|
|
try:
|
|
req = urllib.request.Request(url, headers=headers)
|
|
with _urlopen_model_catalog_request(req, timeout=timeout) as resp:
|
|
payload = json.loads(resp.read().decode())
|
|
except Exception:
|
|
return _cache_catalog(cache_key, {})
|
|
|
|
result: dict[str, dict[str, str]] = {}
|
|
for item in payload.get("data", []):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
mid = item.get("id")
|
|
if not mid:
|
|
continue
|
|
inp = item.get("input_token_price_per_m")
|
|
out = item.get("output_token_price_per_m")
|
|
if inp is None and out is None:
|
|
continue
|
|
result[str(mid)] = {
|
|
"prompt": str(float(inp or 0) / 10_000 / 1_000_000),
|
|
"completion": str(float(out or 0) / 10_000 / 1_000_000),
|
|
}
|
|
|
|
return _cache_catalog(cache_key, result)
|
|
|
|
|
|
# All provider IDs and aliases that are valid for the provider:model syntax.
|
|
_KNOWN_PROVIDER_NAMES: set[str] = (
|
|
set(_PROVIDER_LABELS.keys())
|
|
| set(_PROVIDER_ALIASES.keys())
|
|
| {"openrouter", "custom"}
|
|
)
|
|
|
|
|
|
def _configured_custom_provider_ids() -> set[str]:
|
|
"""Return routable custom-provider IDs configured by the user."""
|
|
ids = {"custom"}
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
from hermes_cli.providers import custom_provider_slug
|
|
|
|
config = load_config()
|
|
providers = config.get("providers", {})
|
|
if isinstance(providers, dict):
|
|
for key, entry in providers.items():
|
|
if isinstance(entry, dict):
|
|
ids.add(custom_provider_slug(str(entry.get("name") or key), str(key)))
|
|
legacy = config.get("custom_providers", [])
|
|
if isinstance(legacy, list):
|
|
for entry in legacy:
|
|
if isinstance(entry, dict):
|
|
ids.add(custom_provider_slug(str(entry.get("name") or "")))
|
|
except (ImportError, OSError, RuntimeError, TypeError, ValueError, AttributeError):
|
|
pass
|
|
return ids
|
|
|
|
def list_available_providers() -> list[dict[str, str]]:
|
|
"""Return info about all providers the user could use with ``provider:model``.
|
|
|
|
Each dict has ``id``, ``label``, and ``aliases``, plus whether valid credentials are
|
|
configured. Derived from :data:`CANONICAL_PROVIDERS`, the single source of truth shared with
|
|
``hermes model`` and ``/model``.
|
|
"""
|
|
# Derive display order from canonical list + custom
|
|
provider_order = [p.slug for p in CANONICAL_PROVIDERS] + ["custom"]
|
|
|
|
# Build reverse alias map
|
|
aliases_for: dict[str, list[str]] = {}
|
|
for alias, canonical in _PROVIDER_ALIASES.items():
|
|
aliases_for.setdefault(canonical, []).append(alias)
|
|
|
|
result = []
|
|
for pid in provider_order:
|
|
label = _PROVIDER_LABELS.get(pid, pid)
|
|
alias_list = aliases_for.get(pid, [])
|
|
# Check if this provider has credentials available
|
|
has_creds = False
|
|
try:
|
|
from hermes_cli.auth import get_auth_status, has_usable_secret
|
|
if pid == "custom":
|
|
custom_base_url = _get_custom_base_url() or ""
|
|
has_creds = bool(custom_base_url.strip())
|
|
elif pid == "openrouter":
|
|
has_creds = has_usable_secret(os.getenv("OPENROUTER_API_KEY", ""))
|
|
else:
|
|
status = get_auth_status(pid)
|
|
has_creds = bool(status.get("logged_in") or status.get("configured"))
|
|
except Exception:
|
|
pass
|
|
result.append({
|
|
"id": pid,
|
|
"label": label,
|
|
"aliases": alias_list,
|
|
"authenticated": has_creds,
|
|
})
|
|
return result
|
|
|
|
|
|
def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]:
|
|
"""Parse ``/model`` input into ``(provider, model)``.
|
|
|
|
The colon is only treated as a provider delimiter if the left side is a recognized provider name
|
|
or alias. This avoids misinterpreting model names that happen to contain colons (e.g.
|
|
``anthropic/claude-3.5-sonnet:beta``).
|
|
"""
|
|
stripped = raw.strip()
|
|
colon = stripped.find(":")
|
|
if colon > 0:
|
|
provider_part = stripped[:colon].strip().lower()
|
|
model_part = stripped[colon + 1:].strip()
|
|
if provider_part and model_part and provider_part in _KNOWN_PROVIDER_NAMES:
|
|
if provider_part == "custom":
|
|
lowered = stripped.lower()
|
|
for custom_id in sorted(
|
|
_configured_custom_provider_ids() - {"custom"},
|
|
key=len,
|
|
reverse=True,
|
|
):
|
|
prefix = f"{custom_id.lower()}:"
|
|
if lowered.startswith(prefix):
|
|
return custom_id, stripped[len(custom_id) + 1 :].strip()
|
|
# Support custom:name:model triple syntax for named custom
|
|
# providers. ``custom:local:qwen`` → ("custom:local", "qwen").
|
|
# Single colon ``custom:qwen`` → ("custom", "qwen") as before.
|
|
if provider_part == "custom" and ":" in model_part:
|
|
second_colon = model_part.find(":")
|
|
custom_name = model_part[:second_colon].strip()
|
|
actual_model = model_part[second_colon + 1:].strip()
|
|
if custom_name and actual_model:
|
|
custom_id = f"custom:{custom_name.lower()}"
|
|
if custom_id in _configured_custom_provider_ids():
|
|
return (custom_id, actual_model)
|
|
return ("custom", model_part)
|
|
return (normalize_provider(provider_part), model_part)
|
|
return (current_provider, stripped)
|
|
|
|
|
|
def _get_custom_base_url() -> str:
|
|
"""Get the custom endpoint base_url from config.yaml."""
|
|
model_cfg = _get_model_config_dict()
|
|
return str(model_cfg.get("base_url", "")).strip()
|
|
|
|
|
|
def _get_provider_config_dict(provider: str) -> dict[str, Any]:
|
|
"""Return config.yaml providers.<provider>, or an empty dict."""
|
|
key = str(provider or "").strip()
|
|
if not key:
|
|
return {}
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
config = load_config()
|
|
providers_cfg = config.get("providers", {})
|
|
if isinstance(providers_cfg, dict):
|
|
entry = providers_cfg.get(key) or providers_cfg.get(key.lower())
|
|
if isinstance(entry, dict):
|
|
return entry
|
|
except (ImportError, OSError, RuntimeError, TypeError, ValueError, AttributeError):
|
|
pass
|
|
return {}
|
|
|
|
|
|
def _root_for_ollama_native_api(base_url: str) -> str:
|
|
"""Convert an OpenAI-style Ollama base URL to the native API root."""
|
|
root = str(base_url or "").strip().rstrip("/")
|
|
if root.startswith(":"):
|
|
root = "http://127.0.0.1" + root
|
|
elif root and "://" not in root:
|
|
root = "http://" + root
|
|
for suffix in ("/api/tags", "/v1/models", "/api", "/v1"):
|
|
if root.endswith(suffix):
|
|
root = root[: -len(suffix)].rstrip("/")
|
|
break
|
|
return root
|
|
|
|
|
|
def _normalize_openai_base_url(base_url: Optional[str]) -> str:
|
|
"""Add a usable HTTP scheme without changing an OpenAI API path."""
|
|
value = str(base_url or "").strip()
|
|
if value.startswith(":"):
|
|
return "http://127.0.0.1" + value
|
|
if value and "://" not in value:
|
|
return "http://" + value
|
|
return value
|
|
|
|
|
|
def _get_ollama_base_url() -> str:
|
|
"""Resolve the local Ollama-compatible endpoint URL.
|
|
|
|
Prefer explicit config under ``providers.ollama.base_url`` because this is how local Ollama-
|
|
compatible endpoints can be wired without changing the active model provider. Fall back to
|
|
active ``model.base_url`` only when the active provider is ollama/custom, then to Ollama's local
|
|
default.
|
|
"""
|
|
provider_cfg = _get_provider_config_dict("ollama")
|
|
configured = (
|
|
provider_cfg.get("base_url", "")
|
|
or provider_cfg.get("api", "")
|
|
or provider_cfg.get("url", "")
|
|
or ""
|
|
)
|
|
if configured:
|
|
return str(configured).strip()
|
|
|
|
model_cfg = _get_model_config_dict()
|
|
model_provider = str(model_cfg.get("provider", "") or "").strip().lower()
|
|
model_base = str(model_cfg.get("base_url", "") or "").strip()
|
|
if model_provider == "ollama" and model_base:
|
|
return model_base
|
|
if model_provider == "custom" and model_base:
|
|
# Only reuse the active bare custom endpoint when it is actually
|
|
# Ollama-compatible. Otherwise a user working against an unrelated
|
|
# OpenAI-compatible endpoint would make the Ollama picker probe that
|
|
# endpoint's /api/tags and hide their local Ollama catalog.
|
|
try:
|
|
if should_use_ollama_native_catalog("custom", model_base):
|
|
return model_base
|
|
except (OSError, RuntimeError, TypeError, ValueError):
|
|
pass
|
|
|
|
env_host = os.getenv("OLLAMA_HOST", "").strip()
|
|
if env_host:
|
|
if env_host.startswith(":") and not env_host.startswith("::"):
|
|
env_host = "127.0.0.1" + env_host
|
|
elif env_host.startswith("[") and env_host.endswith("]"):
|
|
env_host = f"{env_host}:11434"
|
|
elif "://" in env_host:
|
|
try:
|
|
parsed = urllib.parse.urlsplit(env_host)
|
|
if parsed.hostname and parsed.port is None:
|
|
hostname = parsed.hostname
|
|
if ":" in hostname and not hostname.startswith("["):
|
|
hostname = f"[{hostname}]"
|
|
userinfo = (
|
|
parsed.netloc.rsplit("@", 1)[0] + "@"
|
|
if "@" in parsed.netloc
|
|
else ""
|
|
)
|
|
env_host = parsed._replace(
|
|
netloc=f"{userinfo}{hostname}:11434"
|
|
).geturl()
|
|
except ValueError:
|
|
pass
|
|
elif env_host.count(":") > 1 and not env_host.startswith("["):
|
|
env_host = f"[{env_host}]:11434"
|
|
elif ":" not in env_host:
|
|
env_host = f"{env_host}:11434"
|
|
return env_host
|
|
return "http://localhost:11434"
|
|
|
|
|
|
def _get_ollama_request_headers() -> dict[str, str]:
|
|
"""Return configured headers and credentials for native Ollama requests."""
|
|
entry = _get_provider_config_dict("ollama")
|
|
raw = entry.get("extra_headers")
|
|
try:
|
|
from hermes_cli.config import normalize_extra_headers
|
|
|
|
result = normalize_extra_headers(raw)
|
|
except (ImportError, OSError, RuntimeError, TypeError, ValueError):
|
|
result = {}
|
|
|
|
api_key = str(entry.get("api_key") or "").strip()
|
|
if not api_key:
|
|
key_env = str(
|
|
entry.get("key_env") or entry.get("api_key_env") or ""
|
|
).strip()
|
|
api_key = os.getenv(key_env, "").strip() if key_env else ""
|
|
if api_key:
|
|
if not any(key.lower() == "authorization" for key in result):
|
|
result["Authorization"] = f"Bearer {api_key}"
|
|
return result
|
|
|
|
|
|
def _get_ollama_native_headers(
|
|
base_url: Optional[str],
|
|
*,
|
|
api_key: Optional[str] = None,
|
|
) -> dict[str, str]:
|
|
"""Resolve Ollama credentials and headers for one endpoint origin."""
|
|
entry = _get_provider_config_dict("ollama")
|
|
configured_base = str(
|
|
entry.get("base_url") or entry.get("api") or entry.get("url") or ""
|
|
).strip()
|
|
explicit_key = str(api_key or "").strip()
|
|
configured_matches = bool(
|
|
configured_base
|
|
and base_url
|
|
and _same_ollama_native_root(base_url, configured_base)
|
|
)
|
|
if not configured_matches and not explicit_key:
|
|
return {}
|
|
headers = _get_ollama_request_headers() if configured_matches else {}
|
|
if explicit_key:
|
|
# A provider-specific key must not inherit any configured Authorization
|
|
# variant from the Ollama origin when both share a native root.
|
|
for key in tuple(headers):
|
|
if key.lower() == "authorization":
|
|
del headers[key]
|
|
headers["Authorization"] = f"Bearer {explicit_key}"
|
|
return headers
|
|
|
|
|
|
_OLLAMA_LOCAL_MODELS_CACHE_TTL: int = 300 # seconds (5 minutes)
|
|
_OLLAMA_LOCAL_MODELS_CACHE: dict[str, tuple[tuple[str, ...], float]] = {}
|
|
_OLLAMA_LOCAL_PROBE_FAILURE_CACHE: dict[str, float] = {}
|
|
_OLLAMA_LOCAL_PROBE_REACHABLE: dict[str, bool] = {}
|
|
_OLLAMA_LOCAL_PROBE_FAILURE_TTL: int = 30
|
|
_OLLAMA_LOCAL_CACHE_MAX_ENTRIES: int = 256
|
|
|
|
|
|
def _evict_related_ollama_cache_entries(key: str) -> None:
|
|
_OLLAMA_LOCAL_MODELS_CACHE.pop(key, None)
|
|
_OLLAMA_LOCAL_PROBE_REACHABLE.pop(key, None)
|
|
for failure_key in list(_OLLAMA_LOCAL_PROBE_FAILURE_CACHE):
|
|
if failure_key == key or failure_key.startswith(f"{key}|timeout:"):
|
|
_OLLAMA_LOCAL_PROBE_FAILURE_CACHE.pop(failure_key, None)
|
|
|
|
|
|
def _remember_ollama_cache(cache: dict[str, Any], key: str, value: Any) -> None:
|
|
if key not in cache and len(cache) >= _OLLAMA_LOCAL_CACHE_MAX_ENTRIES:
|
|
oldest_key = next(iter(cache))
|
|
_evict_related_ollama_cache_entries(
|
|
oldest_key.split("|timeout:", 1)[0]
|
|
)
|
|
cache[key] = value
|
|
|
|
|
|
def _ollama_probe_cache_key(root: str, headers: Optional[dict[str, str]]) -> str:
|
|
cache_key = root
|
|
if headers:
|
|
import hashlib
|
|
|
|
normalized_headers = sorted(
|
|
(str(key).lower(), str(value)) for key, value in headers.items()
|
|
)
|
|
header_blob = json.dumps(
|
|
normalized_headers, ensure_ascii=False, separators=(",", ":")
|
|
).encode("utf-8", errors="replace")
|
|
header_fingerprint = hashlib.blake2b(header_blob, digest_size=8).hexdigest()
|
|
cache_key = f"{root}|headers:{header_fingerprint}"
|
|
return cache_key
|
|
|
|
|
|
def probe_ollama_local_models(
|
|
base_url: Optional[str] = None,
|
|
timeout: float = 2.0,
|
|
headers: Optional[dict[str, str]] = None,
|
|
) -> Optional[list[str]]:
|
|
"""Probe local Ollama-compatible models from native ``/api/tags``.
|
|
|
|
Returns ``None`` when the endpoint cannot be reached or returns malformed data, and a list
|
|
(possibly empty) when ``/api/tags`` was reachable. Stock Ollama exposes its authoritative local
|
|
model catalog at ``/api/tags``; OpenAI-compatible ``/v1/models`` is not required for local
|
|
Ollama servers.
|
|
"""
|
|
root = _root_for_ollama_native_api(base_url or _get_ollama_base_url())
|
|
if not root:
|
|
return None
|
|
cache_key = _ollama_probe_cache_key(root, headers)
|
|
failure_key = f"{cache_key}|timeout:{float(timeout):.3f}"
|
|
cached = _OLLAMA_LOCAL_MODELS_CACHE.get(cache_key)
|
|
if cached is not None:
|
|
cached_models, cached_at = cached
|
|
if time.monotonic() - cached_at < _OLLAMA_LOCAL_MODELS_CACHE_TTL:
|
|
return list(cached_models)
|
|
failed_at = _OLLAMA_LOCAL_PROBE_FAILURE_CACHE.get(failure_key)
|
|
if failed_at is not None:
|
|
if time.monotonic() - failed_at < _OLLAMA_LOCAL_PROBE_FAILURE_TTL:
|
|
return None
|
|
_OLLAMA_LOCAL_PROBE_FAILURE_CACHE.pop(failure_key, None)
|
|
|
|
try:
|
|
url = root.rstrip("/") + "/api/tags"
|
|
request_headers = {"User-Agent": _HERMES_USER_AGENT}
|
|
request_headers.update(headers or {})
|
|
req = urllib.request.Request(url, headers=request_headers)
|
|
with _urlopen_model_catalog_request(req, timeout=timeout) as resp:
|
|
payload = json.loads(resp.read().decode())
|
|
except (
|
|
ValueError,
|
|
OSError,
|
|
TimeoutError,
|
|
http.client.HTTPException,
|
|
urllib.error.URLError,
|
|
json.JSONDecodeError,
|
|
UnicodeDecodeError,
|
|
):
|
|
_remember_ollama_cache(
|
|
_OLLAMA_LOCAL_PROBE_REACHABLE, cache_key, False
|
|
)
|
|
_remember_ollama_cache(
|
|
_OLLAMA_LOCAL_PROBE_FAILURE_CACHE, failure_key, time.monotonic()
|
|
)
|
|
return None
|
|
|
|
raw_models = payload.get("models") if isinstance(payload, dict) else None
|
|
if not isinstance(raw_models, list):
|
|
_remember_ollama_cache(
|
|
_OLLAMA_LOCAL_PROBE_REACHABLE, cache_key, False
|
|
)
|
|
_remember_ollama_cache(
|
|
_OLLAMA_LOCAL_PROBE_FAILURE_CACHE, failure_key, time.monotonic()
|
|
)
|
|
return None
|
|
|
|
models: list[str] = []
|
|
seen: set[str] = set()
|
|
for item in raw_models:
|
|
if isinstance(item, dict):
|
|
model_id = str(item.get("model") or item.get("name") or "").strip()
|
|
else:
|
|
_remember_ollama_cache(
|
|
_OLLAMA_LOCAL_PROBE_REACHABLE, cache_key, False
|
|
)
|
|
_remember_ollama_cache(
|
|
_OLLAMA_LOCAL_PROBE_FAILURE_CACHE, failure_key, time.monotonic()
|
|
)
|
|
return None
|
|
if not model_id or model_id in seen:
|
|
continue
|
|
seen.add(model_id)
|
|
models.append(model_id)
|
|
if raw_models and not models:
|
|
_remember_ollama_cache(
|
|
_OLLAMA_LOCAL_PROBE_REACHABLE, cache_key, False
|
|
)
|
|
_remember_ollama_cache(
|
|
_OLLAMA_LOCAL_PROBE_FAILURE_CACHE, failure_key, time.monotonic()
|
|
)
|
|
return None
|
|
_remember_ollama_cache(_OLLAMA_LOCAL_PROBE_REACHABLE, cache_key, True)
|
|
_OLLAMA_LOCAL_PROBE_FAILURE_CACHE.pop(failure_key, None)
|
|
_remember_ollama_cache(
|
|
_OLLAMA_LOCAL_MODELS_CACHE,
|
|
cache_key,
|
|
(tuple(models), time.monotonic()),
|
|
)
|
|
return models
|
|
|
|
|
|
def fetch_ollama_local_models(
|
|
base_url: Optional[str] = None,
|
|
timeout: float = 2.0,
|
|
headers: Optional[dict[str, str]] = None,
|
|
) -> Optional[list[str]]:
|
|
"""Fetch local Ollama-compatible models, preserving probe failure as ``None``."""
|
|
return probe_ollama_local_models(base_url, timeout, headers=headers)
|
|
|
|
|
|
def _same_ollama_native_root(left: str, right: str) -> bool:
|
|
"""Return True when two Ollama/OpenAI-style base URLs share an API root."""
|
|
left_root = _root_for_ollama_native_api(left).rstrip("/")
|
|
right_root = _root_for_ollama_native_api(right).rstrip("/")
|
|
if not left_root or not right_root:
|
|
return False
|
|
try:
|
|
left_parts = urllib.parse.urlsplit(left_root)
|
|
right_parts = urllib.parse.urlsplit(right_root)
|
|
return (
|
|
url_origin(left_root) == url_origin(right_root)
|
|
and left_parts.path.rstrip("/") == right_parts.path.rstrip("/")
|
|
)
|
|
except (AttributeError, ValueError):
|
|
return False
|
|
|
|
|
|
def should_use_ollama_native_catalog(
|
|
provider: Optional[str],
|
|
base_url: Optional[str],
|
|
headers: Optional[dict[str, str]] = None,
|
|
) -> bool:
|
|
"""Return True when model discovery should use local Ollama ``/api/tags``.
|
|
|
|
Bare ``ollama`` is normalized to ``custom`` elsewhere so runtime paths share the OpenAI-
|
|
compatible client, but local Ollama's authoritative model list is ``/api/tags``. Use it when
|
|
the caller asked for Ollama explicitly, the base URL matches ``providers.ollama.base_url``,
|
|
or an ambiguous custom URL on Ollama's default port actually serves ``/api/tags``; other
|
|
custom endpoints keep the ``/models`` probe.
|
|
"""
|
|
requested = str(provider or "").strip().lower()
|
|
root = _root_for_ollama_native_api(base_url or "")
|
|
if root:
|
|
try:
|
|
host = (urllib.parse.urlparse(root).hostname or "").lower()
|
|
if host == "ollama.com" or host.endswith(".ollama.com"):
|
|
return False
|
|
except ValueError:
|
|
pass
|
|
|
|
known_non_local_providers = {
|
|
"openrouter",
|
|
"nous",
|
|
"anthropic",
|
|
"openai",
|
|
"openai-codex",
|
|
"gemini",
|
|
"ollama-cloud",
|
|
}
|
|
if requested in known_non_local_providers:
|
|
return False
|
|
|
|
if requested == "ollama":
|
|
if not root:
|
|
return False
|
|
configured = _get_provider_config_dict("ollama")
|
|
configured_base = str(
|
|
configured.get("base_url")
|
|
or configured.get("api")
|
|
or configured.get("url")
|
|
or ""
|
|
).strip()
|
|
if configured_base and not _same_ollama_native_root(root, configured_base):
|
|
return probe_ollama_local_models(root, timeout=0.5, headers=headers) is not None
|
|
return True
|
|
|
|
provider_cfg = _get_provider_config_dict("ollama")
|
|
configured_ollama_base_url = str(
|
|
provider_cfg.get("base_url", "")
|
|
or provider_cfg.get("api", "")
|
|
or provider_cfg.get("url", "")
|
|
or ""
|
|
).strip()
|
|
if configured_ollama_base_url and _same_ollama_native_root(root, configured_ollama_base_url):
|
|
return True
|
|
|
|
if not root:
|
|
return False
|
|
|
|
local_like_providers = {"", "custom", "local", "llamacpp", "llama.cpp", "llama-cpp", "vllm"}
|
|
if requested not in local_like_providers and not requested.startswith("custom:"):
|
|
return False
|
|
|
|
if requested == "custom:ollama" or requested.endswith("-ollama"):
|
|
return True
|
|
|
|
try:
|
|
parsed = urllib.parse.urlparse(root)
|
|
if parsed.port != 11434:
|
|
return False
|
|
except ValueError:
|
|
return False
|
|
|
|
return probe_ollama_local_models(root, timeout=0.5, headers=headers) is not None
|
|
|
|
|
|
def _get_model_config_dict() -> dict[str, Any]:
|
|
"""Return the main model config mapping, or an empty dict."""
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
config = load_config()
|
|
model_cfg = config.get("model", {})
|
|
if isinstance(model_cfg, dict):
|
|
return model_cfg
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def _base_url_looks_like_anthropic_messages(base_url: str) -> bool:
|
|
normalized = str(base_url or "").strip().lower().rstrip("/")
|
|
if not normalized:
|
|
return False
|
|
path = urllib.parse.urlparse(normalized).path.rstrip("/")
|
|
return path.endswith("/anthropic") or path.endswith("/anthropic/v1")
|
|
|
|
|
|
def _anthropic_models_url(base_url: Optional[str] = None) -> str:
|
|
endpoint = str(base_url or "https://api.anthropic.com").strip().rstrip("/")
|
|
if endpoint.endswith("/v1"):
|
|
return endpoint + "/models"
|
|
return endpoint + "/v1/models"
|
|
|
|
|
|
def _provider_keys(provider: str) -> set[str]:
|
|
key = (provider or "").strip().lower()
|
|
normalized = normalize_provider(provider)
|
|
return {k for k in (key, normalized) if k}
|
|
|
|
|
|
def _provider_catalog_names(provider: str) -> tuple[str, ...]:
|
|
"""Active picker models plus retired aliases recognized for detection."""
|
|
active = tuple(_PROVIDER_MODELS.get(provider, []))
|
|
retired = _PROVIDER_RETIRED_ALIASES.get(provider, ())
|
|
return active + retired
|
|
|
|
|
|
def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool:
|
|
return any(
|
|
name_lower == model.lower()
|
|
for provider in providers
|
|
for model in _provider_catalog_names(provider)
|
|
)
|
|
|
|
|
|
def _openrouter_variant_base(model_id: str) -> Optional[str]:
|
|
"""Return the base model id when ``model_id`` carries a recognized OpenRouter routing-variant
|
|
suffix (e.g. ``x-ai/grok-4:nitro`` → ``x-ai/grok-4``), else ``None``.
|
|
"""
|
|
base, sep, suffix = (model_id or "").rpartition(":")
|
|
if not sep or not base:
|
|
return None
|
|
if suffix.lower() in _OPENROUTER_VARIANT_SUFFIXES:
|
|
return base
|
|
return None
|
|
|
|
|
|
def _resolve_static_model_alias(
|
|
name_lower: str,
|
|
current_keys: set[str],
|
|
) -> Optional[tuple[str, str]]:
|
|
"""Resolve short aliases (e.g. sonnet/opus) using static catalogs only."""
|
|
try:
|
|
from hermes_cli.model_switch import MODEL_ALIASES
|
|
except Exception:
|
|
return None
|
|
|
|
identity = MODEL_ALIASES.get(name_lower)
|
|
if identity is None:
|
|
return None
|
|
|
|
vendor = identity.vendor
|
|
family = identity.family
|
|
|
|
def _match(provider: str) -> Optional[str]:
|
|
models = _PROVIDER_MODELS.get(provider, [])
|
|
if not models:
|
|
return None
|
|
prefix = (
|
|
f"{vendor}/{family}"
|
|
if provider in _AGGREGATOR_PROVIDERS
|
|
else family
|
|
).lower()
|
|
for model in models:
|
|
if model.lower().startswith(prefix):
|
|
return model
|
|
return None
|
|
|
|
for provider in current_keys:
|
|
if matched := _match(provider):
|
|
return provider, matched
|
|
|
|
for provider in _PROVIDER_MODELS:
|
|
if (
|
|
provider in current_keys
|
|
or provider in _AGGREGATOR_PROVIDERS
|
|
or provider in _BORROWED_MODEL_PROVIDERS
|
|
):
|
|
continue
|
|
if matched := _match(provider):
|
|
return provider, matched
|
|
|
|
for provider in _AGGREGATOR_PROVIDERS:
|
|
if provider in current_keys and (matched := _match(provider)):
|
|
return provider, matched
|
|
|
|
# Last resort: providers that re-expose other vendors' models. Only reached
|
|
# when no native-vendor catalog matched — so `sonnet` resolves to anthropic.
|
|
# None are currently defined (_BORROWED_MODEL_PROVIDERS is empty).
|
|
for provider in _BORROWED_MODEL_PROVIDERS:
|
|
if provider in current_keys and (matched := _match(provider)):
|
|
return provider, matched
|
|
|
|
return None
|
|
|
|
|
|
def detect_static_provider_for_model(
|
|
model_name: str,
|
|
current_provider: str,
|
|
) -> Optional[tuple[str, str]]:
|
|
"""Auto-detect a provider from static catalogs only.
|
|
|
|
Returns ``(provider_id, model_name)``; the name may be remapped when a static alias or bare
|
|
provider name resolves to a catalog default. Returns ``None`` when no confident match is
|
|
found.
|
|
"""
|
|
name = (model_name or "").strip()
|
|
if not name:
|
|
return None
|
|
|
|
name_lower = name.lower()
|
|
current_keys = _provider_keys(current_provider)
|
|
|
|
alias_match = _resolve_static_model_alias(name_lower, current_keys)
|
|
if alias_match:
|
|
return alias_match
|
|
|
|
# --- Step 0: bare provider name typed as model ---
|
|
# If someone types `/model nous` or `/model anthropic`, treat it as a
|
|
# provider switch and pick the first model from that provider's catalog.
|
|
# Skip "custom" and "openrouter" — custom has no model catalog, and
|
|
# openrouter requires an explicit model name to be useful.
|
|
resolved_provider = _PROVIDER_ALIASES.get(name_lower, name_lower)
|
|
if resolved_provider not in {"custom", "openrouter"}:
|
|
default_models = _PROVIDER_MODELS.get(resolved_provider, [])
|
|
if (
|
|
resolved_provider in _PROVIDER_LABELS
|
|
and default_models
|
|
and resolved_provider not in current_keys
|
|
):
|
|
# Route through the cost-safe default rather than picking
|
|
# ``default_models[0]`` directly. For metered aggregators whose
|
|
# curated list is ordered most-capable-first (e.g. Nous Portal),
|
|
# entry [0] is the priciest flagship, and typing ``/model nous``
|
|
# would silently escalate to it — the exact billing footgun the
|
|
# catalog-labeled silent default (``_SILENT_DEFAULT_PROVIDERS``)
|
|
# exists to prevent. For providers outside that set this is
|
|
# unchanged (it returns ``models[0]``).
|
|
return (
|
|
resolved_provider,
|
|
get_default_model_for_provider(resolved_provider) or default_models[0],
|
|
)
|
|
|
|
# Aggregators list other providers' models — never auto-switch TO them
|
|
# If the model belongs to the current provider's catalog, don't suggest switching
|
|
if _model_in_provider_catalog(name_lower, current_keys):
|
|
return None
|
|
|
|
# --- Step 1: check static provider catalogs for a direct match ---
|
|
# If the current provider is a custom endpoint (custom or custom:*), never
|
|
# auto-switch away from it based on a static catalog match — the user
|
|
# explicitly configured their own endpoint and the same model name may be
|
|
# served there (#48305).
|
|
_is_custom_current = (
|
|
current_provider == "custom"
|
|
or current_provider.startswith("custom:")
|
|
)
|
|
for pid in _PROVIDER_MODELS:
|
|
if (
|
|
pid in current_keys
|
|
or pid in _AGGREGATOR_PROVIDERS
|
|
or pid in _BORROWED_MODEL_PROVIDERS
|
|
):
|
|
continue
|
|
if _is_custom_current:
|
|
continue
|
|
if any(name_lower == m.lower() for m in _provider_catalog_names(pid)):
|
|
return (pid, name)
|
|
|
|
# Borrow-list providers (re-expose other vendors' models) only after every
|
|
# native-vendor catalog, and only when one is the current provider.
|
|
for pid in _BORROWED_MODEL_PROVIDERS:
|
|
if pid in current_keys:
|
|
continue
|
|
if any(name_lower == m.lower() for m in _provider_catalog_names(pid)):
|
|
return (pid, name)
|
|
|
|
return None
|
|
|
|
|
|
def _configured_provider_ids() -> set[str]:
|
|
"""Provider ids defined in the user's config ``providers:`` block.
|
|
|
|
Includes both top-level ids (``ollama``, ``nous``) and ``custom:*`` profile ids. Returns an
|
|
empty set when config is unreadable — callers treat that as "no user-defined providers" and fall
|
|
through to built-in catalogs only.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
|
|
cfg = load_config() or {}
|
|
providers = cfg.get("providers")
|
|
if not isinstance(providers, dict):
|
|
return set()
|
|
ids: set[str] = set()
|
|
for pid in providers:
|
|
key = str(pid).strip().lower()
|
|
if key:
|
|
ids.add(key)
|
|
return ids
|
|
except Exception:
|
|
return set()
|
|
|
|
|
|
def _resolve_provider_prefix(model_name: str) -> Optional[tuple[str, str]]:
|
|
"""Resolve an explicit ``vendor/model`` prefix to a configured provider.
|
|
|
|
``nous/deepseek-v4-pro`` or ``ollama/qwen3.5:4b`` should route to the named provider instead of
|
|
falling back to the configured default (which silently sends non-default models to the wrong
|
|
endpoint, #87189).
|
|
|
|
Only vendors the user actually defined in their ``providers:`` config block (by raw name or
|
|
alias) are routed here.
|
|
"""
|
|
if "/" not in model_name:
|
|
return None
|
|
vendor, model = model_name.split("/", 1)
|
|
vendor = vendor.strip().lower()
|
|
model = model.strip()
|
|
if not vendor or not model:
|
|
return None
|
|
configured = _configured_provider_ids()
|
|
if not configured:
|
|
return None
|
|
# A provider block the user explicitly named (``ollama:``) wins over the
|
|
# built-in alias table, which may canonicalize the same name elsewhere
|
|
# (``ollama`` → ``custom``) and route to the wrong endpoint.
|
|
if vendor in configured:
|
|
return (vendor, model)
|
|
canonical = _PROVIDER_ALIASES.get(vendor, vendor)
|
|
if canonical in configured:
|
|
return (canonical, model)
|
|
return None
|
|
|
|
|
|
def detect_provider_for_model(
|
|
model_name: str,
|
|
current_provider: str,
|
|
) -> Optional[tuple[str, str]]:
|
|
"""Auto-detect the best provider for a model name.
|
|
|
|
Priority: 0. Bare provider name → switch to that provider's default model 1. Direct provider
|
|
static catalog match 2. OpenRouter catalog match
|
|
"""
|
|
name = (model_name or "").strip()
|
|
if not name:
|
|
return None
|
|
|
|
static_match = detect_static_provider_for_model(name, current_provider)
|
|
if static_match:
|
|
return static_match
|
|
if _model_in_provider_catalog(name.lower(), _provider_keys(current_provider)):
|
|
return None
|
|
|
|
# --- Step 2: check OpenRouter catalog ---
|
|
# First try exact match (handles provider/model format)
|
|
or_slug = _find_openrouter_slug(name)
|
|
if or_slug:
|
|
if current_provider != "openrouter":
|
|
return ("openrouter", or_slug)
|
|
# Already on openrouter, just return the resolved slug
|
|
if or_slug != name:
|
|
return ("openrouter", or_slug)
|
|
return None # already on openrouter with matching name
|
|
|
|
# --- Step 3: explicit ``vendor/model`` prefix naming a configured provider ---
|
|
# Checked after the OpenRouter slug lookup so aggregator-native slugs
|
|
# (e.g. ``deepseek/deepseek-chat``) keep their existing routing; only
|
|
# vendors the user defined in their ``providers:`` block route here,
|
|
# so catalog/default behavior for built-in vendor prefixes is unchanged
|
|
# (#87189).
|
|
prefix_match = _resolve_provider_prefix(name)
|
|
if prefix_match is not None:
|
|
return prefix_match
|
|
|
|
return None
|
|
|
|
|
|
def _find_openrouter_slug(model_name: str) -> Optional[str]:
|
|
"""Find the full OpenRouter model slug for a bare or partial model name."""
|
|
name_lower = model_name.strip().lower()
|
|
if not name_lower:
|
|
return None
|
|
|
|
# Exact match (already has provider/ prefix)
|
|
for mid in model_ids():
|
|
if name_lower == mid.lower():
|
|
return mid
|
|
|
|
# Try matching just the model part (after the /)
|
|
for mid in model_ids():
|
|
if "/" in mid:
|
|
_, model_part = mid.split("/", 1)
|
|
if name_lower == model_part.lower():
|
|
return mid
|
|
|
|
return None
|
|
|
|
|
|
def normalize_provider(provider: Optional[str]) -> str:
|
|
"""Normalize provider aliases to Hermes' canonical provider ids.
|
|
|
|
``"auto"`` passes through unchanged — use ``hermes_cli.auth.resolve_provider()`` to resolve
|
|
it to a concrete provider from credentials and environment.
|
|
"""
|
|
normalized = (provider or "openrouter").strip().lower()
|
|
return _PROVIDER_ALIASES.get(normalized, normalized)
|
|
|
|
|
|
def provider_label(provider: Optional[str]) -> str:
|
|
"""Return a human-friendly label for a provider id or alias."""
|
|
original = (provider or "openrouter").strip()
|
|
normalized = original.lower()
|
|
if normalized == "auto":
|
|
return "Auto"
|
|
normalized = normalize_provider(normalized)
|
|
return _PROVIDER_LABELS.get(normalized, original or "OpenRouter")
|
|
|
|
|
|
def _is_openai_fast_model(model_id: Optional[str]) -> bool:
|
|
"""Return True if the model is an OpenAI flagship eligible for Priority Processing."""
|
|
raw = _strip_vendor_prefix(str(model_id or ""))
|
|
base = raw.split(":")[0]
|
|
if not base:
|
|
return False
|
|
# Exclude Codex-series — they route through the Codex Responses API
|
|
# which doesn't accept service_tier.
|
|
if "codex" in base:
|
|
return False
|
|
return any(base.startswith(prefix) for prefix in _OPENAI_FAST_MODE_PREFIXES)
|
|
|
|
|
|
# Models that support Anthropic Fast Mode (speed="fast").
|
|
# See https://platform.claude.com/docs/en/build-with-claude/fast-mode
|
|
#
|
|
# Pattern-based matching — any claude-* model is eligible. The anthropic
|
|
# adapter gates speed=fast on native Anthropic endpoints only (see
|
|
# _is_third_party_anthropic_endpoint in agent/anthropic_adapter.py), so
|
|
# third-party proxies that would reject the beta header are protected.
|
|
|
|
|
|
def _strip_vendor_prefix(model_id: str) -> str:
|
|
"""Strip vendor/ prefix from a model ID (e.g. 'anthropic/claude-opus-4-6' -> 'claude-opus-4-6')."""
|
|
raw = str(model_id or "").strip().lower()
|
|
if "/" in raw:
|
|
raw = raw.split("/", 1)[1]
|
|
return raw
|
|
|
|
|
|
def model_supports_fast_mode(model_id: Optional[str]) -> bool:
|
|
"""Return whether Hermes should expose the /fast toggle for this model."""
|
|
from agent.model_metadata import is_grok_46_family
|
|
|
|
return (
|
|
_is_anthropic_fast_model(model_id)
|
|
or _is_openai_fast_model(model_id)
|
|
or is_grok_46_family(str(model_id or ""))
|
|
)
|
|
|
|
|
|
def _is_anthropic_fast_model(model_id: Optional[str]) -> bool:
|
|
"""Return True if the model accepts the Anthropic Fast Mode ``speed`` param.
|
|
|
|
This gates the *speed=fast request parameter*, which Anthropic supports on Opus 4.8 and Opus 5
|
|
(research preview, Claude API only). It is deliberately NOT a general "is this a fast model"
|
|
check:
|
|
|
|
- Opus 4.7 hard-400s on the parameter. - Dedicated ``…-fast`` model ids (e.g. OpenRouter's
|
|
``claude-opus-4.8-fast``) select fast inference via the model field and must not also receive
|
|
the speed parameter.
|
|
"""
|
|
raw = _strip_vendor_prefix(str(model_id or ""))
|
|
base = raw.split(":")[0]
|
|
if not base.startswith("claude-"):
|
|
return False
|
|
if "-fast" in base:
|
|
return False
|
|
return any(v in base for v in ("opus-4-8", "opus-4.8", "opus-5"))
|
|
|
|
|
|
def _fast_mode_route_supported(
|
|
model_id: Optional[str], provider: Optional[str], base_url: Optional[str]
|
|
) -> bool:
|
|
"""Only the first-party endpoint that bills for fast mode may receive its params."""
|
|
from urllib.parse import urlparse
|
|
|
|
from agent.model_metadata import is_grok_46_family
|
|
|
|
if _is_anthropic_fast_model(model_id):
|
|
allowed = {"anthropic": "api.anthropic.com"}
|
|
elif is_grok_46_family(str(model_id or "")):
|
|
allowed = {"xai": "api.x.ai"}
|
|
else:
|
|
allowed = {"openai": "api.openai.com", "openai-codex": "chatgpt.com"}
|
|
if provider and normalize_provider(provider) not in allowed:
|
|
return False
|
|
host = (urlparse(str(base_url or "")).hostname or "").lower()
|
|
return not host or host in allowed.values()
|
|
|
|
|
|
def resolve_fast_mode_overrides(
|
|
model_id: Optional[str],
|
|
*,
|
|
provider: Optional[str] = None,
|
|
base_url: Optional[str] = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Return request_overrides for fast/priority mode, or None if unsupported.
|
|
|
|
Returns provider-appropriate overrides: - OpenAI models: ``{"service_tier": "priority"}``
|
|
(Priority Processing) - Anthropic models: ``{"speed": "fast"}`` (Anthropic Fast Mode beta) -
|
|
Grok 4.6: ``{"service_tier": "priority"}`` (xAI Priority Processing)
|
|
|
|
When ``provider``/``base_url`` are given the result is also gated on the route (see
|
|
``_fast_mode_route_supported``) so proxies never see the params. This is the single fast-mode
|
|
gate for static ``/fast fast`` and the bounded ``auto``/``cold`` windows in ``agent.fast_mode``.
|
|
"""
|
|
if not model_supports_fast_mode(model_id):
|
|
return None
|
|
if (provider or base_url) and not _fast_mode_route_supported(
|
|
model_id, provider, base_url
|
|
):
|
|
return None
|
|
if _is_anthropic_fast_model(model_id):
|
|
return {"speed": "fast"}
|
|
return {"service_tier": "priority"}
|
|
|
|
|
|
def _resolve_copilot_catalog_api_key() -> str:
|
|
"""Best-effort GitHub token for fetching the Copilot model catalog.
|
|
|
|
Resolution order:
|
|
1. ``resolve_api_key_provider_credentials("copilot")`` — env vars
|
|
(``COPILOT_GITHUB_TOKEN`` / ``GH_TOKEN`` / ``GITHUB_TOKEN``) plus
|
|
the ``gh auth token`` CLI fallback.
|
|
2. ``read_credential_pool("copilot")`` — a token (typically a
|
|
``gho_*`` from device-code login, or a fine-grained PAT) stored in
|
|
``auth.json`` under ``credential_pool.copilot[]``. The pool is
|
|
populated by ``hermes auth add copilot`` and by ``_seed_from_env``
|
|
when the env var is set in ``~/.hermes/.env``.
|
|
3. ``~/.copilot/config.json`` ``copilotTokens`` — the GitHub Copilot
|
|
CLI's own store, written by ``copilot login`` on hosts without an
|
|
OS keychain. Without it, a user whose ONLY credential is the ACP
|
|
CLI login sees the copilot-acp picker fall back to the stale
|
|
curated list instead of the models their subscription serves.
|
|
|
|
Without (2)/(3), users without env-var credentials see the ``/model``
|
|
picker fall back to a stale hardcoded list because the live catalog
|
|
fetch silently 401s. To avoid wedging on a malformed entry, each
|
|
candidate is exchanged via ``exchange_copilot_token`` — only entries
|
|
that actually exchange successfully are returned, so a later valid
|
|
entry is reachable when an earlier one is unsupported.
|
|
"""
|
|
try:
|
|
from hermes_cli.auth import resolve_api_key_provider_credentials
|
|
|
|
creds = resolve_api_key_provider_credentials("copilot")
|
|
api_key = str(creds.get("api_key") or "").strip()
|
|
if api_key:
|
|
return api_key
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
from hermes_cli.auth import read_credential_pool
|
|
from hermes_cli.copilot_auth import (
|
|
exchange_copilot_token,
|
|
validate_copilot_token,
|
|
)
|
|
|
|
for entry in read_credential_pool("copilot"):
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
raw = str(entry.get("access_token") or "").strip()
|
|
if not raw:
|
|
continue
|
|
valid, _ = validate_copilot_token(raw)
|
|
if not valid:
|
|
continue
|
|
try:
|
|
# exchange_copilot_token returns (api_token, expires_at,
|
|
# base_url) — a 2-name unpack raises ValueError, which the
|
|
# except below silently swallowed, disabling this entire
|
|
# resolution path.
|
|
api_token = exchange_copilot_token(raw)[0]
|
|
except Exception:
|
|
continue
|
|
if api_token:
|
|
return api_token
|
|
except Exception:
|
|
pass
|
|
|
|
# 3. Copilot CLI plaintext token store (JSONC — strip //-comment lines).
|
|
try:
|
|
import json as _json
|
|
|
|
from hermes_cli.copilot_auth import (
|
|
exchange_copilot_token,
|
|
validate_copilot_token,
|
|
)
|
|
|
|
cli_config = os.path.expanduser("~/.copilot/config.json")
|
|
if os.path.isfile(cli_config):
|
|
with open(cli_config, "r", encoding="utf-8", errors="ignore") as fh:
|
|
raw_text = "\n".join(
|
|
line for line in fh.read().splitlines()
|
|
if not line.lstrip().startswith("//")
|
|
)
|
|
data = _json.loads(raw_text) if raw_text.strip() else {}
|
|
tokens = data.get("copilotTokens")
|
|
if isinstance(tokens, dict):
|
|
for raw in tokens.values():
|
|
raw = str(raw or "").strip()
|
|
if not raw:
|
|
continue
|
|
valid, _ = validate_copilot_token(raw)
|
|
if not valid:
|
|
continue
|
|
try:
|
|
api_token = exchange_copilot_token(raw)[0]
|
|
except Exception:
|
|
continue
|
|
if api_token:
|
|
return api_token
|
|
except Exception:
|
|
pass
|
|
|
|
return ""
|
|
|
|
|
|
def _model_dedup_key(model_id: str) -> str:
|
|
"""Case-insensitive dedup key that also folds picker-search aliases.
|
|
|
|
Some providers serve one model under both a curated public slug and a bare live wire id
|
|
(Kimi lists ``k3`` while the curated catalog carries ``kimi-k3``). Folding through the
|
|
search-alias table keeps the curated-first merge from emitting both; the primary list's row
|
|
survives and selection sends whichever id it carries.
|
|
"""
|
|
key = str(model_id).strip().lower()
|
|
try:
|
|
from hermes_cli.model_search import model_alias_canonical
|
|
return model_alias_canonical(key)
|
|
except Exception:
|
|
return key
|
|
|
|
|
|
def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]:
|
|
"""Merge curated list with fresh models.dev entries for a preferred provider.
|
|
|
|
Returns models.dev entries first (in models.dev order), then any curated-only entries appended.
|
|
Preserves case for curated fallbacks (e.g. ``MiniMax-M2.7``) while trusting models.dev for newer
|
|
variants.
|
|
|
|
If models.dev is unreachable or returns nothing, the curated list is returned unchanged — this
|
|
is the offline/CI fallback path.
|
|
"""
|
|
try:
|
|
from agent.models_dev import list_agentic_models
|
|
mdev = list_agentic_models(provider)
|
|
except Exception:
|
|
mdev = []
|
|
|
|
if not mdev:
|
|
return list(curated)
|
|
|
|
# Case-insensitive dedup while preserving order and curated casing.
|
|
seen_lower: set[str] = set()
|
|
merged: list[str] = []
|
|
for mid in mdev:
|
|
key = str(mid).lower()
|
|
if key in seen_lower:
|
|
continue
|
|
seen_lower.add(key)
|
|
merged.append(mid)
|
|
for mid in curated:
|
|
key = str(mid).lower()
|
|
if key in seen_lower:
|
|
continue
|
|
seen_lower.add(key)
|
|
merged.append(mid)
|
|
return merged
|
|
|
|
|
|
def _openai_discovery_base_url(provider: str) -> str:
|
|
"""Effective OpenAI endpoint for model discovery.
|
|
|
|
Mirrors the runtime precedence so discovery probes the SAME endpoint inference uses:
|
|
``$OPENAI_BASE_URL`` (explicit env override) → ``model.base_url`` from config.yaml when the
|
|
configured provider matches → the canonical default.
|
|
"""
|
|
env_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/")
|
|
if env_raw:
|
|
return env_raw
|
|
try:
|
|
model_cfg = _get_model_config_dict()
|
|
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
|
if cfg_provider in ("openai", "openai-api") and normalize_provider(provider) == normalize_provider(cfg_provider):
|
|
cfg_url = str(model_cfg.get("base_url") or "").strip().rstrip("/")
|
|
if cfg_url:
|
|
return cfg_url
|
|
except Exception:
|
|
pass
|
|
return "https://api.openai.com/v1"
|
|
|
|
|
|
def _ollama_local_catalog(force_refresh: bool) -> list[str]:
|
|
"""Catalog for the raw ``ollama`` provider: native ``/api/tags`` when the endpoint is a real
|
|
Ollama server, else the OpenAI-style ``/v1/models`` of the configured gateway."""
|
|
if force_refresh:
|
|
_OLLAMA_LOCAL_MODELS_CACHE.clear()
|
|
_OLLAMA_LOCAL_PROBE_FAILURE_CACHE.clear()
|
|
_OLLAMA_LOCAL_PROBE_REACHABLE.clear()
|
|
base_url = _get_ollama_base_url()
|
|
headers = _get_ollama_native_headers(base_url)
|
|
if should_use_ollama_native_catalog("ollama", base_url, headers=headers):
|
|
if headers:
|
|
native_models = fetch_ollama_local_models(base_url, headers=headers)
|
|
else:
|
|
native_models = fetch_ollama_local_models(base_url)
|
|
native_key = _ollama_probe_cache_key(_root_for_ollama_native_api(base_url), headers or None)
|
|
if native_models or _OLLAMA_LOCAL_PROBE_REACHABLE.get(native_key) is True:
|
|
return native_models or []
|
|
# Non-native Ollama-compatible endpoints (incl. Ollama Cloud) and gateways exposing only
|
|
# OpenAI-style /v1/models.
|
|
config = _get_provider_config_dict("ollama")
|
|
fallback_key = str(config.get("api_key") or "").strip()
|
|
if not fallback_key:
|
|
key_env = str(config.get("key_env") or "").strip()
|
|
fallback_key = os.getenv(key_env, "").strip() if key_env else ""
|
|
fallback_base = _normalize_openai_base_url(config.get("base_url") or base_url)
|
|
fallback_headers = _get_ollama_native_headers(fallback_base, api_key=fallback_key)
|
|
return fetch_api_models(fallback_key, fallback_base, headers=fallback_headers or None) or []
|
|
|
|
|
|
def _codex_catalog(normalized: str, force_refresh: bool) -> list[str]:
|
|
from hermes_cli.codex_models import get_codex_model_ids
|
|
|
|
# Pass the live OAuth access token so the picker matches whatever ChatGPT lists for this
|
|
# account right now; falls back to the hardcoded catalog without a token / when unreachable.
|
|
access_token = None
|
|
try:
|
|
from hermes_cli.auth import resolve_codex_runtime_credentials
|
|
|
|
access_token = resolve_codex_runtime_credentials(refresh_if_expiring=True).get("api_key")
|
|
except Exception:
|
|
access_token = None
|
|
return get_codex_model_ids(access_token=access_token)
|
|
|
|
|
|
def _copilot_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]]:
|
|
try:
|
|
live = _fetch_github_models(_resolve_copilot_catalog_api_key())
|
|
if live:
|
|
return live
|
|
except Exception:
|
|
pass
|
|
if normalized == "copilot-acp":
|
|
return list(_PROVIDER_MODELS.get("copilot", []))
|
|
return None
|
|
|
|
|
|
def _nous_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]]:
|
|
try:
|
|
from hermes_cli.auth import fetch_nous_models, resolve_nous_runtime_credentials
|
|
|
|
creds = resolve_nous_runtime_credentials()
|
|
if creds:
|
|
live = fetch_nous_models(api_key=creds.get("api_key", ""), inference_base_url=creds.get("base_url", ""))
|
|
if live:
|
|
return live
|
|
except Exception:
|
|
pass
|
|
# Live failed (or no creds): the docs-hosted manifest — NOT the in-repo snapshot — so newly
|
|
# added Portal models still surface without a Hermes release.
|
|
return get_curated_nous_model_ids() or None
|
|
|
|
|
|
def _api_key_provider_live(normalized: str, force_refresh: bool) -> Optional[list[str]]:
|
|
"""Live /v1/models for a simple api-key provider (stepfun, gmi); None on any miss."""
|
|
try:
|
|
from hermes_cli.auth import resolve_api_key_provider_credentials
|
|
|
|
creds = resolve_api_key_provider_credentials(normalized)
|
|
api_key = str(creds.get("api_key") or "").strip()
|
|
base_url = str(creds.get("base_url") or "").strip()
|
|
if api_key and base_url:
|
|
return fetch_api_models(api_key, base_url) or None
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _anthropic_catalog(normalized: str, force_refresh: bool) -> list[str]:
|
|
model_cfg = _get_model_config_dict()
|
|
cfg_base_url = cfg_api_key = ""
|
|
if normalize_provider(str(model_cfg.get("provider", "") or "")) == "anthropic":
|
|
cfg_base_url = str(model_cfg.get("base_url", "") or "").strip()
|
|
cfg_api_key = str(model_cfg.get("api_key", "") or "").strip()
|
|
live = _fetch_anthropic_models(base_url=cfg_base_url or None, api_key=cfg_api_key or None)
|
|
curated = list(_PROVIDER_MODELS.get("anthropic", []))
|
|
if not live:
|
|
return curated
|
|
if cfg_base_url:
|
|
return live
|
|
# The live /v1/models dump lags newly-routed curated aliases (reachable before enumerated).
|
|
# Curated first, then live-only extras, so a fresh curated model never disappears.
|
|
merged = list(curated)
|
|
merged_lower = {m.lower() for m in curated}
|
|
for m in live:
|
|
if m.lower() not in merged_lower:
|
|
merged.append(m)
|
|
merged_lower.add(m.lower())
|
|
return merged
|
|
|
|
|
|
def _openai_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]]:
|
|
api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
|
if not api_key:
|
|
return None
|
|
base = _openai_discovery_base_url(normalized)
|
|
# Custom OpenAI-compatible endpoints may serve a small curated catalog — use it verbatim.
|
|
# Official OpenAI hosts (canonical AND data-residency regional, identical dump) return 120+
|
|
# embeddings/whisper/tts/dall-e/moderation/legacy entries, so intersect with the curated
|
|
# agentic catalog there so ``/model`` matches ``hermes model``.
|
|
from hermes_cli.providers import is_official_openai_host
|
|
|
|
is_default_openai = is_official_openai_host(base)
|
|
try:
|
|
live = fetch_api_models(api_key, base)
|
|
except Exception:
|
|
return None
|
|
if not live:
|
|
return None
|
|
if not is_default_openai:
|
|
return live
|
|
live_lower = {m.lower() for m in live}
|
|
curated = list(_PROVIDER_MODELS.get(normalized, []))
|
|
# Keep curated order; only surface curated models the account actually has access to. An
|
|
# account serving none of them (rare) falls back to curated so the picker still offers sane
|
|
# defaults.
|
|
filtered = [m for m in curated if m.lower() in live_lower]
|
|
return filtered or curated or live
|
|
|
|
|
|
def _custom_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]]:
|
|
base_url = _get_custom_base_url()
|
|
if not base_url:
|
|
return None
|
|
model_cfg = _get_model_config_dict()
|
|
# Try common API key env vars for custom endpoints.
|
|
api_key = (
|
|
str(model_cfg.get("api_key", "") or "").strip()
|
|
or os.getenv("CUSTOM_API_KEY", "")
|
|
or os.getenv("OPENAI_API_KEY", "")
|
|
or os.getenv("OPENROUTER_API_KEY", "")
|
|
)
|
|
api_mode = "anthropic_messages" if _base_url_looks_like_anthropic_messages(base_url) else None
|
|
return fetch_api_models(api_key, base_url, api_mode=api_mode) or None
|
|
|
|
|
|
def _bedrock_catalog(normalized: str, force_refresh: bool) -> Optional[list[str]]:
|
|
# Live discovery keyed by the resolved AWS region so EU/AP users see eu.*/ap.* ids instead of
|
|
# the static us.* list. A hit skips the _MODELS_DEV_PREFERRED merge (bedrock isn't in it).
|
|
try:
|
|
from agent.bedrock_adapter import bedrock_model_ids_or_none
|
|
|
|
return bedrock_model_ids_or_none()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _opencode_free_catalog(normalized: str, force_refresh: bool) -> list[str]:
|
|
# Keyless live catalog revalidated against the Zen relay every TTL. models.dev's
|
|
# cost.input==0 filter lags reality (a promo model kept "free" there after the relay began
|
|
# 401ing keyless requests), so filter the live /zen/v1/models dump to the anonymous-servable
|
|
# `*-free` tier ourselves; the curated floor only applies when the live fetch fails/is empty.
|
|
return _fetch_opencode_free_models(force_refresh=force_refresh) or list(_PROVIDER_MODELS.get(normalized, []))
|
|
|
|
|
|
# Per-provider catalog sources tried before the generic profile fetch. A fetcher returning None
|
|
# falls through to the profile/curated path; a list is returned as-is (even empty).
|
|
_PROVIDER_CATALOG_FETCHERS: dict[str, Any] = {
|
|
"openrouter": lambda normalized, force_refresh: model_ids(force_refresh=force_refresh),
|
|
"openai-codex": _codex_catalog,
|
|
"copilot": _copilot_catalog,
|
|
"copilot-acp": _copilot_catalog,
|
|
"nous": _nous_catalog,
|
|
"stepfun": _api_key_provider_live,
|
|
"gmi": _api_key_provider_live,
|
|
"anthropic": _anthropic_catalog,
|
|
"ai-gateway": lambda normalized, force_refresh: _fetch_ai_gateway_models() or None,
|
|
# DeepInfra's generic /models mixes chat, image, video, speech and embedding models; the
|
|
# tagged catalog helper is the only safe source for the chat picker, including its
|
|
# empty/failure result.
|
|
"deepinfra": lambda normalized, force_refresh: _fetch_deepinfra_models(force_refresh=force_refresh) or [],
|
|
"ollama-cloud": lambda normalized, force_refresh: fetch_ollama_cloud_models(force_refresh=force_refresh) or None,
|
|
"openai": _openai_catalog,
|
|
"openai-api": _openai_catalog,
|
|
"custom": _custom_catalog,
|
|
"bedrock": _bedrock_catalog,
|
|
"opencode-free": _opencode_free_catalog,
|
|
}
|
|
|
|
|
|
def _profile_live_catalog(normalized: str) -> Optional[list[str]]:
|
|
"""Generic live fetch for any provider registered in providers/ with ``auth_type="api_key"``.
|
|
|
|
Live results are merged with the curated list so models the live endpoint omits (stale cache,
|
|
partial rollout) still appear. Most providers merge curated-first so the newest curated models
|
|
lead even when the live API lags; ``_LIVE_FIRST_PICKER_PROVIDERS`` (OpenCode Zen/Go, whose
|
|
live API is authoritative) merge live-first so stale curated entries stop polluting the top.
|
|
Plugin providers with no static entry use the profile's ``fallback_models`` as the curated
|
|
list so their agentic picks lead the picker (Fireworks lists an image model first).
|
|
"""
|
|
from providers import get_provider_profile
|
|
from hermes_cli.auth import resolve_api_key_provider_credentials
|
|
|
|
profile = get_provider_profile(normalized)
|
|
if not (profile and profile.auth_type == "api_key" and profile.base_url):
|
|
return None
|
|
try:
|
|
creds = resolve_api_key_provider_credentials(normalized)
|
|
api_key = str(creds.get("api_key") or "").strip()
|
|
base_url = str(creds.get("base_url") or "").strip()
|
|
except Exception:
|
|
api_key, base_url = "", profile.base_url
|
|
if not base_url:
|
|
base_url = profile.base_url
|
|
if api_key:
|
|
live = profile.fetch_models(api_key=api_key, base_url=base_url or None)
|
|
if live:
|
|
curated = list(_PROVIDER_MODELS.get(normalized, [])) or list(profile.fallback_models or ())
|
|
if not curated:
|
|
return live
|
|
if normalized in _LIVE_FIRST_PICKER_PROVIDERS:
|
|
primary, secondary = live, curated
|
|
else:
|
|
primary, secondary = curated, live
|
|
merged = list(primary)
|
|
merged_keys = {_model_dedup_key(m) for m in primary}
|
|
for m in secondary:
|
|
if _model_dedup_key(m) not in merged_keys:
|
|
merged.append(m)
|
|
merged_keys.add(_model_dedup_key(m))
|
|
return merged
|
|
if profile.fallback_models:
|
|
return list(profile.fallback_models)
|
|
return None
|
|
|
|
|
|
def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]:
|
|
"""Return the best known model catalog for a provider.
|
|
|
|
Tries live API endpoints where supported (Codex, Nous), falling back to static lists. For
|
|
providers in ``_MODELS_DEV_PREFERRED`` models.dev entries are merged on top of curated so
|
|
new platform models appear in ``/model`` without a Hermes release.
|
|
"""
|
|
requested = str(provider or "").strip().lower()
|
|
if requested == "ollama":
|
|
return _ollama_local_catalog(force_refresh)
|
|
|
|
normalized = normalize_provider(provider)
|
|
fetcher = _PROVIDER_CATALOG_FETCHERS.get(normalized)
|
|
if fetcher is not None:
|
|
models = fetcher(normalized, force_refresh)
|
|
if models is not None:
|
|
return models
|
|
|
|
try:
|
|
models = _profile_live_catalog(normalized)
|
|
if models is not None:
|
|
return models
|
|
except Exception:
|
|
pass
|
|
|
|
curated_static = list(_PROVIDER_MODELS.get(normalized, []))
|
|
if normalized in _MODELS_DEV_PREFERRED:
|
|
merged = _merge_with_models_dev(normalized, curated_static)
|
|
if normalized in {"xai", "xai-oauth"}:
|
|
return _xai_finalize_catalog(merged)
|
|
return merged
|
|
return curated_static
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Generic disk cache for provider_model_ids() — keeps /model picker fast.
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Without this layer, every /model picker open re-fetches every authed
|
|
# provider's /v1/models endpoint. On a well-configured user (anthropic +
|
|
# openai + copilot + gemini + huggingface + ...) that's 2+ seconds of cold
|
|
# HTTP roundtrips just to render the provider list.
|
|
#
|
|
# Cache strategy:
|
|
# - One JSON file at $HERMES_HOME/provider_models_cache.json
|
|
# - Per-provider entries keyed by (provider, credential fingerprint)
|
|
# - Credential fingerprint = sha256 of env-var values that the provider
|
|
# normally reads. Swap your OPENAI_API_KEY and the entry invalidates.
|
|
# - 1h TTL by default. `force_refresh=True` skips the cache entirely
|
|
# and overwrites it on success.
|
|
# - Only NON-EMPTY results are cached. An empty/None response from a
|
|
# transient network error never gets pinned.
|
|
# - Cache file is best-effort. Any read/write error degrades silently
|
|
# to a live fetch — the picker keeps working.
|
|
|
|
_PROVIDER_MODELS_CACHE_TTL = 3600 # 1h
|
|
# Stale-while-revalidate window: an expired-but-same-credentials entry is
|
|
# served IMMEDIATELY (picker opens stay instant) while a background daemon
|
|
# thread re-fetches the live catalog and rewrites the disk cache for the
|
|
# next open. Beyond this bound the entry is considered too old to trust and
|
|
# the caller blocks on a live fetch as before. Rationale: the /model picker's
|
|
# provider listing runs 8-9 serial /v1/models round-trips (~2-3s) whenever
|
|
# the 1h TTL lapses mid-session — model catalogs change on release timescales,
|
|
# not hourly, so serving hour-old data while refreshing off-thread is strictly
|
|
# better than stalling every picker surface (CLI, TUI, dashboard, gateway).
|
|
_PROVIDER_MODELS_STALE_SERVE_MAX = 7 * 24 * 3600 # 7d
|
|
|
|
# Providers with a background SWR refresh currently in flight — dedupes
|
|
# concurrent refreshes so repeated picker opens during one refresh don't
|
|
# stack threads or duplicate network calls.
|
|
_swr_refresh_inflight: set = set()
|
|
_swr_refresh_lock = threading.Lock()
|
|
|
|
|
|
def _spawn_swr_refresh(cache_key: str, refresh_fn=None) -> None:
|
|
"""Kick a background refresh of *cache_key*'s model-id cache entry.
|
|
|
|
Fire-and-forget daemon thread; at most one in flight per cache key. Failures are swallowed — the
|
|
stale entry stays served until a later refresh succeeds (same degradation the blocking path
|
|
already had).
|
|
|
|
``refresh_fn`` (no-args, returns the fresh cache-entry dict or ``None``) lets non-slug keys
|
|
(``custom:<base_url>`` entries from :func:`cached_fetch_api_models`) reuse the same inflight-
|
|
dedupe and thread scaffolding.
|
|
"""
|
|
with _swr_refresh_lock:
|
|
if cache_key in _swr_refresh_inflight:
|
|
return
|
|
_swr_refresh_inflight.add(cache_key)
|
|
|
|
def _default_refresh():
|
|
live = provider_model_ids(cache_key, force_refresh=True)
|
|
if not live and cache_key == "ollama":
|
|
base_url = _get_ollama_base_url()
|
|
headers = _get_ollama_native_headers(base_url) or None
|
|
probe_key = _ollama_probe_cache_key(
|
|
_root_for_ollama_native_api(base_url), headers
|
|
)
|
|
if _OLLAMA_LOCAL_PROBE_REACHABLE.get(probe_key) is True:
|
|
return {
|
|
"fp": _credential_fingerprint(cache_key),
|
|
"at": time.time(),
|
|
"models": [],
|
|
}
|
|
if not live:
|
|
return None
|
|
return {
|
|
"fp": _credential_fingerprint(cache_key),
|
|
"at": time.time(),
|
|
"models": list(live),
|
|
}
|
|
|
|
def _refresh() -> None:
|
|
try:
|
|
entry = (refresh_fn or _default_refresh)()
|
|
if entry:
|
|
cache = _load_provider_models_cache()
|
|
cache[cache_key] = entry
|
|
_save_provider_models_cache(cache)
|
|
except Exception:
|
|
logger.debug("SWR refresh failed for %s", cache_key, exc_info=True)
|
|
finally:
|
|
with _swr_refresh_lock:
|
|
_swr_refresh_inflight.discard(cache_key)
|
|
|
|
threading.Thread(
|
|
target=_refresh, daemon=True, name=f"model-cache-swr-{cache_key}"
|
|
).start()
|
|
|
|
|
|
def _provider_models_cache_path() -> Path:
|
|
from hermes_constants import get_hermes_home
|
|
return get_hermes_home() / "provider_models_cache.json"
|
|
|
|
|
|
def _credential_fingerprint(provider: str) -> str:
|
|
"""Short hash of the credentials ``provider_model_ids(provider)`` would see right now.
|
|
|
|
Rotating any relevant env var invalidates that provider's cache entry: the api-key and base-
|
|
url env vars from ``PROVIDER_REGISTRY`` are hashed. OAuth-backed providers keep tokens in
|
|
``auth.json`` and external credential files, so those files' mtimes are folded in too — re-
|
|
auth busts the cache without parsing every file shape.
|
|
"""
|
|
import hashlib
|
|
import os as _os
|
|
|
|
parts: list[str] = []
|
|
|
|
# Keyless providers have no credential to fingerprint: the catalog is
|
|
# served anonymously, so nothing the user rotates (env vars, auth files,
|
|
# base URLs) should invalidate the cached entry. A stable fingerprint keeps
|
|
# the SWR disk cache alive across unrelated re-auths and only busts on TTL
|
|
# expiry — matching how the live catalog genuinely changes.
|
|
if (provider or "").strip().lower() in _KEYLESS_STABLE_CACHE_PROVIDERS:
|
|
return "keyless:" + (provider or "").strip().lower()
|
|
|
|
# Env vars from PROVIDER_REGISTRY for this slug
|
|
try:
|
|
from hermes_cli.auth import PROVIDER_REGISTRY
|
|
pcfg = PROVIDER_REGISTRY.get(provider)
|
|
if pcfg is not None:
|
|
for ev in getattr(pcfg, "api_key_env_vars", ()) or ():
|
|
parts.append(f"{ev}={_os.environ.get(ev, '')}")
|
|
bev = getattr(pcfg, "base_url_env_var", "") or ""
|
|
if bev:
|
|
parts.append(f"{bev}={_os.environ.get(bev, '')}")
|
|
except Exception:
|
|
pass
|
|
|
|
# Effective configured endpoint: config.yaml's model.base_url changes the
|
|
# endpoint discovery probes (data-residency hosts) without touching any
|
|
# env var, so it must change the fingerprint too or `hermes config set
|
|
# model.base_url ...` keeps serving the previous endpoint's cached
|
|
# catalog until TTL expiry.
|
|
if provider in ("openai", "openai-api"):
|
|
try:
|
|
parts.append(f"effective_base={_openai_discovery_base_url(provider)}")
|
|
except Exception:
|
|
pass
|
|
|
|
if provider == "ollama":
|
|
parts.append(f"OLLAMA_HOST={_os.environ.get('OLLAMA_HOST', '')}")
|
|
provider_cfg = _get_provider_config_dict("ollama")
|
|
parts.append(
|
|
"providers.ollama.base_url="
|
|
f"{provider_cfg.get('base_url', '') or provider_cfg.get('api', '') or provider_cfg.get('url', '')}"
|
|
)
|
|
parts.append(f"providers.ollama.api_key={provider_cfg.get('api_key', '')}")
|
|
key_env = provider_cfg.get("key_env") or provider_cfg.get("api_key_env") or ""
|
|
parts.append(f"providers.ollama.key_env={key_env}")
|
|
if key_env:
|
|
parts.append(f"{key_env}={_os.environ.get(str(key_env), '')}")
|
|
model_cfg = _get_model_config_dict()
|
|
parts.append(
|
|
"model.provider="
|
|
f"{model_cfg.get('provider', '')}|model.base_url={model_cfg.get('base_url', '')}"
|
|
)
|
|
parts.append(
|
|
"providers.ollama.extra_headers="
|
|
+ json.dumps(provider_cfg.get("extra_headers", {}), sort_keys=True, default=str)
|
|
)
|
|
|
|
# OAuth / external-file mtimes that change on re-auth
|
|
try:
|
|
from hermes_constants import get_hermes_home
|
|
for rel in ("auth.json", "credentials.json"):
|
|
p = get_hermes_home() / rel
|
|
try:
|
|
parts.append(f"{rel}@{p.stat().st_mtime_ns}")
|
|
except FileNotFoundError:
|
|
parts.append(f"{rel}@missing")
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
# External well-known credential file locations
|
|
for path in (
|
|
_os.path.expanduser("~/.codex/auth.json"),
|
|
_os.path.expanduser("~/.claude/.credentials.json"),
|
|
_os.path.expanduser("~/.config/github-copilot/hosts.json"),
|
|
_os.path.expanduser("~/.minimax/credentials.json"),
|
|
):
|
|
try:
|
|
mt = _os.stat(path).st_mtime_ns
|
|
parts.append(f"{path}@{mt}")
|
|
except FileNotFoundError:
|
|
parts.append(f"{path}@missing")
|
|
except Exception:
|
|
pass
|
|
|
|
blob = "|".join(parts).encode("utf-8", errors="replace")
|
|
# blake2b for cache-key fingerprinting only — not for credential storage.
|
|
# We never reverse this hash; collisions are harmless (worst case: cache
|
|
# miss → live re-fetch). Use blake2b instead of sha256 here because
|
|
# CodeQL's `py/weak-sensitive-data-hashing` rule flags sha256 over env
|
|
# vars whose names contain "API_KEY" / "TOKEN" even when the hash is
|
|
# used as an identity fingerprint, not for password storage. blake2b
|
|
# is a keyed-hash primitive and isn't flagged.
|
|
return hashlib.blake2b(blob, digest_size=8).hexdigest()
|
|
|
|
|
|
def _load_provider_models_cache() -> dict:
|
|
"""Return the full cache dict, or {} on any error."""
|
|
try:
|
|
path = _provider_models_cache_path()
|
|
if not path.exists():
|
|
return {}
|
|
with open(path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
return data if isinstance(data, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
_cache_write_lock = threading.Lock()
|
|
|
|
|
|
def _save_provider_models_cache(data: dict) -> None:
|
|
"""Persist the cache dict. Best-effort — silent on any error."""
|
|
try:
|
|
from utils import atomic_json_write
|
|
path = _provider_models_cache_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
atomic_json_write(path, data, indent=None)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def update_provider_cache_entry(provider: str, models: list[str]) -> None:
|
|
"""Thread-safe single-entry update of the provider-models disk cache.
|
|
|
|
Used by parallel prefetch workers so concurrent fetches don't clobber each other's writes via
|
|
read-modify-write races on the shared JSON file. Each worker loads the latest cache state under
|
|
the lock, writes its own entry, and saves — best-effort, silent on any error.
|
|
"""
|
|
try:
|
|
normalized = normalize_provider(provider) or (provider or "")
|
|
if not normalized or not models:
|
|
return
|
|
fp = _credential_fingerprint(normalized)
|
|
with _cache_write_lock:
|
|
cache = _load_provider_models_cache()
|
|
cache[normalized] = {
|
|
"fp": fp,
|
|
"at": time.time(),
|
|
"models": list(models),
|
|
}
|
|
_save_provider_models_cache(cache)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def cached_provider_model_ids(
|
|
provider: Optional[str],
|
|
*,
|
|
force_refresh: bool = False,
|
|
ttl_seconds: int = _PROVIDER_MODELS_CACHE_TTL,
|
|
) -> list[str]:
|
|
"""Disk-cached wrapper around :func:`provider_model_ids`.
|
|
|
|
Hits the cache when fresh; otherwise calls the live function and persists a non-empty result.
|
|
Always returns a list (never None).
|
|
"""
|
|
requested = str(provider or "").strip().lower()
|
|
normalized = requested if requested == "ollama" else (normalize_provider(provider) or (provider or ""))
|
|
if not normalized:
|
|
return []
|
|
if normalized == "ollama":
|
|
ttl_seconds = min(ttl_seconds, _OLLAMA_LOCAL_MODELS_CACHE_TTL)
|
|
|
|
cache = _load_provider_models_cache()
|
|
fp = _credential_fingerprint(normalized)
|
|
entry = cache.get(normalized)
|
|
now = time.time()
|
|
|
|
allow_empty_ollama = normalized == "ollama"
|
|
if not force_refresh and _cache_entry_valid(entry, fp, allow_empty=allow_empty_ollama):
|
|
age = now - entry["at"]
|
|
if age < ttl_seconds:
|
|
return list(entry["models"])
|
|
# Empty native catalogs are authoritative only for the short native
|
|
# TTL. Re-probe after expiry so newly pulled models become visible;
|
|
# do not serve an empty row through the generic stale window.
|
|
if entry["models"] and age < _PROVIDER_MODELS_STALE_SERVE_MAX:
|
|
# Stale-while-revalidate: serve the expired entry immediately so
|
|
# interactive picker opens never block on serial /v1/models
|
|
# round-trips; refresh the cache off-thread for the next open.
|
|
_spawn_swr_refresh(normalized)
|
|
return list(entry["models"])
|
|
|
|
# Cache miss / stale / forced refresh — call the live path.
|
|
live = provider_model_ids(normalized, force_refresh=force_refresh)
|
|
if live:
|
|
cache[normalized] = {
|
|
"fp": fp,
|
|
"at": now,
|
|
"models": list(live),
|
|
}
|
|
_save_provider_models_cache(cache)
|
|
return list(live)
|
|
|
|
if normalized == "ollama":
|
|
base_url = _get_ollama_base_url()
|
|
headers = _get_ollama_native_headers(base_url) or None
|
|
probe_key = _ollama_probe_cache_key(
|
|
_root_for_ollama_native_api(base_url), headers
|
|
)
|
|
if _OLLAMA_LOCAL_PROBE_REACHABLE.get(probe_key) is True:
|
|
# A reachable empty native catalog is authoritative for the short
|
|
# native TTL; do not resurrect a stale disk catalog.
|
|
cache[normalized] = {"fp": fp, "at": now, "models": []}
|
|
_save_provider_models_cache(cache)
|
|
return []
|
|
|
|
# A failed/non-native probe is not authoritative. Preserve a stale
|
|
# catalog rather than blanking the picker during a transient outage.
|
|
if (
|
|
isinstance(entry, dict)
|
|
and entry.get("fp") == fp
|
|
and isinstance(entry.get("models"), list)
|
|
and entry["models"]
|
|
):
|
|
return list(entry["models"])
|
|
return []
|
|
|
|
# Live fetch returned nothing. If we have a stale entry with the
|
|
# SAME fingerprint, prefer it over an empty result — stale data
|
|
# beats no data when the network is flaky.
|
|
if _cache_entry_valid(entry, fp):
|
|
return list(entry["models"])
|
|
return list(live or [])
|
|
|
|
|
|
def clear_provider_models_cache(provider: Optional[str] = None) -> None:
|
|
"""Drop a single provider's cache entry, or wipe the whole cache.
|
|
|
|
``provider=None`` wipes everything; otherwise only that provider's entry is removed. Used by
|
|
``/model --refresh`` and ``hermes model --refresh``.
|
|
"""
|
|
try:
|
|
# Native Ollama tags are keyed by root URL rather than provider slug.
|
|
# A targeted refresh for a custom local-Ollama endpoint cannot identify
|
|
# the right root from the provider name alone, so clear this small
|
|
# in-process cache on every explicit provider-cache refresh.
|
|
_OLLAMA_LOCAL_MODELS_CACHE.clear()
|
|
_OLLAMA_LOCAL_PROBE_FAILURE_CACHE.clear()
|
|
_OLLAMA_LOCAL_PROBE_REACHABLE.clear()
|
|
if provider is None:
|
|
path = _provider_models_cache_path()
|
|
if path.exists():
|
|
path.unlink()
|
|
return
|
|
cache = _load_provider_models_cache()
|
|
requested = str(provider or "").strip().lower()
|
|
normalized = requested if requested == "ollama" else (normalize_provider(provider) or provider or "")
|
|
changed = False
|
|
if normalized in cache:
|
|
del cache[normalized]
|
|
changed = True
|
|
if changed:
|
|
_save_provider_models_cache(cache)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _resolve_anthropic_pool_catalog_credentials() -> tuple[str, str]:
|
|
"""Return a read-only API-key pool credential for model discovery.
|
|
|
|
``resolve_anthropic_token()`` intentionally ignores ``api_key`` pool entries because its runtime
|
|
contract is OAuth-oriented.
|
|
"""
|
|
try:
|
|
from agent.credential_pool import AUTH_TYPE_API_KEY
|
|
from hermes_cli.auth import read_credential_pool
|
|
|
|
for entry in read_credential_pool("anthropic"):
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if entry.get("auth_type") != AUTH_TYPE_API_KEY:
|
|
continue
|
|
token = str(entry.get("access_token") or "").strip()
|
|
if not token:
|
|
continue
|
|
endpoint = str(
|
|
entry.get("base_url") or entry.get("inference_base_url") or ""
|
|
).strip()
|
|
return token, endpoint
|
|
except Exception:
|
|
pass
|
|
return "", ""
|
|
|
|
|
|
def _fetch_anthropic_models(
|
|
timeout: float = 5.0,
|
|
*,
|
|
base_url: Optional[str] = None,
|
|
api_key: Optional[str] = None,
|
|
) -> Optional[list[str]]:
|
|
"""Fetch available models from the Anthropic /v1/models endpoint.
|
|
|
|
Uses resolve_anthropic_token() to find credentials (env vars, OAuth, or Claude Code auto-
|
|
discovery) unless api_key is provided explicitly. If those sources are empty, a read-only API-
|
|
key credential_pool entry is used. Returns sorted model IDs or None.
|
|
"""
|
|
try:
|
|
from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token
|
|
except ImportError:
|
|
return None
|
|
|
|
resolved_base_url = base_url
|
|
token = (api_key or "").strip() or resolve_anthropic_token()
|
|
if not token:
|
|
# A pool credential and its endpoint are one security boundary. Never
|
|
# pair the selected pool key with a caller-provided model endpoint.
|
|
token, resolved_base_url = _resolve_anthropic_pool_catalog_credentials()
|
|
if not token:
|
|
return None
|
|
|
|
headers: dict[str, str] = {"anthropic-version": "2023-06-01"}
|
|
is_oauth = _is_oauth_token(token)
|
|
if is_oauth:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA
|
|
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
|
|
else:
|
|
headers["x-api-key"] = token
|
|
|
|
def _do_request(h: dict[str, str]):
|
|
req = urllib.request.Request(
|
|
_anthropic_models_url(resolved_base_url),
|
|
headers=h,
|
|
)
|
|
with _urlopen_model_catalog_request(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode())
|
|
|
|
try:
|
|
try:
|
|
data = _do_request(headers)
|
|
except urllib.error.HTTPError as http_err:
|
|
# Reactive recovery for OAuth subscriptions that reject the 1M
|
|
# context beta with 400 "long context beta is not yet available
|
|
# for this subscription". Retry once without the beta; re-raise
|
|
# anything else so the outer except logs it.
|
|
if (
|
|
is_oauth
|
|
and http_err.code == 400
|
|
):
|
|
try:
|
|
body_text = http_err.read().decode(errors="ignore").lower()
|
|
except Exception:
|
|
body_text = ""
|
|
if "long context beta" in body_text and "not yet available" in body_text:
|
|
headers["anthropic-beta"] = ",".join(
|
|
[b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]
|
|
+ list(_OAUTH_ONLY_BETAS)
|
|
)
|
|
data = _do_request(headers)
|
|
else:
|
|
raise
|
|
else:
|
|
raise
|
|
models = [m["id"] for m in data.get("data", []) if m.get("id")]
|
|
# Sort: latest/largest first (opus > sonnet > haiku, higher version first)
|
|
return sorted(models, key=lambda m: (
|
|
"opus" not in m, # opus first
|
|
"sonnet" not in m, # then sonnet
|
|
"haiku" not in m, # then haiku
|
|
m, # alphabetical within tier
|
|
))
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).debug("Failed to fetch Anthropic models: %s", e)
|
|
return None
|
|
|
|
|
|
def _payload_items(payload: Any) -> list[dict[str, Any]]:
|
|
if isinstance(payload, list):
|
|
return [item for item in payload if isinstance(item, dict)]
|
|
if isinstance(payload, dict):
|
|
data = payload.get("data", [])
|
|
if isinstance(data, list):
|
|
return [item for item in data if isinstance(item, dict)]
|
|
return []
|
|
|
|
|
|
def copilot_default_headers(*, is_agent_turn: bool = True) -> dict[str, str]:
|
|
"""Standard headers for Copilot API requests."""
|
|
try:
|
|
from hermes_cli.copilot_auth import copilot_request_headers
|
|
return copilot_request_headers(is_agent_turn=is_agent_turn)
|
|
except ImportError:
|
|
return {
|
|
"Editor-Version": COPILOT_EDITOR_VERSION,
|
|
"User-Agent": "HermesAgent/1.0",
|
|
"Openai-Intent": "conversation-edits",
|
|
"x-initiator": "agent" if is_agent_turn else "user",
|
|
}
|
|
|
|
|
|
def _copilot_catalog_item_is_text_model(
|
|
item: dict[str, Any], *, ignore_picker_flag: bool = False
|
|
) -> bool:
|
|
model_id = str(item.get("id") or "").strip()
|
|
if not model_id:
|
|
return False
|
|
|
|
if not ignore_picker_flag and item.get("model_picker_enabled") is False:
|
|
return False
|
|
|
|
capabilities = item.get("capabilities")
|
|
if isinstance(capabilities, dict):
|
|
model_type = str(capabilities.get("type") or "").strip().lower()
|
|
if model_type and model_type != "chat":
|
|
return False
|
|
|
|
supported_endpoints = item.get("supported_endpoints")
|
|
if isinstance(supported_endpoints, list):
|
|
normalized_endpoints = {
|
|
str(endpoint).strip()
|
|
for endpoint in supported_endpoints
|
|
if str(endpoint).strip()
|
|
}
|
|
if normalized_endpoints and not normalized_endpoints.intersection(
|
|
{"/chat/completions", "/responses", "/v1/messages"}
|
|
):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
# Module-level cache for the GitHub Copilot /models catalog.
|
|
# The picker path can ask for it multiple times in one process via:
|
|
# list_authenticated_providers -> cached_provider_model_ids -> provider_model_ids -> _fetch_github_models
|
|
# and later get_copilot_model_context()/normalize helpers. Cache the raw filtered
|
|
# catalog for a short TTL so we don't pay repeated TLS handshakes on every picker open.
|
|
# Keyed by the api_key used for the successful fetch so a credential swap
|
|
# mid-process never serves the previous account's catalog. Uses a monotonic
|
|
# clock so wall-clock adjustments can't extend the TTL. Lock-free like the
|
|
# other module caches here — a racing thread at worst duplicates one fetch.
|
|
_github_model_catalog_cache: Optional[list[dict[str, Any]]] = None
|
|
_github_model_catalog_cache_key: Optional[str] = None
|
|
_github_model_catalog_cache_time: float = 0.0
|
|
_GITHUB_MODEL_CATALOG_CACHE_TTL = 300 # 5 minutes
|
|
|
|
|
|
def fetch_github_model_catalog(
|
|
api_key: Optional[str] = None, timeout: float = 5.0
|
|
) -> Optional[list[dict[str, Any]]]:
|
|
"""Fetch the live GitHub Copilot model catalog for this account."""
|
|
global _github_model_catalog_cache, _github_model_catalog_cache_key
|
|
global _github_model_catalog_cache_time
|
|
|
|
if (
|
|
_github_model_catalog_cache is not None
|
|
and _github_model_catalog_cache_key == api_key
|
|
and (time.monotonic() - _github_model_catalog_cache_time) < _GITHUB_MODEL_CATALOG_CACHE_TTL
|
|
):
|
|
# Deep copy: catalog items are dicts, and a shallow copy would let
|
|
# callers mutate the cached entries in place.
|
|
return copy.deepcopy(_github_model_catalog_cache)
|
|
|
|
attempts: list[dict[str, str]] = []
|
|
if api_key:
|
|
attempts.append({
|
|
**copilot_default_headers(),
|
|
"Authorization": f"Bearer {api_key}",
|
|
})
|
|
attempts.append(copilot_default_headers())
|
|
|
|
for headers in attempts:
|
|
req = urllib.request.Request(COPILOT_MODELS_URL, headers=headers)
|
|
try:
|
|
with _urlopen_model_catalog_request(req, timeout=timeout) as resp:
|
|
data = json.loads(resp.read().decode())
|
|
items = _payload_items(data)
|
|
models: list[dict[str, Any]] = []
|
|
seen_ids: set[str] = set()
|
|
for item in items:
|
|
if not _copilot_catalog_item_is_text_model(item):
|
|
continue
|
|
model_id = str(item.get("id") or "").strip()
|
|
if not model_id or model_id in seen_ids:
|
|
continue
|
|
seen_ids.add(model_id)
|
|
models.append(item)
|
|
if not models and items:
|
|
# GitHub has been observed returning
|
|
# ``model_picker_enabled: false`` for EVERY model on some
|
|
# accounts/token types, which would silently reject the
|
|
# whole live catalog and strand the picker on the stale
|
|
# curated fallback. The flag is a display hint, not an
|
|
# availability contract — when honoring it empties the
|
|
# catalog, retry without it (chat/endpoint checks still
|
|
# apply, so embeddings and non-chat rows stay excluded).
|
|
for item in items:
|
|
if not _copilot_catalog_item_is_text_model(
|
|
item, ignore_picker_flag=True
|
|
):
|
|
continue
|
|
model_id = str(item.get("id") or "").strip()
|
|
if not model_id or model_id in seen_ids:
|
|
continue
|
|
seen_ids.add(model_id)
|
|
models.append(item)
|
|
if models:
|
|
_github_model_catalog_cache = copy.deepcopy(models)
|
|
_github_model_catalog_cache_key = api_key
|
|
_github_model_catalog_cache_time = time.monotonic()
|
|
return models
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
|
|
# ─── Copilot catalog context-window helpers ─────────────────────────────────
|
|
|
|
# Module-level cache: {model_id: max_prompt_tokens}
|
|
_copilot_context_cache: dict[str, int] = {}
|
|
_copilot_context_cache_time: float = 0.0
|
|
_COPILOT_CONTEXT_CACHE_TTL = 3600 # 1 hour
|
|
|
|
|
|
def get_copilot_model_context(model_id: str, api_key: Optional[str] = None) -> Optional[int]:
|
|
"""Look up max_prompt_tokens for a Copilot model from the live /models API.
|
|
|
|
Results are cached in-process for 1 hour to avoid repeated API calls. Returns the token limit or
|
|
None if not found.
|
|
"""
|
|
global _copilot_context_cache, _copilot_context_cache_time
|
|
|
|
# Serve from cache if fresh
|
|
if _copilot_context_cache and (time.time() - _copilot_context_cache_time < _COPILOT_CONTEXT_CACHE_TTL):
|
|
if model_id in _copilot_context_cache:
|
|
return _copilot_context_cache[model_id]
|
|
# Cache is fresh but model not in it — don't re-fetch
|
|
return None
|
|
|
|
# Fetch and populate cache
|
|
catalog = fetch_github_model_catalog(api_key=api_key)
|
|
if not catalog:
|
|
return None
|
|
|
|
cache: dict[str, int] = {}
|
|
for item in catalog:
|
|
mid = str(item.get("id") or "").strip()
|
|
if not mid:
|
|
continue
|
|
caps = item.get("capabilities") or {}
|
|
limits = caps.get("limits") or {}
|
|
max_prompt = limits.get("max_prompt_tokens")
|
|
if isinstance(max_prompt, int) and max_prompt > 0:
|
|
cache[mid] = max_prompt
|
|
|
|
_copilot_context_cache = cache
|
|
_copilot_context_cache_time = time.time()
|
|
|
|
return cache.get(model_id)
|
|
|
|
|
|
def _is_github_models_base_url(base_url: Optional[str]) -> bool:
|
|
normalized = (base_url or "").strip().rstrip("/").lower()
|
|
return (
|
|
normalized.startswith(COPILOT_BASE_URL)
|
|
or normalized.startswith("https://models.github.ai/inference")
|
|
or normalized.startswith("https://models.inference.ai.azure.com")
|
|
)
|
|
|
|
|
|
def _lmstudio_server_root(base_url: Optional[str]) -> Optional[str]:
|
|
"""Return the LM Studio server root for native ``/api/v1`` endpoints.
|
|
|
|
Users commonly copy either the OpenAI-compatible runtime URL (``.../v1``) or the native API
|
|
prefix (``.../api`` / ``.../api/v1``). Native probes append ``/api/v1/...`` themselves, so
|
|
normalize all accepted forms back to the bare server root to avoid ``/api/api/v1`` requests.
|
|
"""
|
|
root = (base_url or "").strip().rstrip("/")
|
|
for suffix in ("/api/v1", "/api", "/v1"):
|
|
if root.endswith(suffix):
|
|
root = root[: -len(suffix)].rstrip("/")
|
|
break
|
|
return root or None
|
|
|
|
|
|
def _lmstudio_request_headers(api_key: Optional[str] = None) -> dict:
|
|
"""Build HTTP headers for LM Studio native API requests."""
|
|
headers = {"User-Agent": _HERMES_USER_AGENT}
|
|
token = str(api_key or "").strip()
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
return headers
|
|
|
|
|
|
def _lmstudio_fetch_raw_models(
|
|
api_key: Optional[str] = None,
|
|
base_url: Optional[str] = None,
|
|
timeout: float = 5.0,
|
|
) -> Optional[list[dict]]:
|
|
"""Fetch the raw model list from LM Studio's ``/api/v1/models``."""
|
|
server_root = _lmstudio_server_root(base_url)
|
|
if not server_root:
|
|
return None
|
|
|
|
headers = _lmstudio_request_headers(api_key)
|
|
request = urllib.request.Request(server_root + "/api/v1/models", headers=headers)
|
|
try:
|
|
with _urlopen_model_catalog_request(request, timeout=timeout) as resp:
|
|
payload = json.loads(resp.read().decode())
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code in {401, 403}:
|
|
from hermes_cli.auth import AuthError
|
|
raise AuthError(
|
|
f"LM Studio rejected the request with HTTP {exc.code}.",
|
|
provider="lmstudio",
|
|
code="auth_rejected",
|
|
) from exc
|
|
import logging
|
|
logging.getLogger(__name__).debug(
|
|
"LM Studio probe at %s failed with HTTP %s", server_root, exc.code,
|
|
)
|
|
return None
|
|
except Exception as exc:
|
|
import logging
|
|
logging.getLogger(__name__).debug(
|
|
"LM Studio probe at %s failed: %s", server_root, exc,
|
|
)
|
|
return None
|
|
|
|
raw_models = payload.get("models") if isinstance(payload, dict) else None
|
|
if not isinstance(raw_models, list):
|
|
import logging
|
|
logging.getLogger(__name__).debug(
|
|
"LM Studio probe at %s returned malformed payload (no `models` list)",
|
|
server_root,
|
|
)
|
|
return None
|
|
return raw_models
|
|
|
|
|
|
def probe_lmstudio_models(
|
|
api_key: Optional[str] = None,
|
|
base_url: Optional[str] = None,
|
|
timeout: float = 5.0,
|
|
) -> Optional[list[str]]:
|
|
"""Probe LM Studio's model listing.
|
|
|
|
Returns chat-capable model keys, including a valid empty list when the server is reachable
|
|
but has no non-embedding models; returns ``None`` on network errors, malformed responses, or
|
|
bad base URLs. Raises ``AuthError`` on HTTP 401/403 so token issues surface separately from
|
|
reachability.
|
|
"""
|
|
raw_models = _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
if raw_models is None:
|
|
return None
|
|
|
|
keys: list[str] = []
|
|
for raw in raw_models:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
if str(raw.get("type") or "").strip().lower() == "embedding":
|
|
continue
|
|
key = str(raw.get("key") or raw.get("id") or "").strip()
|
|
if key and key not in keys:
|
|
keys.append(key)
|
|
return keys
|
|
|
|
|
|
def fetch_lmstudio_models(
|
|
api_key: Optional[str] = None,
|
|
base_url: Optional[str] = None,
|
|
timeout: float = 5.0,
|
|
) -> list[str]:
|
|
"""Fetch LM Studio chat-capable model keys from native ``/api/v1/models``.
|
|
|
|
Embedding models are filtered out; network errors, malformed responses, and bad base URLs
|
|
yield an empty list. Raises ``AuthError`` on HTTP 401/403 so callers can distinguish a
|
|
missing or wrong ``LM_API_KEY`` from an unreachable server — the most common LM Studio
|
|
support case.
|
|
"""
|
|
models = probe_lmstudio_models(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
return models or []
|
|
|
|
|
|
class LMStudioLoadResult(NamedTuple):
|
|
"""Verified LM Studio runtime plus load-attempt provenance."""
|
|
|
|
context_length: Optional[int]
|
|
load_attempted: bool = False
|
|
rejected: bool = False
|
|
|
|
|
|
def ensure_lmstudio_model_loaded(
|
|
model: str,
|
|
base_url: Optional[str],
|
|
api_key: Optional[str],
|
|
target_context_length: Optional[int],
|
|
timeout: float = 120.0,
|
|
*,
|
|
return_load_result: bool = False,
|
|
) -> Optional[int] | LMStudioLoadResult:
|
|
"""Ensure ``model`` is loaded and return verified runtime context.
|
|
|
|
Existing loaded-instance context is authoritative. Cold loads omit ``context_length`` unless the
|
|
caller supplied an explicit override; the returned context must come from LM Studio's echoed or
|
|
refreshed state.
|
|
"""
|
|
|
|
def _result(
|
|
context_length: Optional[int],
|
|
*,
|
|
load_attempted: bool = False,
|
|
rejected: bool = False,
|
|
) -> Optional[int] | LMStudioLoadResult:
|
|
value = LMStudioLoadResult(context_length, load_attempted, rejected)
|
|
return value if return_load_result else context_length
|
|
|
|
def _positive_int(value: Any) -> Optional[int]:
|
|
if isinstance(value, int) and not isinstance(value, bool) and value > 0:
|
|
return value
|
|
return None
|
|
|
|
def _loaded_context(entry: dict) -> Optional[int]:
|
|
instances = entry.get("loaded_instances")
|
|
if not isinstance(instances, list):
|
|
return None
|
|
for instance in instances:
|
|
config = instance.get("config") if isinstance(instance, dict) else None
|
|
context = config.get("context_length") if isinstance(config, dict) else None
|
|
parsed = _positive_int(context)
|
|
if parsed is not None:
|
|
return parsed
|
|
return None
|
|
|
|
def _find_entry(raw_models: list[dict]) -> Optional[dict]:
|
|
for raw in raw_models:
|
|
if isinstance(raw, dict) and (raw.get("key") == model or raw.get("id") == model):
|
|
return raw
|
|
return None
|
|
|
|
server_root = _lmstudio_server_root(base_url)
|
|
if not server_root:
|
|
return _result(None)
|
|
|
|
explicit_context = _positive_int(target_context_length)
|
|
if target_context_length is not None and explicit_context is None:
|
|
return _result(None)
|
|
|
|
headers = _lmstudio_request_headers(api_key)
|
|
|
|
try:
|
|
raw_models = _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=10)
|
|
except Exception:
|
|
raw_models = None
|
|
if raw_models is None:
|
|
return _result(None)
|
|
|
|
target_entry = _find_entry(raw_models)
|
|
if target_entry is None:
|
|
return _result(None)
|
|
|
|
max_ctx = _positive_int(target_entry.get("max_context_length"))
|
|
if explicit_context is not None and max_ctx is not None and explicit_context > max_ctx:
|
|
return _result(None, rejected=True)
|
|
|
|
current_context = _loaded_context(target_entry)
|
|
if current_context is not None:
|
|
return _result(current_context)
|
|
|
|
loaded_instances = target_entry.get("loaded_instances")
|
|
if not isinstance(loaded_instances, list) or loaded_instances:
|
|
return _result(None)
|
|
|
|
load_payload: dict[str, Any] = {"model": model, "echo_load_config": True}
|
|
if explicit_context is not None:
|
|
load_payload["context_length"] = explicit_context
|
|
body = json.dumps(load_payload).encode()
|
|
load_headers = dict(headers)
|
|
load_headers["Content-Type"] = "application/json"
|
|
try:
|
|
load_request = urllib.request.Request(
|
|
server_root + "/api/v1/models/load",
|
|
data=body,
|
|
headers=load_headers,
|
|
method="POST",
|
|
)
|
|
with _urlopen_model_catalog_request(load_request, timeout=timeout) as resp:
|
|
response_body = resp.read()
|
|
except Exception:
|
|
return _result(None, load_attempted=True)
|
|
|
|
try:
|
|
response_payload = json.loads(response_body.decode())
|
|
except Exception:
|
|
response_payload = None
|
|
load_config = response_payload.get("load_config") if isinstance(response_payload, dict) else None
|
|
applied_context = (
|
|
_positive_int(load_config.get("context_length"))
|
|
if isinstance(load_config, dict)
|
|
else None
|
|
)
|
|
if applied_context is not None:
|
|
return _result(applied_context, load_attempted=True)
|
|
|
|
try:
|
|
refreshed_models = _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=10)
|
|
except Exception:
|
|
refreshed_models = None
|
|
if refreshed_models is None:
|
|
return _result(None, load_attempted=True)
|
|
refreshed_entry = _find_entry(refreshed_models)
|
|
refreshed_context = _loaded_context(refreshed_entry) if refreshed_entry is not None else None
|
|
return _result(refreshed_context, load_attempted=True)
|
|
|
|
|
|
def lmstudio_model_reasoning_options(
|
|
model: str,
|
|
base_url: Optional[str],
|
|
api_key: Optional[str] = None,
|
|
timeout: float = 5.0,
|
|
) -> list[str]:
|
|
"""Return the reasoning ``allowed_options`` LM Studio publishes for ``model``.
|
|
|
|
Reads ``capabilities.reasoning.allowed_options`` from ``/api/v1/models``; returns ``[]``
|
|
when the model is unknown, the endpoint is unreachable, or no reasoning capability is
|
|
declared.
|
|
"""
|
|
try:
|
|
raw_models = _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
except Exception:
|
|
raw_models = None
|
|
if not raw_models:
|
|
return []
|
|
|
|
for raw in raw_models:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
if raw.get("key") != model and raw.get("id") != model:
|
|
continue
|
|
caps = raw.get("capabilities")
|
|
reasoning = caps.get("reasoning") if isinstance(caps, dict) else None
|
|
opts = reasoning.get("allowed_options") if isinstance(reasoning, dict) else None
|
|
if isinstance(opts, list):
|
|
return [str(o).strip().lower() for o in opts if isinstance(o, str)]
|
|
return []
|
|
return []
|
|
|
|
|
|
def ollama_model_supports_thinking(
|
|
model: str,
|
|
base_url: Optional[str],
|
|
api_key: Optional[str] = None,
|
|
timeout: float = 5.0,
|
|
) -> Optional[bool]:
|
|
"""Return True if an Ollama (Cloud or local) model advertises ``thinking``.
|
|
|
|
Probes native ``/api/show`` and checks ``capabilities`` — the authoritative source, since
|
|
the OpenAI-compat ``/v1/models`` endpoint omits it. Tri-state: True when ``thinking`` is
|
|
declared, False when the probe succeeded without it, None when the probe failed so the
|
|
caller picks the fallback (treated as "don't emit").
|
|
"""
|
|
import httpx
|
|
|
|
server_url = (base_url or "").strip().rstrip("/")
|
|
if server_url.endswith("/v1"):
|
|
server_url = server_url[:-3]
|
|
if not server_url:
|
|
return None
|
|
|
|
bare_model = _strip_ollama_cloud_suffix((model or "").strip())
|
|
if not bare_model:
|
|
return None
|
|
|
|
token = str(api_key or "").strip()
|
|
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
|
|
try:
|
|
with httpx.Client(timeout=timeout, headers=headers) as client:
|
|
resp = client.post(f"{server_url}/api/show", json={"name": bare_model})
|
|
if resp.status_code != 200:
|
|
return None
|
|
caps = resp.json().get("capabilities")
|
|
if isinstance(caps, list):
|
|
return "thinking" in caps
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
def _fetch_github_models(api_key: Optional[str] = None, timeout: float = 5.0) -> Optional[list[str]]:
|
|
catalog = fetch_github_model_catalog(api_key=api_key, timeout=timeout)
|
|
if not catalog:
|
|
return None
|
|
return [item.get("id", "") for item in catalog if item.get("id")]
|
|
|
|
|
|
def _copilot_catalog_ids(
|
|
catalog: Optional[list[dict[str, Any]]] = None,
|
|
api_key: Optional[str] = None,
|
|
) -> set[str]:
|
|
if catalog is None and api_key:
|
|
catalog = fetch_github_model_catalog(api_key=api_key)
|
|
if not catalog:
|
|
return set()
|
|
return {
|
|
str(item.get("id") or "").strip()
|
|
for item in catalog
|
|
if str(item.get("id") or "").strip()
|
|
}
|
|
|
|
|
|
def normalize_copilot_model_id(
|
|
model_id: Optional[str],
|
|
*,
|
|
catalog: Optional[list[dict[str, Any]]] = None,
|
|
api_key: Optional[str] = None,
|
|
) -> str:
|
|
raw = str(model_id or "").strip()
|
|
if not raw:
|
|
return ""
|
|
|
|
catalog_ids = _copilot_catalog_ids(catalog=catalog, api_key=api_key)
|
|
alias = _COPILOT_MODEL_ALIASES.get(raw)
|
|
if alias:
|
|
return alias
|
|
|
|
candidates = [raw]
|
|
if "/" in raw:
|
|
candidates.append(raw.split("/", 1)[1].strip())
|
|
|
|
if raw.endswith("-mini"):
|
|
candidates.append(raw[:-5])
|
|
if raw.endswith("-nano"):
|
|
candidates.append(raw[:-5])
|
|
if raw.endswith("-chat"):
|
|
candidates.append(raw[:-5])
|
|
|
|
seen: set[str] = set()
|
|
for candidate in candidates:
|
|
if not candidate or candidate in seen:
|
|
continue
|
|
seen.add(candidate)
|
|
if candidate in _COPILOT_MODEL_ALIASES:
|
|
return _COPILOT_MODEL_ALIASES[candidate]
|
|
if candidate in catalog_ids:
|
|
return candidate
|
|
|
|
if "/" in raw:
|
|
return raw.split("/", 1)[1].strip()
|
|
return raw
|
|
|
|
|
|
def _github_reasoning_efforts_for_model_id(model_id: str) -> list[str]:
|
|
raw = (model_id or "").strip().lower()
|
|
if raw.startswith(("openai/o1", "openai/o3", "openai/o4", "o1", "o3", "o4")):
|
|
return list(COPILOT_REASONING_EFFORTS_O_SERIES)
|
|
normalized = normalize_copilot_model_id(model_id).lower()
|
|
if normalized.startswith("gpt-5"):
|
|
return list(COPILOT_REASONING_EFFORTS_GPT5)
|
|
return []
|
|
|
|
|
|
def _should_use_copilot_responses_api(model_id: str) -> bool:
|
|
"""Decide whether a Copilot model should use the Responses API.
|
|
|
|
Replicates opencode's ``shouldUseCopilotResponsesApi``: GPT-5+ models use the Responses API
|
|
except ``gpt-5-mini``; all non-GPT models (Claude, Gemini, ...) use Chat Completions.
|
|
"""
|
|
import re
|
|
|
|
match = re.match(r"^gpt-(\d+)", model_id)
|
|
if not match:
|
|
return False
|
|
major = int(match.group(1))
|
|
return major >= 5 and not model_id.startswith("gpt-5-mini")
|
|
|
|
|
|
def copilot_model_api_mode(
|
|
model_id: Optional[str],
|
|
*,
|
|
catalog: Optional[list[dict[str, Any]]] = None,
|
|
api_key: Optional[str] = None,
|
|
) -> str:
|
|
"""Determine the API mode for a Copilot model.
|
|
|
|
Uses the model ID pattern (matching opencode's approach) as the primary signal. Falls back to
|
|
the catalog's ``supported_endpoints`` only for models not covered by the pattern check.
|
|
"""
|
|
# Fetch the catalog once so normalize + endpoint check share it
|
|
# (avoids two redundant network calls for non-GPT-5 models).
|
|
if catalog is None and api_key:
|
|
catalog = fetch_github_model_catalog(api_key=api_key)
|
|
|
|
normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key)
|
|
if not normalized:
|
|
return "chat_completions"
|
|
|
|
# Primary: model ID pattern (matches opencode's shouldUseCopilotResponsesApi)
|
|
if _should_use_copilot_responses_api(normalized):
|
|
return "codex_responses"
|
|
|
|
# Copilot's Claude models are exposed through its OpenAI-compatible chat
|
|
# endpoint, not through Hermes' native Anthropic adapter. The live catalog may
|
|
# advertise /v1/messages, but the Copilot token/header scheme is handled by
|
|
# the OpenAI client path; selecting anthropic_messages would send the wrong
|
|
# auth/wire shape. Keep non-GPT Copilot slots on chat_completions.
|
|
return "chat_completions"
|
|
|
|
|
|
def azure_foundry_model_api_mode(model_name: Optional[str]) -> Optional[str]:
|
|
"""Infer Azure Foundry api_mode from a deployment/model name.
|
|
|
|
Returns ``"codex_responses"`` when the model name matches a family that only accepts the
|
|
Responses API on Azure Foundry (GPT-5.x, codex, o1/o3/o4 reasoning models).
|
|
"""
|
|
raw = str(model_name or "").strip().lower()
|
|
if not raw:
|
|
return None
|
|
# Strip any vendor/ prefix a user may have copied from OpenRouter / Copilot.
|
|
if "/" in raw:
|
|
raw = raw.rsplit("/", 1)[-1]
|
|
# gpt-5-mini speaks chat completions on Copilot but Azure Foundry deploys
|
|
# the full gpt-5 family uniformly on Responses API — don't carve an
|
|
# exception here.
|
|
for prefix in _AZURE_FOUNDRY_RESPONSES_PREFIXES:
|
|
if raw.startswith(prefix):
|
|
return "codex_responses"
|
|
return None
|
|
|
|
|
|
def opencode_provider_family(provider_id: Optional[str]) -> Optional[str]:
|
|
"""Resolve a provider id to its OpenCode family, or None.
|
|
|
|
``opencode-go`` is checked before ``opencode-zen`` but the two slugs are not prefixes of each
|
|
other, so order is cosmetic.
|
|
"""
|
|
raw = str(provider_id or "").strip().lower()
|
|
if not raw:
|
|
return None
|
|
canonical = normalize_provider(provider_id)
|
|
if canonical in {"opencode-zen", "opencode-go", "opencode-free"}:
|
|
return canonical
|
|
if raw.startswith("opencode-free"):
|
|
return "opencode-free"
|
|
if raw.startswith("opencode-go"):
|
|
return "opencode-go"
|
|
if raw.startswith("opencode-zen"):
|
|
return "opencode-zen"
|
|
return None
|
|
|
|
|
|
def normalize_opencode_model_id(provider_id: Optional[str], model_id: Optional[str]) -> str:
|
|
"""Normalize OpenCode config IDs to the bare model slug used in API requests."""
|
|
family = opencode_provider_family(provider_id)
|
|
current = str(model_id or "").strip()
|
|
if not current or family is None:
|
|
return current
|
|
|
|
prefix = f"{provider_id}/" if provider_id else f"{family}/"
|
|
if current.lower().startswith(prefix.lower()):
|
|
return current[len(prefix):]
|
|
fallback_prefix = f"{family}/"
|
|
if current.lower().startswith(fallback_prefix.lower()):
|
|
return current[len(fallback_prefix):]
|
|
return current
|
|
|
|
|
|
# OpenCode Zen free-tier models (``*-free`` slugs, e.g. x-preview-f-free /
|
|
# "Ox Alpha", plus unsuffixed free models like big-pickle) are served
|
|
# ANONYMOUSLY on the Zen relay: a request with no Authorization header
|
|
# succeeds, while ANY non-empty bearer the relay doesn't recognize is
|
|
# rejected with 401 "Invalid API key" — including our "no-key-required"
|
|
# placeholder and OpenCode GO subscription keys (the Go relay doesn't serve
|
|
# the free tier at all: "Model x is not supported").
|
|
# Verified live 2026-08-21 against POST /zen/v1/chat/completions.
|
|
OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER = "opencode-zen-free-keyless"
|
|
_OPENCODE_ZEN_FREE_BASE_URL = "https://opencode.ai/zen/v1"
|
|
|
|
# Models whose slug carries ``-free`` but are NOT anonymous-servable: they are
|
|
# KEYED (Go-subscription) models and must be excluded from the keyless free
|
|
# catalog even though the suffix looks free. ox-alpha-free is the Go relay's
|
|
# subscription twin of the Zen keyless Ox Alpha (verified 2026-08-21).
|
|
_OPENCODE_FREE_KEYED_SUFFIX_MODELS = frozenset({"ox-alpha-free"})
|
|
|
|
# In-process memo for _fetch_opencode_free_models(): (fetched_at, ids-or-None).
|
|
# Direct provider_model_ids("opencode-free") callers (model validation, healing)
|
|
# can run several times per resolution — without this each would block on a
|
|
# network round-trip. Failures are memoized too (negative caching) so an
|
|
# unreachable relay doesn't stall every validation for `timeout` seconds.
|
|
_opencode_free_live_memo: Optional[tuple[float, Optional[list[str]]]] = None
|
|
_OPENCODE_FREE_LIVE_MEMO_TTL = 300.0 # 5 min; SWR disk cache handles the rest
|
|
|
|
|
|
def opencode_zen_free_headers() -> dict:
|
|
"""Client default_headers for anonymous OpenCode Zen free-tier requests.
|
|
|
|
``Authorization: ""`` overrides the OpenAI SDK's ``Bearer <api_key>`` header so the placeholder
|
|
key never reaches the wire — the Zen relay accepts anonymous requests for free models but 401s
|
|
any unknown bearer. Attribution headers mirror the opencode provider profile.
|
|
"""
|
|
try:
|
|
from hermes_cli import __version__ as _v
|
|
except Exception:
|
|
_v = "0"
|
|
return {
|
|
"Authorization": "",
|
|
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
|
"X-Title": "Hermes Agent",
|
|
"User-Agent": f"HermesAgent/{_v}",
|
|
}
|
|
|
|
|
|
def _fetch_opencode_free_models(
|
|
timeout: float = 8.0, *, force_refresh: bool = False
|
|
) -> Optional[list[str]]:
|
|
"""Fetch the live keyless OpenCode Free catalog from the Zen relay.
|
|
|
|
The Zen ``/models`` dump also lists paid/subscription IDs (e.g. Go ``ox-alpha-free`` is KEYED
|
|
despite the suffix), so a bare ``*-free`` suffix filter is not safe on its own — this mirrors
|
|
the existing ``opencode_zen_free_runtime`` contract, which uses membership in the verified
|
|
keyless catalog as the routing criterion.
|
|
"""
|
|
import urllib.request
|
|
|
|
from hermes_cli.urllib_security import open_credentialed_url
|
|
|
|
now = time.time()
|
|
if not force_refresh:
|
|
memo = _opencode_free_live_memo
|
|
if memo is not None and now - memo[0] < _OPENCODE_FREE_LIVE_MEMO_TTL:
|
|
return list(memo[1]) if memo[1] else None
|
|
|
|
url = f"{_OPENCODE_ZEN_FREE_BASE_URL.rstrip('/')}/models"
|
|
req = urllib.request.Request(url)
|
|
req.add_header("Accept", "application/json")
|
|
for k, v in opencode_zen_free_headers().items():
|
|
if k.lower() != "authorization": # never send a bearer keylessly
|
|
req.add_header(k, v)
|
|
try:
|
|
with open_credentialed_url(req, timeout=timeout) as resp:
|
|
data = json.loads(resp.read().decode())
|
|
items = data if isinstance(data, list) else data.get("data", [])
|
|
except Exception:
|
|
_set_opencode_free_live_memo(None)
|
|
return None
|
|
ids = [m["id"] for m in items if isinstance(m, dict) and isinstance(m.get("id"), str)]
|
|
# Filter to the anonymous-servable free tier. The Zen dump can contain
|
|
# keyed/Go IDs; only the verified free set belongs in the keyless picker.
|
|
live_free = [
|
|
mid
|
|
for mid in ids
|
|
if mid.lower().endswith("-free")
|
|
and mid.lower() not in _OPENCODE_FREE_KEYED_SUFFIX_MODELS
|
|
]
|
|
result = live_free if live_free else None
|
|
_set_opencode_free_live_memo(result)
|
|
return result
|
|
|
|
|
|
def _set_opencode_free_live_memo(ids: Optional[list[str]]) -> None:
|
|
global _opencode_free_live_memo
|
|
_opencode_free_live_memo = (time.time(), list(ids) if ids else None)
|
|
|
|
|
|
def _opencode_free_known_model_slugs() -> set[str]:
|
|
"""Lowercased keyless free-tier slugs known right now — WITHOUT network I/O.
|
|
|
|
Union of the static ``_PROVIDER_MODELS["opencode-free"]`` floor, the in-process live memo, and
|
|
the SWR disk-cache entry. Used by the ``opencode_zen_free_runtime`` healing path, which runs
|
|
during model resolution and must never block on a live fetch.
|
|
"""
|
|
known = {m.lower() for m in _PROVIDER_MODELS.get("opencode-free", [])}
|
|
memo = _opencode_free_live_memo
|
|
if memo is not None and memo[1]:
|
|
known.update(m.lower() for m in memo[1])
|
|
try:
|
|
entry = _load_provider_models_cache().get("opencode-free") or {}
|
|
known.update(str(m).lower() for m in entry.get("models", []) or [])
|
|
except Exception:
|
|
pass
|
|
return known
|
|
|
|
|
|
def opencode_zen_free_runtime(provider_id: Optional[str], model_id: Optional[str]) -> Optional[dict]:
|
|
"""Keyless runtime entry for an OpenCode Zen free-tier model, or None.
|
|
|
|
- ``provider_id`` is ``opencode-free`` (the dedicated keyless provider — EVERY model on it
|
|
routes anonymously; that is the provider's contract), or - ``provider_id`` is any other
|
|
OpenCode-family provider and ``model_id`` is in the VERIFIED keyless catalog
|
|
(``_PROVIDER_MODELS["opencode-free"]``) — heals a free-model selection made under opencode-
|
|
zen/opencode-go, whose keys the free tier rejects.
|
|
|
|
Membership means the union of the cached LIVE keyless catalog (in-process memo / SWR disk cache
|
|
— never a blocking fetch on this hot path) and the static floor, so a newly-live free model
|
|
heals without a release.
|
|
"""
|
|
family = opencode_provider_family(provider_id)
|
|
if family is None:
|
|
return None
|
|
if family != "opencode-free":
|
|
bare = normalize_opencode_model_id(provider_id, model_id).strip().lower()
|
|
if bare not in _opencode_free_known_model_slugs():
|
|
return None
|
|
normalized = normalize_opencode_model_id(provider_id, model_id)
|
|
api_mode = opencode_model_api_mode("opencode-zen", normalized)
|
|
base_url = normalize_opencode_base_url(
|
|
"opencode-zen", api_mode, _OPENCODE_ZEN_FREE_BASE_URL
|
|
)
|
|
return {
|
|
"provider": family,
|
|
"api_mode": api_mode,
|
|
"base_url": base_url,
|
|
"api_key": OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER,
|
|
"default_headers": opencode_zen_free_headers(),
|
|
"source": "opencode-zen-free-keyless",
|
|
}
|
|
|
|
|
|
def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) -> str:
|
|
"""Determine the API mode for an OpenCode Zen / Go model.
|
|
|
|
OpenCode routes models behind different surfaces per its Zen/Go docs: GPT/Codex/Grok and
|
|
Muse Spark use ``/v1/responses`` (Muse Spark 503s on chat/completions); Claude and Qwen on
|
|
Zen and MiniMax/Qwen on Go use ``/v1/messages``; everything else uses
|
|
``/v1/chat/completions``.
|
|
"""
|
|
family = opencode_provider_family(provider_id)
|
|
# opencode-free is Zen-hosted (the free tier lives on the Zen relay),
|
|
# so it shares Zen's per-model endpoint routing.
|
|
if family == "opencode-free":
|
|
family = "opencode-zen"
|
|
normalized = normalize_opencode_model_id(provider_id, model_id).lower()
|
|
if not normalized:
|
|
return "chat_completions"
|
|
|
|
if family == "opencode-go":
|
|
if normalized.startswith("gpt-") or normalized.startswith("grok-"):
|
|
# GPT and Grok models on Go (gpt-5.6-luna, grok-4.5) are served
|
|
# via /v1/responses per the published Go endpoint table, same as
|
|
# GPT/Grok on Zen: https://opencode.ai/docs/go/#endpoints
|
|
return "codex_responses"
|
|
if normalized.startswith("muse-spark"):
|
|
# Muse Spark (standard + contributor) is Responses-only on Go.
|
|
# /v1/chat/completions returns HTTP 503 with an empty assistant
|
|
# message; /v1/responses completes. See opencode.ai/docs/go.
|
|
return "codex_responses"
|
|
if normalized.startswith("minimax-"):
|
|
return "anthropic_messages"
|
|
if normalized.startswith("qwen"):
|
|
# All Qwen models on Go (qwen3.7-max, qwen3.7-plus, qwen3.6-plus)
|
|
# are served via /v1/messages per the published Go endpoint table.
|
|
return "anthropic_messages"
|
|
return "chat_completions"
|
|
|
|
if family == "opencode-zen":
|
|
if normalized.startswith("claude-"):
|
|
return "anthropic_messages"
|
|
if normalized.startswith("gpt-") or normalized.startswith("grok-"):
|
|
# GPT-5/Codex and all Grok models on Zen (grok-4.6, grok-4.5,
|
|
# grok-build-0.1) are served via /v1/responses per the Zen
|
|
# endpoint table.
|
|
return "codex_responses"
|
|
if normalized.startswith("muse-spark"):
|
|
# Standard Muse Spark on Zen is served via /v1/responses:
|
|
# https://opencode.ai/docs/zen/#endpoints
|
|
return "codex_responses"
|
|
if normalized.startswith("qwen"):
|
|
# Qwen models on Zen moved to /v1/messages per the published
|
|
# Zen endpoint table.
|
|
return "anthropic_messages"
|
|
return "chat_completions"
|
|
|
|
return "chat_completions"
|
|
|
|
|
|
def normalize_opencode_base_url(
|
|
provider_id: Optional[str], api_mode: Optional[str], base_url: Optional[str]
|
|
) -> str:
|
|
"""Normalize an OpenCode Zen / Go base URL for the target API mode.
|
|
|
|
Crucially this must be SYMMETRIC. The stripped URL gets persisted to config (``model.base_url``)
|
|
by the TUI/desktop and gateway after switching into an anthropic-routed model (e.g. minimax-m2.7
|
|
on Go).
|
|
|
|
Only opencode.ai-hosted URLs are re-suffixed; custom proxy overrides via ``OPENCODE_*_BASE_URL``
|
|
are left alone unless they already carry ``/v1``.
|
|
"""
|
|
url = str(base_url or "").strip().rstrip("/")
|
|
if not url:
|
|
return url
|
|
if opencode_provider_family(provider_id) is None:
|
|
return url
|
|
|
|
import re as _re
|
|
|
|
if api_mode == "anthropic_messages":
|
|
return _re.sub(r"/v1$", "", url)
|
|
|
|
# chat_completions / codex_responses: ensure the /v1 suffix is present on
|
|
# official opencode.ai hosts (heals a persisted anthropic-stripped URL).
|
|
if url.endswith("/v1"):
|
|
return url
|
|
try:
|
|
host = urllib.parse.urlparse(url).netloc.lower()
|
|
except Exception:
|
|
host = ""
|
|
if host == "opencode.ai" or host.endswith(".opencode.ai"):
|
|
return url + "/v1"
|
|
return url
|
|
|
|
|
|
def github_model_reasoning_efforts(
|
|
model_id: Optional[str],
|
|
*,
|
|
catalog: Optional[list[dict[str, Any]]] = None,
|
|
api_key: Optional[str] = None,
|
|
) -> list[str]:
|
|
"""Return supported reasoning-effort levels for a Copilot-visible model."""
|
|
normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key)
|
|
if not normalized:
|
|
return []
|
|
|
|
catalog_entry = None
|
|
if catalog is not None:
|
|
catalog_entry = next((item for item in catalog if item.get("id") == normalized), None)
|
|
elif api_key:
|
|
fetched_catalog = fetch_github_model_catalog(api_key=api_key)
|
|
if fetched_catalog:
|
|
catalog_entry = next((item for item in fetched_catalog if item.get("id") == normalized), None)
|
|
|
|
if catalog_entry is not None:
|
|
capabilities = catalog_entry.get("capabilities")
|
|
if isinstance(capabilities, dict):
|
|
supports = capabilities.get("supports")
|
|
if isinstance(supports, dict):
|
|
efforts = supports.get("reasoning_effort")
|
|
if isinstance(efforts, list):
|
|
normalized_efforts = [
|
|
str(effort).strip().lower()
|
|
for effort in efforts
|
|
if str(effort).strip()
|
|
]
|
|
return list(dict.fromkeys(normalized_efforts))
|
|
return []
|
|
legacy_capabilities = {
|
|
str(capability).strip().lower()
|
|
for capability in catalog_entry.get("capabilities", [])
|
|
if str(capability).strip()
|
|
}
|
|
if "reasoning" not in legacy_capabilities:
|
|
return []
|
|
|
|
return _github_reasoning_efforts_for_model_id(str(model_id or normalized))
|
|
|
|
|
|
def probe_api_models(
|
|
api_key: Optional[str],
|
|
base_url: Optional[str],
|
|
timeout: float = 5.0,
|
|
api_mode: Optional[str] = None,
|
|
request_headers: Optional[dict[str, str]] = None,
|
|
) -> dict[str, Any]:
|
|
"""Probe a ``/models`` endpoint with light URL heuristics.
|
|
|
|
For ``anthropic_messages`` mode, sends ``x-api-key`` and ``anthropic-version`` headers
|
|
instead of ``Authorization: Bearer``; the response shape (``data[].id``) is identical so one
|
|
parser serves both.
|
|
"""
|
|
normalized = (base_url or "").strip().rstrip("/")
|
|
if not normalized:
|
|
return {
|
|
"models": None,
|
|
"probed_url": None,
|
|
"resolved_base_url": "",
|
|
"suggested_base_url": None,
|
|
"used_fallback": False,
|
|
}
|
|
|
|
if _is_github_models_base_url(normalized):
|
|
models = _fetch_github_models(api_key=api_key, timeout=timeout)
|
|
return {
|
|
"models": models,
|
|
"probed_url": COPILOT_MODELS_URL,
|
|
"resolved_base_url": COPILOT_BASE_URL,
|
|
"suggested_base_url": None,
|
|
"used_fallback": False,
|
|
}
|
|
|
|
if normalized.endswith("/v1"):
|
|
alternate_base = normalized[:-3].rstrip("/")
|
|
else:
|
|
alternate_base = normalized + "/v1"
|
|
|
|
candidates: list[tuple[str, bool]] = [(normalized, False)]
|
|
if alternate_base and alternate_base != normalized:
|
|
candidates.append((alternate_base, True))
|
|
|
|
tried: list[str] = []
|
|
headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT}
|
|
if urllib.parse.urlparse(normalized).hostname == "generativelanguage.googleapis.com":
|
|
headers["X-Goog-Api-Client"] = f"hermes-agent/{_HERMES_VERSION}"
|
|
if api_key and api_mode == "anthropic_messages":
|
|
headers["x-api-key"] = api_key
|
|
headers["anthropic-version"] = "2023-06-01"
|
|
elif api_key:
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
if normalized.startswith(COPILOT_BASE_URL):
|
|
headers.update(copilot_default_headers())
|
|
if isinstance(request_headers, dict):
|
|
# Per-provider custom headers can contain auth/proxy secrets. Merge
|
|
# last so endpoint-specific config wins, and never log the values.
|
|
from hermes_cli.config import normalize_extra_headers
|
|
|
|
headers.update(normalize_extra_headers(request_headers))
|
|
|
|
_ssl_context = _custom_provider_ssl_context(normalized)
|
|
for candidate_base, is_fallback in candidates:
|
|
url = candidate_base.rstrip("/") + "/models"
|
|
tried.append(url)
|
|
req = urllib.request.Request(url, headers=headers)
|
|
# Only thread ssl_context when a per-provider TLS override actually
|
|
# applies. Public/unconfigured endpoints keep the original 2-arg call,
|
|
# so nothing changes for them (and existing call-seam mocks stay valid).
|
|
_open_kwargs: dict[str, Any] = {"timeout": timeout}
|
|
if _ssl_context is not None:
|
|
_open_kwargs["ssl_context"] = _ssl_context
|
|
try:
|
|
with _urlopen_model_catalog_request(req, **_open_kwargs) as resp:
|
|
data = json.loads(resp.read().decode())
|
|
return {
|
|
"models": [m.get("id", "") for m in data.get("data", [])],
|
|
"probed_url": url,
|
|
"resolved_base_url": candidate_base.rstrip("/"),
|
|
"suggested_base_url": alternate_base if alternate_base != candidate_base else normalized,
|
|
"used_fallback": is_fallback,
|
|
}
|
|
except Exception:
|
|
continue
|
|
|
|
return {
|
|
"models": None,
|
|
"probed_url": tried[0] if tried else normalized.rstrip("/") + "/models",
|
|
"resolved_base_url": normalized,
|
|
"suggested_base_url": alternate_base if alternate_base != normalized else None,
|
|
"used_fallback": False,
|
|
}
|
|
|
|
|
|
# Legacy filter — used when an item has no surface tag (rolling out
|
|
# 2026-05). Once every model returned by the catalog endpoint carries an
|
|
# explicit surface tag (``chat``/``embed``/``image-gen``/``tts``/``stt``)
|
|
# the regex path becomes unreachable and can be removed.
|
|
_DEEPINFRA_EXCLUDE_RE = re.compile(
|
|
r"(?i)(embed|rerank|whisper|stable-diffusion|flux|sdxl|"
|
|
r"tts|bark|speech|image-gen|clip|vit-|dpt-)",
|
|
)
|
|
|
|
# Surface tags announce *what kind of model* this is. When none of these
|
|
# are present on a catalog entry, the tags array only carries capability
|
|
# tags (``reasoning``, ``vision``, ``prompt_cache``, …) and we have to
|
|
# fall back to id-regex inference for the chat surface.
|
|
_DEEPINFRA_SURFACE_TAGS: frozenset[str] = frozenset({
|
|
"chat", "embed", "image-gen", "tts", "stt", "video-gen",
|
|
})
|
|
|
|
_DEEPINFRA_DEFAULT_BASE_URL = "https://api.deepinfra.com/v1/openai"
|
|
_DEEPINFRA_MODELS_QUERY = "filter=true&sort_by=hermes"
|
|
|
|
# Module-level cache for the full tagged catalog response, keyed by base URL.
|
|
# Each value is the parsed ``data`` list. Surface-specific filters read from
|
|
# this cache so a single network round-trip serves chat / image-gen / tts /
|
|
# stt callers across the whole process lifetime.
|
|
_deepinfra_catalog_cache: dict[str, list[dict]] = {}
|
|
|
|
# Negative cache: monotonic timestamp of the last failed fetch, keyed by base
|
|
# URL. Without this, an unreachable catalog (offline / DNS / firewall) makes
|
|
# every surface helper (chat picker, pricing, image/video/tts/stt defaults,
|
|
# vision) re-attempt a fresh blocking fetch that eats the full timeout each
|
|
# time — several sequential stalls in one user-visible operation. A short TTL
|
|
# lets connectivity recover without a process restart.
|
|
_deepinfra_catalog_neg_cache: dict[str, float] = {}
|
|
_DEEPINFRA_CATALOG_NEG_TTL = 60.0 # seconds
|
|
|
|
|
|
def _deepinfra_catalog_url() -> tuple[str, str]:
|
|
"""Return ``(cache_key, full_url)`` for the DeepInfra catalog endpoint."""
|
|
base = os.getenv("DEEPINFRA_BASE_URL", "").strip() or _DEEPINFRA_DEFAULT_BASE_URL
|
|
cache_key = base.rstrip("/")
|
|
return cache_key, f"{cache_key}/models?{_DEEPINFRA_MODELS_QUERY}"
|
|
|
|
|
|
def _fetch_deepinfra_catalog(
|
|
*,
|
|
timeout: float = 5.0,
|
|
force_refresh: bool = False,
|
|
) -> Optional[list[dict]]:
|
|
"""Fetch the raw DeepInfra catalog list with module-level caching.
|
|
|
|
The endpoint serves chat, embed, image-gen, TTS and STT models in one response. Auth is
|
|
optional but a Bearer token is attached when available so user-scoped catalogs (private
|
|
fine-tunes) show.
|
|
"""
|
|
cache_key, url = _deepinfra_catalog_url()
|
|
if not force_refresh:
|
|
if cache_key in _deepinfra_catalog_cache:
|
|
return _deepinfra_catalog_cache[cache_key]
|
|
last_fail = _deepinfra_catalog_neg_cache.get(cache_key)
|
|
if last_fail is not None and (time.monotonic() - last_fail) < _DEEPINFRA_CATALOG_NEG_TTL:
|
|
return None
|
|
|
|
headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT}
|
|
api_key = os.getenv("DEEPINFRA_API_KEY", "").strip()
|
|
if api_key:
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
|
|
req = urllib.request.Request(url, headers=headers)
|
|
try:
|
|
with _urlopen_model_catalog_request(req, timeout=timeout) as resp:
|
|
payload = json.loads(resp.read().decode())
|
|
except Exception:
|
|
_deepinfra_catalog_neg_cache[cache_key] = time.monotonic()
|
|
return None
|
|
|
|
data = payload.get("data")
|
|
if not isinstance(data, list):
|
|
_deepinfra_catalog_neg_cache[cache_key] = time.monotonic()
|
|
return None
|
|
|
|
_deepinfra_catalog_cache[cache_key] = data
|
|
_deepinfra_catalog_neg_cache.pop(cache_key, None)
|
|
return data
|
|
|
|
|
|
def _fetch_deepinfra_models_by_tag(
|
|
tag: str,
|
|
*,
|
|
timeout: float = 5.0,
|
|
force_refresh: bool = False,
|
|
) -> Optional[list[dict]]:
|
|
"""Return DeepInfra models whose ``metadata.tags`` includes *tag*.
|
|
|
|
Each item is ``{"id", "metadata"}`` so callers can inspect context length, pricing and
|
|
units. For the chat surface, items with no ``tags`` field fall through to the legacy name-
|
|
regex exclusion so this keeps working while the tag rollout is in flight. Returns ``None``
|
|
on network failure.
|
|
"""
|
|
data = _fetch_deepinfra_catalog(timeout=timeout, force_refresh=force_refresh)
|
|
if data is None:
|
|
return None
|
|
|
|
matched: list[dict] = []
|
|
for item in data:
|
|
mid = item.get("id")
|
|
if not mid:
|
|
continue
|
|
# ``metadata is None`` means DeepInfra returns a stub without
|
|
# pricing/context — typically a model that's listed but not
|
|
# served. Skip those for every surface.
|
|
raw_metadata = item.get("metadata")
|
|
if raw_metadata is None:
|
|
continue
|
|
metadata = raw_metadata if isinstance(raw_metadata, dict) else {}
|
|
raw_tags = metadata.get("tags")
|
|
tags = raw_tags if isinstance(raw_tags, list) else []
|
|
has_surface_tag = any(t in _DEEPINFRA_SURFACE_TAGS for t in tags)
|
|
|
|
if has_surface_tag:
|
|
if tag in tags:
|
|
matched.append({"id": mid, "metadata": metadata})
|
|
continue
|
|
# Surface-tag rollout incomplete — fall back to id-regex inference.
|
|
# Only meaningful for the chat surface; embed/image-gen/tts/stt
|
|
# cannot be safely inferred from an id alone.
|
|
if tag == "chat" and not _DEEPINFRA_EXCLUDE_RE.search(mid):
|
|
matched.append({"id": mid, "metadata": metadata})
|
|
|
|
return matched
|
|
|
|
|
|
def _fetch_deepinfra_models(
|
|
timeout: float = 5.0,
|
|
*,
|
|
force_refresh: bool = False,
|
|
) -> Optional[list[str]]:
|
|
"""Return DeepInfra chat-model ids (tag-aware, regex fallback).
|
|
|
|
Thin wrapper over :func:`_fetch_deepinfra_models_by_tag` so historical callers in
|
|
:func:`provider_model_ids` keep their string-list contract. Returns ``None`` on network failure,
|
|
an empty list if the catalog contains no chat-tagged ids (which would itself be surprising).
|
|
"""
|
|
items = _fetch_deepinfra_models_by_tag(
|
|
"chat", timeout=timeout, force_refresh=force_refresh
|
|
)
|
|
if items is None:
|
|
return None
|
|
return [item["id"] for item in items] or None
|
|
|
|
|
|
def deepinfra_model_ids(tag: str, *, force_refresh: bool = False) -> list[str]:
|
|
"""Return DeepInfra model ids carrying surface *tag* (``[]`` on failure)."""
|
|
items = _fetch_deepinfra_models_by_tag(tag, force_refresh=force_refresh)
|
|
return [item["id"] for item in items] if items else []
|
|
|
|
|
|
def deepinfra_base_url(section: Optional[dict] = None) -> str:
|
|
"""Resolve the DeepInfra OpenAI-compatible base URL, normalized.
|
|
|
|
Precedence: config-section ``base_url`` → ``DEEPINFRA_BASE_URL`` env → default. Always stripped
|
|
with any trailing slash removed.
|
|
"""
|
|
candidate = section.get("base_url") if isinstance(section, dict) else None
|
|
value = candidate or os.getenv("DEEPINFRA_BASE_URL") or _DEEPINFRA_DEFAULT_BASE_URL
|
|
return str(value).strip().rstrip("/")
|
|
|
|
|
|
def _fetch_deepinfra_pricing(
|
|
timeout: float = 5.0,
|
|
*,
|
|
force_refresh: bool = False,
|
|
) -> dict[str, dict[str, str]]:
|
|
"""Return picker-shape pricing for DeepInfra chat models.
|
|
|
|
DeepInfra publishes ``input_tokens``/``output_tokens``/``cache_read_tokens`` in $/MTok; the
|
|
picker expects per-token strings under ``prompt``/``completion``/``input_cache_read``
|
|
(OpenRouter shape). Cached via the catalog helper so repeated picker renders are free.
|
|
"""
|
|
items = _fetch_deepinfra_models_by_tag(
|
|
"chat", timeout=timeout, force_refresh=force_refresh
|
|
)
|
|
if not items:
|
|
return {}
|
|
|
|
result: dict[str, dict[str, str]] = {}
|
|
for item in items:
|
|
metadata = item.get("metadata") or {}
|
|
pricing = metadata.get("pricing") if isinstance(metadata, dict) else None
|
|
if not isinstance(pricing, dict):
|
|
continue
|
|
entry: dict[str, str] = {}
|
|
inp = pricing.get("input_tokens")
|
|
out = pricing.get("output_tokens")
|
|
cache_read = pricing.get("cache_read_tokens")
|
|
if inp is not None:
|
|
entry["prompt"] = str(float(inp) / 1_000_000)
|
|
if out is not None:
|
|
entry["completion"] = str(float(out) / 1_000_000)
|
|
if cache_read is not None:
|
|
entry["input_cache_read"] = str(float(cache_read) / 1_000_000)
|
|
if entry:
|
|
result[item["id"]] = entry
|
|
return result
|
|
|
|
|
|
def _fetch_ai_gateway_models(timeout: float = 5.0) -> Optional[list[str]]:
|
|
"""Fetch available language models with tool-use from AI Gateway."""
|
|
api_key = os.getenv("AI_GATEWAY_API_KEY", "").strip()
|
|
if not api_key:
|
|
return None
|
|
base_url = os.getenv("AI_GATEWAY_BASE_URL", "").strip()
|
|
if not base_url:
|
|
from hermes_constants import AI_GATEWAY_BASE_URL
|
|
base_url = AI_GATEWAY_BASE_URL
|
|
|
|
url = base_url.rstrip("/") + "/models"
|
|
headers: dict[str, str] = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"User-Agent": _HERMES_USER_AGENT,
|
|
}
|
|
req = urllib.request.Request(url, headers=headers)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
data = json.loads(resp.read().decode())
|
|
return [
|
|
m["id"]
|
|
for m in data.get("data", [])
|
|
if m.get("id")
|
|
and m.get("type") == "language"
|
|
and "tool-use" in (m.get("tags") or [])
|
|
]
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def fetch_api_models(
|
|
api_key: Optional[str],
|
|
base_url: Optional[str],
|
|
timeout: float = 5.0,
|
|
api_mode: Optional[str] = None,
|
|
headers: Optional[dict[str, str]] = None,
|
|
) -> Optional[list[str]]:
|
|
"""Fetch the list of available model IDs from the provider's ``/models`` endpoint."""
|
|
return probe_api_models(
|
|
api_key,
|
|
base_url,
|
|
timeout=timeout,
|
|
api_mode=api_mode,
|
|
request_headers=headers,
|
|
).get("models")
|
|
|
|
|
|
def _custom_endpoint_fingerprint(
|
|
api_key: Optional[str],
|
|
api_mode: Optional[str],
|
|
headers: Optional[dict[str, str]],
|
|
) -> str:
|
|
"""Fingerprint the credentials/wire-shape used to probe a custom endpoint.
|
|
|
|
Custom OpenAI-compatible endpoints have no ``PROVIDER_REGISTRY`` slug to key off (unlike
|
|
``_credential_fingerprint``), so this hashes exactly the values callers pass to
|
|
:func:`fetch_api_models`: a rotated ``api_key``, a changed ``api_mode``, or an edited
|
|
``extra_headers`` block each bust the cache entry on their own.
|
|
"""
|
|
import hashlib
|
|
|
|
blob = "|".join((
|
|
api_key or "",
|
|
api_mode or "",
|
|
json.dumps(headers or {}, sort_keys=True),
|
|
)).encode("utf-8", errors="replace")
|
|
# blake2b for cache-key fingerprinting only, same rationale as
|
|
# _credential_fingerprint (avoids CodeQL's sha256-over-secrets rule).
|
|
return hashlib.blake2b(blob, digest_size=8).hexdigest()
|
|
|
|
|
|
def _cache_entry_valid(
|
|
entry: Any,
|
|
fp: str,
|
|
*,
|
|
allow_empty: bool = False,
|
|
) -> "TypeGuard[dict[str, Any]]":
|
|
"""True when *entry* is a well-formed cache row for fingerprint *fp*.
|
|
|
|
Requires a numeric ``at`` so corrupt disk state (hand-edited JSON with ``"at": "yesterday"`` or
|
|
``null``) degrades to a cache miss / live fetch instead of raising out of the wrapper. Empty
|
|
model lists are valid only for callers that explicitly opt into an authoritative empty catalog.
|
|
"""
|
|
return (
|
|
isinstance(entry, dict)
|
|
and entry.get("fp") == fp
|
|
and isinstance(entry.get("models"), list)
|
|
and (allow_empty or bool(entry["models"]))
|
|
and isinstance(entry.get("at"), (int, float))
|
|
and not isinstance(entry.get("at"), bool)
|
|
)
|
|
|
|
|
|
def cached_fetch_api_models(
|
|
api_key: Optional[str],
|
|
base_url: Optional[str],
|
|
*,
|
|
timeout: float = 5.0,
|
|
api_mode: Optional[str] = None,
|
|
headers: Optional[dict[str, str]] = None,
|
|
force_refresh: bool = False,
|
|
cache_only: bool = False,
|
|
ttl_seconds: int = _PROVIDER_MODELS_CACHE_TTL,
|
|
) -> Optional[list[str]]:
|
|
"""Disk-cached wrapper around :func:`fetch_api_models` for custom endpoints.
|
|
|
|
Callers that deliberately skip live probing for latency reasons (GUI picker opens, which must
|
|
not block on a stopped local endpoint) use this so a warm catalog still reaches the picker
|
|
instead of collapsing to the config-declared subset.
|
|
"""
|
|
normalized_url = str(base_url or "").strip().rstrip("/").lower()
|
|
if not normalized_url:
|
|
if cache_only:
|
|
return None
|
|
# No base_url means nothing to key the cache on — fall through to a
|
|
# live call so callers keep getting fetch_api_models' own behavior.
|
|
return fetch_api_models(
|
|
api_key, base_url, timeout=timeout, api_mode=api_mode, headers=headers
|
|
)
|
|
|
|
cache_key = f"custom:{normalized_url}"
|
|
fp = _custom_endpoint_fingerprint(api_key, api_mode, headers)
|
|
cache = _load_provider_models_cache()
|
|
entry = cache.get(cache_key)
|
|
now = time.time()
|
|
|
|
if cache_only:
|
|
# Same trust window as the stale-while-revalidate tier below, minus
|
|
# the revalidation: an entry this side of the bound is good enough to
|
|
# render, and anything older is treated as a miss so the caller falls
|
|
# back to its configured list rather than showing a stale catalog.
|
|
if force_refresh or not _cache_entry_valid(entry, fp):
|
|
return None
|
|
if now - entry["at"] >= _PROVIDER_MODELS_STALE_SERVE_MAX:
|
|
return None
|
|
return list(entry["models"])
|
|
|
|
if not force_refresh and _cache_entry_valid(entry, fp):
|
|
age = now - entry["at"]
|
|
if age < ttl_seconds:
|
|
return list(entry["models"])
|
|
if age < _PROVIDER_MODELS_STALE_SERVE_MAX:
|
|
# Stale-while-revalidate: serve the expired entry immediately so
|
|
# picker opens never block on a live /v1/models round-trip
|
|
# (#72762's stall class, which a plain TTL would reintroduce an
|
|
# hour into the session); refresh off-thread for the next open.
|
|
def _refresh_custom():
|
|
live = fetch_api_models(
|
|
api_key, base_url,
|
|
timeout=timeout, api_mode=api_mode, headers=headers,
|
|
)
|
|
if not live:
|
|
return None
|
|
return {"fp": fp, "at": time.time(), "models": list(live)}
|
|
|
|
_spawn_swr_refresh(cache_key, _refresh_custom)
|
|
return list(entry["models"])
|
|
|
|
live = fetch_api_models(
|
|
api_key, base_url, timeout=timeout, api_mode=api_mode, headers=headers
|
|
)
|
|
if live:
|
|
cache[cache_key] = {"fp": fp, "at": now, "models": list(live)}
|
|
_save_provider_models_cache(cache)
|
|
return list(live)
|
|
|
|
# Live fetch returned nothing (offline endpoint, timeout, auth hiccup).
|
|
# A stale same-fingerprint entry beats an empty result.
|
|
if _cache_entry_valid(entry, fp):
|
|
return list(entry["models"])
|
|
return live
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ollama Cloud — merged model discovery with disk cache
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_OLLAMA_CLOUD_CACHE_TTL = 3600 # 1 hour
|
|
|
|
|
|
def _strip_ollama_cloud_suffix(model_id: str) -> str:
|
|
"""Strip :cloud / -cloud suffixes that models.dev appends to Ollama Cloud IDs.
|
|
|
|
The live API uses clean IDs (e.g. 'kimi-k2.6') while models.dev sometimes returns them as
|
|
'kimi-k2.6:cloud'. Normalising before the dedup merge prevents duplicate entries in the merged
|
|
model list.
|
|
"""
|
|
for suffix in (":cloud", "-cloud"):
|
|
if model_id.endswith(suffix):
|
|
return model_id[: -len(suffix)]
|
|
return model_id
|
|
|
|
|
|
def _ollama_cloud_cache_path() -> Path:
|
|
"""Return the path for the Ollama Cloud model cache."""
|
|
from hermes_constants import get_hermes_home
|
|
return get_hermes_home() / "ollama_cloud_models_cache.json"
|
|
|
|
|
|
def _load_ollama_cloud_cache(*, ignore_ttl: bool = False) -> Optional[dict]:
|
|
"""Load cached Ollama Cloud models from disk."""
|
|
try:
|
|
cache_path = _ollama_cloud_cache_path()
|
|
if not cache_path.exists():
|
|
return None
|
|
with open(cache_path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if not isinstance(data, dict):
|
|
return None
|
|
models = data.get("models")
|
|
if not (isinstance(models, list) and models):
|
|
return None
|
|
if not ignore_ttl:
|
|
cached_at = data.get("cached_at", 0)
|
|
if (time.time() - cached_at) > _OLLAMA_CLOUD_CACHE_TTL:
|
|
return None # stale
|
|
return data
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _save_ollama_cloud_cache(models: list[str]) -> None:
|
|
"""Persist the merged Ollama Cloud model list to disk."""
|
|
try:
|
|
from utils import atomic_json_write
|
|
cache_path = _ollama_cloud_cache_path()
|
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
atomic_json_write(cache_path, {"models": models, "cached_at": time.time()}, indent=None)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def fetch_ollama_cloud_models(
|
|
api_key: Optional[str] = None,
|
|
base_url: Optional[str] = None,
|
|
*,
|
|
force_refresh: bool = False,
|
|
) -> list[str]:
|
|
"""Fetch Ollama Cloud models by merging live API + models.dev, with disk cache.
|
|
|
|
Resolution order: 1. Disk cache (if fresh, < 1 hour, and not force_refresh) 2. Live
|
|
``/v1/models`` endpoint (primary — freshest source) 3. models.dev registry (secondary — fills
|
|
gaps for unlisted models) 4. Merge: live models first, then models.dev additions (deduped)
|
|
|
|
Returns a list of model IDs (never None — empty list on total failure).
|
|
"""
|
|
# 1. Check disk cache
|
|
if not force_refresh:
|
|
cached = _load_ollama_cloud_cache()
|
|
if cached is not None:
|
|
return cached["models"]
|
|
|
|
# 2. Live API probe
|
|
if not api_key:
|
|
api_key = os.getenv("OLLAMA_API_KEY", "")
|
|
if not base_url:
|
|
base_url = os.getenv("OLLAMA_BASE_URL", "") or "https://ollama.com/v1"
|
|
|
|
live_models: list[str] = []
|
|
if api_key:
|
|
result = fetch_api_models(api_key, base_url, timeout=8.0)
|
|
if result:
|
|
live_models = result
|
|
|
|
# 3. models.dev registry
|
|
mdev_models: list[str] = []
|
|
try:
|
|
from agent.models_dev import list_agentic_models
|
|
mdev_models = list_agentic_models("ollama-cloud")
|
|
except Exception:
|
|
pass
|
|
|
|
# 4. Merge: live first, then models.dev additions (deduped, order-preserving)
|
|
if live_models or mdev_models:
|
|
seen: set[str] = set()
|
|
merged: list[str] = []
|
|
for m in live_models:
|
|
if m and m not in seen:
|
|
seen.add(m)
|
|
merged.append(m)
|
|
for m in mdev_models:
|
|
normalized = _strip_ollama_cloud_suffix(m)
|
|
if normalized and normalized not in seen:
|
|
seen.add(normalized)
|
|
merged.append(normalized)
|
|
if merged:
|
|
_save_ollama_cloud_cache(merged)
|
|
return merged
|
|
|
|
# Total failure — return stale cache if available (ignore TTL)
|
|
stale = _load_ollama_cloud_cache(ignore_ttl=True)
|
|
if stale is not None:
|
|
return stale["models"]
|
|
|
|
return []
|