7843 lines
309 KiB
Python
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."
|
|
),
|
|
}
|