Files
aiturk-hermes-ide/hermes_cli/models.py
T

7843 lines
309 KiB
Python

"""
Canonical model catalogs and lightweight validation helpers.
Add, remove, or reorder entries here — both `hermes setup` and
`hermes` provider-selection will pick up the change automatically.
"""
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 difflib import get_close_matches
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 utils import atomic_json_write, base_url_host_matches
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``
discovery probe honors a provider's ``ssl_ca_cert`` / ``ssl_verify``
instead of falling back to the process-wide ``SSL_CERT_FILE`` / certifi
bundle. Returns None when no per-provider TLS override applies, so the
caller keeps urllib's default policy for public/unconfigured endpoints.
"""
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
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
# (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [
# Anthropic
("anthropic/claude-fable-5.1", ""),
("anthropic/claude-fable-5", ""),
("anthropic/claude-opus-5", ""),
("anthropic/claude-opus-5-fast", "2x price, higher output speed"),
("anthropic/claude-opus-4.8", ""),
("anthropic/claude-opus-4.8-fast", "2x price, higher output speed"),
("anthropic/claude-sonnet-5", ""),
("anthropic/claude-haiku-4.5", ""),
# OpenAI
("openai/gpt-5.6-sol", ""),
("openai/gpt-5.6-sol-pro", ""),
("openai/gpt-5.6-terra", ""),
("openai/gpt-5.6-terra-pro", ""),
("openai/gpt-5.6-luna", ""),
("openai/gpt-5.6-luna-pro", ""),
("openai/gpt-5.5", ""),
("openai/gpt-5.5-pro", ""),
("openai/gpt-5.4-mini", ""),
# Google
("google/gemini-3.1-pro-preview", ""),
("google/gemini-3.8-flash", ""),
("google/gemini-3.7-flash", ""),
# xAI
("x-ai/grok-4.6", ""),
# DeepSeek
("deepseek/deepseek-v4-pro", ""),
("deepseek/deepseek-v4-pro-0813", "dated snapshot of v4-pro"),
("deepseek/deepseek-v4-flash", ""),
("deepseek/deepseek-v4-flash-0731", "dated snapshot of v4-flash"),
# Qwen
("qwen/qwen3.8-max", ""),
("qwen/qwen3.8-flash", ""),
# MoonshotAI
("moonshotai/kimi-k3", "recommended"),
# MiniMax
("minimax/minimax-m3", ""),
# Z-AI
("z-ai/glm-5.3", ""),
("z-ai/glm-5.3-flash", ""),
("z-ai/glm-5.2", "default"),
# Xiaomi
("xiaomi/mimo-v2.5-pro", ""),
# Tencent
("tencent/hy4-preview", ""),
("tencent/hy3", ""),
# StepFun
("stepfun/step-3.7-flash", ""),
# NVIDIA
("nvidia/nemotron-3-super-120b-a12b", ""),
# Meta
("meta/muse-spark-1.2", ""),
("meta/muse-spark-1.2-contributor", ""),
("meta/muse-spark-1.3", ""),
("meta/muse-spark-1.3-contributor", ""),
# Sakana
("sakana/fugu-ultra", ""),
# OpenRouter routers
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
# Free tier
("thinkingmachines/inkling:free", "free"),
("thinkingmachines/inkling-small:free", "free"),
("minimax/minimax-m3:free", "free"),
("z-ai/glm-5.2:free", "free"),
("poolside/laguna-s-2.1:free", "free"),
("poolside/laguna-xs-2.1:free", "free"),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
("nvidia/nemotron-3-ultra-550b-a55b:free", "free"),
("nvidia/nemotron-3.5-lightning:free", "free"),
]
_openrouter_catalog_cache: list[tuple[str, str]] | None = None
# Fallback Vercel AI Gateway snapshot used when the live catalog is unavailable.
# OSS / open-weight models prioritized first, then closed-source by family.
# Slugs match Vercel's actual /v1/models catalog (e.g. alibaba/ for Qwen,
# zai/ and xai/ without hyphens).
VERCEL_AI_GATEWAY_MODELS: list[tuple[str, str]] = [
("moonshotai/kimi-k2.6", "recommended"),
("alibaba/qwen3.6-plus", ""),
("zai/glm-5.1", ""),
("minimax/minimax-m2.7", ""),
("anthropic/claude-sonnet-4.6", ""),
("anthropic/claude-opus-4.7", ""),
("anthropic/claude-opus-4.6", ""),
("anthropic/claude-haiku-4.5", ""),
("openai/gpt-5.4", ""),
("openai/gpt-5.4-mini", ""),
("openai/gpt-5.3-codex", ""),
("google/gemini-3.1-pro-preview", ""),
("google/gemini-3-flash", ""),
("google/gemini-3.1-flash-lite-preview", ""),
("xai/grok-4.20-reasoning", ""),
]
_ai_gateway_catalog_cache: list[tuple[str, str]] | None = None
def _codex_curated_models() -> list[str]:
"""Derive the openai-codex curated list from codex_models.py.
Single source of truth: DEFAULT_CODEX_MODELS + forward-compat synthesis.
This keeps the gateway /model picker in sync with the CLI `hermes model`
flow without maintaining a separate static list.
"""
from hermes_cli.codex_models import DEFAULT_CODEX_MODELS, _finalize_codex_models
return _finalize_codex_models(list(DEFAULT_CODEX_MODELS))
# Static fallback for xAI when the models.dev disk cache is empty (fresh
# install, offline first run, etc.). Mirrors the xAI-direct model IDs from
# $HERMES_HOME/models_dev_cache.json as of 2026-04-28. Whenever xAI renames
# or retires a model, the disk cache picks it up on the next refresh and the
# fallback here only matters until that refresh lands.
#
# Models retired by xAI on May 15, 2026 are excluded — see
# https://docs.x.ai/developers/migration/may-15-retirement
# (grok-4, grok-4-0709, grok-4-fast{,-reasoning,-non-reasoning},
# grok-4-1-fast{,-reasoning,-non-reasoning}, grok-code-fast-1 → grok-4.3).
_XAI_STATIC_FALLBACK: list[str] = [
"grok-4.6",
"grok-build-0.1",
"grok-4.5",
"grok-4.3",
"grok-4.20-0309-reasoning",
"grok-4.20-0309-non-reasoning",
"grok-4.20-multi-agent-0309",
]
# Callable via xAI OAuth but omitted from models.dev and /v1/models listings.
_XAI_CURATED_EXTRAS: list[str] = [
"grok-4.6", # GA 2026-08 — kept until the models.dev disk cache refreshes
"grok-4.5", # GA 2026-07 — kept until the models.dev disk cache refreshes
"grok-composer-2.5-fast",
]
_XAI_TOP_MODEL = "grok-4.6"
def _xai_promote_top(ids: list[str]) -> list[str]:
"""Pin the headline xAI model to the top of the curated list."""
if _XAI_TOP_MODEL in ids:
return [_XAI_TOP_MODEL] + [m for m in ids if m != _XAI_TOP_MODEL]
return ids
def _xai_merge_curated_extras(ids: list[str]) -> list[str]:
"""Append Hermes-curated xAI models that are missing from models.dev."""
out = list(ids)
for extra in _XAI_CURATED_EXTRAS:
if extra in out:
continue
# Keep the headline model pinned; slot extras immediately after it.
insert_at = 1 if out and out[0] == _XAI_TOP_MODEL else len(out)
out.insert(insert_at, extra)
return out
def _xai_finalize_catalog(ids: list[str]) -> list[str]:
return _xai_promote_top(_xai_merge_curated_extras(ids))
def _xai_curated_models() -> list[str]:
"""Offline curated floor for xAI / xAI OAuth pickers.
Reads $HERMES_HOME/models_dev_cache.json directly (no network). Falls
back to ``_XAI_STATIC_FALLBACK`` when the cache is empty or unreadable.
"""
try:
from agent.models_dev import _load_disk_cache
data = _load_disk_cache()
xai = data.get("xai") if isinstance(data, dict) else None
models = xai.get("models") if isinstance(xai, dict) else None
if isinstance(models, dict) and models:
ids = [mid for mid in models.keys() if isinstance(mid, str)]
if ids:
return _xai_finalize_catalog(sorted(ids))
except Exception:
# Any failure (missing file, malformed JSON, import error)
# falls through to the static list.
pass
return _xai_finalize_catalog(list(_XAI_STATIC_FALLBACK))
_PROVIDER_MODELS: dict[str, list[str]] = {
"moa": ["default"],
"nous": [
# Anthropic
"anthropic/claude-fable-5.1",
"anthropic/claude-fable-5",
"anthropic/claude-opus-5",
"anthropic/claude-opus-4.8",
"anthropic/claude-sonnet-5",
"anthropic/claude-haiku-4.5",
# OpenAI
"openai/gpt-5.6-sol",
"openai/gpt-5.6-sol-pro",
"openai/gpt-5.6-terra",
"openai/gpt-5.6-terra-pro",
"openai/gpt-5.6-luna",
"openai/gpt-5.6-luna-pro",
"openai/gpt-5.5",
"openai/gpt-5.5-pro",
"openai/gpt-5.4-mini",
# Google
"google/gemini-3.1-pro-preview",
"google/gemini-3.8-flash",
"google/gemini-3.7-flash",
# xAI
"x-ai/grok-4.6",
# DeepSeek
"deepseek/deepseek-v4-pro",
"deepseek/deepseek-v4-pro-0813",
"deepseek/deepseek-v4-flash",
"deepseek/deepseek-v4-flash-0731",
# Qwen
"qwen/qwen3.8-max",
"qwen/qwen3.8-flash",
# MoonshotAI
"moonshotai/kimi-k3",
# MiniMax
"minimax/minimax-m3",
# Z-AI
"z-ai/glm-5.3",
"z-ai/glm-5.3-flash",
"z-ai/glm-5.2",
# Xiaomi
"xiaomi/mimo-v2.5-pro",
# Tencent
"tencent/hy4-preview",
"tencent/hy3",
# StepFun
"stepfun/step-3.7-flash",
# NVIDIA
"nvidia/nemotron-3-super-120b-a12b",
# Sakana
"sakana/fugu-ultra",
],
# Native OpenAI Chat Completions (api.openai.com). Used by /model counts and
# provider_model_ids fallback when /v1/models is unavailable.
"openai": [
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5-mini",
"gpt-5.3-codex",
"gpt-5.2-codex",
"gpt-4.1",
"gpt-4o",
"gpt-4o-mini",
],
"openai-api": [
"gpt-5.6-sol",
"gpt-5.6-sol-pro",
"gpt-5.6-terra",
"gpt-5.6-terra-pro",
"gpt-5.6-luna",
"gpt-5.6-luna-pro",
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5-mini",
"gpt-5.3-codex",
"gpt-4.1",
"gpt-4o",
"gpt-4o-mini",
],
"openai-codex": _codex_curated_models(),
"xai-oauth": _xai_curated_models(),
"copilot-acp": [
"copilot-acp",
],
"copilot": [
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5-mini",
"gpt-5.3-codex",
"gpt-5.2-codex",
"gpt-4.1",
"gpt-4o",
"gpt-4o-mini",
"claude-sonnet-4.6",
"claude-sonnet-5",
"claude-sonnet-4",
"claude-sonnet-4.5",
"claude-haiku-4.5",
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
"gemini-2.5-pro",
],
"gemini": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3.6-flash",
"gemini-3.1-flash-lite-preview",
],
"zai": [
"glm-5.3",
"glm-5.3-flash",
"glm-5.2",
"glm-5.1",
"glm-5",
"glm-5v-turbo",
"glm-5-turbo",
"glm-4.7",
"glm-4.5",
"glm-4.5-flash",
],
"xai": _xai_curated_models(),
"nvidia": [
# NVIDIA flagship reasoning models
"nvidia/nemotron-3-ultra-550b-a55b",
"nvidia/nemotron-3-super-120b-a12b",
"nvidia/nemotron-3.5-lightning-30b-a3b",
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning",
# Third-party agentic models hosted on build.nvidia.com
# (map to OpenRouter defaults — users get familiar picks on NIM)
"z-ai/glm-5.3",
"z-ai/glm-5.2",
"moonshotai/kimi-k2.6",
"minimaxai/minimax-m3",
],
"kimi-coding": [
"kimi-k3",
"kimi-k2.7-code",
"kimi-k2.6",
"kimi-k2.5",
"kimi-for-coding",
"kimi-for-coding-highspeed",
"kimi-k2-thinking",
"kimi-k2-thinking-turbo",
"kimi-k2-turbo-preview",
"kimi-k2-0905-preview",
],
"kimi-coding-cn": [
"kimi-k3",
"kimi-k2.7-code",
"kimi-k2.7-code-highspeed",
"kimi-k2.6",
"kimi-k2.5",
"kimi-k2-thinking",
"kimi-k2-turbo-preview",
"kimi-k2-0905-preview",
],
"stepfun": [
"step-3.5-flash",
"step-3.5-flash-2603",
],
"moonshot": [
"kimi-k3",
"kimi-k2.6",
"kimi-k2.5",
"kimi-k2-thinking",
"kimi-k2-turbo-preview",
"kimi-k2-0905-preview",
],
"minimax": [
"MiniMax-M3",
"MiniMax-M2.7",
"MiniMax-M2.5",
"MiniMax-M2.1",
"MiniMax-M2",
],
"minimax-oauth": [
"MiniMax-M3",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
],
"minimax-cn": [
"MiniMax-M3",
"MiniMax-M2.7",
"MiniMax-M2.5",
"MiniMax-M2.1",
"MiniMax-M2",
],
"anthropic": [
"claude-fable-5",
"claude-sonnet-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-opus-4-5-20251101",
"claude-sonnet-4-5-20250929",
"claude-opus-4-20250514",
"claude-sonnet-4-20250514",
"claude-haiku-4-5-20251001",
],
"deepseek": [
"deepseek-v4-pro",
"deepseek-v4-flash",
],
"xiaomi": [
"mimo-v2.5-pro",
"mimo-v2.5",
"mimo-v2-pro",
"mimo-v2-omni",
"mimo-v2-flash",
],
"tencent-tokenhub": [
"hy4-preview",
"hy3",
"hy3-preview",
],
"tencent-tokenplan": [
"hy4-preview",
"hy3",
"hy3-preview",
],
"arcee": [
"trinity-large-thinking",
"trinity-large-preview",
"trinity-mini",
],
"gmi": [
"zai-org/GLM-5.1-FP8",
"deepseek-ai/DeepSeek-V3.2",
"moonshotai/Kimi-K2.5",
"google/gemini-3.1-flash-lite-preview",
"anthropic/claude-sonnet-5",
"anthropic/claude-sonnet-4.6",
"openai/gpt-5.4",
],
# Synced against https://opencode.ai/docs/zen/ + live GET /zen/v1/models
# (2026-08-20). Zen/Go are _LIVE_FIRST_PICKER_PROVIDERS, so this list is a
# discovery floor — live entries lead in the picker and stale curated
# names never pollute the top.
"opencode-zen": [
"x-preview-f-free", # "Ox Alpha" stealth model — free, 1M ctx, ZDR
"kimi-k3",
"kimi-k2.5",
"kimi-k2.6",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.4-pro",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.1",
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5",
"gpt-5-codex",
"gpt-5-nano",
"claude-fable-5",
"claude-opus-5",
"claude-sonnet-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-opus-4-5",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-sonnet-4",
"claude-haiku-4-5",
"gemini-3.7-flash",
"gemini-3.6-flash",
"gemini-3.5-flash",
"gemini-3.5-flash-lite",
"gemini-3.1-pro",
"gemini-3-flash",
"grok-4.6",
"grok-4.5",
"grok-build-0.1",
"muse-spark-1.2",
"minimax-m3",
"minimax-m2.7",
"minimax-m2.5",
"glm-5.3",
"glm-5.3-flash",
"glm-5.2",
"glm-5.1",
"glm-5",
"kimi-k2.7-code",
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-v4-flash-free",
"qwen3.6-plus",
"qwen3.5-plus",
"big-pickle",
"mimo-v2.5-free",
"hy3-free",
"laguna-s-2.1-free",
"nemotron-3-ultra-free",
"nemotron-3.5-lightning-free",
"muse-spark-1.2-contributor-free",
"muse-spark-1.3-contributor-free",
],
# OpenCode free tier — keyless (no OpenCode account needed). This is the
# OFFLINE FLOOR only: provider_model_ids("opencode-free") revalidates live
# against GET /zen/v1/models (keyless) and filters to the anonymous free
# tier, so a relay-delisted model stops appearing in the picker and a
# newly-live one becomes selectable without a release. This floor keeps the
# picker populated when the relay is unreachable. Note: this floor may lag
# the live relay — that is intentional; the live revalidation is the
# source of truth when reachable. Known-delisted models are REMOVED from
# the floor (x-preview-f-free delisted 2026-08-26 — offline fallback must
# not offer a model that 401s). deepseek-v4-flash-free and mimo-v2.5-free
# are back on the live list.
"opencode-free": [
"deepseek-v4-flash-free",
"hy3-free",
"mimo-v2.5-free",
"laguna-s-2.1-free",
"nemotron-3-ultra-free",
"nemotron-3.5-lightning-free",
"muse-spark-1.2-contributor-free",
"muse-spark-1.3-contributor-free",
],
# Synced against https://opencode.ai/docs/go/ + live GET /zen/go/v1/models
# (2026-08-20).
"opencode-go": [
"kimi-k3",
"kimi-k2.7-code",
"kimi-k2.6",
"kimi-k2.5",
"gpt-5.6-luna",
"grok-4.5",
"glm-5.3",
"glm-5.3-flash",
"glm-5.2",
"glm-5.1",
"glm-5",
"mimo-v2.5-pro",
"mimo-v2.5",
"mimo-v2-pro",
"mimo-v2-omni",
"minimax-m3",
"minimax-m2.7",
"minimax-m2.5",
"deepseek-v4-pro",
"deepseek-v4-flash",
"qwen3.8-max",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
"qwen3.5-plus",
"hy3",
"hy3-preview",
"muse-spark-1.2-contributor",
"muse-spark-1.3-contributor",
# Go-subscription twin of the Zen keyless Ox Alpha (live go/v1
# catalog 2026-08-21; NOT keyless — Go relay requires a Go key).
"ox-alpha-free",
],
"kilocode": [
"anthropic/claude-opus-4.6",
"anthropic/claude-sonnet-4.6",
"openai/gpt-5.4",
"google/gemini-3-pro-preview",
"google/gemini-3-flash-preview",
],
# Alibaba DashScope Coding platform (coding-intl) — default endpoint.
# Supports Qwen models + third-party providers (GLM, Kimi, MiniMax).
# Users with classic DashScope keys should override DASHSCOPE_BASE_URL
# to https://dashscope-intl.aliyuncs.com/compatible-mode/v1 (OpenAI-compat)
# or https://dashscope-intl.aliyuncs.com/apps/anthropic (Anthropic-compat).
"alibaba": [
# Qwen 千问系列 (DashScope / Qwen Cloud)
"qwen3.8-max",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
"qwen3.6-flash",
"kimi-k2.5",
"qwen3.5-plus",
"qwen3-coder-plus",
"qwen3-coder-next",
# Third-party models available on coding-intl / DashScope
"glm-5.2",
"glm-5",
"glm-4.7",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"MiniMax-M2.5",
],
# Alibaba DashScope (China) — same platform as alibaba, domestic endpoint
# (dashscope.aliyuncs.com); same catalog as the international tier.
"alibaba-cn": [
"qwen3.8-max",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
"qwen3.6-flash",
"kimi-k2.5",
"qwen3.5-plus",
"qwen3-coder-plus",
"qwen3-coder-next",
"glm-5.2",
"glm-5",
"glm-4.7",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"MiniMax-M2.5",
],
# Alibaba Coding Plan — same platform as alibaba (DashScope coding-intl),
# separate provider ID with its own base_url_env_var.
"alibaba-coding-plan": [
"qwen3.7-plus",
"qwen3.6-plus",
"qwen3.5-plus",
"qwen3-max-2026-01-23",
"qwen3-coder-plus",
"qwen3-coder-next",
"kimi-k2.5",
"glm-5",
"glm-4.7",
"MiniMax-M2.5",
],
# Alibaba Coding Plan (China) — domestic coding endpoint
# (coding.dashscope.aliyuncs.com); same catalog as the international tier.
"alibaba-coding-plan-cn": [
"qwen3.7-plus",
"qwen3.6-plus",
"qwen3.5-plus",
"qwen3-max-2026-01-23",
"qwen3-coder-plus",
"qwen3-coder-next",
"kimi-k2.5",
"glm-5",
"glm-4.7",
"MiniMax-M2.5",
],
# Alibaba Token Plan (Personal Edition) — dedicated token-plan endpoint
# (token-plan.ap-southeast-1.maas.aliyuncs.com), key tier `sk-sp-...`.
# Catalog verified against a live Token Plan subscription (2026-08-03).
"alibaba-token-plan": [
"qwen3.8-max-preview",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-v3.2",
"kimi-k2.7-code",
"kimi-k2.6",
"kimi-k2.5",
"glm-5.2",
"glm-5.1",
"glm-5",
],
# Alibaba Token Plan (China) — domestic token-plan endpoint
# (token-plan.cn-beijing.maas.aliyuncs.com); same catalog as intl.
"alibaba-token-plan-cn": [
"qwen3.8-max-preview",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-v3.2",
"kimi-k2.7-code",
"kimi-k2.6",
"kimi-k2.5",
"glm-5.2",
"glm-5.1",
"glm-5",
],
# Curated HF model list — only agentic models that map to OpenRouter defaults.
"huggingface": [
"moonshotai/Kimi-K2.5",
"Qwen/Qwen3.5-397B-A17B",
"Qwen/Qwen3.5-35B-A3B",
"deepseek-ai/DeepSeek-V3.2",
"MiniMaxAI/MiniMax-M2.5",
"zai-org/GLM-5",
"XiaomiMiMo/MiMo-V2-Flash",
"moonshotai/Kimi-K2-Thinking",
"moonshotai/Kimi-K2.6",
],
# AWS Bedrock — static fallback list used when dynamic discovery is
# unavailable (no boto3, no credentials, or API error). The agent
# prefers live discovery via ListFoundationModels + ListInferenceProfiles.
# Use inference profile IDs (us.*) since most models require them.
"bedrock": [
"us.anthropic.claude-sonnet-5",
"us.anthropic.claude-sonnet-4-6",
"us.anthropic.claude-opus-4-6-v1",
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"openai.gpt-5.5",
"openai.gpt-5.6-sol",
"openai.gpt-5.6-terra",
"openai.gpt-5.6-luna",
"us.amazon.nova-pro-v1:0",
"us.amazon.nova-lite-v1:0",
"us.amazon.nova-micro-v1:0",
"deepseek.v3.2",
"us.meta.llama4-maverick-17b-instruct-v1:0",
"us.meta.llama4-scout-17b-instruct-v1:0",
],
# Azure Foundry: user-provided endpoint and model.
# Empty list because models depend on the endpoint configuration.
"azure-foundry": [],
# Google Vertex AI — static curated list. Vertex's OpenAI-compatible
# endpoint has no /models listing route, so without this entry the
# /model picker only ever shows the currently-configured model.
# Model IDs use the "google/" publisher prefix Vertex's openapi
# endpoint expects (see hermes_cli/model_setup_flows.py).
# Entries validated live against a GCP project (global region,
# HTTP 200) as of 2026-07-21 (PR #68767).
"vertex": [
"google/gemini-3.1-pro-preview",
"google/gemini-3-pro-preview",
"google/gemini-3.6-flash",
"google/gemini-3.5-flash",
"google/gemini-3.5-flash-lite",
"google/gemini-3-flash-preview",
"google/gemini-3.1-flash-lite-preview",
"google/gemini-3.1-flash-lite",
],
"novita": [
"moonshotai/kimi-k2.5",
"minimax/minimax-m2.7",
"zai-org/glm-5",
"deepseek/deepseek-v3-0324",
"deepseek/deepseek-r1-0528",
"qwen/qwen3-235b-a22b-fp8",
],
}
# Vercel AI Gateway: derive the bare-model-id catalog from the curated
# ``VERCEL_AI_GATEWAY_MODELS`` snapshot so both the picker (tuples with descriptions)
# and the static fallback catalog (bare ids) stay in sync from a single
# source of truth.
_PROVIDER_MODELS["ai-gateway"] = [mid for mid, _ in VERCEL_AI_GATEWAY_MODELS]
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# Nous Portal account tier detection
# ---------------------------------------------------------------------------
def is_nous_free_tier(account_info: dict[str, Any]) -> bool:
"""Return True if the account info indicates a free (unpaid) tier.
Prefer the Portal's explicit ``paid_service_access.allowed`` entitlement
decision. Legacy payloads fall back to ``subscription.monthly_charge == 0``.
Returns False when both signals are missing or unparseable.
"""
paid_access = account_info.get("paid_service_access")
if isinstance(paid_access, dict):
allowed = paid_access.get("allowed")
if isinstance(allowed, bool):
return not allowed
paid = paid_access.get("paid_access")
if isinstance(paid, bool):
return not paid
sub = account_info.get("subscription")
if not isinstance(sub, dict):
return False
charge = sub.get("monthly_charge")
if charge is None:
return False
try:
return float(charge) == 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 paid-tier users: all models are selectable, none unavailable.
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``.
The Portal's ``/api/nous/recommended-models`` endpoint advertises which
models are free *right now* — independent of what the in-repo
``_PROVIDER_MODELS["nous"]`` list happens to contain or whether the
docs-hosted catalog manifest has been rebuilt since the last release.
For free-tier users this is the source of truth: any model the Portal
flags as free should be selectable, even if the user is running an
older Hermes that doesn't ship that model in its hardcoded curated
list. This function returns an augmented ``(model_ids, pricing)``
pair where:
* 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).
* ``pricing`` gets a synthetic ``{"prompt": "0", "completion": "0"}``
entry for any free recommendation missing from the live pricing
map, so :func:`partition_nous_models_by_tier` keeps it.
Failures (network, parse, missing field) are silent and degrade to
returning the inputs unchanged.
"""
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``.
Mirror of :func:`union_with_portal_free_recommendations` for paid-tier
users. The Portal's ``/api/nous/recommended-models`` endpoint advertises
which paid models are blessed *right now* — independent of what the
in-repo ``_PROVIDER_MODELS["nous"]`` list happens to contain or whether
the docs-hosted catalog manifest has been rebuilt since the last release.
For paid-tier users this lets newly-launched paid models surface in the
picker even if the user is running an older Hermes that doesn't ship
them in its hardcoded curated list. This function returns an augmented
``(model_ids, pricing)`` pair where:
* 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. Live pricing is fetched separately
via :func:`get_pricing_for_provider`; if the live endpoint hasn't
published pricing yet, the picker shows a blank price column rather
than fabricating numbers. (The free helper synthesizes ``$0`` so
:func:`partition_nous_models_by_tier` keeps free models selectable;
no equivalent gating applies on the paid side, so synthesis would
only mislead the user.)
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: dict[str, tuple[bool, float]] = {}
def _pricing_profile_key() -> str:
"""Return the stable profile identity for process-local pricing caches."""
from hermes_constants import hermes_home_key
return hermes_home_key()
def get_cached_nous_free_tier() -> Optional[bool]:
"""Return this profile's live cached entitlement, or ``None`` if unknown."""
cached = _free_tier_cache.get(_pricing_profile_key())
if cached is None:
return None
result, cached_at = cached
if time.monotonic() - cached_at >= _FREE_TIER_CACHE_TTL:
return None
return result
def check_nous_free_tier(
*, force_fresh: bool = False, cached_only: 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.
``cached_only`` returns a live cached answer or the fail-open ``False``
default without contacting Portal.
Returns True only when entitlement is known to be free. Unknown/error
states return False so this compatibility wrapper does not block users.
"""
now = time.monotonic()
profile_key = _pricing_profile_key()
if not force_fresh:
cached_result = get_cached_nous_free_tier()
if cached_result is not None:
return cached_result
if cached_only:
return False
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[profile_key] = (result, now)
return result
except Exception:
_free_tier_cache[profile_key] = (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.
When the live fetch fails (network, parse, non-2xx) and the in-process
cache is empty, the disk copy is returned instead of ``{}`` — so a
transient Portal hiccup no longer silently drops the free/paid model
recommendations from the picker. Self-heals on the next successful fetch.
Returns the parsed JSON dict, or ``{}`` only when neither the network nor
any cache layer can supply data. Callers must treat missing/null fields
as "no recommendation" and fall back to their own default.
"""
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.
Picks the best field from the Portal's recommended-models payload:
* ``vision=True`` → ``paidRecommendedVisionModel`` (paid tier) or
``freeRecommendedVisionModel`` (free tier)
* ``vision=False`` → ``paidRecommendedCompactionModel`` or
``freeRecommendedCompactionModel``
When ``free_tier`` is ``None`` (default) the user's tier is auto-detected
via :func:`check_nous_free_tier`. Pass an explicit bool to bypass the
detection — useful for tests or when the caller already knows the tier.
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).
Returns ``None`` when every candidate is missing, null, or the fetch
fails — callers should fall back to their own default (currently
``google/gemini-3-flash-preview``).
"""
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
# ---------------------------------------------------------------------------
# Canonical provider list — single source of truth for provider identity.
# Every code path that lists, displays, or iterates providers derives from
# this list: hermes model, /model, list_authenticated_providers.
#
# Fields:
# slug — internal provider ID (used in config.yaml, --provider flag)
# label — short display name
# tui_desc — longer description for the `hermes model` interactive picker
# ---------------------------------------------------------------------------
class ProviderEntry(NamedTuple):
slug: str
label: str
tui_desc: str # detailed description for `hermes model` TUI
CANONICAL_PROVIDERS: list[ProviderEntry] = [
ProviderEntry("nous", "Nous Portal", "Nous Portal (Everything your agent needs, 300+ models with bundled tool use)"),
ProviderEntry("fireworks", "Fireworks AI", "Fireworks AI (OpenAI-compatible direct model API)"),
ProviderEntry("openrouter", "OpenRouter", "OpenRouter (Pay-per-use API aggregator)"),
ProviderEntry("moa", "Mixture of Agents", "Mixture of Agents (named presets; aggregator acts after reference models)"),
ProviderEntry("novita", "NovitaAI", "NovitaAI (Cloud: Model API, Agent Sandbox, GPU Cloud)"),
ProviderEntry("lmstudio", "LM Studio", "LM Studio (Local desktop app with built-in model server)"),
ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models via API key or Claude Code)"),
ProviderEntry("openai-codex", "ChatGPT or Codex Subscription", "ChatGPT or Codex Subscription (Sign in with your ChatGPT account, uses Codex models)"),
ProviderEntry("openai-api", "OpenAI API", "OpenAI API (api.openai.com, API key)"),
ProviderEntry("alibaba", "Qwen Cloud", "Qwen Cloud / DashScope (Qwen + multi-provider)"),
ProviderEntry("xai-oauth", "xAI Grok OAuth (SuperGrok / Premium+)", "xAI Grok OAuth (SuperGrok / Premium+ subscription)"),
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2.5 and V2 models: pro, omni, flash)"),
ProviderEntry("tencent-tokenhub", "Tencent TokenHub", "Tencent TokenHub (Hy4 preview via tokenhub.tencentmaas.com)"),
ProviderEntry("tencent-tokenplan", "Tencent TokenPlan", "Tencent TokenPlan (Hy4 preview via api.lkeap.cloud.tencent.com, Anthropic Messages)"),
ProviderEntry("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models via build.nvidia.com or local NIM)"),
ProviderEntry("copilot", "GitHub Copilot", "GitHub Copilot (Uses GITHUB_TOKEN or gh auth token)"),
ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (Spawns copilot --acp --stdio)"),
ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers"),
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Native Gemini API)"),
ProviderEntry("vertex", "Google Vertex AI", "Google Vertex AI (Gemini via GCP; OAuth2 service account or ADC, GCP billing/quotas)"),
ProviderEntry("deepseek", "DeepSeek", "DeepSeek (V3, R1, coder, direct API)"),
ProviderEntry("xai", "xAI", "xAI Grok (Direct API)"),
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu direct API)"),
ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "Kimi Coding Plan (api.kimi.com & Moonshot API)"),
ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Domestic direct API)"),
ProviderEntry("stepfun", "StepFun Step Plan", "StepFun Step Plan (Agent / coding models via Step Plan API)"),
ProviderEntry("minimax", "MiniMax", "MiniMax (Global direct API)"),
ProviderEntry("minimax-oauth", "MiniMax (OAuth)", "MiniMax via OAuth browser login (Coding Plan, minimax.io)"),
ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (Domestic direct API)"),
ProviderEntry("ollama-cloud", "Ollama Cloud", "Ollama Cloud (Cloud-hosted open models, ollama.com)"),
ProviderEntry("arcee", "Arcee AI", "Arcee AI (Trinity models, direct API)"),
ProviderEntry("gmi", "GMI Cloud", "GMI Cloud (Multi-model direct API)"),
ProviderEntry("kilocode", "Kilo Code", "Kilo Code (Kilo Gateway API)"),
ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (Curated models, pay-as-you-go)"),
ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (Open models subscription)"),
ProviderEntry("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek; IAM or API key)"),
ProviderEntry("azure-foundry", "Azure Foundry", "Azure Foundry (OpenAI-style or Anthropic-style endpoint, your Azure AI deployment)"),
ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway (Multi-model aggregator)"),
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (Reuses local Qwen CLI login)"),
]
# Auto-extend CANONICAL_PROVIDERS with any provider registered in providers/
# that is not already in the list above. Adding plugins/model-providers/<name>/
# is sufficient to expose a new provider in the model picker, /model, and all
# downstream consumers — no edits to this file needed.
_canonical_slugs = {p.slug for p in CANONICAL_PROVIDERS}
try:
from providers import list_providers as _list_providers_for_canonical
for _pp in _list_providers_for_canonical():
if _pp.name in _canonical_slugs:
continue
if _pp.auth_type in {"oauth_device_code", "oauth_external", "external_process", "aws_sdk", "copilot", "vertex"}:
continue # non-api-key flows need bespoke picker UX; skip auto-inject
_label = _pp.display_name or _pp.name
_desc = _pp.description or f"{_label} (direct API)"
CANONICAL_PROVIDERS.append(ProviderEntry(_pp.name, _label, _desc))
_canonical_slugs.add(_pp.name)
except Exception:
pass
# Derived dicts — used throughout the codebase
_PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
# ---------------------------------------------------------------------------
# Provider groups — DISPLAY ONLY
#
# Some vendors expose several Hermes provider slugs (one per endpoint /
# auth method: global API, China API, OAuth coding plan, ...). Listing every
# slug as a top-level row in the interactive `hermes model` / setup wizard /
# Telegram `/model` pickers makes that list long and noisy.
#
# These groups fold related slugs under one top-level row in INTERACTIVE
# PICKERS only. They do NOT change ``CANONICAL_PROVIDERS``, slug identity,
# the ``--provider`` flag, ``/model <provider:model>``, or any typed path —
# every member slug remains individually addressable. Grouping is a pure
# display affordance; ``group_providers()`` is the single fold used by all
# three picker surfaces so they stay consistent.
#
# group_id -> (display_label, group_description, [member_slug, ...])
#
# ``group_description`` is a short blurb shown on the collapsed top-level group
# row in the interactive pickers (alongside the label). Member-specific detail
# lives in each member's ``tui_desc`` and shows in the drill-down sub-picker.
# Member order is the order shown inside the group submenu.
# ---------------------------------------------------------------------------
PROVIDER_GROUPS: dict[str, tuple[str, str, list[str]]] = {
"kimi": ("Kimi / Moonshot", "Coding Plan, Moonshot global & China endpoints", ["kimi-coding", "kimi-coding-cn"]),
"minimax": ("MiniMax", "Global, OAuth Coding Plan & China endpoints", ["minimax", "minimax-oauth", "minimax-cn"]),
"xai": ("xAI Grok", "Direct API or SuperGrok / Premium+ OAuth", ["xai", "xai-oauth"]),
"google": ("Google Gemini", "Google AI Studio (API key)", ["gemini"]),
"openai": ("OpenAI", "ChatGPT/Codex subscription or direct OpenAI API", ["openai-codex", "openai-api"]),
"qwen": ("Qwen", "Qwen Cloud / DashScope, Coding Plan, Token Plan & Qwen CLI OAuth", ["alibaba", "alibaba-cn", "alibaba-coding-plan", "alibaba-coding-plan-cn", "alibaba-token-plan", "alibaba-token-plan-cn", "qwen-oauth"]),
"opencode": ("OpenCode", "Zen pay-as-you-go, Go subscription, or free tier", ["opencode-zen", "opencode-go", "opencode-free"]),
"copilot": ("GitHub Copilot", "GitHub token API or copilot --acp process", ["copilot", "copilot-acp"]),
"tencent": ("Tencent Hy", "Hy4 / Hy3 via TokenHub & TokenPlan", ["tencent-tokenhub", "tencent-tokenplan"]),
}
# Reverse index: member slug -> group_id. Built once at import.
_SLUG_TO_GROUP: dict[str, str] = {
slug: gid for gid, (_label, _desc, members) in PROVIDER_GROUPS.items() for slug in members
}
def provider_group_for_slug(slug: str) -> str:
"""Return the group_id a provider slug belongs to, or "" if ungrouped."""
return _SLUG_TO_GROUP.get(str(slug or "").strip().lower(), "")
def group_providers(slugs):
"""Fold a flat ordered slug iterable into picker rows by provider group.
DISPLAY ONLY. Used by every interactive picker (``hermes model``, the
setup wizard, the Telegram ``/model`` keyboard) so grouping is identical
across surfaces.
Each returned row is a dict::
{"kind": "single", "slug": <slug>} # ungrouped, or
# 1-member group
{"kind": "group", "group_id": <gid>, "label": <label>,
"description": <desc>, "members": [<slug>, ...]} # 2+ members
Rules:
* A group row appears at the position of its FIRST present member, in
the input order. Subsequent members fold into that row (and are not
emitted again).
* Member order inside a group follows ``PROVIDER_GROUPS`` declaration,
restricted to the members actually present in ``slugs``.
* A group reduced to a single present member degrades to a ``single``
row — no pointless one-item submenu.
* Slugs not in any group pass through as ``single`` rows, order
preserved.
* Duplicate slugs in the input are ignored after first sight.
"""
seen: set[str] = set()
# Which present members each group has, in declaration order.
group_members: dict[str, list[str]] = {}
for gid, (_label, _desc, members) in PROVIDER_GROUPS.items():
present = [m for m in members if m in set(slugs)]
if present:
group_members[gid] = present
rows = []
emitted_groups: set[str] = set()
for slug in slugs:
s = str(slug or "").strip().lower()
if not s or s in seen:
continue
seen.add(s)
gid = _SLUG_TO_GROUP.get(s, "")
if not gid:
rows.append({"kind": "single", "slug": s})
continue
if gid in emitted_groups:
continue # already folded at the first member's position
emitted_groups.add(gid)
members = group_members.get(gid, [s])
if len(members) <= 1:
rows.append({"kind": "single", "slug": members[0]})
else:
label, desc, _ = PROVIDER_GROUPS[gid]
rows.append(
{"kind": "group", "group_id": gid, "label": label,
"description": desc, "members": list(members)}
)
return rows
_PROVIDER_ALIASES = {
"glm": "zai",
"z-ai": "zai",
"z.ai": "zai",
"zhipu": "zai",
"github": "copilot",
"github-copilot": "copilot",
"github-models": "copilot",
"github-model": "copilot",
"github-copilot-acp": "copilot-acp",
"copilot-acp-agent": "copilot-acp",
"google": "gemini",
"google-gemini": "gemini",
"google-ai-studio": "gemini",
"google-vertex": "vertex",
"vertex-ai": "vertex",
"gcp-vertex": "vertex",
"vertexai": "vertex",
"kimi": "kimi-coding",
"moonshot": "kimi-coding",
"kimi-cn": "kimi-coding-cn",
"moonshot-cn": "kimi-coding-cn",
"step": "stepfun",
"stepfun-coding-plan": "stepfun",
"arcee-ai": "arcee",
"arceeai": "arcee",
"gmi-cloud": "gmi",
"gmicloud": "gmi",
"fireworks-ai": "fireworks",
"fw": "fireworks",
"actual-computer": "actual",
"actualcomputer": "actual",
"aci": "actual",
"nebius": "nebius-token-factory",
"nebius-tokenfactory": "nebius-token-factory",
"nebius-tf": "nebius-token-factory",
"token-factory": "nebius-token-factory",
"tokenfactory": "nebius-token-factory",
"minimax-china": "minimax-cn",
"minimax_cn": "minimax-cn",
"minimax-portal": "minimax-oauth",
"minimax-global": "minimax-oauth",
"minimax_oauth": "minimax-oauth",
"claude": "anthropic",
"claude-code": "anthropic",
"deep-seek": "deepseek",
"opencode": "opencode-zen",
"zen": "opencode-zen",
"go": "opencode-go",
"opencode-go-sub": "opencode-go",
"free": "opencode-free",
"opencode_free": "opencode-free",
"aigateway": "ai-gateway",
"vercel": "ai-gateway",
"vercel-ai-gateway": "ai-gateway",
"kilo": "kilocode",
"kilo-code": "kilocode",
"kilo-gateway": "kilocode",
"dashscope": "alibaba",
"aliyun": "alibaba",
"qwen": "alibaba",
"alibaba-cloud": "alibaba",
"qwen-portal": "qwen-oauth",
"hf": "huggingface",
"hugging-face": "huggingface",
"huggingface-hub": "huggingface",
"novita-ai": "novita",
"novitaai": "novita",
"mimo": "xiaomi",
"xiaomi-mimo": "xiaomi",
"tencent": "tencent-tokenhub",
"tokenhub": "tencent-tokenhub",
"tencent-cloud": "tencent-tokenhub",
"tencentmaas": "tencent-tokenhub",
"tokenplan": "tencent-tokenplan",
"tencent-lkeap": "tencent-tokenplan",
"aws": "bedrock",
"aws-bedrock": "bedrock",
"amazon-bedrock": "bedrock",
"amazon": "bedrock",
"grok": "xai",
"grok-oauth": "xai-oauth",
"xai-oauth": "xai-oauth",
"x-ai-oauth": "xai-oauth",
"xai-grok-oauth": "xai-oauth",
"x-ai": "xai",
"x.ai": "xai",
"nim": "nvidia",
"nvidia-nim": "nvidia",
"build-nvidia": "nvidia",
"nemotron": "nvidia",
"lmstudio": "lmstudio",
"lm-studio": "lmstudio",
"lm_studio": "lmstudio",
"ollama": "custom", # bare "ollama" = local; use "ollama-cloud" for cloud
"ollama_cloud": "ollama-cloud",
}
# In-repo fallback for the model Hermes silently lands on when the user never
# picked one (GUI onboarding confirm card, empty ``model.default``,
# provider-set-but-model-missing resolution). The AUTHORITATIVE source is the
# remote model catalog: the manifest labels exactly one entry per provider
# with ``"default": true`` (see get_default_model_from_cache in
# model_catalog.py), so maintainers can rotate the default without shipping a
# release. This constant is the offline/fresh-install fallback and MUST match
# the labeled entry in website/static/api/model-catalog.json. Deliberately a
# capable low-cost model rather than the curated lists' entry [0]: aggregator
# lists are ordered most-capable-first, so [0] is the priciest Anthropic
# flagship (claude-fable-5 / opus) — silently billing the most expensive model
# for traffic the user never opted into.
PREFERRED_SILENT_DEFAULT_MODEL = "z-ai/glm-5.2"
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 ""
# Providers whose *silent* auto-default must go through the cost-safe
# catalog-labeled default (``get_preferred_silent_default_model``) instead of
# curated-list entry [0]. Metered aggregators (Nous Portal, OpenRouter) order
# their lists best-/most-capable-first — entry [0] is the priciest flagship
# (``anthropic/claude-fable-5``). Using that as the non-interactive fallback
# when a profile sets a provider with no model silently bills the most
# expensive model for traffic the user never opted into (a missing default
# escalated to Opus and billed 863 requests before the user noticed). The
# catalog manifest labels the default entry (``"default": true``) so it can
# rotate without a release; a missing model must never escalate to the
# flagship.
#
# This is deliberately a network-free lookup for the hot resolution path
# (cache-only catalog read). The *interactive* default (GUI onboarding /
# ``hermes model``) uses the richer free/paid-tier-aware resolver — see
# ``get_recommended_default_model`` in hermes_cli/web_server.py and
# ``partition_nous_models_by_tier`` — which can hit the Portal.
_SILENT_DEFAULT_PROVIDERS: frozenset[str] = frozenset({"nous", "openrouter"})
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``).
For most providers this is the first entry in ``_PROVIDER_MODELS`` — the
same model the ``hermes model`` picker offers first. For metered aggregators
whose curated list is ordered most-capable-first, that entry is also the
most EXPENSIVE one, so silently defaulting to it is a billing footgun.
Those providers (``_SILENT_DEFAULT_PROVIDERS``) resolve through the
catalog-labeled default instead; a missing model must never auto-escalate
to the flagship.
"""
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. Only hide models
whose ``supported_parameters`` is an explicit list that omits ``tools``.
Ported from Kilo-Org/kilocode#9068.
"""
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
def parse_openrouter_reasoning_capabilities(item: Any) -> Optional[dict[str, Any]]:
"""Normalize one OpenRouter catalog entry's reasoning metadata.
OpenRouter's ``/v1/models`` catalog advertises reasoning support two ways:
``supported_parameters`` contains ``"reasoning"`` when the route accepts
reasoning controls at all, and a top-level ``reasoning`` object may add
detail (``mandatory``, ``supported_efforts``). Per OpenRouter semantics
the top-level object is only trusted after ``supported_parameters``
confirms the route accepts reasoning controls; ``supported_efforts``
omitted/None means every effort is accepted.
Returns:
``{"supports_reasoning": True, "supported_efforts": [...] | None,
"mandatory": bool}`` when the entry advertises reasoning controls,
``{"supports_reasoning": False}`` when it explicitly does not
(``supported_parameters`` is a list omitting ``reasoning``), or
``None`` when capability can't be determined from the entry
(missing/malformed ``supported_parameters``).
Ported from PrimeIntellect-ai/prime-agent#1258 (derive reasoning levels
from provider metadata instead of hardcoded model-family lists).
"""
if not isinstance(item, dict):
return None
params = item.get("supported_parameters")
if not isinstance(params, list):
# Field absent / malformed — unknown capability (mirror the
# permissive stance of _openrouter_model_supports_tools).
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,
}
# model id → parsed reasoning capabilities (see
# parse_openrouter_reasoning_capabilities). Populated by one full-catalog
# fetch and kept for the process lifetime — model capabilities don't change.
_openrouter_reasoning_caps_cache: dict[str, Optional[dict[str, Any]]] | None = None
# monotonic timestamp of the last FAILED fetch; suppresses re-fetch storms
# from per-turn callers while the catalog is unreachable (60s TTL, mirrors
# the LM Studio/Ollama capability-probe caching in run_agent.py).
_openrouter_reasoning_caps_failed_at: float | None = None
# ── 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. Persisting the parsed catalog makes every run after
# the first correct from its first turn.
#
# 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]:
try:
with _reasoning_caps_disk_path().open(encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
return {}
return data if isinstance(data, dict) else {}
def _load_reasoning_caps_disk(
url: str,
) -> tuple[Optional[dict[str, Optional[dict[str, Any]]]], float]:
"""Return ``(caps, age_seconds)`` for *url*, or ``(None, 0.0)``."""
entry = _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: dict[str, Optional[dict[str, Any]]]
) -> None:
"""Merge *url*'s catalog into the shared disk mirror, atomically."""
try:
data = _read_reasoning_caps_disk()
data[url] = {"ts": time.time(), "caps": caps}
path = _reasoning_caps_disk_path()
path.parent.mkdir(parents=True, exist_ok=True)
atomic_json_write(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. Skipped under pytest, where a
mid-suite background fetch would make cache state, and therefore test
behavior, timing-dependent.
"""
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):
"""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[dict[str, Optional[dict[str, Any]]]]:
"""Parse a ``/v1/models`` ``data`` array and mirror it for *url*.
Takes the payload rather than fetching it, so the picker and pricing
fetches — which pull the same document a capability fetch would — leave the
mirror warm at no network cost. 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: dict[str, Optional[dict[str, Any]]] = {}
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[dict[str, Optional[dict[str, Any]]]]:
"""Fetch one OpenRouter-shaped ``/v1/models`` catalog → per-model caps.
Shared by every aggregator that serves OpenRouter's catalog schema
(OpenRouter itself, Nous Portal). Returns None when the catalog is
unreachable or carries no usable entries, so callers can remember the
failure and fall back rather than caching an empty result.
Sends a User-Agent because the Portal 403s anonymous catalog reads.
"""
headers = {"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 None
return _seed_reasoning_caps(url, payload.get("data"))
_OPENROUTER_CATALOG_URL = "https://openrouter.ai/api/v1/models"
def _fetch_openrouter_reasoning_caps(
timeout: float = 6.0, *, force: bool = False
) -> Optional[dict[str, Optional[dict[str, Any]]]]:
"""Fetch + cache per-model reasoning capabilities from the live catalog.
Returns None (without poisoning the cache) when the catalog is
unreachable so callers can retry later and fall back in the meantime.
Failed fetches are remembered for 60 seconds so hot per-turn callers
don't pay an HTTP round-trip on every call while offline. *force* refetches
past a cache populated from the disk mirror.
"""
global _openrouter_reasoning_caps_cache, _openrouter_reasoning_caps_failed_at
if _openrouter_reasoning_caps_cache is not None and not force:
return _openrouter_reasoning_caps_cache
if (
_openrouter_reasoning_caps_failed_at is not None
and (time.monotonic() - _openrouter_reasoning_caps_failed_at) < 60
):
return None
caps_by_id = _fetch_reasoning_caps_catalog(_OPENROUTER_CATALOG_URL, timeout)
if caps_by_id is None:
_openrouter_reasoning_caps_failed_at = time.monotonic()
return None
_openrouter_reasoning_caps_cache = caps_by_id
return caps_by_id
def _refresh_openrouter_reasoning_caps() -> None:
_fetch_openrouter_reasoning_caps(force=True)
def openrouter_model_reasoning_capabilities(
model_id: Optional[str],
*,
timeout: float = 6.0,
allow_fetch: bool = False,
) -> Optional[dict[str, Any]]:
"""Return live-catalog reasoning capabilities for an OpenRouter model.
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 yet, model not listed
(private/custom route), or entry malformed. Callers should fall back
to their static heuristics rather than treating this as a negative.
By default this is a CACHE-ONLY lookup — safe on per-request hot paths
(never blocks on HTTP). The cache is populated for free whenever
``fetch_openrouter_models()`` runs (model picker, setup), from the disk
mirror a previous run left behind, by the non-blocking
``warm_openrouter_reasoning_caps_async()`` warmer, or by passing
``allow_fetch=True`` from non-latency-sensitive callers.
"""
model = str(model_id or "").strip()
if not model:
return None
caps_by_id = _openrouter_caps_cached()
if caps_by_id is None and allow_fetch:
caps_by_id = _fetch_openrouter_reasoning_caps(timeout=timeout)
if caps_by_id is None:
return None
return caps_by_id.get(model)
_openrouter_caps_disk_checked = False
_openrouter_caps_warm_started = False
def _openrouter_caps_cached() -> Optional[dict[str, Optional[dict[str, Any]]]]:
"""Cache-only OpenRouter caps: memory, else the disk mirror. Never HTTP."""
global _openrouter_reasoning_caps_cache, _openrouter_caps_disk_checked
if _openrouter_reasoning_caps_cache is None and not _openrouter_caps_disk_checked:
_openrouter_caps_disk_checked = True
_openrouter_reasoning_caps_cache = _hydrate_reasoning_caps_from_disk(
_OPENROUTER_CATALOG_URL, _refresh_openrouter_reasoning_caps
)
return _openrouter_reasoning_caps_cache
def warm_openrouter_reasoning_caps_async() -> None:
"""Warm the OpenRouter reasoning-capability cache in the background."""
global _openrouter_caps_warm_started
if _openrouter_caps_warm_started or _openrouter_caps_cached() is not None:
return
_openrouter_caps_warm_started = True
_warm_reasoning_caps_async(_refresh_openrouter_reasoning_caps)
# Nous Portal serves OpenRouter's catalog schema, so the same parser and
# tri-state contract apply. Kept in its own cache because the two catalogs
# list different models (and different capabilities for shared ids).
_nous_reasoning_caps_cache: dict[str, Optional[dict[str, Any]]] | None = None
_nous_reasoning_caps_failed_at: float | None = None
def nous_catalog_url() -> str:
"""The Portal ``/v1/models`` URL for the endpoint we actually talk to.
Resolved through the documented ladder rather than pinned to production —
``NOUS_INFERENCE_BASE_URL`` → resolved credential base → prod — so a
staging profile reads staging's capabilities. Reading prod's would answer
the reasoning-mandatory question for the wrong deployment.
"""
return f"{_resolve_nous_pricing_credentials()[1]}/v1/models"
def _fetch_nous_reasoning_caps(
timeout: float = 6.0, *, force: bool = False
) -> Optional[dict[str, Optional[dict[str, Any]]]]:
"""Nous Portal counterpart of :func:`_fetch_openrouter_reasoning_caps`."""
global _nous_reasoning_caps_cache, _nous_reasoning_caps_failed_at
if _nous_reasoning_caps_cache is not None and not force:
return _nous_reasoning_caps_cache
if (
_nous_reasoning_caps_failed_at is not None
and (time.monotonic() - _nous_reasoning_caps_failed_at) < 60
):
return None
caps_by_id = _fetch_reasoning_caps_catalog(nous_catalog_url(), timeout)
if caps_by_id is None:
_nous_reasoning_caps_failed_at = time.monotonic()
return None
_nous_reasoning_caps_cache = caps_by_id
return caps_by_id
def _refresh_nous_reasoning_caps() -> None:
_fetch_nous_reasoning_caps(force=True)
def nous_model_reasoning_capabilities(
model_id: Optional[str],
*,
timeout: float = 6.0,
allow_fetch: bool = False,
) -> Optional[dict[str, Any]]:
"""Return live-catalog reasoning capabilities for a Nous Portal model.
Same tri-state contract and cache-only default as
:func:`openrouter_model_reasoning_capabilities`; warm the cache with
:func:`warm_nous_reasoning_caps_async` from hot paths.
"""
model = str(model_id or "").strip()
if not model:
return None
caps_by_id = _nous_caps_cached()
if caps_by_id is None and allow_fetch:
caps_by_id = _fetch_nous_reasoning_caps(timeout=timeout)
if caps_by_id is None:
return None
return caps_by_id.get(model)
_nous_caps_disk_checked = False
_nous_caps_warm_started = False
def _nous_caps_cached() -> Optional[dict[str, Optional[dict[str, Any]]]]:
"""Cache-only Portal caps: memory, else the disk mirror. Never HTTP.
Guarded to one attempt per process because naming the catalog means
resolving Portal credentials, which can itself reach the network to
refresh a token — far too expensive for a caller that runs every turn.
"""
global _nous_reasoning_caps_cache, _nous_caps_disk_checked
if _nous_reasoning_caps_cache is None and not _nous_caps_disk_checked:
_nous_caps_disk_checked = True
_nous_reasoning_caps_cache = _hydrate_reasoning_caps_from_disk(
nous_catalog_url(), _refresh_nous_reasoning_caps
)
return _nous_reasoning_caps_cache
def warm_nous_reasoning_caps_async() -> None:
"""Nous Portal counterpart of :func:`warm_openrouter_reasoning_caps_async`."""
global _nous_caps_warm_started
if _nous_caps_warm_started or _nous_caps_cached() is not None:
return
_nous_caps_warm_started = True
_warm_reasoning_caps_async(_refresh_nous_reasoning_caps)
def refresh_reasoning_caps_async(provider: Optional[str]) -> None:
"""Force a background re-fetch of *provider*'s reasoning-capability catalog.
The in-memory cache is otherwise held for the process lifetime, so a
route that flips to reasoning-mandatory mid-process (GLM-5.3-flash, Sep
2026) keeps being sent disables it now rejects. Called from the
conversation loop's reasoning_mandatory recovery so the profile guard is
right again on the next request; no-op for providers without a catalog.
"""
refresh = {
"nous": _refresh_nous_reasoning_caps,
"nous-portal": _refresh_nous_reasoning_caps,
"nousresearch": _refresh_nous_reasoning_caps,
"openrouter": _refresh_openrouter_reasoning_caps,
}.get(str(provider or "").strip().lower())
if refresh is not None:
_warm_reasoning_caps_async(refresh)
# Canonical low→high ordering used for nearest-level clamping. Kept as an
# alias of the single source of truth in ``agent.reasoning_effort``.
from agent.reasoning_effort import EFFORT_LADDER as _REASONING_EFFORT_ORDER
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.
Ported from PrimeIntellect-ai/prime-agent#1258's thinking-level-map
normalization.
"""
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]]] = {}
_pricing_provider_cache_keys: dict[tuple[str, 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 a base URL but no credential — newest first, and
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.
Examples:
"0.000003" → "$3.00" (per million tokens)
"0.00003" → "$30.00"
"0.00000015" → "$0.15"
"0.0000001" → "$0.10"
"0.00018" → "$180.00"
"0.0000000018" → "$0.0018" (promo: $0.0018/Mtok)
"0" → "free"
"""
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. Percent is
``round((1 - current/original) * 100)`` — never hardcoded, and a discount
that rounds below 1% is treated as no sale (never render "-0%"). Returns
``None`` when there is no sale (missing/equal/invalid original), so UIs
show normal prices.
Free / $0 models are a special case: they are always "-100%" sale chrome
(Teknium, Aug 2026 — the picker's discount column should say 100% off
rather than sit blank on free rows). The ``was_*`` raws come from
``original`` when the gateway serves one and are empty strings otherwise;
callers must skip the "was" segment when both are empty.
"""
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. Other providers never opt in — OpenRouter
(and anything else sharing this helper) keeps the legacy
``{prompt, completion}`` shape even if a response happens to nest
``original``.
"""
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.
The Nous inference ``/v1/models`` endpoint exposes pricing without
authentication, so the api_key is best-effort: when runtime credential
resolution fails (expired refresh token, missing auth.json, etc.) we
still return a usable inference base URL so the picker keeps working
with anonymous pricing data. Free-tier users in particular need this
— pricing drives the free/paid partition, and silently returning empty
pricing because of an auth blip makes the picker look broken ("No free
models currently available").
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 ``None`` or empty *allowed* leaves the list untouched.
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_cached_nous_inference_base_url() -> str:
"""Return the profile's persisted Nous endpoint without refreshing auth."""
try:
from hermes_cli.auth import (
_load_auth_store,
_load_provider_state,
_optional_base_url,
_validate_nous_inference_url_from_network,
)
state = _load_provider_state(_load_auth_store(), "nous") or {}
return (
_validate_nous_inference_url_from_network(
_optional_base_url(state.get("inference_base_url"))
)
or ""
).rstrip("/").removesuffix("/v1")
except Exception:
return ""
def pricing_cache_scope(
provider: str,
*,
current_provider: str = "",
current_base_url: str = "",
) -> str:
"""Return the current endpoint identity used by a provider's pricing cache.
This only resolves local configuration; it never fetches a catalog. Picker
prewarm single-flight uses the result to let an endpoint rotation start a
new worker while the previous endpoint is still slow or unreachable.
"""
normalized = normalize_provider(provider)
if normalized == "openrouter":
return "https://openrouter.ai/api"
if normalized == "ai-gateway":
from hermes_constants import AI_GATEWAY_BASE_URL
return AI_GATEWAY_BASE_URL.rstrip("/")
if normalized == "novita":
return (
os.getenv("NOVITA_BASE_URL", "").strip()
or "https://api.novita.ai/openai/v1"
).rstrip("/")
if normalized == "deepinfra":
cache_key, _url = _deepinfra_catalog_url()
return cache_key
if normalized == "fireworks":
return "models.dev/fireworks"
if normalized == "nous":
try:
from hermes_cli.auth import _nous_inference_env_override
env_base = _nous_inference_env_override()
except Exception:
env_base = None
if env_base:
return env_base.rstrip("/").removesuffix("/v1")
if normalize_provider(current_provider) == "nous" and current_base_url:
return current_base_url.rstrip("/").removesuffix("/v1")
persisted_base = get_cached_nous_inference_base_url()
if persisted_base:
return persisted_base
return _pricing_provider_cache_keys.get(
(_pricing_profile_key(), normalized), _DEFAULT_NOUS_INFERENCE_BASE
)
return ""
def get_pricing_for_provider(
provider: str,
*,
force_refresh: bool = False,
cached_only: bool = False,
) -> dict[str, dict[str, str]]:
"""Return pricing for providers that publish it.
``cached_only`` never starts provider I/O. Normal picker opens use it so
cold endpoints cannot hold the response path; a background prewarm fills
the same caches for later opens.
"""
normalized = normalize_provider(provider)
if cached_only:
if normalized == "deepinfra":
cache_key, _url = _deepinfra_catalog_url()
if cache_key not in _deepinfra_catalog_cache:
return {}
return _fetch_deepinfra_pricing()
cache_key = _pricing_provider_cache_keys.get(
(_pricing_profile_key(), normalized)
)
if cache_key is None:
if normalized == "openrouter":
cache_key = "https://openrouter.ai/api"
elif normalized == "ai-gateway":
from hermes_constants import AI_GATEWAY_BASE_URL
cache_key = AI_GATEWAY_BASE_URL.rstrip("/")
elif normalized == "fireworks":
cache_key = "models.dev/fireworks"
return (_cached_catalog(cache_key) or {}) if cache_key else {}
if normalized == "openrouter":
_pricing_provider_cache_keys[
(_pricing_profile_key(), normalized)
] = "https://openrouter.ai/api"
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":
from hermes_constants import AI_GATEWAY_BASE_URL
_pricing_provider_cache_keys[
(_pricing_profile_key(), normalized)
] = AI_GATEWAY_BASE_URL.rstrip("/")
return fetch_ai_gateway_pricing(force_refresh=force_refresh)
if normalized == "novita":
base_url = os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1"
_pricing_provider_cache_keys[
(_pricing_profile_key(), normalized)
] = base_url.rstrip("/")
return _fetch_novita_pricing(force_refresh=force_refresh)
if normalized == "deepinfra":
return _fetch_deepinfra_pricing(force_refresh=force_refresh)
if normalized == "fireworks":
_pricing_provider_cache_keys[
(_pricing_profile_key(), normalized)
] = "models.dev/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:
_pricing_provider_cache_keys[
(_pricing_profile_key(), normalized)
] = base_url.rstrip("/")
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. Results are additionally memoized in
``_pricing_cache`` so repeated menu renders within a process are free.
models.dev publishes Fireworks costs in USD per 1M tokens; the shared
pricing formatter expects per-token strings, so divide by 1M.
"""
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 returns input/output prices per million tokens in units of
0.0001 USD. Convert them to the per-token strings used by the shared
pricing formatter.
Results are cached in ``_pricing_cache`` keyed on the resolved base URL,
matching the pattern used by ``fetch_ai_gateway_pricing`` — without this,
every menu render or pricing lookup re-hits 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``.
Checks which providers have valid credentials configured.
Derives the provider list from :data:`CANONICAL_PROVIDERS` (single
source of truth shared with ``hermes model``, ``/model``, etc.).
"""
# 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)``.
Supports ``provider:model`` syntax to switch providers at runtime::
openrouter:anthropic/claude-sonnet-4.5 → ("openrouter", "anthropic/claude-sonnet-4.5")
nous:hermes-3 → ("nous", "hermes-3")
anthropic/claude-sonnet-4.5 → (current_provider, "anthropic/claude-sonnet-4.5")
gpt-5.4 → (current_provider, "gpt-5.4")
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``).
Returns ``(provider, model)`` where *provider* is either the explicit
provider from the input or *current_provider* if none was specified.
"""
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 can
share the OpenAI-compatible chat client. For model discovery and validation,
however, local Ollama's authoritative model list is ``/api/tags``. Use that
path when the caller explicitly asked for Ollama, when the base URL matches
configured ``providers.ollama.base_url``, or when an ambiguous custom URL on
Ollama's default local port actually serves ``/api/tags``. Ordinary custom
endpoints stay on the existing OpenAI-compatible ``/models`` probe path.
"""
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 curated_models_for_provider(
provider: Optional[str],
*,
force_refresh: bool = False,
) -> list[tuple[str, str]]:
"""Return ``(model_id, description)`` tuples for a provider's model list.
Tries to fetch the live model list from the provider's API first,
falling back to the static ``_PROVIDER_MODELS`` catalog if the API
is unreachable.
"""
normalized = normalize_provider(provider)
if normalized == "openrouter":
return fetch_openrouter_models(force_refresh=force_refresh)
# Try live API first (Codex, Nous, etc. all support /models)
live = provider_model_ids(normalized)
if live:
return [(m, "") for m in live]
# Fallback to static catalog
models = _PROVIDER_MODELS.get(normalized, [])
return [(m, "") for m in 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}
# Retired model IDs kept for /model auto-detect only — not shown in pickers.
# DeepSeek cut these off on 2026-07-24; model_normalize remaps them on the wire.
_PROVIDER_RETIRED_ALIASES: dict[str, tuple[str, ...]] = {
"deepseek": ("deepseek-chat", "deepseek-reasoner"),
}
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)
)
_AGGREGATOR_PROVIDERS = frozenset(
{"nous", "openrouter", "ai-gateway", "copilot", "kilocode"}
)
# OpenRouter request-time routing variants (docs: guides/routing/model-variants).
# These suffixes are per-request routing modifiers valid on ANY model id —
# ":nitro" sorts the endpoint pool by throughput and admits priority-tier
# endpoints, ":floor" sorts by price and admits flex-tier endpoints, ":exacto"
# applies quality-first provider sorting, ":online" attaches the web plugin.
# They are never separate catalog entries: /models lists only the base id.
# NOT in this set: ":free", ":batch", ":thinking", ":extended" — those ARE
# distinct catalog SKUs that appear in /models when they exist, so absence
# from the listing is authoritative for them and the direct-membership check
# above handles the valid ones.
_OPENROUTER_VARIANT_SUFFIXES = frozenset({"nitro", "floor", "exacto", "online"})
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
# Subscription/OAuth providers whose catalogs RE-EXPOSE other vendors' models
# would be listed here (tried only as a last resort for bare short-alias
# resolution, after every native-vendor catalog, so they never hijack an alias
# away from the model's native vendor). None are currently defined.
_BORROWED_MODEL_PROVIDERS: frozenset[str] = frozenset()
# Providers whose live /v1/models endpoint is the authoritative catalog, so the
# curated list is a discovery-only fallback. For these, the picker merges
# live-first (live entries lead, curated-only entries append). Every OTHER
# provider keeps curated-first (commit 658ac1d86, #46309) so a deliberately
# surfaced newest model stays at the top even when the live API lags. OpenCode
# Zen / Go re-expose dozens of upstream vendors and rotate them frequently, so
# their stale curated entries must not pollute the top of the picker. (#49129)
_LIVE_FIRST_PICKER_PROVIDERS: frozenset[str] = frozenset(
{"opencode-zen", "opencode-go", "meta-ai"}
)
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 model 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. Built-in vendor prefixes
(``google/gemini-2.5-flash``, ``deepseek/deepseek-chat``) deliberately
stay on the existing catalog / OpenRouter-slug / default-provider path:
those slug forms are aggregator-native, and rerouting them to the vendor
provider would change established provider-switch behavior (see
``TestDenormalizeProviderSwitch`` in tests/hermes_cli/test_web_server.py).
The returned model is the suffix with the prefix stripped — the target
provider's API expects the bare id.
"""
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.
Returns ``(provider_id, model_name)`` — the model name may be remapped
(e.g. bare ``deepseek-chat`` → ``deepseek/deepseek-chat`` for OpenRouter).
Returns ``None`` when no confident match is found.
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.
Handles:
- Exact match: ``anthropic/claude-opus-4.6`` → as-is
- Bare name: ``deepseek-chat`` → ``deepseek/deepseek-chat``
- Bare name: ``claude-opus-4.6`` → ``anthropic/claude-opus-4.6``
"""
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.
Note: ``"auto"`` passes through unchanged — use
``hermes_cli.auth.resolve_provider()`` to resolve it to a concrete
provider based on 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")
# Models that support OpenAI Priority Processing (service_tier="priority").
# See https://openai.com/api-priority-processing/ for the canonical list.
#
# Pattern-based matching — any OpenAI flagship model (gpt-*, o1*, o3*, o4*)
# is assumed to support Priority Processing. service_tier=priority is silently
# ignored by non-OpenAI endpoints (OpenRouter/Copilot/opencode-zen proxies
# strip the field), so false positives are harmless. Codex-series models
# (gpt-5-codex, gpt-5.3-codex, etc.) are excluded — they don't expose the
# service_tier parameter through the Codex Responses API.
_OPENAI_FAST_MODE_PREFIXES: tuple[str, ...] = (
"gpt-",
"o1",
"o3",
"o4",
)
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.6 had fast mode at launch and LOST it (2026-06-29) — the param
is silently ignored (standard speed, standard billing), so exposing a
toggle for it would show users a switch that does nothing.
- 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.
Keep this in lock-step with ``agent.anthropic_adapter._supports_fast_mode``
so the UI never shows a Fast toggle that the runtime would drop.
"""
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.
OpenRouter, Nous, Copilot, Azure, Bedrock, and custom base_urls either
strip ``service_tier``/``speed`` (charging nothing) or 400 on them.
"""
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``.
The overrides are injected into the API request kwargs by
``build_api_kwargs`` — each API path handles its own keys
(service_tier for OpenAI/Codex, speed for Anthropic Messages).
"""
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 ""
# Providers where models.dev is treated as authoritative: curated static
# lists are kept only as an offline fallback and to capture custom additions
# the registry doesn't publish yet. Adding a provider here causes its
# curated list to be merged with fresh models.dev entries (fresh first, any
# curated-only names appended) for both the CLI and the gateway /model picker.
#
# DELIBERATELY EXCLUDED:
# - "openrouter": curated list is already a hand-picked agentic subset of
# OpenRouter's 400+ catalog. Blindly merging would dump everything.
# - "nous": curated list and Portal /models endpoint are the source of
# truth for the subscription tier.
# Also excluded: providers that already have dedicated live-endpoint
# branches below (copilot, anthropic, ai-gateway, ollama-cloud, custom,
# stepfun, openai-codex) — those paths handle freshness themselves.
_MODELS_DEV_PREFERRED: frozenset[str] = frozenset({
"opencode-go",
"opencode-zen",
"deepseek",
"kilocode",
"fireworks",
"mistral",
"togetherai",
"cohere",
"perplexity",
"groq",
"nvidia",
"huggingface",
"zai",
"gemini",
"google",
"xai",
"xai-oauth",
})
def _model_dedup_key(model_id: str) -> str:
"""Case-insensitive dedup key that also folds picker-search aliases.
Some providers serve the same model under both a curated public slug and
a bare live wire id (Kimi Coding Plan lists its flagship as ``k3`` while
the curated catalog carries ``kimi-k3``). Folding through the search-alias
table keeps the curated-first merge from emitting both as separate rows.
The row that survives is the primary list's entry; selection still sends
whichever id the surviving row 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. Previously this read the env var only, so a
config-set data-residency host (``us.api.openai.com``) was ignored and
the catalog kept coming from ``api.openai.com``.
"""
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 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 for providers that support them (Codex, Nous),
falling back to static lists. For providers in ``_MODELS_DEV_PREFERRED``
(opencode-go/zen, xiaomi, deepseek, smaller inference providers, etc.),
models.dev entries are merged on top of curated so new models released
on the platform appear in ``/model`` without a Hermes release.
"""
requested = str(provider or "").strip().lower()
if requested == "ollama":
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)
use_native = should_use_ollama_native_catalog(
"ollama", base_url, headers=headers
)
if use_native:
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 []
else:
# Non-native Ollama-compatible endpoints (including Ollama Cloud)
# retain the generic OpenAI-compatible catalog path.
pass
# gateways that expose 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
)
fallback_models = fetch_api_models(
fallback_key,
fallback_base,
headers=fallback_headers or None,
)
return fallback_models or []
normalized = normalize_provider(provider)
if normalized == "openrouter":
return model_ids(force_refresh=force_refresh)
if normalized == "openai-codex":
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 (new models appear without
# a Hermes release). Falls back to the hardcoded catalog if no token
# or the endpoint is unreachable.
access_token = None
try:
from hermes_cli.auth import resolve_codex_runtime_credentials
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
access_token = creds.get("api_key")
except Exception:
access_token = None
return get_codex_model_ids(access_token=access_token)
if normalized in {"copilot", "copilot-acp"}:
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", []))
if normalized == "nous":
# Try live Nous Portal /models endpoint
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). Fall back to the docs-hosted manifest
# — NOT the in-repo _PROVIDER_MODELS["nous"] snapshot — so newly
# added Portal models still surface without a Hermes release.
manifest_ids = get_curated_nous_model_ids()
if manifest_ids:
return manifest_ids
if normalized == "stepfun":
try:
from hermes_cli.auth import resolve_api_key_provider_credentials
creds = resolve_api_key_provider_credentials("stepfun")
api_key = str(creds.get("api_key") or "").strip()
base_url = str(creds.get("base_url") or "").strip()
if api_key and base_url:
live = fetch_api_models(api_key, base_url)
if live:
return live
except Exception:
pass
if normalized == "anthropic":
model_cfg = _get_model_config_dict()
cfg_provider = normalize_provider(str(model_cfg.get("provider", "") or ""))
if cfg_provider == "anthropic":
cfg_base_url = str(model_cfg.get("base_url", "") or "").strip()
cfg_api_key = str(model_cfg.get("api_key", "") or "").strip()
else:
cfg_base_url = ""
cfg_api_key = ""
live = _fetch_anthropic_models(
base_url=cfg_base_url or None,
api_key=cfg_api_key or None,
)
if live:
if cfg_base_url:
return live
# The live /v1/models dump lags newly-routed curated aliases
# (e.g. claude-fable-5, which is reachable on Anthropic before it
# is enumerated by the models endpoint). Surface curated entries
# first, then append any live-only models, so a fresh curated
# model never disappears just because the API hasn't listed it yet.
curated = list(_PROVIDER_MODELS.get("anthropic", []))
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
return list(_PROVIDER_MODELS.get("anthropic", []))
if normalized == "ai-gateway":
live = _fetch_ai_gateway_models()
if live:
return live
if normalized == "deepinfra":
# DeepInfra's generic /models endpoint 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.
return _fetch_deepinfra_models(force_refresh=force_refresh) or []
if normalized == "ollama-cloud":
live = fetch_ollama_cloud_models(force_refresh=force_refresh)
if live:
return live
if normalized in ("openai", "openai-api"):
api_key = os.getenv("OPENAI_API_KEY", "").strip()
if api_key:
base = _openai_discovery_base_url(normalized)
# Custom OpenAI-compatible endpoints (proxies, gateways, self-hosted)
# may serve a small curated catalog — use the live list verbatim so
# discovery works. But the official OpenAI hosts (canonical AND the
# data-residency regional hosts, which serve the identical dump)
# return 120+ entries of embeddings, whisper, tts, dall-e,
# moderation and legacy chat models — none of which belong in the
# agent model picker. For official hosts, intersect the live list
# with our curated agentic catalog so ``/model`` matches what
# ``hermes model`` shows.
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)
if live:
if is_default_openai:
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.
filtered = [m for m in curated if m.lower() in live_lower]
if filtered:
return filtered
# Account serves none of the curated models (rare —
# e.g. org without GPT-5 access). Fall back to curated
# so the picker still offers sane defaults.
return curated or live
return live
except Exception:
pass
if normalized == "gmi":
try:
from hermes_cli.auth import resolve_api_key_provider_credentials
creds = resolve_api_key_provider_credentials("gmi")
api_key = str(creds.get("api_key") or "").strip()
base_url = str(creds.get("base_url") or "").strip()
if api_key and base_url:
live = fetch_api_models(api_key, base_url)
if live:
return live
except Exception:
pass
if normalized == "custom":
base_url = _get_custom_base_url()
if base_url:
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
live = fetch_api_models(api_key, base_url, api_mode=api_mode)
if live:
return live
# Bedrock uses live discovery keyed by the resolved AWS region so that
# EU/AP users see eu.*/ap.* model IDs instead of the static us.* list.
# Note: early return intentionally skips _MODELS_DEV_PREFERRED merge
# below — bedrock is not expected to appear in that table.
if normalized == "bedrock":
try:
from agent.bedrock_adapter import bedrock_model_ids_or_none
ids = bedrock_model_ids_or_none()
if ids is not None:
return ids
except Exception:
pass
# OpenCode Free: keyless live catalog, revalidated against the Zen relay
# every TTL. models.dev's cost.input==0 filter lags reality
# (deepseek-v4-flash-free stayed "free" there after its promo ended and the
# relay began 401ing keyless requests), so we filter the live /zen/v1/models
# dump to the anonymous-servable `*-free` tier ourselves and fall back to
# the curated _PROVIDER_MODELS floor only when the live fetch fails or is
# empty. This is what keeps a relay-delisted model (e.g. x-preview-f-free)
# from lingering in the picker until a release re-syncs the snapshot.
if normalized == "opencode-free":
return _fetch_opencode_free_models(
force_refresh=force_refresh
) or list(_PROVIDER_MODELS.get(normalized, []))
# ── Profile-based generic live fetch (all simple api-key providers) ──
# Handles any provider registered in providers/ with auth_type="api_key".
# Replaces per-provider copy-paste blocks (stepfun, gmi, zai, etc.).
try:
from providers import get_provider_profile
from hermes_cli.auth import resolve_api_key_provider_credentials
_p = get_provider_profile(normalized)
if _p and _p.auth_type == "api_key" and _p.base_url:
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 = "", _p.base_url
if not base_url:
base_url = _p.base_url
if api_key:
live = _p.fetch_models(api_key=api_key, base_url=base_url or None)
if live:
# Merge static curated list with live API results so
# models that the live endpoint omits (stale cache,
# partial rollout) still appear in the picker.
#
# Single providers (kimi, zai) use curated-first
# (commit 658ac1d86) to surface newest models even when live
# API lags (#46309). OpenCode Zen / Go are different: their
# live API is the authoritative catalog, so they merge
# live-first — live entries lead and stale curated entries
# no longer pollute the top of the picker. (#49129)
#
# Plugin providers with no static _PROVIDER_MODELS entry fall
# back to the profile's curated fallback_models so their
# agentic picks lead the picker instead of whatever the live
# catalog happens to return first (e.g. Fireworks lists an
# image model, flux-*, ahead of its chat models).
curated = list(_PROVIDER_MODELS.get(normalized, [])) or list(
_p.fallback_models or ()
)
if curated:
if normalized in _LIVE_FIRST_PICKER_PROVIDERS:
primary, secondary = live, curated
else:
primary, secondary = curated, live
merged = list(primary)
merged_lower = {_model_dedup_key(m) for m in primary}
for m in secondary:
if _model_dedup_key(m) not in merged_lower:
merged.append(m)
merged_lower.add(_model_dedup_key(m))
return merged
return live
# Use profile's fallback_models if defined
if _p.fallback_models:
return list(_p.fallback_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
# Providers whose catalog is served with NO credential and therefore gets a
# stable (constant) credential fingerprint in the disk cache. The opencode-free
# catalog is anonymous — its freshness comes from TTL revalidation, not from
# user-rotatable credentials — so folding in unrelated auth.json mtimes would
# only needlessly bust the SWR cache.
_KEYLESS_STABLE_CACHE_PROVIDERS = frozenset({"opencode-free"})
# 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. When omitted, *cache_key* is treated as a
``PROVIDER_REGISTRY`` slug and refreshed via :func:`provider_model_ids`
(the original behavior).
"""
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:
"""Return a short hash representing the credentials that
``provider_model_ids(provider)`` would see right now.
Rotating any of the relevant env vars invalidates the cached entry
for that provider. We hash AT LEAST the api-key + base-url env vars
declared in ``PROVIDER_REGISTRY``. For OAuth-backed providers
(codex, copilot, anthropic-via-claude-code, nous portal), the
relevant tokens live in ``$HERMES_HOME/auth.json`` and external
credential files. Rather than parse every shape, we additionally
fold the mtime of those files into the fingerprint so refreshes
after re-auth bust the cache.
"""
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. The model catalog
supports regular ``x-api-key`` auth, so it needs a narrow fallback that
preserves the credential's configured endpoint instead of sending a
proxy-scoped key to Anthropic's public host.
"""
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.
Includes Openai-Intent and x-initiator headers that opencode and the
Copilot CLI send on every request.
"""
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.
Returns ``None`` when the base URL is empty/invalid.
"""
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``.
Returns the ``models`` list of dicts on success, ``None`` on network
errors or malformed responses. Raises ``AuthError`` on HTTP 401/403.
"""
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 on success, including the valid empty-list
case when the server is reachable but has no non-embedding models.
Returns ``None`` on network errors, malformed responses, or empty/invalid
base URLs.
Raises ``AuthError`` on HTTP 401/403 so callers can surface token issues
separately from reachability problems.
"""
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``.
Returns a list of model keys (e.g. ``publisher/model-name``) with embedding
models filtered out. Returns an empty list on network errors, malformed
responses, or empty/invalid base URLs.
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 once auth-enabled mode is turned on.
"""
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``.
Pulls ``capabilities.reasoning.allowed_options`` from ``/api/v1/models``.
Returns ``[]`` when the model is unknown, the endpoint is unreachable,
or the model does not declare a reasoning capability.
"""
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 the native ``/api/show`` endpoint and checks the ``capabilities``
list, which Ollama populates from the model's metadata (e.g.
``deepseek-v4-pro`` → ``["completion", "tools", "thinking"]`` while
``gemma3:27b`` → ``["completion", "vision"]``). This is the authoritative
capability source — the OpenAI-compat ``/v1/models`` endpoint omits it.
Returns:
True — the model declares the ``thinking`` capability.
False — ``/api/show`` succeeded but the model has no ``thinking`` cap.
None — the probe failed (unreachable / non-Ollama / error); the caller
decides the fallback (we treat None 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")]
_COPILOT_MODEL_ALIASES = {
"openai/gpt-5": "gpt-5-mini",
"openai/gpt-5-chat": "gpt-5-mini",
"openai/gpt-5-mini": "gpt-5-mini",
"openai/gpt-5-nano": "gpt-5-mini",
"openai/gpt-4.1": "gpt-4.1",
"openai/gpt-4.1-mini": "gpt-4.1",
"openai/gpt-4.1-nano": "gpt-4.1",
"openai/gpt-4o": "gpt-4o",
"openai/gpt-4o-mini": "gpt-4o-mini",
"openai/o1": "gpt-5.2",
"openai/o1-mini": "gpt-5-mini",
"openai/o1-preview": "gpt-5.2",
"openai/o3": "gpt-5.3-codex",
"openai/o3-mini": "gpt-5-mini",
"openai/o4-mini": "gpt-5-mini",
"anthropic/claude-opus-4.6": "claude-opus-4.6",
"anthropic/claude-sonnet-5": "claude-sonnet-5",
"anthropic/claude-sonnet-4.6": "claude-sonnet-4.6",
"anthropic/claude-sonnet-4": "claude-sonnet-4",
"anthropic/claude-sonnet-4.5": "claude-sonnet-4.5",
"anthropic/claude-haiku-4.5": "claude-haiku-4.5",
# Dash-notation fallbacks: Hermes' default Claude IDs elsewhere use
# hyphens (anthropic native format), but Copilot's API only accepts
# dot-notation. Accept both so users who configure copilot + a
# default hyphenated Claude model don't hit HTTP 400
# "model_not_supported". See issue #6879.
"claude-sonnet-5": "claude-sonnet-5",
"claude-opus-4-6": "claude-opus-4.6",
"claude-sonnet-4-6": "claude-sonnet-4.6",
"claude-sonnet-4-0": "claude-sonnet-4",
"claude-sonnet-4-5": "claude-sonnet-4.5",
"claude-haiku-4-5": "claude-haiku-4.5",
"anthropic/claude-opus-4-6": "claude-opus-4.6",
"anthropic/claude-sonnet-5": "claude-sonnet-5",
"anthropic/claude-sonnet-4-6": "claude-sonnet-4.6",
"anthropic/claude-sonnet-4-0": "claude-sonnet-4",
"anthropic/claude-sonnet-4-5": "claude-sonnet-4.5",
"anthropic/claude-haiku-4-5": "claude-haiku-4.5",
}
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`` logic:
GPT-5+ models use Responses API, except ``gpt-5-mini`` which uses
Chat Completions. All non-GPT models (Claude, Gemini, etc.) 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"
# Azure Foundry model families that require the Responses API. Azure
# rejects /chat/completions against these deployments with
# ``400 "The requested operation is unsupported."`` — the same payload Bob
# Dobolina hit in April 2026 on ``gpt-5.3-codex`` while ``gpt-4o-pure`` on
# the same endpoint worked fine. Keep the patterns broad enough to cover
# vendor-renamed deployments (e.g. ``gpt-5.3-codex``, ``gpt-5-codex``,
# ``gpt-5.4``, ``o1-preview``) but tight enough to leave GPT-4 / 3.5 / Llama /
# Mistral / Grok deployments on chat completions.
_AZURE_FOUNDRY_RESPONSES_PREFIXES = (
"codex", # codex-*, codex-mini
"gpt-5", # gpt-5, gpt-5.x, gpt-5-codex, gpt-5.x-codex
"o1", # o1, o1-preview, o1-mini
"o3", # o3, o3-mini
"o4", # o4, o4-mini
)
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). Returns ``None`` otherwise — the caller should fall
back to the configured/default api_mode (typically ``chat_completions``)
so GPT-4o, GPT-4 Turbo, Llama, Mistral, etc. keep working.
Intentionally does NOT return ``anthropic_messages``; Anthropic-style
Azure endpoints are disambiguated by URL (``/anthropic`` suffix) in
``runtime_provider._detect_api_mode_for_url`` and by the user setting
``model.api_mode: anthropic_messages`` explicitly.
"""
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.
Returns ``"opencode-zen"`` or ``"opencode-go"`` for the built-in
providers AND for custom providers whose name extends a family slug
(e.g. ``opencode-go-bridge`` pointing at ``https://opencode.ai/zen/go/v1``,
issue #85589). Matching is case-insensitive. Custom family providers
need the same per-model api_mode routing and /v1 base-url normalization
as the built-ins — this predicate is the single owner of that
family-membership question; do not re-implement it inline.
``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"
# Free-tier models whose slug does NOT carry the ``-free`` suffix.
# (big-pickle is OpenCode's rotating free stealth slot.)
_OPENCODE_KEYLESS_EXTRA_SLUGS = frozenset({"big-pickle"})
# 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 is_opencode_zen_free_model(model_id: Optional[str]) -> bool:
"""True when ``model_id`` is an OpenCode Zen free-tier slug.
Matches the ``*-free`` suffix plus the known unsuffixed free slugs
(``big-pickle``). Tolerates provider-prefixed ids
(``opencode-zen/x-preview-f-free``). The Go catalog serves no free
models (verified 2026-08-21), so this identifies the Zen free tier
across the OpenCode family.
"""
bare = str(model_id or "").strip().rsplit("/", 1)[-1].lower()
if not bare:
return False
return bare.endswith("-free") or bare in _OPENCODE_KEYLESS_EXTRA_SLUGS
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.
GETs ``{_OPENCODE_ZEN_FREE_BASE_URL}/models`` ANONYMOUSLY (the free tier
rejects any unrecognized Authorization bearer with 401) and filters the
dump to the anonymous-servable ``*-free`` tier. Returns ``None`` on any
network/auth/parse failure so callers fall back to the curated
``_PROVIDER_MODELS["opencode-free"]`` floor; an empty filtered result is
treated as a failure for the same reason (a relay with zero free models is
not worth trusting over the floor).
A short in-process memo (``_OPENCODE_FREE_LIVE_MEMO_TTL``) keeps direct
``provider_model_ids("opencode-free")`` callers — model validation runs
it several times per resolution — from issuing one blocking network
round-trip each. The picker's cross-process freshness still comes from
the SWR disk cache one layer up; ``force_refresh=True`` (the SWR refresh
path) bypasses and repopulates the memo.
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. Union (not replacement)
so a stale cache can only widen healing, never silently disable it.
"""
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.
Returns a resolve_runtime_provider-shaped dict pinning the request to the
Zen relay with the keyless placeholder whenever:
- ``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, not the ``-free`` suffix, is the heal criterion: the suffix
stopped being a reliable keyless signal when ``ox-alpha-free`` appeared
on the Go relay as a KEYED subscription model (2026-08-21) — suffix-based
healing would have routed it to a Zen relay that doesn't serve it.
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 different models behind different API surfaces:
- GPT-5 / Codex / Grok models on Zen use ``/v1/responses``
- GPT / Grok models on Go (gpt-5.6-luna, grok-4.5) use ``/v1/responses``
- Muse Spark on Go and Zen uses ``/v1/responses`` (chat/completions 503s)
- Claude models on Zen use ``/v1/messages``
- MiniMax and Qwen models on Go use ``/v1/messages``
- GLM / Kimi / DeepSeek / MiMo on Go use ``/v1/chat/completions``
- Qwen models on Zen use ``/v1/messages``
- Other Zen models (Gemini, GLM, Kimi, MiniMax, DeepSeek, etc.) use
``/v1/chat/completions``
This follows the published OpenCode docs for Zen and Go endpoints
(https://opencode.ai/docs/zen/ and https://opencode.ai/docs/go/).
"""
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.
OpenCode's OpenAI-compatible endpoints live under ``/v1`` (the OpenAI SDK
appends ``/chat/completions`` or ``/responses``), while the Anthropic SDK
appends its own ``/v1/messages`` — so anthropic_messages needs the ``/v1``
suffix stripped.
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). A later switch
to a chat_completions model (glm, deepseek, kimi) then inherited the
stripped URL and POSTed to ``https://opencode.ai/zen/go/chat/completions``
— a 404 (the marketing site). Re-append ``/v1`` for non-anthropic modes
so previously-stripped URLs heal themselves.
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, uses ``x-api-key`` and
``anthropic-version`` headers (Anthropic's native auth) instead of
``Authorization: Bearer``. The response shape (``data[].id``) is
identical, so the same parser works for 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 + stt models in one
response. Authentication is optional but Bearer-attached when available
so user-scoped catalogs (private fine-tunes etc.) are visible.
"""
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 returned item is ``{"id": str, "metadata": dict}`` so callers can
inspect context length, pricing, default dimensions (image-gen),
pricing units (tts ``input_characters``, stt ``input_seconds``), etc.
For the chat surface, items without any ``tags`` field fall through
to the legacy name-regex exclusion so this keeps working while the
tag rollout (mid-2026) is still 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).
Single source of truth for the per-surface model shims (TTS/STT/vision),
replacing the copy-pasted ``import _fetch_deepinfra_models_by_tag → fetch
→ [item["id"] …]`` wrapper each of them used to carry.
"""
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. Single source
of truth for the base-URL chain the TTS/STT/image/video shims each used
to re-code (with subtly divergent normalization).
"""
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`` (mirrors the
OpenRouter shape consumed by
:func:`format_model_pricing_table`). 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.
Returns a list of model ID strings, or ``None`` if the endpoint could not
be reached (network error, timeout, auth failure, etc.).
"""
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.
Mirrors :func:`cached_provider_model_ids` — including its
stale-while-revalidate tier — but keys ``provider_models_cache.json``
off ``custom:<base_url>`` instead of a ``PROVIDER_REGISTRY`` slug, since
custom endpoints (named ``custom_providers`` rows, bare
``provider: custom``, and per-endpoint-map entries) have none. Same
stale-beats-nothing fallback policy: a live-fetch failure serves the
last same-fingerprint result rather than an empty list. Returns whatever
:func:`fetch_api_models` would (a list or ``None``); corrupt cache rows
degrade to a live fetch instead of raising.
``cache_only`` serves a previously-discovered catalog without touching
the network at all — no live fetch, no background revalidation — and
returns ``None`` when nothing usable is cached. 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.
Args:
ignore_ttl: If True, return data even if the TTL has expired (stale fallback).
"""
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 []
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.
Performs format checks first, then probes the live API to confirm
the model actually exists.
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
"""
requested = (model_name or "").strip()
normalized = normalize_provider(provider)
if normalized == "openrouter" and base_url and not base_url_host_matches(base_url, "openrouter.ai"):
normalized = "custom"
requested_for_lookup = requested
if normalized == "copilot":
requested_for_lookup = normalize_copilot_model_id(
requested,
api_key=api_key,
) or requested
if not requested:
return {
"accepted": False,
"persist": False,
"recognized": False,
"message": "Model name cannot be empty.",
}
if normalized == "moa":
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 {"accepted": True, "persist": True, "recognized": True, "message": None}
return {
"accepted": False, "persist": False, "recognized": False,
"message": f"MoA preset `{requested}` was not found. Run `hermes moa list`.",
}
except Exception as exc:
return {
"accepted": False, "persist": False, "recognized": False,
"message": f"Could not read MoA presets: {exc}",
}
if any(ch.isspace() for ch in requested):
return {
"accepted": False,
"persist": False,
"recognized": False,
"message": "Model names cannot contain spaces.",
}
# OpenRouter presets are account-scoped configurations, so direct
# ``@preset/<slug>`` references never appear in the public /v1/models
# listing. Combined ``<model>@preset/<slug>`` references are also valid;
# validate their base model normally and preserve the preset suffix if a
# close match is auto-corrected. OpenRouter validates the preset slug when
# the inference request is made.
preset_suffix = ""
def _with_preset_suffix(model_id: str) -> str:
"""Re-attach a preserved ``@preset/<slug>`` suffix after auto-correction."""
return f"{model_id}{preset_suffix}"
if normalized == "openrouter":
marker = "@preset/"
if marker in requested:
if requested.count(marker) != 1:
preset_slug = ""
preset_base = requested
else:
preset_base, preset_slug = requested.split(marker, 1)
if re.fullmatch(r"[A-Za-z0-9._~-]+", preset_slug) is None:
return {
"accepted": False,
"persist": False,
"recognized": False,
"message": (
"OpenRouter preset slugs must be non-empty URL-safe "
"identifiers using only letters, digits, '.', '_', "
"'~', or '-'."
),
}
preset_suffix = f"{marker}{preset_slug}"
if not preset_base:
return {
"accepted": True,
"persist": True,
"recognized": False,
"message": None,
}
requested_for_lookup = preset_base
if normalized == "lmstudio":
from hermes_cli.auth import AuthError
# Use probe_lmstudio_models so we can distinguish None (unreachable
# / malformed response) from [] (reachable, but no chat-capable models
# are loaded). fetch_lmstudio_models collapses both to [].
try:
models = probe_lmstudio_models(api_key=api_key, base_url=base_url)
except AuthError as exc:
return {
"accepted": False, "persist": False, "recognized": False,
"message": (
f"{exc} Set `LM_API_KEY` (or update it) to match the server's bearer token."
),
}
if models is None:
return {
"accepted": False, "persist": False, "recognized": False,
"message": f"Could not reach LM Studio's `/api/v1/models` to validate `{requested}`.",
}
if not models:
return {
"accepted": False, "persist": False, "recognized": False,
"message": (
f"LM Studio is reachable but no chat-capable models are loaded. "
f"Load `{requested}` in LM Studio (Developer tab → Load Model) and try again."
),
}
if requested_for_lookup in set(models):
return {"accepted": True, "persist": True, "recognized": True, "message": None}
return {
"accepted": False, "persist": False, "recognized": False,
"message": f"Model `{requested}` was not found in LM Studio's model listing.",
}
if str(provider or "").strip().lower() == "ollama" and not base_url:
base_url = _get_ollama_base_url()
ollama_base_url = base_url
configured_ollama_base_url = str(
(
_get_provider_config_dict("ollama").get("base_url")
or _get_provider_config_dict("ollama").get("api")
or _get_provider_config_dict("ollama").get("url")
or ""
)
).strip()
configured_headers_allowed = not (
configured_ollama_base_url
and not _same_ollama_native_root(ollama_base_url or "", configured_ollama_base_url)
)
if headers is not None:
ollama_headers = {}
if configured_headers_allowed:
ollama_headers.update(
_get_ollama_native_headers(ollama_base_url, api_key=api_key)
)
for key in tuple(ollama_headers):
if key.lower() == "authorization":
del ollama_headers[key]
ollama_headers.update(headers)
caller_has_authorization = any(
key.lower() == "authorization" for key in headers
)
if api_key and not caller_has_authorization:
for key in tuple(ollama_headers):
if key.lower() == "authorization":
del ollama_headers[key]
ollama_headers["Authorization"] = f"Bearer {api_key}"
elif configured_headers_allowed:
ollama_headers = _get_ollama_native_headers(ollama_base_url, api_key=api_key)
else:
ollama_headers = {}
if should_use_ollama_native_catalog(
provider, ollama_base_url, headers=ollama_headers
):
ollama_models = probe_ollama_local_models(
ollama_base_url, headers=ollama_headers
)
if ollama_models is None:
# A failed native probe is not authoritative; fall back to the
# existing OpenAI-compatible catalog before accepting blindly.
ollama_models = probe_api_models(
api_key,
_normalize_openai_base_url(ollama_base_url),
request_headers=ollama_headers,
).get("models")
if ollama_models is None:
return {
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Note: could not reach this Ollama endpoint's `/api/tags` model listing to validate `{requested}`. "
"Hermes will save the model name, but local Ollama model discovery could not verify it."
),
}
if requested_for_lookup in set(ollama_models):
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
suggestions = get_close_matches(requested_for_lookup, ollama_models, n=3, cutoff=0.5)
suggestion_text = ""
if suggestions:
suggestion_text = "\n Similar local Ollama models: " + ", ".join(f"`{s}`" for s in suggestions)
empty_hint = " No models are currently listed by `/api/tags`." if not ollama_models else ""
return {
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Note: `{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"{suggestion_text}"
),
}
if normalized == "custom" or normalized.startswith("custom:"):
# Try probing with correct auth for the api_mode.
if api_mode == "anthropic_messages":
probe = probe_api_models(
api_key,
base_url,
api_mode=api_mode,
request_headers=headers,
)
else:
probe = probe_api_models(
api_key,
base_url,
request_headers=headers,
)
api_models = probe.get("models")
if api_models is not None:
if requested_for_lookup in set(api_models):
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
# Auto-correct if the top match is very similar (e.g. typo)
auto = get_close_matches(requested_for_lookup, api_models, n=1, cutoff=0.9)
if auto:
return {
"accepted": True,
"persist": True,
"recognized": True,
"corrected_model": auto[0],
"message": f"Auto-corrected `{requested}` → `{auto[0]}`",
}
suggestions = get_close_matches(requested, api_models, n=3, cutoff=0.5)
suggestion_text = ""
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
message = (
f"Note: `{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"{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 {
"accepted": True,
"persist": True,
"recognized": False,
"message": message,
}
message = (
f"Note: could not reach this custom endpoint's model listing at `{probe.get('probed_url')}`. "
f"Hermes will still save `{requested}`, but the endpoint should expose `/models` for verification."
)
if 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')}`"
return {
"accepted": api_mode == "anthropic_messages",
"persist": True,
"recognized": False,
"message": message,
}
# Providers with non-standard catalog validation — /v1/models probing is not the right path.
if normalized in {"openai-codex", "xai-oauth"}:
try:
catalog_models = provider_model_ids(normalized)
except Exception:
catalog_models = []
# Ineligible ``-900k`` aliases (e.g. `gpt-5.5-900k`) must be rejected
# BEFORE the hidden-slug soft-accept below: the suffix is a Hermes
# picker convention, so an unknown `*-900k` name can never be a real
# hidden provider slug — soft-accepting one silently runs at 272K on
# a different model than the user thinks (#92797 review).
if normalized == "openai-codex":
from agent.model_metadata import (
CODEX_CONTEXT_VARIANT_SUFFIX,
is_codex_context_variant,
)
_req_lower = requested_for_lookup.strip().lower()
if (
_req_lower.endswith(CODEX_CONTEXT_VARIANT_SUFFIX)
and requested_for_lookup not in set(catalog_models)
):
if is_codex_context_variant(requested_for_lookup):
# Valid variant that a stale catalog hasn't synthesized
# yet. Accept it directly — falling through would let the
# typo auto-corrector "fix" it to the base slug and
# silently drop the large-context opt-in.
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
_base_guess = requested_for_lookup[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)]
return {
"accepted": False,
"persist": False,
"recognized": False,
"message": (
f"`{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 catalog_models:
if requested_for_lookup in set(catalog_models):
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
# Auto-correct if the top match is very similar (e.g. typo)
auto = get_close_matches(requested_for_lookup, catalog_models, n=1, cutoff=0.9)
if auto:
return {
"accepted": True,
"persist": True,
"recognized": True,
"corrected_model": auto[0],
"message": f"Auto-corrected `{requested}` → `{auto[0]}`",
}
suggestions = get_close_matches(requested_for_lookup, catalog_models, n=3, cutoff=0.5)
suggestion_text = ""
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
provider_label = "OpenAI Codex" if normalized == "openai-codex" else "xAI Grok OAuth (SuperGrok / Premium+)"
# Plausibility gate (#45006): the soft-accept (#16172 / #19729) exists
# for entitlement-gated *hidden* slugs the curated listing hasn't
# caught up with — but those are always the provider's own family
# (openai-codex -> gpt-*; xai-oauth -> grok-*). Accepting an
# unrelated typed name (e.g. `qwen3.5-4b`, `llama-3.1-8b`) here turns
# what should be an actionable "did you mean --provider <x>?" error
# into a confusing success that 400s on the next turn. Only soft-
# accept names that share the provider's family prefix; reject the
# rest with guidance to pin the right provider.
_family_prefixes = {
"openai-codex": ("gpt-", "codex-", "o1", "o3", "o4"),
"xai-oauth": ("grok-",),
}.get(normalized, ())
_lower = requested_for_lookup.strip().lower()
_plausible = (not _family_prefixes) or any(
_lower.startswith(p) for p in _family_prefixes
)
if not _plausible:
return {
"accepted": False,
"persist": False,
"recognized": False,
"message": (
f"`{requested}` doesn't look like a {provider_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"{suggestion_text}"
),
}
return {
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Note: `{requested}` was not found in the {provider_label} model listing. "
"It may still work if your account has access to a newer or hidden model ID."
f"{suggestion_text}"
),
}
# MiniMax providers don't expose a /models endpoint — validate against
# the static catalog instead, similar to openai-codex.
if normalized in {"minimax", "minimax-cn"}:
try:
catalog_models = provider_model_ids(normalized)
except Exception:
catalog_models = []
if catalog_models:
# Case-insensitive lookup (catalog uses mixed case like MiniMax-M2.7)
catalog_lower = {m.lower(): m for m in catalog_models}
if requested_for_lookup.lower() in catalog_lower:
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
# Auto-correct close matches (case-insensitive)
catalog_lower_list = list(catalog_lower.keys())
auto = get_close_matches(requested_for_lookup.lower(), catalog_lower_list, n=1, cutoff=0.9)
if auto:
corrected = catalog_lower[auto[0]]
return {
"accepted": True,
"persist": True,
"recognized": True,
"corrected_model": corrected,
"message": f"Auto-corrected `{requested}` → `{corrected}`",
}
suggestions = get_close_matches(requested_for_lookup.lower(), catalog_lower_list, n=3, cutoff=0.5)
suggestion_text = ""
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{catalog_lower[s]}`" for s in suggestions)
return {
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Note: `{requested}` was not found in the MiniMax catalog."
f"{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."
),
}
# Native Anthropic provider: /v1/models requires x-api-key (or Bearer for
# OAuth) plus anthropic-version headers. The generic OpenAI-style probe
# below uses plain Bearer auth and 401s against Anthropic, so dispatch to
# the native fetcher which handles both API keys and Claude-Code OAuth
# tokens. (The api_mode=="anthropic_messages" branch below handles the
# Messages-API transport case separately.)
if normalized == "anthropic":
anthropic_models = _fetch_anthropic_models(
base_url=base_url or None,
api_key=api_key or None,
)
if anthropic_models is not None:
if requested_for_lookup in set(anthropic_models):
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
auto = get_close_matches(requested_for_lookup, anthropic_models, n=1, cutoff=0.9)
if auto:
return {
"accepted": True,
"persist": True,
"recognized": True,
"corrected_model": auto[0],
"message": f"Auto-corrected `{requested}` → `{auto[0]}`",
}
suggestions = get_close_matches(requested, anthropic_models, n=3, cutoff=0.5)
suggestion_text = ""
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
# Accept anyway — Anthropic sometimes gates newer/preview models
# (e.g. snapshot IDs, early-access releases) behind accounts
# even though they aren't listed on /v1/models.
return {
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Note: `{requested}` was not found in Anthropic's /v1/models listing. "
f"It may still work if you have early-access or snapshot IDs."
f"{suggestion_text}"
),
}
# _fetch_anthropic_models returned None — no token resolvable or
# network failure. Fall through to the generic warning below.
# Anthropic Messages API: many proxies don't implement /v1/models.
# Try probing with correct auth; if it fails, accept with a warning.
if api_mode == "anthropic_messages":
api_models = fetch_api_models(api_key, base_url, api_mode=api_mode)
if api_models is not None:
if requested_for_lookup in set(api_models):
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
auto = get_close_matches(requested_for_lookup, api_models, n=1, cutoff=0.9)
if auto:
return {
"accepted": True,
"persist": True,
"recognized": True,
"corrected_model": auto[0],
"message": f"Auto-corrected `{requested}` → `{auto[0]}`",
}
# Probe failed or model not found — accept anyway (proxy likely
# doesn't implement the Anthropic Models API).
return {
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Note: could not verify `{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."
),
}
# Probe the live API to check if the model actually exists
api_models = fetch_api_models(api_key, base_url)
if api_models is not None:
# Gemini's OpenAI-compat /v1beta/openai/models endpoint returns IDs
# prefixed with "models/" (e.g. "models/gemini-2.5-flash") — native
# Gemini-API convention. Our curated list and user input both use
# the bare ID, so a direct set-membership check drops every known
# Gemini model. Strip the prefix before comparison. See #12532.
if normalized == "gemini":
api_models = [
m[len("models/"):] if isinstance(m, str) and m.startswith("models/") else m
for m in api_models
]
if requested_for_lookup in set(api_models):
# API confirmed the model exists
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
# OpenRouter routing variants (":nitro", ":floor", ...) are request-time
# modifiers, not catalog entries — /models lists only the base id.
# Validate the BASE against the listing but preserve the suffixed id,
# and do this BEFORE fuzzy auto-correction: get_close_matches would
# otherwise "correct" `model:nitro` → `model` and silently strip the
# user's routing opt-in.
_variant_base = (
_openrouter_variant_base(requested_for_lookup)
if normalized == "openrouter"
else None
)
if _variant_base is not None and _variant_base in set(api_models):
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
else:
# API responded but model is not listed. Accept anyway —
# the user may have access to models not shown in the public
# listing (e.g. Z.AI Pro/Max plans can use glm-5 on coding
# endpoints even though it's not in /models). Warn but allow.
# Auto-correct if the top match is very similar (e.g. typo)
auto = get_close_matches(requested_for_lookup, api_models, n=1, cutoff=0.9)
if auto:
corrected = _with_preset_suffix(auto[0])
return {
"accepted": True,
"persist": True,
"recognized": True,
"corrected_model": corrected,
"message": f"Auto-corrected `{requested}` → `{corrected}`",
}
suggestions = get_close_matches(
requested_for_lookup, api_models, n=3, cutoff=0.5
)
suggestion_text = ""
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
# Model not in live /v1/models — check the curated catalog
# before rejecting. Providers may omit models from their live
# listing that are still valid (stale cache, partial rollout,
# gated previews). Use the pure-catalog helper (no extra live
# fetch) so we only accept models Hermes actually ships. (#46850)
#
# EXCEPTION: official OpenAI hosts (canonical api.openai.com and
# the data-residency regional hosts). Their /v1/models listing is
# access-scoped and authoritative — a model absent from it is one
# this key CANNOT serve, so the curated soft-accept would
# manufacture a selection that 400s at first use. Custom
# OpenAI-compatible proxies keep the fallback (incomplete
# listings are common there).
_openai_listing_is_authoritative = False
if normalized in ("openai", "openai-api"):
from hermes_cli.providers import is_official_openai_host
_openai_listing_is_authoritative = is_official_openai_host(base_url)
if not _openai_listing_is_authoritative and _model_in_provider_catalog(
(_variant_base or requested_for_lookup).lower(),
_provider_keys(normalized),
):
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": (
f"Note: `{requested}` was not found in the live /v1/models listing "
f"but exists in the curated catalog — accepted."
),
}
# Nous provider: also check the Portal's live
# /api/nous/recommended-models feed. That feed can list a model
# (e.g. a newly-promoted free/paid recommendation) before it's
# been added to the hardcoded _PROVIDER_MODELS["nous"] curated
# list or the docs-hosted catalog manifest has been rebuilt.
# `hermes chat` already accepts these models via
# union_with_portal_free/paid_recommendations() at model-list
# build time; this mirrors that same source of truth for the
# per-message /model validation path (messaging platform
# pickers, /model command), which previously only checked the
# curated catalog and rejected valid Portal-recommended models.
if normalized == "nous":
try:
portal_payload = fetch_nous_recommended_models(
_resolve_nous_portal_url()
)
portal_model_names = {
name.lower()
for tier in ("freeRecommendedModels", "paidRecommendedModels")
for entry in (portal_payload.get(tier) or [])
if (name := _extract_model_name(entry))
}
except Exception:
portal_model_names = set()
if requested_for_lookup.lower() in portal_model_names:
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": (
f"Note: `{requested}` was not found in the live /v1/models "
f"listing but is a current Nous Portal recommendation — accepted."
),
}
return {
"accepted": False,
"persist": False,
"recognized": False,
"message": (
f"Model `{requested}` was not found in this provider's model listing."
f"{suggestion_text}"
),
}
# api_models is None — couldn't reach API. Accept and persist,
# but warn so typos don't silently break things.
# Bedrock: use our own discovery instead of HTTP /models endpoint.
# Bedrock's bedrock-runtime URL doesn't support /models — it uses the
# AWS SDK control plane (ListFoundationModels + ListInferenceProfiles).
if normalized == "bedrock":
try:
from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_runtime_region
region = resolve_bedrock_runtime_region()
discovered = discover_bedrock_models(region)
discovered_ids = {m["id"] for m in discovered}
if requested in discovered_ids:
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
# Not in discovered list — still accept (user may have custom
# inference profiles or cross-account access), but warn.
suggestions = get_close_matches(requested, list(discovered_ids), n=3, cutoff=0.4)
suggestion_text = ""
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
return {
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Note: `{requested}` was not found in Bedrock model discovery for {region}. "
f"It may still work with custom inference profiles or cross-account access."
f"{suggestion_text}"
),
}
except Exception:
pass # Fall through to generic warning
# Static-catalog fallback: when the /models probe was unreachable,
# validate against the curated list from provider_model_ids() — same
# pattern as the openai-codex and minimax branches above. This keeps
# /model switches working in the gateway for providers whose /models
# endpoint is temporarily unreachable or returns a non-JSON payload.
# Without this block, validate_requested_model would reject every model
# on such providers, switch_model() would return success=False, and
# the gateway would never write to _session_model_overrides.
provider_label = _PROVIDER_LABELS.get(normalized, normalized)
try:
catalog_models = provider_model_ids(normalized)
except Exception:
catalog_models = []
if catalog_models:
catalog_lower = {m.lower(): m for m in catalog_models}
if requested_for_lookup.lower() in catalog_lower:
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
# OpenRouter routing-variant suffixes: validate the base id against
# the catalog, keep the suffixed id (same rule as the live-listing
# path above — variants never appear as catalog entries).
if normalized == "openrouter":
_cat_variant_base = _openrouter_variant_base(requested_for_lookup)
if (
_cat_variant_base is not None
and _cat_variant_base.lower() in catalog_lower
):
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
catalog_lower_list = list(catalog_lower.keys())
auto = get_close_matches(
requested_for_lookup.lower(), catalog_lower_list, n=1, cutoff=0.9
)
if auto:
corrected = catalog_lower[auto[0]]
corrected_with_suffix = _with_preset_suffix(corrected)
return {
"accepted": True,
"persist": True,
"recognized": True,
"corrected_model": corrected_with_suffix,
"message": (
f"Auto-corrected `{requested}` → `{corrected_with_suffix}`"
),
}
suggestions = get_close_matches(
requested_for_lookup.lower(), catalog_lower_list, n=3, cutoff=0.5
)
suggestion_text = ""
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(
f"`{catalog_lower[s]}`" for s in suggestions
)
return {
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Note: `{requested}` was not found in the {provider_label} curated catalog "
f"and the /models endpoint was unreachable.{suggestion_text}"
f"\n The model may still work if it exists on the provider."
),
}
# No catalog available — accept with a warning, matching the comment's
# stated intent ("Accept and persist, but warn").
return {
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Note: could not reach the {provider_label} API to validate `{requested}`. "
f"If the service isn't down, this model may not be valid."
),
}