Merge branch 'simp/r2m-models' into simp/integration2

This commit is contained in:
Teknium
2026-09-02 17:05:09 -07:00
6 changed files with 4447 additions and 4987 deletions
+845 -4987
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+824
View File
@@ -0,0 +1,824 @@
"""Local / self-hosted model servers.
Ollama (native ``/api/tags`` probe, request headers, base-url resolution), LM Studio
(``/api/v1/models``, load-on-demand), and Ollama Cloud (live + models.dev merged catalog with a
disk cache).
Split out of ``hermes_cli.models``; every moved name is re-imported there, so
``hermes_cli.models.<name>`` keeps resolving (and monkeypatching) as before.
"""
from __future__ import annotations
import http.client
import json
import logging
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, NamedTuple, Optional
from hermes_cli.urllib_security import url_origin
# Log-record parity with the origin module.
logger = logging.getLogger("hermes_cli.models")
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 _configured_ollama_base_url() -> str:
"""``providers.ollama.base_url`` (legacy keys ``api`` / ``url``), or ``""``."""
from hermes_cli.models import _get_provider_config_dict
cfg = _get_provider_config_dict("ollama")
return str(cfg.get("base_url") or cfg.get("api") or cfg.get("url") or "").strip()
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.
"""
from hermes_cli.models import _get_model_config_dict, should_use_ollama_native_catalog
configured = _configured_ollama_base_url()
if configured:
return configured
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 the Ollama picker would probe an unrelated endpoint's /api/tags and hide the
# 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."""
from hermes_cli.models import _get_provider_config_dict
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 and 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."""
from hermes_cli.models import _get_ollama_request_headers
configured_base = _configured_ollama_base_url()
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
# Native /api/tags probe caches, keyed by root (+ header fingerprint): successful catalogs,
# failure timestamps (short negative TTL), and whether the root answered the native probe.
_OLLAMA_LOCAL_MODELS_CACHE_TTL: int = 300 # seconds
_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 _parse_ollama_tags(payload: Any) -> Optional[list[str]]:
"""Model ids from an ``/api/tags`` payload; None when the shape is not Ollama's."""
raw_models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(raw_models, list):
return None
models: list[str] = []
seen: set[str] = set()
for item in raw_models:
if not isinstance(item, dict):
return None
model_id = str(item.get("model") or item.get("name") or "").strip()
if model_id and model_id not in seen:
seen.add(model_id)
models.append(model_id)
if raw_models and not models:
return None
return models
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.
"""
from hermes_cli.models import _HERMES_USER_AGENT, _get_ollama_base_url, _urlopen_model_catalog_request
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)
def _unreachable() -> None:
_remember_ollama_cache(_OLLAMA_LOCAL_PROBE_REACHABLE, cache_key, False)
_remember_ollama_cache(_OLLAMA_LOCAL_PROBE_FAILURE_CACHE, failure_key, time.monotonic())
try:
request_headers = {"User-Agent": _HERMES_USER_AGENT, **(headers or {})}
req = urllib.request.Request(root.rstrip("/") + "/api/tags", 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):
_unreachable()
return None
models = _parse_ollama_tags(payload)
if models is None:
_unreachable()
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``."""
from hermes_cli.models import probe_ollama_local_models
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.
"""
from hermes_cli.models import probe_ollama_local_models
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
if requested in {"openrouter", "nous", "anthropic", "openai", "openai-codex", "gemini", "ollama-cloud"}:
return False
configured_base = _configured_ollama_base_url()
if requested == "ollama":
if not root:
return False
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
if configured_base and _same_ollama_native_root(root, configured_base):
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 _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."""
from hermes_cli.models import _get_ollama_base_url, _get_ollama_native_headers, _get_provider_config_dict, fetch_api_models, fetch_ollama_local_models, should_use_ollama_native_catalog
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 _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."""
from hermes_cli.models import _HERMES_USER_AGENT
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``."""
from hermes_cli.models import _urlopen_model_catalog_request
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
logger.debug("LM Studio probe at %s failed with HTTP %s", server_root, exc.code)
return None
except Exception as exc:
logger.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):
logger.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.
"""
from hermes_cli.models import _lmstudio_fetch_raw_models
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.
"""
from hermes_cli.models import probe_lmstudio_models
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.
"""
from hermes_cli.models import _lmstudio_fetch_raw_models, _urlopen_model_catalog_request
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.
"""
from hermes_cli.models import _lmstudio_fetch_raw_models
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
_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 (None when missing, empty, or stale)."""
from hermes_cli.models import _read_json_cache
try:
data = _read_json_cache(_ollama_cloud_cache_path())
if data is None:
return None
models = data.get("models")
if not (isinstance(models, list) and models):
return None
if not ignore_ttl and (time.time() - data.get("cached_at", 0)) > _OLLAMA_CLOUD_CACHE_TTL:
return None # stale
return data
except Exception:
return None
def _save_ollama_cloud_cache(models: list[str]) -> None:
"""Persist the merged Ollama Cloud model list to disk. Best-effort."""
from hermes_cli.models import _write_json_cache
try:
_write_json_cache(_ollama_cloud_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).
"""
from hermes_cli.models import fetch_api_models
# 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 []
+615
View File
@@ -0,0 +1,615 @@
"""Live model pricing.
OpenRouter-compatible ``/v1/models`` pricing fetch with a per-endpoint/per-credential cache,
Nous Portal sale chrome and org-policy filtering, and the Vercel AI Gateway / Novita / Fireworks /
DeepInfra pricing adapters.
Split out of ``hermes_cli.models``; every moved name is re-imported there, so
``hermes_cli.models.<name>`` keeps resolving (and monkeypatching) as before.
"""
from __future__ import annotations
import json
import os
import time
import urllib.request
from typing import Any, Optional
from hermes_cli.models_reasoning_caps import _seed_reasoning_caps
# 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."""
from hermes_cli.models import _pricing_cache, _pricing_cache_retry_after
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.
"""
from hermes_cli.models import _pricing_cache, _pricing_cache_retry_after
_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.
"""
from hermes_cli.models import _pricing_cache
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 _get_json(url: str, headers: dict[str, str], timeout: float, opener=None) -> Optional[dict]:
"""GET *url* as JSON via the origin's catalog opener (or *opener*); None on any failure."""
from hermes_cli.models import _urlopen_model_catalog_request
try:
req = urllib.request.Request(url, headers=headers)
with (opener or _urlopen_model_catalog_request)(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
except Exception:
return None
def _pricing_entry(pricing: dict, prompt_key: str = "prompt", completion_key: str = "completion") -> dict[str, Any]:
"""Picker-shape ``{prompt, completion[, input_cache_read, input_cache_write]}`` from a catalog
``pricing`` block whose cache fields already use the hermes names."""
entry: dict[str, Any] = {
"prompt": str(pricing.get(prompt_key, "")),
"completion": str(pricing.get(completion_key, "")),
}
for key in ("input_cache_read", "input_cache_write"):
if pricing.get(key):
entry[key] = str(pricing[key])
return entry
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.
"""
from hermes_cli.models import _HERMES_USER_AGENT
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 = {"Accept": "application/json", "User-Agent": _HERMES_USER_AGENT}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = _get_json(url, headers, timeout)
if payload is None:
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 = _pricing_entry(pricing)
# Sale chrome is Nous Portal-only; never copy pricing.original for other catalogs.
original = pricing.get("original") if include_sale_original else None
if isinstance(original, dict):
orig_entry = {
key: str(original[key])
for key in ("prompt", "completion", "input_cache_read", "input_cache_write")
if original.get(key) not in (None, "")
}
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
payload = _get_json(f"{cache_key}/models", {"Accept": "application/json"}, timeout, opener=urllib.request.urlopen)
if payload is None:
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 mid and isinstance(pricing, dict):
result[mid] = _pricing_entry(pricing, "input", "output")
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.
"""
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.
"""
from hermes_cli.models import _resolve_nous_pricing_credentials, fetch_models_with_pricing
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)."""
from hermes_cli.models import _resolve_nous_pricing_credentials, fetch_models_with_pricing, normalize_provider
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.
"""
from hermes_cli.models import _HERMES_USER_AGENT
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
headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json", "User-Agent": _HERMES_USER_AGENT}
payload = _get_json(cache_key + "/models", headers, timeout)
if payload is None:
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)
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.
"""
from hermes_cli.models import _fetch_deepinfra_models_by_tag
items = _fetch_deepinfra_models_by_tag("chat", timeout=timeout, force_refresh=force_refresh)
result: dict[str, dict[str, str]] = {}
for item in items or []:
metadata = item.get("metadata") or {}
pricing = metadata.get("pricing") if isinstance(metadata, dict) else None
if not isinstance(pricing, dict):
continue
entry = {
ours: str(float(pricing[theirs]) / 1_000_000)
for theirs, ours in (("input_tokens", "prompt"), ("output_tokens", "completion"), ("cache_read_tokens", "input_cache_read"))
if pricing.get(theirs) is not None
}
if entry:
result[item["id"]] = entry
return result
+310
View File
@@ -0,0 +1,310 @@
"""Per-model reasoning capabilities from OpenRouter-schema ``/v1/models`` catalogs.
Split out of ``hermes_cli.models``; every public/patched name is re-imported there. The
OpenRouter and Nous Portal catalogs share one implementation parametrized by
:class:`_CapsSource`; the per-source module globals (``_openrouter_reasoning_caps_cache``,
``_nous_caps_disk_checked``, ...) stay defined on ``hermes_cli.models`` — tests reset them there —
and are read/written by attribute name through the origin module.
Tri-state contract for callers deciding whether to emit reasoning controls:
- dict with ``supports_reasoning: True`` (+ ``supported_efforts``, ``mandatory``) — the route
advertises reasoning controls;
- dict with ``supports_reasoning: False`` — the catalog knows the model and it does NOT accept
reasoning controls (definitive negative);
- ``None`` — unknown: catalog not loaded, model not listed (private/custom route), malformed.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional
logger = logging.getLogger("hermes_cli.models")
Caps = dict[str, Optional[dict[str, Any]]]
def _origin():
from hermes_cli import models
return models
def parse_openrouter_reasoning_capabilities(item: Any) -> Optional[dict[str, Any]]:
"""Normalize one OpenRouter catalog entry's reasoning metadata.
``supported_parameters`` contains ``"reasoning"`` when the route accepts reasoning controls at
all; a top-level ``reasoning`` object may add detail (``mandatory``, ``supported_efforts``).
A missing/malformed ``supported_parameters`` is "unknown" (None), mirroring the permissive
stance of ``_openrouter_model_supports_tools``.
"""
if not isinstance(item, dict):
return None
params = item.get("supported_parameters")
if not isinstance(params, list):
return None
if "reasoning" not in params:
return {"supports_reasoning": False}
reasoning = item.get("reasoning")
mandatory = isinstance(reasoning, dict) and reasoning.get("mandatory") is True
efforts: Optional[list[str]] = None
if isinstance(reasoning, dict):
raw_efforts = reasoning.get("supported_efforts")
if isinstance(raw_efforts, list):
efforts = list(dict.fromkeys(
str(effort).strip().lower()
for effort in raw_efforts
if str(effort).strip()
))
return {
"supports_reasoning": True,
"supported_efforts": efforts,
"mandatory": mandatory,
}
# ── Disk mirror ────────────────────────────────────────────────────────
#
# The in-process caches are always cold in a short-lived process, and every consumer is on a hot
# path that must never block on HTTP — so without a disk copy, `hermes -p`, a cron job, or a
# freshly booted gateway answers "capability unknown" for its whole first turn and falls back to
# the conservative wire shape. One file holds every catalog, keyed by the URL it came from:
# OpenRouter and the Nous Portal list different models, and a staging Portal must not answer for
# production.
_REASONING_CAPS_DISK_TTL_SECONDS = 24 * 3600
def _reasoning_caps_disk_path() -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / "reasoning_caps.json"
def _read_reasoning_caps_disk() -> dict[str, Any]:
from hermes_cli.models import _read_json_cache
return _read_json_cache(_reasoning_caps_disk_path()) or {}
def _load_reasoning_caps_disk(url: str) -> tuple[Optional[Caps], float]:
"""Return ``(caps, age_seconds)`` for *url*, or ``(None, 0.0)``."""
entry = _origin()._read_reasoning_caps_disk().get(url)
if not isinstance(entry, dict):
return None, 0.0
caps = entry.get("caps")
if not isinstance(caps, dict) or not caps:
return None, 0.0
try:
age = max(0.0, time.time() - float(entry.get("ts") or 0))
except (TypeError, ValueError):
age = float(_REASONING_CAPS_DISK_TTL_SECONDS)
return {str(mid): model_caps for mid, model_caps in caps.items()}, age
def _save_reasoning_caps_disk(url: str, caps: Caps) -> None:
"""Merge *url*'s catalog into the shared disk mirror, atomically."""
from hermes_cli.models import _write_json_cache
try:
data = _origin()._read_reasoning_caps_disk()
data[url] = {"ts": time.time(), "caps": caps}
_write_json_cache(_reasoning_caps_disk_path(), data, indent=0, separators=(",", ":"))
except Exception as exc:
logger.debug("Failed to save reasoning-caps disk cache: %s", exc)
def _warm_reasoning_caps_async(refresh) -> None:
"""Run *refresh* in a background thread. Fire-and-forget.
Called from hot paths that found the cache cold or the disk copy stale, so the next call — or,
via the disk mirror, the next process — benefits without this turn ever blocking on HTTP.
Callers own the once-per-process guard; the fetch keeps its own failure TTL.
"""
if os.environ.get("PYTEST_CURRENT_TEST"):
return
threading.Thread(target=refresh, name="reasoning-caps-warm", daemon=True).start()
def _hydrate_reasoning_caps_from_disk(url: str, refresh) -> Optional[Caps]:
"""The disk copy of *url*'s catalog, queueing *refresh* when it's stale.
A copy past its TTL is still returned — a stale verdict beats no verdict, and reasoning
capabilities change rarely — with a background refresh so the next run is current.
"""
caps, age = _load_reasoning_caps_disk(url)
if caps is None:
return None
if age >= _REASONING_CAPS_DISK_TTL_SECONDS:
_warm_reasoning_caps_async(refresh)
return caps
def _seed_reasoning_caps(url: str, items: Any) -> Optional[Caps]:
"""Parse a ``/v1/models`` ``data`` array and mirror it for *url*.
Takes the payload rather than fetching it, so picker and pricing fetches (which pull the same
document) leave the mirror warm at no network cost. Returns None when the array has no usable
entries, which callers remember as a failure rather than caching as empty.
"""
if not isinstance(items, list):
return None
caps_by_id: Caps = {}
for item in items:
if not isinstance(item, dict):
continue
mid = str(item.get("id") or "").strip()
if not mid:
continue
caps_by_id[mid] = parse_openrouter_reasoning_capabilities(item)
if not caps_by_id:
return None
_save_reasoning_caps_disk(url, caps_by_id)
return caps_by_id
def _fetch_reasoning_caps_catalog(url: str, timeout: float) -> Optional[Caps]:
"""Fetch one OpenRouter-shaped ``/v1/models`` catalog → per-model caps.
Returns None when the catalog is unreachable or has no usable entries, so callers remember the
failure and fall back rather than caching an empty result. Sends a User-Agent because the
Portal 403s anonymous catalog reads.
"""
m = _origin()
headers = {"Accept": "application/json", "User-Agent": m._HERMES_USER_AGENT}
try:
req = urllib.request.Request(url, headers=headers)
with m._urlopen_model_catalog_request(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
except Exception:
return None
return _seed_reasoning_caps(url, payload.get("data"))
# ── Per-source cache (OpenRouter, Nous Portal) ─────────────────────────
@dataclass(frozen=True)
class _CapsSource:
"""One catalog's cache slots on ``hermes_cli.models`` plus how to name its URL.
``cache``: model id → parsed caps, populated by one full-catalog fetch and kept for the process
lifetime (capabilities don't change). ``failed_at``: monotonic timestamp of the last FAILED
fetch; suppresses re-fetch storms from per-turn callers while the catalog is unreachable (60s,
mirrors the LM Studio/Ollama capability-probe caching). ``disk_checked`` / ``warm_started``:
once-per-process guards for the disk hydrate and the background warm.
"""
cache: str
failed_at: str
disk_checked: str
warm_started: str
url: Callable[[], str]
def _fetch_caps(src: _CapsSource, timeout: float = 6.0, *, force: bool = False) -> Optional[Caps]:
"""Fetch + cache the source's per-model caps. None (without poisoning the cache) when
unreachable, so callers retry later and fall back meanwhile."""
m = _origin()
cached = getattr(m, src.cache)
if cached is not None and not force:
return cached
failed_at = getattr(m, src.failed_at)
if failed_at is not None and (time.monotonic() - failed_at) < 60:
return None
caps_by_id = _fetch_reasoning_caps_catalog(src.url(), timeout)
if caps_by_id is None:
setattr(m, src.failed_at, time.monotonic())
return None
setattr(m, src.cache, caps_by_id)
return caps_by_id
def _caps_cached(src: _CapsSource) -> Optional[Caps]:
"""Cache-only caps: memory, else the disk mirror. Never HTTP.
Guarded to one disk attempt per process: for the Portal, naming the catalog means resolving
credentials, which can itself reach the network to refresh a token — far too expensive for a
caller that runs every turn.
"""
m = _origin()
if getattr(m, src.cache) is None and not getattr(m, src.disk_checked):
setattr(m, src.disk_checked, True)
setattr(m, src.cache, _hydrate_reasoning_caps_from_disk(src.url(), lambda: _fetch_caps(src, force=True)))
return getattr(m, src.cache)
def _model_caps(src: _CapsSource, model_id: Optional[str], *, timeout: float, allow_fetch: bool) -> Optional[dict[str, Any]]:
model = str(model_id or "").strip()
if not model:
return None
caps_by_id = _caps_cached(src)
if caps_by_id is None and allow_fetch:
caps_by_id = _fetch_caps(src, timeout=timeout)
if caps_by_id is None:
return None
return caps_by_id.get(model)
def _warm_caps_async(src: _CapsSource) -> None:
m = _origin()
if getattr(m, src.warm_started) or _caps_cached(src) is not None:
return
setattr(m, src.warm_started, True)
_warm_reasoning_caps_async(lambda: _fetch_caps(src, force=True))
_OPENROUTER_CATALOG_URL = "https://openrouter.ai/api/v1/models"
_OPENROUTER_CAPS = _CapsSource(
"_openrouter_reasoning_caps_cache", "_openrouter_reasoning_caps_failed_at",
"_openrouter_caps_disk_checked", "_openrouter_caps_warm_started",
lambda: _OPENROUTER_CATALOG_URL,
)
# Nous Portal serves OpenRouter's catalog schema, so the same parser and contract apply. Its own
# cache because the two catalogs list different models (and different capabilities for shared ids).
_NOUS_CAPS = _CapsSource(
"_nous_reasoning_caps_cache", "_nous_reasoning_caps_failed_at",
"_nous_caps_disk_checked", "_nous_caps_warm_started",
lambda: _origin().nous_catalog_url(),
)
def nous_catalog_url() -> str:
"""The Portal ``/v1/models`` URL for the endpoint we actually talk to.
Resolved through the ladder ``NOUS_INFERENCE_BASE_URL`` → resolved credential base → prod
rather than pinned to production, so a staging profile reads staging's capabilities.
"""
return f"{_origin()._resolve_nous_pricing_credentials()[1]}/v1/models"
def openrouter_model_reasoning_capabilities(
model_id: Optional[str], *, timeout: float = 6.0, allow_fetch: bool = False,
) -> Optional[dict[str, Any]]:
"""Live-catalog reasoning capabilities for an OpenRouter model (tri-state, see module doc).
CACHE-ONLY by default — safe on per-request hot paths (never blocks on HTTP)."""
return _model_caps(_OPENROUTER_CAPS, model_id, timeout=timeout, allow_fetch=allow_fetch)
def nous_model_reasoning_capabilities(
model_id: Optional[str], *, timeout: float = 6.0, allow_fetch: bool = False,
) -> Optional[dict[str, Any]]:
"""Nous Portal counterpart of :func:`openrouter_model_reasoning_capabilities`; warm the cache
with :func:`warm_nous_reasoning_caps_async` from hot paths."""
return _model_caps(_NOUS_CAPS, model_id, timeout=timeout, allow_fetch=allow_fetch)
def warm_openrouter_reasoning_caps_async() -> None:
"""Warm the OpenRouter reasoning-capability cache in the background."""
_warm_caps_async(_OPENROUTER_CAPS)
def warm_nous_reasoning_caps_async() -> None:
"""Nous Portal counterpart of :func:`warm_openrouter_reasoning_caps_async`."""
_warm_caps_async(_NOUS_CAPS)
+629
View File
@@ -0,0 +1,629 @@
"""Validate a requested ``/model`` value against the active provider's catalog.
Split out of ``hermes_cli.models``; :func:`validate_requested_model` is re-imported there, so
``hermes_cli.models.validate_requested_model`` keeps resolving. Every catalog fetcher this module
calls is looked up on ``hermes_cli.models`` at call time (``_m.<name>``), so existing
``patch("hermes_cli.models.<name>")`` mocks keep intercepting.
Every provider branch returns one of four verdict shapes (see :func:`_verdict`) or ``None`` to
mean "not decided here — keep walking the ladder". The ladder ORDER is behavior: moa → whitespace
→ OpenRouter preset parse → LM Studio → Ollama native → custom → codex/xai static → MiniMax →
Anthropic native → Anthropic Messages → live listing → Bedrock → curated-catalog fallback.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from difflib import get_close_matches
from typing import Any, Optional
from utils import base_url_host_matches
# ── Verdicts ─────────────────────────────────────────────────────────────
def _verdict(accepted: bool, persist: bool, recognized: bool, message: Optional[str],
corrected_model: Optional[str] = None) -> dict[str, Any]:
"""Build the verdict dict. ``corrected_model`` is only present when set (key order matters
to nobody, but keep it identical to the historical literals anyway)."""
out: dict[str, Any] = {"accepted": accepted, "persist": persist, "recognized": recognized}
if corrected_model is not None:
out["corrected_model"] = corrected_model
out["message"] = message
return out
def _accept() -> dict[str, Any]:
return _verdict(True, True, True, None)
def _reject(message: str) -> dict[str, Any]:
return _verdict(False, False, False, message)
def _soft_accept(message: Optional[str]) -> dict[str, Any]:
"""Accept + persist an unrecognized name, with a warning."""
return _verdict(True, True, False, message)
def _corrected(requested: str, corrected: str) -> dict[str, Any]:
return _verdict(True, True, True, f"Auto-corrected `{requested}` → `{corrected}`",
corrected_model=corrected)
# ── Catalog matching ─────────────────────────────────────────────────────
@dataclass
class _Match:
exact: bool = False
corrected: Optional[str] = None
suggestion_text: str = ""
def _match_in_catalog(
query: str,
candidates,
*,
case_insensitive: bool = False,
auto_correct: bool = True,
suggest_query: Optional[str] = None,
suggest_cutoff: float = 0.5,
suggest_label: str = "Similar models",
) -> _Match:
"""The shared ladder: exact membership → typo auto-correct (cutoff .9) → suggestion text.
``case_insensitive`` matches on lower-cased ids and maps results back to the catalog's
spelling (MiniMax ships mixed-case ids). ``suggest_query`` overrides the string the
suggestion search uses (some branches search on the raw request, not the lookup form).
"""
candidates = list(candidates)
if case_insensitive:
display = {c.lower(): c for c in candidates}
pool = list(display)
query = query.lower()
suggest_query = query if suggest_query is None else suggest_query.lower()
else:
display = None
pool = candidates
suggest_query = query if suggest_query is None else suggest_query
def _show(cid: str) -> str:
return display[cid] if display is not None else cid
if query in set(pool):
return _Match(exact=True)
if auto_correct:
auto = get_close_matches(query, pool, n=1, cutoff=0.9)
if auto:
return _Match(corrected=_show(auto[0]))
suggestions = get_close_matches(suggest_query, pool, n=3, cutoff=suggest_cutoff)
text = ""
if suggestions:
text = f"\n {suggest_label}: " + ", ".join(f"`{_show(s)}`" for s in suggestions)
return _Match(suggestion_text=text)
# ── Request context ──────────────────────────────────────────────────────
@dataclass
class _Request:
requested: str
lookup: str # id used for catalog membership (copilot-normalized / preset base)
provider: Optional[str] # raw caller value (Ollama checks look at this, not ``normalized``)
normalized: str
api_key: Optional[str]
base_url: Optional[str]
api_mode: Optional[str]
headers: Optional[dict[str, str]]
preset_suffix: str = ""
def with_preset_suffix(self, model_id: str) -> str:
"""Re-attach a preserved ``@preset/<slug>`` suffix after auto-correction."""
return f"{model_id}{self.preset_suffix}"
# ── Provider branches (None = not decided here) ─────────────────────────
def _validate_moa(requested: str) -> dict[str, Any]:
try:
from hermes_cli.config import load_config
from hermes_cli.moa_config import normalize_moa_config
cfg = normalize_moa_config(load_config().get("moa") or {})
if requested in cfg["presets"]:
return _accept()
return _reject(f"MoA preset `{requested}` was not found. Run `hermes moa list`.")
except Exception as exc:
return _reject(f"Could not read MoA presets: {exc}")
def _parse_openrouter_preset(req: _Request) -> Optional[dict[str, Any]]:
"""OpenRouter presets are account-scoped, so ``@preset/<slug>`` never appears in the public
/v1/models listing. A bare preset is accepted unverified; ``<model>@preset/<slug>`` validates
the base model and preserves the suffix through auto-correction. OpenRouter validates the slug
at request time."""
marker = "@preset/"
if marker not in req.requested:
return None
if req.requested.count(marker) != 1:
preset_slug, preset_base = "", req.requested
else:
preset_base, preset_slug = req.requested.split(marker, 1)
if re.fullmatch(r"[A-Za-z0-9._~-]+", preset_slug) is None:
return _reject(
"OpenRouter preset slugs must be non-empty URL-safe "
"identifiers using only letters, digits, '.', '_', "
"'~', or '-'."
)
req.preset_suffix = f"{marker}{preset_slug}"
if not preset_base:
return _soft_accept(None)
req.lookup = preset_base
return None
def _validate_lmstudio(req: _Request) -> dict[str, Any]:
from hermes_cli import models as _m
from hermes_cli.auth import AuthError
# probe_lmstudio_models distinguishes None (unreachable / malformed) from [] (reachable,
# nothing chat-capable loaded); fetch_lmstudio_models collapses both to [].
try:
models = _m.probe_lmstudio_models(api_key=req.api_key, base_url=req.base_url)
except AuthError as exc:
return _reject(f"{exc} Set `LM_API_KEY` (or update it) to match the server's bearer token.")
if models is None:
return _reject(f"Could not reach LM Studio's `/api/v1/models` to validate `{req.requested}`.")
if not models:
return _reject(
f"LM Studio is reachable but no chat-capable models are loaded. "
f"Load `{req.requested}` in LM Studio (Developer tab → Load Model) and try again."
)
if req.lookup in set(models):
return _accept()
return _reject(f"Model `{req.requested}` was not found in LM Studio's model listing.")
def _ollama_probe_headers(req: _Request) -> dict[str, str]:
"""Headers for the Ollama native probe.
Configured ``providers.ollama.extra_headers`` are only applied when the probed endpoint is
the configured one (never leak them to a different host). Caller headers win; a caller
``api_key`` becomes the Authorization header unless the caller already sent one.
"""
from hermes_cli import models as _m
from hermes_cli.models_local import _configured_ollama_base_url
configured_base = _configured_ollama_base_url()
configured_allowed = not (
configured_base and not _m._same_ollama_native_root(req.base_url or "", configured_base)
)
if req.headers is None:
return _m._get_ollama_native_headers(req.base_url, api_key=req.api_key) if configured_allowed else {}
out: dict[str, str] = {}
if configured_allowed:
out.update(_m._get_ollama_native_headers(req.base_url, api_key=req.api_key))
for key in tuple(out):
if key.lower() == "authorization":
del out[key]
out.update(req.headers)
caller_has_authorization = any(key.lower() == "authorization" for key in req.headers)
if req.api_key and not caller_has_authorization:
for key in tuple(out):
if key.lower() == "authorization":
del out[key]
out["Authorization"] = f"Bearer {req.api_key}"
return out
def _validate_ollama_native(req: _Request) -> Optional[dict[str, Any]]:
"""Runs for EVERY provider: the native ``/api/tags`` catalog is used whenever the endpoint
looks like a local Ollama server. Also resolves ``base_url`` for the raw ``ollama`` provider,
which later branches (custom) rely on."""
from hermes_cli import models as _m
if str(req.provider or "").strip().lower() == "ollama" and not req.base_url:
req.base_url = _m._get_ollama_base_url()
headers = _ollama_probe_headers(req)
if not _m.should_use_ollama_native_catalog(req.provider, req.base_url, headers=headers):
return None
models = _m.probe_ollama_local_models(req.base_url, headers=headers)
if models is None:
# A failed native probe is not authoritative; fall back to the OpenAI-compatible
# catalog before accepting blindly.
models = _m.probe_api_models(
req.api_key,
_m._normalize_openai_base_url(req.base_url),
request_headers=headers,
).get("models")
if models is None:
return _soft_accept(
f"Note: could not reach this Ollama endpoint's `/api/tags` model listing to validate `{req.requested}`. "
"Hermes will save the model name, but local Ollama model discovery could not verify it."
)
match = _match_in_catalog(req.lookup, models, auto_correct=False,
suggest_label="Similar local Ollama models")
if match.exact:
return _accept()
empty_hint = " No models are currently listed by `/api/tags`." if not models else ""
return _soft_accept(
f"Note: `{req.requested}` was not found in this Ollama endpoint's `/api/tags` model listing."
f"{empty_hint} It may still work if the server supports hidden or aliased models."
f"{match.suggestion_text}"
)
def _validate_custom(req: _Request) -> dict[str, Any]:
from hermes_cli import models as _m
# Probe with the auth shape the api_mode expects.
if req.api_mode == "anthropic_messages":
probe = _m.probe_api_models(req.api_key, req.base_url, api_mode=req.api_mode,
request_headers=req.headers)
else:
probe = _m.probe_api_models(req.api_key, req.base_url, request_headers=req.headers)
api_models = probe.get("models")
if api_models is not None:
match = _match_in_catalog(req.lookup, api_models, suggest_query=req.requested)
if match.exact:
return _accept()
if match.corrected:
return _corrected(req.requested, match.corrected)
message = (
f"Note: `{req.requested}` was not found in this custom endpoint's model listing "
f"({probe.get('probed_url')}). It may still work if the server supports hidden or aliased models."
f"{match.suggestion_text}"
)
if probe.get("used_fallback"):
message += (
f"\n Endpoint verification succeeded after trying `{probe.get('resolved_base_url')}`. "
f"Consider saving that as your base URL."
)
return _soft_accept(message)
message = (
f"Note: could not reach this custom endpoint's model listing at `{probe.get('probed_url')}`. "
f"Hermes will still save `{req.requested}`, but the endpoint should expose `/models` for verification."
)
if req.api_mode == "anthropic_messages":
message += (
"\n Many Anthropic-compatible proxies do not implement the Models API "
"(GET /v1/models). The model name has been accepted without verification."
)
if probe.get("suggested_base_url"):
message += f"\n If this server expects `/v1`, try base URL: `{probe.get('suggested_base_url')}`"
# Anthropic-style proxies routinely lack /v1/models, so only they are accepted unverified.
return _verdict(req.api_mode == "anthropic_messages", True, False, message)
def _static_catalog(normalized: str) -> list[str]:
from hermes_cli import models as _m
try:
return _m.provider_model_ids(normalized)
except Exception:
return []
_STATIC_FAMILY_PREFIXES = {
"openai-codex": ("gpt-", "codex-", "o1", "o3", "o4"),
"xai-oauth": ("grok-",),
}
_STATIC_LABELS = {"openai-codex": "OpenAI Codex", "xai-oauth": "xAI Grok OAuth (SuperGrok / Premium+)"}
def _validate_static_catalog(req: _Request) -> Optional[dict[str, Any]]:
"""openai-codex / xai-oauth: no /v1/models probing — validate against the curated catalog.
Returns None (fall through) when the catalog is empty."""
catalog = _static_catalog(req.normalized)
if req.normalized == "openai-codex":
from agent.model_metadata import CODEX_CONTEXT_VARIANT_SUFFIX, is_codex_context_variant
# Ineligible ``-900k`` aliases must be rejected BEFORE the hidden-slug soft-accept:
# the suffix is a Hermes picker convention, so an unknown `*-900k` can never be a real
# hidden provider slug — soft-accepting one silently runs at 272K on a different model.
if req.lookup.strip().lower().endswith(CODEX_CONTEXT_VARIANT_SUFFIX) and req.lookup not in set(catalog):
if is_codex_context_variant(req.lookup):
# Valid variant a stale catalog hasn't synthesized yet. Accept directly — the typo
# auto-corrector would otherwise "fix" it to the base slug and drop the opt-in.
return _accept()
base_guess = req.lookup[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)]
return _reject(
f"`{req.requested}` is not a valid large-context variant — "
f"`{base_guess}` enforces the standard 272K window on "
f"Codex, so no `-900k` option exists for it. Pick the "
f"base model, or a verified variant from the `/model` "
f"picker (e.g. `gpt-5.6-sol-900k`)."
)
if not catalog:
return None
match = _match_in_catalog(req.lookup, catalog)
if match.exact:
return _accept()
if match.corrected:
return _corrected(req.requested, match.corrected)
label = _STATIC_LABELS[req.normalized]
# Plausibility gate: the soft-accept exists for entitlement-gated *hidden* slugs the curated
# listing hasn't caught up with — always the provider's own family (gpt-* / grok-*). An
# unrelated name (`qwen3.5-4b`) would turn an actionable "did you mean --provider <x>?" into
# a confusing success that 400s on the next turn, so reject it with guidance instead.
prefixes = _STATIC_FAMILY_PREFIXES.get(req.normalized, ())
lower = req.lookup.strip().lower()
if prefixes and not any(lower.startswith(p) for p in prefixes):
return _reject(
f"`{req.requested}` doesn't look like a {label} model "
f"and isn't in its listing, so it was not accepted. If it "
f"belongs to another configured provider, switch with "
f"`--provider <slug>` (or select it from the `/model` "
f"picker)."
f"{match.suggestion_text}"
)
return _soft_accept(
f"Note: `{req.requested}` was not found in the {label} model listing. "
"It may still work if your account has access to a newer or hidden model ID."
f"{match.suggestion_text}"
)
def _validate_minimax(req: _Request) -> Optional[dict[str, Any]]:
"""MiniMax has no /models endpoint — static catalog, case-insensitive (ids like MiniMax-M2.7).
Returns None when the catalog is empty."""
catalog = _static_catalog(req.normalized)
if not catalog:
return None
match = _match_in_catalog(req.lookup, catalog, case_insensitive=True)
if match.exact:
return _accept()
if match.corrected:
return _corrected(req.requested, match.corrected)
return _soft_accept(
f"Note: `{req.requested}` was not found in the MiniMax catalog."
f"{match.suggestion_text}"
"\n MiniMax does not expose a /models endpoint, so Hermes cannot verify the model name."
"\n The model may still work if it exists on the server."
)
def _validate_anthropic(req: _Request) -> Optional[dict[str, Any]]:
"""Native Anthropic: /v1/models needs x-api-key (or OAuth Bearer) + anthropic-version, so the
generic Bearer probe 401s — use the native fetcher. Returns None (fall through to the generic
ladder) when no token is resolvable or the network failed."""
from hermes_cli import models as _m
models = _m._fetch_anthropic_models(base_url=req.base_url or None, api_key=req.api_key or None)
if models is None:
return None
match = _match_in_catalog(req.lookup, models, suggest_query=req.requested)
if match.exact:
return _accept()
if match.corrected:
return _corrected(req.requested, match.corrected)
# Accept anyway — Anthropic gates newer/preview models (snapshot IDs, early access) behind
# accounts even though they aren't listed on /v1/models.
return _soft_accept(
f"Note: `{req.requested}` was not found in Anthropic's /v1/models listing. "
f"It may still work if you have early-access or snapshot IDs."
f"{match.suggestion_text}"
)
def _validate_anthropic_messages(req: _Request) -> dict[str, Any]:
"""Anthropic Messages transport: many proxies don't implement /v1/models — probe, and accept
with a warning when the probe fails or the model isn't listed."""
from hermes_cli import models as _m
models = _m.fetch_api_models(req.api_key, req.base_url, api_mode=req.api_mode)
if models is not None:
match = _match_in_catalog(req.lookup, models)
if match.exact:
return _accept()
if match.corrected:
return _corrected(req.requested, match.corrected)
return _soft_accept(
f"Note: could not verify `{req.requested}` against this endpoint's "
f"model listing. Many Anthropic-compatible proxies do not "
f"implement GET /v1/models. The model name has been accepted "
f"without verification."
)
def _nous_portal_recommended_names() -> set[str]:
"""Lower-cased ids from the Portal's live recommended-models feed (empty on any failure)."""
from hermes_cli import models as _m
try:
payload = _m.fetch_nous_recommended_models(_m._resolve_nous_portal_url())
return {
name.lower()
for tier in ("freeRecommendedModels", "paidRecommendedModels")
for entry in (payload.get(tier) or [])
if (name := _m._extract_model_name(entry))
}
except Exception:
return set()
def _validate_live_listing(req: _Request) -> Optional[dict[str, Any]]:
"""Generic live /v1/models probe. Returns None when the API was unreachable (the caller then
tries Bedrock discovery / the curated catalog)."""
from hermes_cli import models as _m
api_models = _m.fetch_api_models(req.api_key, req.base_url)
if api_models is None:
return None
if req.normalized == "gemini":
# Gemini's OpenAI-compat listing prefixes ids with "models/"; curated list and user
# input use the bare id, so strip before comparing.
api_models = [
m[len("models/"):] if isinstance(m, str) and m.startswith("models/") else m
for m in api_models
]
match = _match_in_catalog(req.lookup, api_models)
if match.exact:
return _accept()
# OpenRouter routing variants (":nitro", ":floor", ...) are request-time modifiers, not
# catalog entries — validate the BASE but keep the suffixed id. Must run BEFORE fuzzy
# auto-correction, which would otherwise "correct" `model:nitro` → `model` and silently
# strip the routing opt-in.
variant_base = _m._openrouter_variant_base(req.lookup) if req.normalized == "openrouter" else None
if variant_base is not None and variant_base in set(api_models):
return _accept()
# Listed but not found: the account may reach models absent from the public listing
# (e.g. Z.AI Pro/Max plans use glm-5 on coding endpoints) — warn but allow where plausible.
if match.corrected:
corrected = req.with_preset_suffix(match.corrected)
return _corrected(req.requested, corrected)
# Curated-catalog soft-accept: providers omit valid models from live listings (stale cache,
# partial rollout, gated previews). EXCEPTION: official OpenAI hosts (canonical + data-
# residency regional) — their listing is access-scoped and authoritative, so an absent model
# is one this key CANNOT serve; a soft-accept would 400 at first use. Custom OpenAI-compatible
# proxies keep the fallback.
listing_authoritative = False
if req.normalized in ("openai", "openai-api"):
from hermes_cli.providers import is_official_openai_host
listing_authoritative = is_official_openai_host(req.base_url)
if not listing_authoritative and _m._model_in_provider_catalog(
(variant_base or req.lookup).lower(), _m._provider_keys(req.normalized)
):
return _verdict(True, True, True,
f"Note: `{req.requested}` was not found in the live /v1/models listing "
f"but exists in the curated catalog — accepted.")
# Nous: the Portal's recommended-models feed can list a model before the curated list or the
# docs-hosted manifest catches up; `hermes chat` already accepts those at model-list build
# time, so mirror that source of truth for per-message /model validation.
if req.normalized == "nous" and req.lookup.lower() in _nous_portal_recommended_names():
return _verdict(True, True, True,
f"Note: `{req.requested}` was not found in the live /v1/models "
f"listing but is a current Nous Portal recommendation — accepted.")
return _reject(
f"Model `{req.requested}` was not found in this provider's model listing."
f"{match.suggestion_text}"
)
def _validate_bedrock(req: _Request) -> Optional[dict[str, Any]]:
"""Bedrock's runtime URL has no /models; discovery goes through the AWS control plane
(ListFoundationModels + ListInferenceProfiles). Any failure falls through (None)."""
try:
from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_runtime_region
region = resolve_bedrock_runtime_region()
discovered_ids = {m["id"] for m in discover_bedrock_models(region)}
match = _match_in_catalog(req.requested, list(discovered_ids), auto_correct=False,
suggest_cutoff=0.4)
if match.exact:
return _accept()
# Still accept (custom inference profiles / cross-account access), but warn.
return _soft_accept(
f"Note: `{req.requested}` was not found in Bedrock model discovery for {region}. "
f"It may still work with custom inference profiles or cross-account access."
f"{match.suggestion_text}"
)
except Exception:
return None
def _validate_catalog_fallback(req: _Request) -> dict[str, Any]:
"""The /models probe was unreachable: validate against the curated ``provider_model_ids()``
list so gateway /model switches keep working while a provider's endpoint is down (otherwise
switch_model() would fail and the gateway never writes the session override). No catalog at
all → accept with a warning."""
from hermes_cli import models as _m
label = _m._PROVIDER_LABELS.get(req.normalized, req.normalized)
catalog = _static_catalog(req.normalized)
if not catalog:
return _soft_accept(
f"Note: could not reach the {label} API to validate `{req.requested}`. "
f"If the service isn't down, this model may not be valid."
)
match = _match_in_catalog(req.lookup, catalog, case_insensitive=True)
if match.exact:
return _accept()
# Same OpenRouter routing-variant rule as the live-listing path.
if req.normalized == "openrouter":
variant_base = _m._openrouter_variant_base(req.lookup)
if variant_base is not None and variant_base.lower() in {m.lower() for m in catalog}:
return _accept()
if match.corrected:
corrected = req.with_preset_suffix(match.corrected)
return _corrected(req.requested, corrected)
return _soft_accept(
f"Note: `{req.requested}` was not found in the {label} curated catalog "
f"and the /models endpoint was unreachable.{match.suggestion_text}"
f"\n The model may still work if it exists on the provider."
)
# ── Orchestrator ─────────────────────────────────────────────────────────
def validate_requested_model(
model_name: str,
provider: Optional[str],
*,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
api_mode: Optional[str] = None,
headers: Optional[dict[str, str]] = None,
) -> dict[str, Any]:
"""Validate a ``/model`` value for the active provider.
Returns a dict with: - accepted: whether the CLI should switch to the requested model now -
persist: whether it is safe to save to config - recognized: whether it matched a known provider
catalog - message: optional warning / guidance for the user (- corrected_model: when a typo
was auto-corrected).
"""
from hermes_cli import models as _m
requested = (model_name or "").strip()
normalized = _m.normalize_provider(provider)
if normalized == "openrouter" and base_url and not base_url_host_matches(base_url, "openrouter.ai"):
normalized = "custom"
lookup = requested
if normalized == "copilot":
lookup = _m.normalize_copilot_model_id(requested, api_key=api_key) or requested
if not requested:
return _reject("Model name cannot be empty.")
if normalized == "moa":
return _validate_moa(requested)
if any(ch.isspace() for ch in requested):
return _reject("Model names cannot contain spaces.")
req = _Request(requested, lookup, provider, normalized, api_key, base_url, api_mode, headers)
if normalized == "openrouter":
verdict = _parse_openrouter_preset(req)
if verdict is not None:
return verdict
if normalized == "lmstudio":
return _validate_lmstudio(req)
verdict = _validate_ollama_native(req)
if verdict is not None:
return verdict
if normalized == "custom" or normalized.startswith("custom:"):
return _validate_custom(req)
if normalized in {"openai-codex", "xai-oauth"}:
verdict = _validate_static_catalog(req)
if verdict is not None:
return verdict
if normalized in {"minimax", "minimax-cn"}:
verdict = _validate_minimax(req)
if verdict is not None:
return verdict
if normalized == "anthropic":
verdict = _validate_anthropic(req)
if verdict is not None:
return verdict
if api_mode == "anthropic_messages":
return _validate_anthropic_messages(req)
verdict = _validate_live_listing(req)
if verdict is not None:
return verdict
# API unreachable — accept and persist, but warn so typos don't silently break things.
if normalized == "bedrock":
verdict = _validate_bedrock(req)
if verdict is not None:
return verdict
return _validate_catalog_fallback(req)