From 8507b731bd04d68626e58bd8ed9bd9b89cf10e63 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:21:32 -0700 Subject: [PATCH 1/6] refactor(hermes_cli): compact static catalog tables; derive nous/copilot/-cn twins from shared lists (values identical) --- hermes_cli/models_catalog_static.py | 1388 ++++++++------------------- 1 file changed, 377 insertions(+), 1011 deletions(-) diff --git a/hermes_cli/models_catalog_static.py b/hermes_cli/models_catalog_static.py index a4460bd04c..c1b3f118e5 100644 --- a/hermes_cli/models_catalog_static.py +++ b/hermes_cli/models_catalog_static.py @@ -1,9 +1,7 @@ -"""Static provider/model catalog tables. +"""Static provider/model catalog tables (data only — no network). -Curated per-provider model lists, the canonical provider registry, display groups, and the -alias maps. Data only — no network. - -Split out of ``hermes_cli.models``; every moved name is re-imported there, so +Curated per-provider model lists, the canonical provider registry, display groups and alias +maps. Split out of ``hermes_cli.models``, which re-imports every name so ``hermes_cli.models.`` keeps resolving (and monkeypatching) as before. """ @@ -12,140 +10,77 @@ from __future__ import annotations from typing import NamedTuple -# Fallback OpenRouter snapshot used when the live catalog is unavailable. -# (model_id, display description shown in menus) +# Fallback OpenRouter snapshot used when the live catalog is unavailable, as +# ``(model_id, description shown in menus)``. ``:free`` SKUs are described "free". +_OPENROUTER_DESCRIPTIONS = { + "anthropic/claude-opus-5-fast": "2x price, higher output speed", + "anthropic/claude-opus-4.8-fast": "2x price, higher output speed", + "deepseek/deepseek-v4-pro-0813": "dated snapshot of v4-pro", + "deepseek/deepseek-v4-flash-0731": "dated snapshot of v4-flash", + "moonshotai/kimi-k3": "recommended", + "z-ai/glm-5.2": "default", + "openrouter/pareto-code": "auto-routes to cheapest coder meeting openrouter.min_coding_score", +} 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", ""), - # 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"), + (mid, _OPENROUTER_DESCRIPTIONS.get(mid, "free" if mid.endswith(":free") else "")) + for mid in ( + "anthropic/claude-fable-5.1", "anthropic/claude-fable-5", "anthropic/claude-opus-5", + "anthropic/claude-opus-5-fast", "anthropic/claude-opus-4.8", "anthropic/claude-opus-4.8-fast", + "anthropic/claude-sonnet-5", "anthropic/claude-haiku-4.5", "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/gemini-3.1-pro-preview", "google/gemini-3.8-flash", "google/gemini-3.7-flash", + "x-ai/grok-4.6", "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-pro-0813", + "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-flash-0731", "qwen/qwen3.8-max", + "qwen/qwen3.8-flash", "moonshotai/kimi-k3", "minimax/minimax-m3", "z-ai/glm-5.3", + "z-ai/glm-5.3-flash", "z-ai/glm-5.2", "xiaomi/mimo-v2.5-pro", "tencent/hy4-preview", "tencent/hy3", + "stepfun/step-3.7-flash", "nvidia/nemotron-3-super-120b-a12b", "meta/muse-spark-1.2", + "sakana/fugu-ultra", "openrouter/pareto-code", "thinkingmachines/inkling:free", + "thinkingmachines/inkling-small:free", "minimax/minimax-m3:free", "z-ai/glm-5.2:free", + "poolside/laguna-s-2.1:free", "poolside/laguna-xs-2.1:free", + "nvidia/nemotron-3-super-120b-a12b:free", "nvidia/nemotron-3-ultra-550b-a55b:free", + "nvidia/nemotron-3.5-lightning:free", + ) ] +# OpenRouter entries the Nous Portal does not carry (routing/fast variants, free tier). +_OPENROUTER_ONLY = { + "anthropic/claude-opus-5-fast", "anthropic/claude-opus-4.8-fast", "meta/muse-spark-1.2", + "openrouter/pareto-code", +} -# 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", ""), + +# Fallback Vercel AI Gateway snapshot (open-weight first, then closed-source by family). Slugs +# match Vercel's /v1/models catalog (``alibaba/`` for Qwen, ``zai/`` and ``xai/`` without hyphens). +VERCEL_AI_GATEWAY_MODELS: list[tuple[str, str]] = [("moonshotai/kimi-k2.6", "recommended")] + [ + (mid, "") for mid in ( + "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", + ) ] 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. - """ + """openai-codex curated list from codex_models.py (DEFAULT_CODEX_MODELS + forward-compat + synthesis) so the gateway /model picker and the CLI ``hermes model`` flow share one source.""" 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). +# Static xAI fallback when the models.dev disk cache is empty (fresh install, offline first run). +# Mirrors the xAI-direct IDs from $HERMES_HOME/models_dev_cache.json; the cache overrides it on the +# next refresh. Models xAI retired on 2026-05-15 (grok-4*, grok-4-fast*, grok-4-1-fast*, +# grok-code-fast-1) are excluded — see docs.x.ai/developers/migration/may-15-retirement. _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", + "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. grok-4.6 / grok-4.5 +# stay here until the models.dev disk cache refreshes. +_XAI_CURATED_EXTRAS: list[str] = ["grok-4.6", "grok-4.5", "grok-composer-2.5-fast"] _XAI_TOP_MODEL = "grok-4.6" @@ -158,14 +93,11 @@ def _xai_promote_top(ids: list[str]) -> list[str]: def _xai_merge_curated_extras(ids: list[str]) -> list[str]: - """Append Hermes-curated xAI models that are missing from models.dev.""" + """Append Hermes-curated xAI models missing from models.dev, right after the pinned headline.""" 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) + if extra not in out: + out.insert(1 if out and out[0] == _XAI_TOP_MODEL else len(out), extra) return out @@ -174,11 +106,8 @@ def _xai_finalize_catalog(ids: list[str]) -> list[str]: 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. - """ + """Offline curated floor for xAI / xAI OAuth pickers: $HERMES_HOME/models_dev_cache.json + (no network), else ``_XAI_STATIC_FALLBACK``. Any failure falls through to the static list.""" try: from agent.models_dev import _load_disk_cache data = _load_disk_cache() @@ -189,674 +118,269 @@ def _xai_curated_models() -> list[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)) +# Native OpenAI Chat Completions (api.openai.com); also the head of the Copilot list. +_OPENAI_CHAT_MODELS = [ + "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", +] +_MINIMAX_MODELS = ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"] +_TENCENT_MODELS = ["hy4-preview", "hy3", "hy3-preview"] +# Alibaba DashScope Coding platform (coding-intl): Qwen + third-party (GLM, Kimi, MiniMax, DeepSeek). +# Classic DashScope keys should override DASHSCOPE_BASE_URL to +# https://dashscope-intl.aliyuncs.com/compatible-mode/v1 (OpenAI-compat) or /apps/anthropic. +_ALIBABA_MODELS = [ + "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_MODELS = [ + "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", +] +# Verified against a live Token Plan subscription (key tier ``sk-sp-...``). +_ALIBABA_TOKEN_PLAN_MODELS = [ + "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", +] +_XAI_MODELS = _xai_curated_models() + +# Curated per-provider lists. ``-cn`` twins share the international catalog on a domestic endpoint. _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", - ], + "nous": [mid for mid, _ in OPENROUTER_MODELS if mid not in _OPENROUTER_ONLY and not mid.endswith(":free")], + # Used by /model counts and provider_model_ids fallback when /v1/models is unavailable. + "openai": list(_OPENAI_CHAT_MODELS), "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", + "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", + "xai-oauth": list(_XAI_MODELS), + "copilot-acp": ["copilot-acp"], + "copilot": _OPENAI_CHAT_MODELS + [ + "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-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", + "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(), + "xai": list(_XAI_MODELS), + # Nemotron flagships, then third-party agentic models hosted on build.nvidia.com. "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", + "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", + "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-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", + "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-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", - ], + "minimax": list(_MINIMAX_MODELS), + "minimax-oauth": ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"], + "minimax-cn": list(_MINIMAX_MODELS), "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", + "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": list(_TENCENT_MODELS), + "tencent-tokenplan": list(_TENCENT_MODELS), + "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", + "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. + # Synced against opencode.ai/docs/zen + live GET /zen/v1/models. Zen/Go are + # _LIVE_FIRST_PICKER_PROVIDERS, so this is a discovery floor: live entries lead in the picker + # and stale curated names never pollute the top. "x-preview-f-free" = "Ox Alpha" stealth model. "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", + "x-preview-f-free", "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", ], - # 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 keyless free tier — OFFLINE FLOOR only. provider_model_ids("opencode-free") + # revalidates live against GET /zen/v1/models and filters to the anonymous tier, so this list + # may lag the relay (intentional). Known-delisted models are REMOVED (the offline fallback must + # not offer a model that 401s, e.g. x-preview-f-free). "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", + "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", ], - # Synced against https://opencode.ai/docs/go/ + live GET /zen/go/v1/models - # (2026-08-20). + # Synced against opencode.ai/docs/go + live GET /zen/go/v1/models. "ox-alpha-free" is the + # Go-subscription twin of Zen's keyless Ox Alpha (NOT keyless — the Go relay requires a Go key). "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", - # 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", + "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", "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", + "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. + "alibaba": list(_ALIBABA_MODELS), + "alibaba-cn": list(_ALIBABA_MODELS), + "alibaba-coding-plan": list(_ALIBABA_CODING_PLAN_MODELS), + "alibaba-coding-plan-cn": list(_ALIBABA_CODING_PLAN_MODELS), + "alibaba-token-plan": list(_ALIBABA_TOKEN_PLAN_MODELS), + "alibaba-token-plan-cn": list(_ALIBABA_TOKEN_PLAN_MODELS), + # Only agentic HF 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", + "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. + # Static fallback when live discovery (ListFoundationModels + ListInferenceProfiles) is + # unavailable. Inference-profile IDs (us.*) because 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", + "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 models depend on the user's 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's OpenAI-compatible endpoint has no /models route, so without this the /model picker + # only shows the configured model. IDs carry the "google/" publisher prefix Vertex expects + # (see hermes_cli/model_setup_flows.py); validated live against a GCP project (global region). "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", + "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", + "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", ], + # Bare ids derived from the picker snapshot so both stay in sync. + "ai-gateway": [mid for mid, _ in VERCEL_AI_GATEWAY_MODELS], } -# 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] - - # --------------------------------------------------------------------------- -# 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 +# Canonical provider list — single source of truth for provider identity. Every code path that +# lists, displays, or iterates providers (hermes model, /model, list_authenticated_providers) +# derives from it. slug = internal ID (config.yaml, --provider); label = short display name; +# tui_desc = longer description for the `hermes model` picker. # --------------------------------------------------------------------------- class ProviderEntry(NamedTuple): slug: str label: str - tui_desc: str # detailed description for `hermes model` TUI + tui_desc: str -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)"), -] +CANONICAL_PROVIDERS: list[ProviderEntry] = [ProviderEntry(*row) for row in ( + ("nous", "Nous Portal", "Nous Portal (Everything your agent needs, 300+ models with bundled tool use)"), + ("fireworks", "Fireworks AI", "Fireworks AI (OpenAI-compatible direct model API)"), + ("openrouter", "OpenRouter", "OpenRouter (Pay-per-use API aggregator)"), + ("moa", "Mixture of Agents", "Mixture of Agents (named presets; aggregator acts after reference models)"), + ("novita", "NovitaAI", "NovitaAI (Cloud: Model API, Agent Sandbox, GPU Cloud)"), + ("lmstudio", "LM Studio", "LM Studio (Local desktop app with built-in model server)"), + ("anthropic", "Anthropic", "Anthropic (Claude models via API key or Claude Code)"), + ("openai-codex", "ChatGPT or Codex Subscription", "ChatGPT or Codex Subscription (Sign in with your ChatGPT account, uses Codex models)"), + ("openai-api", "OpenAI API", "OpenAI API (api.openai.com, API key)"), + ("alibaba", "Qwen Cloud", "Qwen Cloud / DashScope (Qwen + multi-provider)"), + ("xai-oauth", "xAI Grok OAuth (SuperGrok / Premium+)", "xAI Grok OAuth (SuperGrok / Premium+ subscription)"), + ("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2.5 and V2 models: pro, omni, flash)"), + ("tencent-tokenhub", "Tencent TokenHub", "Tencent TokenHub (Hy4 preview via tokenhub.tencentmaas.com)"), + ("tencent-tokenplan", "Tencent TokenPlan", "Tencent TokenPlan (Hy4 preview via api.lkeap.cloud.tencent.com, Anthropic Messages)"), + ("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models via build.nvidia.com or local NIM)"), + ("copilot", "GitHub Copilot", "GitHub Copilot (Uses GITHUB_TOKEN or gh auth token)"), + ("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (Spawns copilot --acp --stdio)"), + ("huggingface", "Hugging Face", "Hugging Face Inference Providers"), + ("gemini", "Google AI Studio", "Google AI Studio (Native Gemini API)"), + ("vertex", "Google Vertex AI", "Google Vertex AI (Gemini via GCP; OAuth2 service account or ADC, GCP billing/quotas)"), + ("deepseek", "DeepSeek", "DeepSeek (V3, R1, coder, direct API)"), + ("xai", "xAI", "xAI Grok (Direct API)"), + ("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu direct API)"), + ("kimi-coding", "Kimi / Kimi Coding Plan", "Kimi Coding Plan (api.kimi.com & Moonshot API)"), + ("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Domestic direct API)"), + ("stepfun", "StepFun Step Plan", "StepFun Step Plan (Agent / coding models via Step Plan API)"), + ("minimax", "MiniMax", "MiniMax (Global direct API)"), + ("minimax-oauth", "MiniMax (OAuth)", "MiniMax via OAuth browser login (Coding Plan, minimax.io)"), + ("minimax-cn", "MiniMax (China)", "MiniMax China (Domestic direct API)"), + ("ollama-cloud", "Ollama Cloud", "Ollama Cloud (Cloud-hosted open models, ollama.com)"), + ("arcee", "Arcee AI", "Arcee AI (Trinity models, direct API)"), + ("gmi", "GMI Cloud", "GMI Cloud (Multi-model direct API)"), + ("kilocode", "Kilo Code", "Kilo Code (Kilo Gateway API)"), + ("opencode-zen", "OpenCode Zen", "OpenCode Zen (Curated models, pay-as-you-go)"), + ("opencode-go", "OpenCode Go", "OpenCode Go (Open models subscription)"), + ("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek; IAM or API key)"), + ("azure-foundry", "Azure Foundry", "Azure Foundry (OpenAI-style or Anthropic-style endpoint, your Azure AI deployment)"), + ("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway (Multi-model aggregator)"), + ("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// -# is sufficient to expose a new provider in the model picker, /model, and all -# downstream consumers — no edits to this file needed. +# Auto-extend CANONICAL_PROVIDERS with providers registered under plugins/model-providers// +# so a new provider reaches the picker, /model and every downstream consumer without edits here. +# Non-api-key flows need bespoke picker UX and are skipped. _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: + if _pp.name in _canonical_slugs or _pp.auth_type in { + "oauth_device_code", "oauth_external", "external_process", "aws_sdk", "copilot", "vertex", + }: 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_PROVIDERS.append(ProviderEntry(_pp.name, _label, _pp.description or f"{_label} (direct API)")) _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 ``, 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 — DISPLAY ONLY. Vendors with several slugs (global API, China API, OAuth plan, +# ...) fold under one top-level row in the INTERACTIVE PICKERS (``hermes model``, setup wizard, +# Telegram ``/model``). They do NOT change CANONICAL_PROVIDERS, slug identity, ``--provider``, +# ``/model `` or any typed path — every member slug stays individually addressable. +# ``group_providers()`` is the single fold used by all three surfaces. +# group_id -> (display_label, group_description shown on the collapsed row, [member_slug, ...]) +# Member order is the order shown inside the group submenu; member detail lives in ``tui_desc``. # --------------------------------------------------------------------------- PROVIDER_GROUPS: dict[str, tuple[str, str, list[str]]] = { "kimi": ("Kimi / Moonshot", "Coding Plan, Moonshot global & China endpoints", ["kimi-coding", "kimi-coding-cn"]), @@ -870,8 +394,7 @@ PROVIDER_GROUPS: dict[str, tuple[str, str, list[str]]] = { "tencent": ("Tencent Hy", "Hy4 / Hy3 via TokenHub & TokenPlan", ["tencent-tokenhub", "tencent-tokenplan"]), } - -# Reverse index: member slug -> group_id. Built once at import. +# Reverse index: member slug -> group_id. _SLUG_TO_GROUP: dict[str, str] = { slug: gid for gid, (_label, _desc, members) in PROVIDER_GROUPS.items() for slug in members } @@ -883,25 +406,19 @@ def provider_group_for_slug(slug: str) -> str: def group_providers(slugs): - """Fold a flat ordered slug iterable into picker rows by provider group. + """Fold a flat ordered slug iterable into picker rows by provider group (DISPLAY ONLY). - DISPLAY ONLY. Used by every interactive picker (``hermes model``, the setup wizard, the Telegram - ``/model`` keyboard) so grouping is identical across surfaces. - - 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 row appears at the position of its FIRST present member, in input order; later + members fold into it. Member order inside a group follows ``PROVIDER_GROUPS`` declaration, + restricted to the members present in ``slugs``. """ - 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 - + present = set(slugs) + group_members = { + gid: [m for m in members if m in present] + for gid, (_label, _desc, members) in PROVIDER_GROUPS.items() + } rows = [] + seen: set[str] = set() emitted_groups: set[str] = set() for slug in slugs: s = str(slug or "").strip().lower() @@ -915,310 +432,159 @@ def group_providers(slugs): if gid in emitted_groups: continue # already folded at the first member's position emitted_groups.add(gid) - members = group_members.get(gid, [s]) + members = group_members.get(gid) or [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)} - ) + 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", -} +_PROVIDER_ALIASES = dict(( + ("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. +# Offline/fresh-install 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). The +# AUTHORITATIVE source is the remote catalog manifest, which labels exactly one entry per provider +# ``"default": true`` (get_default_model_from_cache) so the default rotates without a release; this +# 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. PREFERRED_SILENT_DEFAULT_MODEL = "z-ai/glm-5.2" -# 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. +# Providers whose *silent* auto-default goes through the cost-safe catalog-labeled default +# (``get_preferred_silent_default_model``) instead of curated entry [0]. Metered aggregators order +# best-first, so [0] is the priciest flagship; a profile that sets a provider with no model would +# otherwise silently bill the most expensive model (863 Opus requests before one user noticed). +# Network-free (cache-only) on purpose — this is the hot resolution path. The *interactive* default +# (GUI onboarding / ``hermes model``) uses the tier-aware ``get_recommended_default_model`` in +# hermes_cli/web_server.py + ``partition_nous_models_by_tier``, which may hit the Portal. _SILENT_DEFAULT_PROVIDERS: frozenset[str] = frozenset({"nous", "openrouter"}) -# 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. +# Retired model IDs kept for /model auto-detect only — not shown in pickers. DeepSeek cut these +# off; model_normalize remaps them on the wire. _PROVIDER_RETIRED_ALIASES: dict[str, tuple[str, ...]] = { "deepseek": ("deepseek-chat", "deepseek-reasoner"), } -_AGGREGATOR_PROVIDERS = frozenset( - {"nous", "openrouter", "ai-gateway", "copilot", "kilocode"} -) +_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 request-time routing variants (docs: guides/routing/model-variants): per-request +# modifiers valid on ANY model id (":nitro" throughput sort + priority tier, ":floor" price sort + +# flex tier, ":exacto" quality-first provider sort, ":online" web plugin). Never separate catalog +# entries — /models lists only the base id. NOT here: ":free", ":batch", ":thinking", ":extended" +# — those ARE distinct SKUs that appear in /models when they exist, so absence is authoritative. _OPENROUTER_VARIANT_SUFFIXES = frozenset({"nitro", "floor", "exacto", "online"}) -# 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. +# Subscription/OAuth providers whose catalogs RE-EXPOSE other vendors' models; tried only as a last +# resort for bare short-alias resolution (after every native-vendor catalog) so they never hijack +# an alias from the model's native vendor. None 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"} -) +# Providers whose live /v1/models is the authoritative catalog: the picker merges live-first (live +# entries lead, curated-only append). Every OTHER provider keeps curated-first so a deliberately +# surfaced newest model stays on top when the live API lags. Zen/Go re-expose dozens of vendors +# and rotate them often, so their stale curated entries must not pollute the top. +_LIVE_FIRST_PICKER_PROVIDERS: frozenset[str] = frozenset({"opencode-zen", "opencode-go"}) -# 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", -) +# Models supporting OpenAI Priority Processing (service_tier="priority"; see +# openai.com/api-priority-processing). Pattern-based: any OpenAI flagship (gpt-*, o1*, o3*, o4*). +# Non-OpenAI endpoints (OpenRouter/Copilot/opencode-zen proxies) strip service_tier, so false +# positives are harmless. Codex-series models are excluded — the Codex Responses API doesn't +# expose service_tier. +_OPENAI_FAST_MODE_PREFIXES: tuple[str, ...] = ("gpt-", "o1", "o3", "o4") -# 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. +# Providers where models.dev is authoritative: the curated list is an offline fallback plus custom +# additions the registry lacks, merged fresh-first (curated-only names appended) for both the CLI +# and the gateway /model picker. DELIBERATELY EXCLUDED: "openrouter" (curated list is a hand-picked +# agentic subset of 400+ models — merging would dump everything), "nous" (curated list + Portal +# /models are the subscription-tier source of truth), and providers with dedicated live-endpoint +# branches (copilot, anthropic, ai-gateway, ollama-cloud, custom, stepfun, openai-codex). _MODELS_DEV_PREFERRED: frozenset[str] = frozenset({ - "opencode-go", - "opencode-zen", - "deepseek", - "kilocode", - "fireworks", - "mistral", - "togetherai", - "cohere", - "perplexity", - "groq", - "nvidia", - "huggingface", - "zai", - "gemini", - "google", - "xai", + "opencode-go", "opencode-zen", "deepseek", "kilocode", "fireworks", "mistral", "togetherai", + "cohere", "perplexity", "groq", "nvidia", "huggingface", "zai", "gemini", "google", "xai", "xai-oauth", }) -# 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. +# Providers whose catalog is served with NO credential get a constant credential fingerprint in +# the disk cache: the anonymous opencode-free catalog's freshness comes from TTL revalidation, +# so folding in unrelated auth.json mtimes would only bust the SWR cache needlessly. _KEYLESS_STABLE_CACHE_PROVIDERS = frozenset({"opencode-free"}) -_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", -} +# OpenRouter-style ids -> Copilot ids. Dash-notation Claude ids are accepted too: Hermes' default +# Claude IDs use hyphens (Anthropic native) but Copilot's API only accepts dot-notation, so a +# copilot + hyphenated default would otherwise hit HTTP 400 "model_not_supported". +_COPILOT_MODEL_ALIASES = dict(( + ("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"), ("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-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"), +)) -# 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 -) +# Azure Foundry model families that require the Responses API: Azure rejects /chat/completions +# against them with ``400 "The requested operation is unsupported."`` (seen on gpt-5.3-codex while +# gpt-4o on the same endpoint worked). Broad enough for vendor-renamed deployments (gpt-5.x-codex, +# o1-preview), tight enough to leave GPT-4 / 3.5 / Llama / Mistral / Grok on chat completions. +_AZURE_FOUNDRY_RESPONSES_PREFIXES = ("codex", "gpt-5", "o1", "o3", "o4") From 1cf91f6e138237de9939b36c114c1bb25e947c7c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:53:45 -0700 Subject: [PATCH 2/6] refactor(hermes_cli): dedupe URL-suffix stripping, api-key lookup and LM Studio entry helpers in models_local --- hermes_cli/models_local.py | 468 +++++++++++++++---------------------- 1 file changed, 191 insertions(+), 277 deletions(-) diff --git a/hermes_cli/models_local.py b/hermes_cli/models_local.py index 1ec9b48ad7..51819a1f04 100644 --- a/hermes_cli/models_local.py +++ b/hermes_cli/models_local.py @@ -1,15 +1,15 @@ """Local / self-hosted model servers. Ollama (native ``/api/tags`` probe, request headers, base-url resolution), LM Studio -(``/api/v1/models``, load-on-demand), and Ollama Cloud (live + models.dev merged catalog with a -disk cache). - -Split out of ``hermes_cli.models``; every moved name is re-imported there, so -``hermes_cli.models.`` keeps resolving (and monkeypatching) as before. +(``/api/v1/models``, load-on-demand) and Ollama Cloud (live + models.dev merged catalog with a +disk cache). Split out of ``hermes_cli.models``, which re-imports every name; origin helpers are +looked up on ``hermes_cli.models`` at call time so ``patch("hermes_cli.models.")`` mocks +keep intercepting. """ from __future__ import annotations +import hashlib import http.client import json import logging @@ -25,18 +25,14 @@ from hermes_cli.urllib_security import url_origin # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.models") +_OLLAMA_DEFAULT_PORT = 11434 -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"): + +def _strip_suffixes(root: str, suffixes: tuple[str, ...]) -> str: + """Drop the first matching path suffix (and any trailing slash) from *root*.""" + for suffix in suffixes: if root.endswith(suffix): - root = root[: -len(suffix)].rstrip("/") - break + return root[: -len(suffix)].rstrip("/") return root @@ -50,6 +46,16 @@ def _normalize_openai_base_url(base_url: Optional[str]) -> str: return value +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 + return _strip_suffixes(root, ("/api/tags", "/v1/models", "/api", "/v1")) + + def _configured_ollama_base_url() -> str: """``providers.ollama.base_url`` (legacy keys ``api`` / ``url``), or ``""``.""" from hermes_cli.models import _get_provider_config_dict @@ -58,13 +64,39 @@ def _configured_ollama_base_url() -> str: return str(cfg.get("base_url") or cfg.get("api") or cfg.get("url") or "").strip() +def _ollama_host_from_env(env_host: str) -> str: + """Apply Ollama's own ``OLLAMA_HOST`` defaulting rules (port 11434, IPv6 bracketing).""" + port = _OLLAMA_DEFAULT_PORT + if env_host.startswith(":") and not env_host.startswith("::"): + return "127.0.0.1" + env_host + if env_host.startswith("[") and env_host.endswith("]"): + return f"{env_host}:{port}" + if "://" 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 "" + return parsed._replace(netloc=f"{userinfo}{hostname}:{port}").geturl() + except ValueError: + pass + return env_host + if env_host.count(":") > 1 and not env_host.startswith("["): + return f"[{env_host}]:{port}" + if ":" not in env_host: + return f"{env_host}:{port}" + return env_host + + 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. + Precedence: explicit ``providers.ollama.base_url`` (wires local endpoints without changing the + active provider) → active ``model.base_url`` when the active provider is ollama, or custom AND + the endpoint actually serves ``/api/tags`` (otherwise the picker would probe an unrelated + endpoint and hide the local catalog) → ``OLLAMA_HOST`` → Ollama's local default. """ from hermes_cli.models import _get_model_config_dict, should_use_ollama_native_catalog configured = _configured_ollama_base_url() @@ -77,9 +109,6 @@ def _get_ollama_base_url() -> str: if model_provider == "ollama" and model_base: return model_base if model_provider == "custom" and model_base: - # Only reuse the active bare custom endpoint when it is actually Ollama-compatible; - # otherwise the Ollama picker would probe an unrelated endpoint's /api/tags and hide the - # local Ollama catalog. try: if should_use_ollama_native_catalog("custom", model_base): return model_base @@ -88,51 +117,37 @@ def _get_ollama_base_url() -> str: 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 _ollama_host_from_env(env_host) return "http://localhost:11434" +def _api_key_from_provider_config(entry: dict, *env_keys: str) -> str: + """``api_key`` from a provider config block, else the env var named by the first set *env_keys*.""" + api_key = str(entry.get("api_key") or "").strip() + if api_key: + return api_key + key_env = str(next((entry.get(k) for k in env_keys if entry.get(k)), "") or "").strip() + return os.getenv(key_env, "").strip() if key_env else "" + + +def _drop_authorization(headers: dict[str, str]) -> None: + for key in tuple(headers): + if key.lower() == "authorization": + del headers[key] + + def _get_ollama_request_headers() -> dict[str, str]: """Return configured headers and credentials for native Ollama requests.""" from hermes_cli.models import _get_provider_config_dict entry = _get_provider_config_dict("ollama") - raw = entry.get("extra_headers") try: from hermes_cli.config import normalize_extra_headers - result = normalize_extra_headers(raw) + result = normalize_extra_headers(entry.get("extra_headers")) 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 "" + api_key = _api_key_from_provider_config(entry, "key_env", "api_key_env") if api_key and not any(key.lower() == "authorization" for key in result): result["Authorization"] = f"Bearer {api_key}" return result @@ -143,7 +158,11 @@ def _get_ollama_native_headers( *, api_key: Optional[str] = None, ) -> dict[str, str]: - """Resolve Ollama credentials and headers for one endpoint origin.""" + """Resolve Ollama credentials and headers for one endpoint origin. + + Configured headers apply only when *base_url* shares the configured Ollama root; an explicit + *api_key* replaces any configured Authorization variant rather than inheriting it. + """ from hermes_cli.models import _get_ollama_request_headers configured_base = _configured_ollama_base_url() explicit_key = str(api_key or "").strip() @@ -152,11 +171,7 @@ def _get_ollama_native_headers( 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] + _drop_authorization(headers) headers["Authorization"] = f"Bearer {explicit_key}" return headers @@ -168,8 +183,6 @@ _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 @@ -183,27 +196,16 @@ def _evict_related_ollama_cache_entries(key: str) -> 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] - ) + _evict_related_ollama_cache_entries(next(iter(cache)).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 + if not headers: + return root + 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") + return f"{root}|headers:{hashlib.blake2b(header_blob, digest_size=8).hexdigest()}" def _parse_ollama_tags(payload: Any) -> Optional[list[str]]: @@ -212,13 +214,11 @@ def _parse_ollama_tags(payload: Any) -> Optional[list[str]]: if not isinstance(raw_models, list): return None models: list[str] = [] - seen: set[str] = set() for item in raw_models: if not isinstance(item, dict): return None model_id = str(item.get("model") or item.get("name") or "").strip() - if model_id and model_id not in seen: - seen.add(model_id) + if model_id and model_id not in models: models.append(model_id) if raw_models and not models: return None @@ -233,9 +233,8 @@ def probe_ollama_local_models( """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. + (possibly empty) when ``/api/tags`` was reachable — Ollama's authoritative local catalog + (``/v1/models`` is not required for local servers). """ from hermes_cli.models import _HERMES_USER_AGENT, _get_ollama_base_url, _urlopen_model_catalog_request root = _root_for_ollama_native_api(base_url or _get_ollama_base_url()) @@ -244,33 +243,25 @@ def probe_ollama_local_models( 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) + if cached is not None and time.monotonic() - cached[1] < _OLLAMA_LOCAL_MODELS_CACHE_TTL: + return list(cached[0]) failed_at = _OLLAMA_LOCAL_PROBE_FAILURE_CACHE.get(failure_key) if failed_at is not None: if time.monotonic() - failed_at < _OLLAMA_LOCAL_PROBE_FAILURE_TTL: return None _OLLAMA_LOCAL_PROBE_FAILURE_CACHE.pop(failure_key, None) - def _unreachable() -> None: - _remember_ollama_cache(_OLLAMA_LOCAL_PROBE_REACHABLE, cache_key, False) - _remember_ollama_cache(_OLLAMA_LOCAL_PROBE_FAILURE_CACHE, failure_key, time.monotonic()) - try: request_headers = {"User-Agent": _HERMES_USER_AGENT, **(headers or {})} req = urllib.request.Request(root.rstrip("/") + "/api/tags", headers=request_headers) with _urlopen_model_catalog_request(req, timeout=timeout) as resp: - payload = json.loads(resp.read().decode()) + models = _parse_ollama_tags(json.loads(resp.read().decode())) except (ValueError, OSError, TimeoutError, http.client.HTTPException, urllib.error.URLError, json.JSONDecodeError, UnicodeDecodeError): - _unreachable() - return None - - models = _parse_ollama_tags(payload) + models = None if models is None: - _unreachable() + _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) @@ -305,6 +296,10 @@ def _same_ollama_native_root(left: str, right: str) -> bool: return False +_NEVER_OLLAMA_PROVIDERS = frozenset({"openrouter", "nous", "anthropic", "openai", "openai-codex", "gemini", "ollama-cloud"}) +_LOCAL_LIKE_PROVIDERS = frozenset({"", "custom", "local", "llamacpp", "llama.cpp", "llama-cpp", "vllm"}) + + def should_use_ollama_native_catalog( provider: Optional[str], base_url: Optional[str], @@ -329,7 +324,7 @@ def should_use_ollama_native_catalog( except ValueError: pass - if requested in {"openrouter", "nous", "anthropic", "openai", "openai-codex", "gemini", "ollama-cloud"}: + if requested in _NEVER_OLLAMA_PROVIDERS: return False configured_base = _configured_ollama_base_url() @@ -346,16 +341,14 @@ def should_use_ollama_native_catalog( 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:"): + 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: + if urllib.parse.urlparse(root).port != _OLLAMA_DEFAULT_PORT: return False except ValueError: return False @@ -365,7 +358,8 @@ def should_use_ollama_native_catalog( def _ollama_local_catalog(force_refresh: bool) -> list[str]: """Catalog for the raw ``ollama`` provider: native ``/api/tags`` when the endpoint is a real - Ollama server, else the OpenAI-style ``/v1/models`` of the configured gateway.""" + Ollama server, else the OpenAI-style ``/v1/models`` of the configured gateway (incl. Ollama + Cloud).""" from hermes_cli.models import _get_ollama_base_url, _get_ollama_native_headers, _get_provider_config_dict, fetch_api_models, fetch_ollama_local_models, should_use_ollama_native_catalog if force_refresh: _OLLAMA_LOCAL_MODELS_CACHE.clear() @@ -381,31 +375,20 @@ def _ollama_local_catalog(force_refresh: bool) -> list[str]: native_key = _ollama_probe_cache_key(_root_for_ollama_native_api(base_url), headers or None) if native_models or _OLLAMA_LOCAL_PROBE_REACHABLE.get(native_key) is True: return native_models or [] - # Non-native Ollama-compatible endpoints (incl. Ollama Cloud) and gateways exposing only - # OpenAI-style /v1/models. config = _get_provider_config_dict("ollama") - fallback_key = str(config.get("api_key") or "").strip() - if not fallback_key: - key_env = str(config.get("key_env") or "").strip() - fallback_key = os.getenv(key_env, "").strip() if key_env else "" + fallback_key = _api_key_from_provider_config(config, "key_env") fallback_base = _normalize_openai_base_url(config.get("base_url") or base_url) fallback_headers = _get_ollama_native_headers(fallback_base, api_key=fallback_key) return fetch_api_models(fallback_key, fallback_base, headers=fallback_headers or None) or [] def _lmstudio_server_root(base_url: Optional[str]) -> Optional[str]: - """Return the LM Studio server root for native ``/api/v1`` endpoints. + """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. + Users paste either the OpenAI runtime URL (``.../v1``) or the native prefix (``.../api`` / + ``.../api/v1``); native probes append ``/api/v1/...`` themselves, so strip all accepted forms. """ - 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 + return _strip_suffixes((base_url or "").strip().rstrip("/"), ("/api/v1", "/api", "/v1")) or None def _lmstudio_request_headers(api_key: Optional[str] = None) -> dict: @@ -423,14 +406,16 @@ def _lmstudio_fetch_raw_models( base_url: Optional[str] = None, timeout: float = 5.0, ) -> Optional[list[dict]]: - """Fetch the raw model list from LM Studio's ``/api/v1/models``.""" + """Fetch the raw model list from LM Studio's ``/api/v1/models``. + + None on network errors / malformed payloads; raises ``AuthError`` on HTTP 401/403. + """ from hermes_cli.models import _urlopen_model_catalog_request server_root = _lmstudio_server_root(base_url) if not server_root: return None - headers = _lmstudio_request_headers(api_key) - request = urllib.request.Request(server_root + "/api/v1/models", headers=headers) + request = urllib.request.Request(server_root + "/api/v1/models", headers=_lmstudio_request_headers(api_key)) try: with _urlopen_model_catalog_request(request, timeout=timeout) as resp: payload = json.loads(resp.read().decode()) @@ -455,6 +440,22 @@ def _lmstudio_fetch_raw_models( return raw_models +def _lmstudio_raw_models_or_none(api_key, base_url, timeout) -> Optional[list[dict]]: + """``_lmstudio_fetch_raw_models`` with every failure (incl. AuthError) collapsed to None.""" + from hermes_cli.models import _lmstudio_fetch_raw_models + try: + return _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=timeout) + except Exception: + return None + + +def _lmstudio_entry_for(raw_models: list, model: str) -> 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 + + def probe_lmstudio_models( api_key: Optional[str] = None, base_url: Optional[str] = None, @@ -462,10 +463,9 @@ def probe_lmstudio_models( ) -> Optional[list[str]]: """Probe LM Studio's model listing. - Returns chat-capable model keys, including a valid empty list when the server is reachable - but has no non-embedding models; returns ``None`` on network errors, malformed responses, or - bad base URLs. Raises ``AuthError`` on HTTP 401/403 so token issues surface separately from - reachability. + Returns chat-capable model keys — a valid empty list when the server is reachable but has no + non-embedding models; ``None`` on network errors, malformed responses, or bad base URLs. + Raises ``AuthError`` on HTTP 401/403 so token issues surface separately from reachability. """ from hermes_cli.models import _lmstudio_fetch_raw_models raw_models = _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=timeout) @@ -474,9 +474,7 @@ def probe_lmstudio_models( keys: list[str] = [] for raw in raw_models: - if not isinstance(raw, dict): - continue - if str(raw.get("type") or "").strip().lower() == "embedding": + if not isinstance(raw, dict) or 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: @@ -489,16 +487,10 @@ def fetch_lmstudio_models( base_url: Optional[str] = None, timeout: float = 5.0, ) -> list[str]: - """Fetch LM Studio chat-capable model keys from native ``/api/v1/models``. - - Embedding models are filtered out; network errors, malformed responses, and bad base URLs - yield an empty list. Raises ``AuthError`` on HTTP 401/403 so callers can distinguish a - missing or wrong ``LM_API_KEY`` from an unreachable server — the most common LM Studio - support case. - """ + """LM Studio chat-capable model keys; ``[]`` when unreachable/malformed. Raises ``AuthError`` on + HTTP 401/403 so callers can tell a wrong ``LM_API_KEY`` from an unreachable server.""" from hermes_cli.models import probe_lmstudio_models - models = probe_lmstudio_models(api_key=api_key, base_url=base_url, timeout=timeout) - return models or [] + return probe_lmstudio_models(api_key=api_key, base_url=base_url, timeout=timeout) or [] class LMStudioLoadResult(NamedTuple): @@ -509,6 +501,25 @@ class LMStudioLoadResult(NamedTuple): rejected: bool = False +def _positive_int(value: Any) -> Optional[int]: + if isinstance(value, int) and not isinstance(value, bool) and value > 0: + return value + return None + + +def _lmstudio_loaded_context(entry: Optional[dict]) -> Optional[int]: + """First positive ``loaded_instances[*].config.context_length`` of a model entry.""" + instances = entry.get("loaded_instances") if entry is not None else None + if not isinstance(instances, list): + return None + for instance in instances: + config = instance.get("config") if isinstance(instance, dict) else None + parsed = _positive_int(config.get("context_length") if isinstance(config, dict) else None) + if parsed is not None: + return parsed + return None + + def ensure_lmstudio_model_loaded( model: str, base_url: Optional[str], @@ -524,40 +535,12 @@ def ensure_lmstudio_model_loaded( caller supplied an explicit override; the returned context must come from LM Studio's echoed or refreshed state. """ - from hermes_cli.models import _lmstudio_fetch_raw_models, _urlopen_model_catalog_request + from hermes_cli.models import _urlopen_model_catalog_request - def _result( - context_length: Optional[int], - *, - load_attempted: bool = False, - rejected: bool = False, - ) -> Optional[int] | LMStudioLoadResult: + def _result(context_length: Optional[int], *, load_attempted: bool = False, rejected: bool = False): 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) @@ -566,16 +549,8 @@ def ensure_lmstudio_model_loaded( 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) + raw_models = _lmstudio_raw_models_or_none(api_key, base_url, 10) + target_entry = _lmstudio_entry_for(raw_models, model) if raw_models is not None else None if target_entry is None: return _result(None) @@ -583,7 +558,7 @@ def ensure_lmstudio_model_loaded( 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) + current_context = _lmstudio_loaded_context(target_entry) if current_context is not None: return _result(current_context) @@ -594,13 +569,11 @@ def ensure_lmstudio_model_loaded( 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" + load_headers = {**_lmstudio_request_headers(api_key), "Content-Type": "application/json"} try: load_request = urllib.request.Request( server_root + "/api/v1/models/load", - data=body, + data=json.dumps(load_payload).encode(), headers=load_headers, method="POST", ) @@ -614,23 +587,14 @@ def ensure_lmstudio_model_loaded( 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 - ) + 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 + refreshed_models = _lmstudio_raw_models_or_none(api_key, base_url, 10) 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) + return _result(_lmstudio_loaded_context(_lmstudio_entry_for(refreshed_models, model)), load_attempted=True) def lmstudio_model_reasoning_options( @@ -639,31 +603,16 @@ def lmstudio_model_reasoning_options( api_key: Optional[str] = None, timeout: float = 5.0, ) -> list[str]: - """Return the reasoning ``allowed_options`` LM Studio publishes for ``model``. - - Reads ``capabilities.reasoning.allowed_options`` from ``/api/v1/models``; returns ``[]`` - when the model is unknown, the endpoint is unreachable, or no reasoning capability is - declared. - """ - from hermes_cli.models import _lmstudio_fetch_raw_models - try: - raw_models = _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=timeout) - except Exception: - raw_models = None - if not raw_models: - return [] - - for raw in raw_models: - if not isinstance(raw, dict): - continue - if raw.get("key") != model and raw.get("id") != model: - continue - caps = raw.get("capabilities") - reasoning = caps.get("reasoning") if isinstance(caps, dict) else None - opts = reasoning.get("allowed_options") if isinstance(reasoning, dict) else None - if isinstance(opts, list): - return [str(o).strip().lower() for o in opts if isinstance(o, str)] + """Reasoning ``allowed_options`` LM Studio publishes for ``model`` under + ``capabilities.reasoning`` in ``/api/v1/models``; ``[]`` when unknown, unreachable, or absent.""" + raw = _lmstudio_entry_for(_lmstudio_raw_models_or_none(api_key, base_url, timeout) or [], model) + if raw is None: return [] + 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 [] @@ -673,28 +622,23 @@ def ollama_model_supports_thinking( api_key: Optional[str] = None, timeout: float = 5.0, ) -> Optional[bool]: - """Return True if an Ollama (Cloud or local) model advertises ``thinking``. + """Tri-state: True if an Ollama (Cloud or local) model advertises ``thinking``. - Probes native ``/api/show`` and checks ``capabilities`` — the authoritative source, since - the OpenAI-compat ``/v1/models`` endpoint omits it. Tri-state: True when ``thinking`` is - declared, False when the probe succeeded without it, None when the probe failed so the - caller picks the fallback (treated as "don't emit"). + Probes native ``/api/show`` ``capabilities`` — the authoritative source, since OpenAI-compat + ``/v1/models`` omits it. False when the probe succeeded without it; None when the probe failed + so the caller picks the fallback (treated as "don't emit"). """ import httpx server_url = (base_url or "").strip().rstrip("/") if server_url.endswith("/v1"): server_url = server_url[:-3] - if not server_url: - return None - bare_model = _strip_ollama_cloud_suffix((model or "").strip()) - if not bare_model: + if not server_url or 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}) @@ -712,12 +656,8 @@ _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. - """ + """Strip the ``:cloud`` / ``-cloud`` suffix models.dev appends to Ollama Cloud IDs (the live + API uses bare ids), so the dedup merge does not produce duplicates.""" for suffix in (":cloud", "-cloud"): if model_id.endswith(suffix): return model_id[: -len(suffix)] @@ -725,7 +665,6 @@ def _strip_ollama_cloud_suffix(model_id: str) -> str: 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" @@ -764,34 +703,22 @@ def fetch_ollama_cloud_models( *, 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). - """ + """Ollama Cloud models: fresh disk cache (< 1h, unless force_refresh) → live ``/v1/models`` + (freshest) merged with models.dev additions (deduped, live first) → stale cache → ``[]``. + Never None.""" from hermes_cli.models import fetch_api_models - # 1. Check disk cache if not force_refresh: cached = _load_ollama_cloud_cache() if cached is not None: return cached["models"] - # 2. Live API probe - if not api_key: - api_key = os.getenv("OLLAMA_API_KEY", "") - if not base_url: - base_url = os.getenv("OLLAMA_BASE_URL", "") or "https://ollama.com/v1" + api_key = api_key or os.getenv("OLLAMA_API_KEY", "") + base_url = base_url or 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 + live_models = fetch_api_models(api_key, base_url, timeout=8.0) or [] - # 3. models.dev registry mdev_models: list[str] = [] try: from agent.models_dev import list_agentic_models @@ -799,26 +726,13 @@ def fetch_ollama_cloud_models( 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 + merged: list[str] = [] + for m in [*live_models, *(_strip_ollama_cloud_suffix(m) for m in mdev_models)]: + if m and m not in merged: + merged.append(m) + 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 [] + return stale["models"] if stale is not None else [] From 07904249a375281c3592d5f701b5be125de35d30 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:12:47 -0700 Subject: [PATCH 3/6] refactor(hermes_cli): dispatch-table pricing fetchers, shared price parsing, slot accessors on _CapsSource --- hermes_cli/models_pricing.py | 408 ++++++++++++---------------- hermes_cli/models_reasoning_caps.py | 156 +++++------ 2 files changed, 233 insertions(+), 331 deletions(-) diff --git a/hermes_cli/models_pricing.py b/hermes_cli/models_pricing.py index f6c2d4cc45..8b4298ef61 100644 --- a/hermes_cli/models_pricing.py +++ b/hermes_cli/models_pricing.py @@ -2,10 +2,9 @@ OpenRouter-compatible ``/v1/models`` pricing fetch with a per-endpoint/per-credential cache, Nous Portal sale chrome and org-policy filtering, and the Vercel AI Gateway / Novita / Fireworks / -DeepInfra pricing adapters. - -Split out of ``hermes_cli.models``; every moved name is re-imported there, so -``hermes_cli.models.`` keeps resolving (and monkeypatching) as before. +DeepInfra pricing adapters. Split out of ``hermes_cli.models``, which re-imports every name; +origin helpers and the cache dicts are looked up on ``hermes_cli.models`` at call time so +``patch("hermes_cli.models.")`` mocks keep intercepting. """ from __future__ import annotations @@ -21,17 +20,12 @@ from hermes_cli.models_reasoning_caps import _seed_reasoning_caps # Cache: maps model_id → {"prompt": str, "completion": str} per endpoint _pricing_cache: dict[str, dict[str, dict[str, str]]] = {} - -# A failed fetch caches its empty result too, so an unreachable endpoint isn't -# re-dialed on every call — but only until this deadline. Cached forever, one -# bad moment (a blip during startup, a key that hadn't been written yet) turns -# into no live model discovery for the life of the process, and the processes -# that read this most are the ones that run for weeks: the gateway, the desktop -# backend. Every caller falls back to a curated list meanwhile, so the cost of -# the stale entry is silent and invisible. +# 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 blip at startup would mean no live model +# discovery for the life of a process that runs for weeks (gateway, desktop backend), silently: +# every caller falls back to a curated list meanwhile. _FAILED_CATALOG_TTL_SECONDS = 120.0 - _pricing_cache_retry_after: dict[str, float] = {} @@ -56,21 +50,18 @@ def _cache_catalog( ) -> 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. + *ttl_seconds* expires a non-empty result too — only for a catalog whose contents depend on + server-side state the client cannot observe (an org's model policy can change while a long- + lived process holds the entry). """ from hermes_cli.models import _pricing_cache, _pricing_cache_retry_after _pricing_cache[cache_key] = result - if result: - if ttl_seconds: - _pricing_cache_retry_after[cache_key] = time.monotonic() + ttl_seconds - else: - _pricing_cache_retry_after.pop(cache_key, None) + if not result: + _pricing_cache_retry_after[cache_key] = time.monotonic() + _FAILED_CATALOG_TTL_SECONDS + elif ttl_seconds: + _pricing_cache_retry_after[cache_key] = time.monotonic() + ttl_seconds else: - _pricing_cache_retry_after[cache_key] = ( - time.monotonic() + _FAILED_CATALOG_TTL_SECONDS - ) + _pricing_cache_retry_after.pop(cache_key, None) return result @@ -79,12 +70,9 @@ _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`. - """ + """Cache-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 fingerprinting only (same rationale as ``_custom_endpoint_fingerprint``).""" if not api_key: return "" import hashlib @@ -97,14 +85,11 @@ def peek_cached_pricing(base_url: str) -> dict[str, dict[str, Any]]: """Pricing already cached for *base_url*, or ``{}``. Never fetches. Accepts a ``/v1``-suffixed URL as well as the pre-``/v1`` root the fetchers key on, and - prefers an authenticated catalog. Scans rather than rebuilding a key because callers hold no - credential — newest first, skipping expired entries, so a rotated credential does not keep - answering from the catalog its predecessor read. + prefers an authenticated catalog. Scans (callers hold no credential) newest first, skipping + expired entries, so a rotated credential does not keep answering from its predecessor's catalog. """ from hermes_cli.models import _pricing_cache - root = (base_url or "").rstrip("/") - if root.endswith("/v1"): - root = root[:-3].rstrip("/") + root = _strip_v1((base_url or "").rstrip("/")) authed_prefix = root + _PRICING_AUTH_KEY_PREFIX for key in reversed(list(_pricing_cache)): if key.startswith(authed_prefix): @@ -114,15 +99,16 @@ def peek_cached_pricing(base_url: str) -> dict[str, dict[str, Any]]: return _cached_catalog(root) or {} +def _strip_v1(url: str) -> str: + return url[:-3].rstrip("/") if url.endswith("/v1") else url + + def _format_price_per_mtok(per_token_str: str) -> str: - """Convert a per-token price string to a human-friendly $/Mtok string. + """Per-token price string → 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. + Always 2 decimals so right-justified prices align on the decimal point. Sub-cent prices (deep- + discount cache-hit promos) widen precision until the value shows, keep one extra digit and trim + trailing zeros instead of collapsing to "$0.00". """ try: val = float(per_token_str) @@ -133,8 +119,6 @@ def _format_price_per_mtok(per_token_str: str) -> str: 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 @@ -142,80 +126,58 @@ def _format_price_per_mtok(per_token_str: str) -> str: return f"${text}" +def _price_float(raw: Any, *, positive: bool) -> float | None: + """*raw* as a finite float (> 0, or >= 0 when not *positive*); None when unset/invalid/NaN.""" + if raw in (None, ""): + return None + try: + n = float(raw) + except (TypeError, ValueError): + return None + if n != n or (n <= 0 if positive else n < 0): + return None + return n + + +def _sale_pct(current: Any, original: Any) -> int | None: + """Percent discount when *current* is strictly below *original* (both positive finite).""" + cur, orig = _price_float(current, positive=True), _price_float(original, positive=True) + if cur is None or orig is None or cur >= orig: + return None + return int(round((1.0 - (cur / orig)) * 100)) + + 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``. + """Derive sale chrome from gateway ``pricing.original`` when cheaper (Nous Portal only; callers + gate on the provider and 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. + original. Free / $0 models get a flat 100% off, with "was" prices only when the gateway served + an original (a natively-free stealth model gets bare "-100%" chrome). """ - 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") + was_prompt_str = str(was_prompt) if was_prompt not in (None, "") else "" + was_completion_str = str(was_completion) if was_completion not in (None, "") else "" - # 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 _price_float(prompt, positive=False) == 0 and _price_float(completion, positive=False) in (0, None): + return (100, was_prompt_str, was_completion_str) - if not isinstance(original, dict): + if not isinstance(original, dict) or (not was_prompt_str and not was_completion_str): 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), - ) - + pct = _sale_pct(prompt, was_prompt) + if pct is not None: + return (pct, was_prompt_str, was_completion_str) if pct >= 1 else None + pct = _sale_pct(completion, was_completion) + if pct is not None: + return (pct, was_prompt_str, was_completion_str) if pct >= 1 else None return None @@ -244,6 +206,15 @@ def _pricing_entry(pricing: dict, prompt_key: str = "prompt", completion_key: st return entry +def _per_token(per_mtok: Any) -> str: + """$/MTok → the per-token price string the picker expects.""" + return str(float(per_mtok) / 1_000_000) + + +def _catalog_items(payload: dict) -> list[dict]: + return [item for item in payload.get("data", []) if isinstance(item, dict)] + + def fetch_models_with_pricing( api_key: str | None = None, base_url: str = "https://openrouter.ai/api", @@ -255,13 +226,10 @@ def fetch_models_with_pricing( ) -> 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. + 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). *include_sale_original* (Nous Portal only) copies the gateway's pre-discount + ``pricing.original`` rates through as a nested ``original`` dict for sale chrome. """ from hermes_cli.models import _HERMES_USER_AGENT url_root = (base_url or "").rstrip("/") @@ -310,11 +278,8 @@ def fetch_ai_gateway_pricing( *, 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. - """ + """Vercel AI Gateway /v1/models pricing, translating its ``input`` / ``output`` field names to + the picker's ``prompt`` / ``completion`` (cache read/write names already match).""" from hermes_constants import AI_GATEWAY_BASE_URL cache_key = AI_GATEWAY_BASE_URL.rstrip("/") @@ -328,9 +293,7 @@ def fetch_ai_gateway_pricing( return _cache_catalog(cache_key, {}) result: dict[str, dict[str, str]] = {} - for item in payload.get("data", []): - if not isinstance(item, dict): - continue + for item in _catalog_items(payload): mid = item.get("id") pricing = item.get("pricing") if mid and isinstance(pricing, dict): @@ -347,13 +310,12 @@ _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. + """``(api_key, base_url)`` for Nous Portal pricing; base_url is the bare origin (no ``/v1``). - 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. + Base URL precedence mirrors runtime credential resolution: ``NOUS_INFERENCE_BASE_URL`` env + override (staging / preview) → resolved runtime credential ``base_url`` → production default. + Without the override a staging profile's sale ``pricing.original`` would never reach the + pickers (prod has no ``original`` field). """ try: from hermes_cli.auth import _nous_inference_env_override @@ -375,25 +337,39 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]: 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) +# 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. +# Other providers' catalogs carry no such state and keep the default no-expiry caching. +_NOUS_CATALOG_TTL_SECONDS = 300.0 + + +def _fetch_nous_pricing(api_key: str, base_url: str, *, force_refresh: bool) -> dict[str, dict[str, Any]]: + """Shared by pricing and policy lookups so both read one cache entry.""" + from hermes_cli.models import fetch_models_with_pricing + return fetch_models_with_pricing( + api_key=api_key, + base_url=base_url, + force_refresh=force_refresh, + include_sale_original=True, # Sale chrome (pricing.original) is Nous Portal-only. + cache_ttl_seconds=_NOUS_CATALOG_TTL_SECONDS, + ) + + 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. + response's keys are the reachable set. ``None`` (leave the caller's list alone) covers the + three states that cannot narrow it: no policy (or a token too old to say), an anonymous read + whose catalog is unfiltered, and an empty read (a fetch failure, not an org that may reach + nothing). """ - from hermes_cli.models import _resolve_nous_pricing_credentials, fetch_models_with_pricing + from hermes_cli.models import _resolve_nous_pricing_credentials try: from hermes_cli.nous_account import nous_policy_present @@ -405,33 +381,14 @@ def nous_policy_allowed_ids(*, force_refresh: bool = False) -> Optional[set[str] 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 + return set(_fetch_nous_pricing(api_key, base_url, force_refresh=force_refresh)) 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. +# 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]], @@ -442,68 +399,51 @@ def restrict_to_nous_policy( 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. + the authoritative gate, while hiding a row the gate would serve is unrecoverable client-side. + *rescue_empty*: an allowlist naming only models the curated manifest lacks would leave an + empty picker — worse than no filter — so return the allowlist itself. Opt-in per list: an + already-empty list (a paid tier's gated models) means "nothing to gate", not "nothing survived". """ 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". + kept = [mid for mid in model_ids if mid in allowed or mid.split(":", 1)[0] in allowed] if rescue_empty and not kept and len(allowed) <= _NOUS_POLICY_APPEND_MAX: return sorted(allowed) return kept +def _fetch_openrouter_pricing(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]: + from hermes_cli.models import fetch_models_with_pricing + return fetch_models_with_pricing( + api_key=_resolve_openrouter_api_key(), + base_url="https://openrouter.ai/api", + force_refresh=force_refresh, + ) + + +def _fetch_nous_pricing_for_provider(*, force_refresh: bool = False) -> dict[str, dict[str, Any]]: + from hermes_cli.models import _resolve_nous_pricing_credentials + api_key, base_url = _resolve_nous_pricing_credentials() + if not base_url: + return {} + return _fetch_nous_pricing(api_key, base_url, force_refresh=force_refresh) + + def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> dict[str, dict[str, str]]: - """Return live pricing for providers that support it (openrouter, nous, ai-gateway, novita).""" - from hermes_cli.models import _resolve_nous_pricing_credentials, fetch_models_with_pricing, normalize_provider - normalized = normalize_provider(provider) - if normalized == "openrouter": - return fetch_models_with_pricing( - api_key=_resolve_openrouter_api_key(), - base_url="https://openrouter.ai/api", - force_refresh=force_refresh, - ) - if normalized == "ai-gateway": - return fetch_ai_gateway_pricing(force_refresh=force_refresh) - if normalized == "novita": - return _fetch_novita_pricing(force_refresh=force_refresh) - if normalized == "deepinfra": - return _fetch_deepinfra_pricing(force_refresh=force_refresh) - if normalized == "fireworks": - return _fireworks_pricing_from_models_dev(force_refresh=force_refresh) - if normalized == "nous": - api_key, base_url = _resolve_nous_pricing_credentials() - if base_url: - return fetch_models_with_pricing( - api_key=api_key, - base_url=base_url, - force_refresh=force_refresh, - # Sale chrome (pricing.original) is Nous Portal-only. - include_sale_original=True, - cache_ttl_seconds=_NOUS_CATALOG_TTL_SECONDS, - ) - return {} + """Return live pricing for providers that support it (openrouter, nous, ai-gateway, novita, + deepinfra, fireworks); ``{}`` for everything else.""" + from hermes_cli.models import normalize_provider + fetcher = _PRICING_FETCHERS.get(normalize_provider(provider)) + return fetcher(force_refresh=force_refresh) if fetcher else {} 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. - """ + """Fireworks picker pricing from the models.dev registry cache — no dedicated network fetch: + ``fetch_models_dev()`` already keeps a shared in-memory + disk cache (1h TTL), so this is a + pure dict transform with no per-render network call.""" cache_key = "models.dev/fireworks" if not force_refresh: cached = _cached_catalog(cache_key) @@ -514,24 +454,16 @@ def _fireworks_pricing_from_models_dev( 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") + for mid, entry in (_get_provider_models("fireworks") or {}).items(): + cost = entry.get("cost") if isinstance(entry, dict) else None if not isinstance(cost, dict): continue - inp = cost.get("input") - out = cost.get("output") + inp, out = cost.get("input"), 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) + row = {"prompt": _per_token(inp or 0), "completion": _per_token(out or 0)} + if cost.get("cache_read"): + row["input_cache_read"] = _per_token(cost["cache_read"]) result[str(mid)] = row except Exception: result = {} @@ -544,19 +476,15 @@ def _fetch_novita_pricing( *, force_refresh: bool = False, ) -> dict[str, dict[str, str]]: - """Fetch pricing from NovitaAI /v1/models. - - NovitaAI reports per-million-token prices in units of 0.0001 USD; they are converted to the - per-token strings the shared pricing formatter expects. Results are cached in - ``_pricing_cache`` keyed on the resolved base URL so menu renders don't re-hit the network. - """ + """NovitaAI /v1/models pricing. Novita reports per-million-token prices in units of 0.0001 USD; + converted to the per-token strings the shared formatter expects. Cached on the resolved base + URL so menu renders don't re-hit the network.""" from hermes_cli.models import _HERMES_USER_AGENT api_key = os.getenv("NOVITA_API_KEY", "").strip() if not api_key: return {} - base_url = os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1" - cache_key = base_url.rstrip("/") + cache_key = (os.getenv("NOVITA_BASE_URL", "").strip() or "https://api.novita.ai/openai/v1").rstrip("/") if not force_refresh: cached = _cached_catalog(cache_key) if cached is not None: @@ -568,15 +496,10 @@ def _fetch_novita_pricing( return _cache_catalog(cache_key, {}) result: dict[str, dict[str, str]] = {} - for item in payload.get("data", []): - if not isinstance(item, dict): - continue + for item in _catalog_items(payload): 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: + inp, out = item.get("input_token_price_per_m"), item.get("output_token_price_per_m") + if not mid or (inp is None and out is None): continue result[str(mid)] = { "prompt": str(float(inp or 0) / 10_000 / 1_000_000), @@ -591,12 +514,9 @@ def _fetch_deepinfra_pricing( *, force_refresh: bool = False, ) -> dict[str, dict[str, str]]: - """Return picker-shape pricing for DeepInfra chat models. - - DeepInfra publishes ``input_tokens``/``output_tokens``/``cache_read_tokens`` in $/MTok; the - picker expects per-token strings under ``prompt``/``completion``/``input_cache_read`` - (OpenRouter shape). Cached via the catalog helper so repeated picker renders are free. - """ + """Picker-shape pricing for DeepInfra chat models: ``input_tokens`` / ``output_tokens`` / + ``cache_read_tokens`` in $/MTok → per-token ``prompt`` / ``completion`` / ``input_cache_read``. + Cached via the models-by-tag helper so repeated picker renders are free.""" from hermes_cli.models import _fetch_deepinfra_models_by_tag items = _fetch_deepinfra_models_by_tag("chat", timeout=timeout, force_refresh=force_refresh) result: dict[str, dict[str, str]] = {} @@ -606,10 +526,20 @@ def _fetch_deepinfra_pricing( if not isinstance(pricing, dict): continue entry = { - ours: str(float(pricing[theirs]) / 1_000_000) + ours: _per_token(pricing[theirs]) for theirs, ours in (("input_tokens", "prompt"), ("output_tokens", "completion"), ("cache_read_tokens", "input_cache_read")) if pricing.get(theirs) is not None } if entry: result[item["id"]] = entry return result + + +_PRICING_FETCHERS = { + "openrouter": _fetch_openrouter_pricing, + "ai-gateway": fetch_ai_gateway_pricing, + "novita": _fetch_novita_pricing, + "deepinfra": _fetch_deepinfra_pricing, + "fireworks": _fireworks_pricing_from_models_dev, + "nous": _fetch_nous_pricing_for_provider, +} diff --git a/hermes_cli/models_reasoning_caps.py b/hermes_cli/models_reasoning_caps.py index 35d89ad804..ac26c6b3e9 100644 --- a/hermes_cli/models_reasoning_caps.py +++ b/hermes_cli/models_reasoning_caps.py @@ -1,17 +1,15 @@ """Per-model reasoning capabilities from OpenRouter-schema ``/v1/models`` catalogs. -Split out of ``hermes_cli.models``; every public/patched name is re-imported there. The -OpenRouter and Nous Portal catalogs share one implementation parametrized by -:class:`_CapsSource`; the per-source module globals (``_openrouter_reasoning_caps_cache``, -``_nous_caps_disk_checked``, ...) stay defined on ``hermes_cli.models`` — tests reset them there — -and are read/written by attribute name through the origin module. +Split out of ``hermes_cli.models``; every public/patched name is re-imported there. OpenRouter and +Nous Portal share one implementation parametrized by :class:`_CapsSource`; the per-source module +globals (``_openrouter_reasoning_caps_cache``, ``_nous_caps_disk_checked``, ...) stay defined on +``hermes_cli.models`` — tests reset them there — and are read/written by attribute name. -Tri-state contract for callers deciding whether to emit reasoning controls: -- dict with ``supports_reasoning: True`` (+ ``supported_efforts``, ``mandatory``) — the route - advertises reasoning controls; -- dict with ``supports_reasoning: False`` — the catalog knows the model and it does NOT accept - reasoning controls (definitive negative); -- ``None`` — unknown: catalog not loaded, model not listed (private/custom route), malformed. +Tri-state contract for callers deciding whether to emit reasoning controls: a dict with +``supports_reasoning: True`` (+ ``supported_efforts``, ``mandatory``) — the route advertises +reasoning controls; ``supports_reasoning: False`` — the catalog knows the model and it does NOT +accept them (definitive negative); ``None`` — unknown (catalog not loaded, model not listed, +malformed). """ from __future__ import annotations @@ -42,9 +40,8 @@ def parse_openrouter_reasoning_capabilities(item: Any) -> Optional[dict[str, Any """Normalize one OpenRouter catalog entry's reasoning metadata. ``supported_parameters`` contains ``"reasoning"`` when the route accepts reasoning controls at - all; a top-level ``reasoning`` object may add detail (``mandatory``, ``supported_efforts``). - A missing/malformed ``supported_parameters`` is "unknown" (None), mirroring the permissive - stance of ``_openrouter_model_supports_tools``. + all; a top-level ``reasoning`` object may add ``mandatory`` / ``supported_efforts``. A missing + or malformed ``supported_parameters`` is "unknown" (None), like ``_openrouter_model_supports_tools``. """ if not isinstance(item, dict): return None @@ -54,31 +51,28 @@ def parse_openrouter_reasoning_capabilities(item: Any) -> Optional[dict[str, Any if "reasoning" not in params: return {"supports_reasoning": False} reasoning = item.get("reasoning") - mandatory = isinstance(reasoning, dict) and reasoning.get("mandatory") is True + if not isinstance(reasoning, dict): + reasoning = {} + raw_efforts = reasoning.get("supported_efforts") 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() - )) + 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, + "mandatory": reasoning.get("mandatory") is True, } # ── Disk mirror ──────────────────────────────────────────────────────── # -# The in-process caches are always cold in a short-lived process, and every consumer is on a hot -# path that must never block on HTTP — so without a disk copy, `hermes -p`, a cron job, or a -# freshly booted gateway answers "capability unknown" for its whole first turn and falls back to -# the conservative wire shape. One file holds every catalog, keyed by the URL it came from: -# OpenRouter and the Nous Portal list different models, and a staging Portal must not answer for -# production. +# 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. One file holds every +# catalog keyed by URL: OpenRouter and the Portal list different models, and a staging Portal must +# not answer for production. _REASONING_CAPS_DISK_TTL_SECONDS = 24 * 3600 @@ -96,9 +90,7 @@ def _read_reasoning_caps_disk() -> dict[str, Any]: def _load_reasoning_caps_disk(url: str) -> tuple[Optional[Caps], float]: """Return ``(caps, age_seconds)`` for *url*, or ``(None, 0.0)``.""" entry = _origin()._read_reasoning_caps_disk().get(url) - if not isinstance(entry, dict): - return None, 0.0 - caps = entry.get("caps") + caps = entry.get("caps") if isinstance(entry, dict) else None if not isinstance(caps, dict) or not caps: return None, 0.0 try: @@ -121,27 +113,19 @@ def _save_reasoning_caps_disk(url: str, caps: Caps) -> None: 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. - """ + """Run *refresh* in a daemon thread (fire-and-forget) so a cold/stale cache is warm for the + next call or, via the disk mirror, the next process without this turn blocking on HTTP. + Callers own the once-per-process guard; the fetch keeps its own failure TTL.""" if os.environ.get("PYTEST_CURRENT_TEST"): return threading.Thread(target=refresh, name="reasoning-caps-warm", daemon=True).start() def _hydrate_reasoning_caps_from_disk(url: str, refresh) -> Optional[Caps]: - """The disk copy of *url*'s catalog, queueing *refresh* when it's stale. - - A copy past its TTL is still returned — a stale verdict beats no verdict, and reasoning - capabilities change rarely — with a background refresh so the next run is current. - """ + """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 capabilities change rarely.""" caps, age = _load_reasoning_caps_disk(url) - if caps is None: - return None - if age >= _REASONING_CAPS_DISK_TTL_SECONDS: + if caps is not None and age >= _REASONING_CAPS_DISK_TTL_SECONDS: _warm_reasoning_caps_async(refresh) return caps @@ -149,20 +133,17 @@ def _hydrate_reasoning_caps_from_disk(url: str, refresh) -> Optional[Caps]: def _seed_reasoning_caps(url: str, items: Any) -> Optional[Caps]: """Parse a ``/v1/models`` ``data`` array and mirror it for *url*. - Takes the payload rather than fetching it, so picker and pricing fetches (which pull the same - document) leave the mirror warm at no network cost. Returns None when the array has no usable - entries, which callers remember as a failure rather than caching as empty. + Takes the payload rather than fetching it, so picker and pricing fetches (same document) leave + the mirror warm at no network cost. None when the array has no usable entries — callers + remember that as a failure rather than caching empty. """ if not isinstance(items, list): return None caps_by_id: Caps = {} for item in items: - if not isinstance(item, dict): - continue - mid = str(item.get("id") or "").strip() - if not mid: - continue - caps_by_id[mid] = parse_openrouter_reasoning_capabilities(item) + mid = str(item.get("id") or "").strip() if isinstance(item, dict) else "" + if mid: + caps_by_id[mid] = parse_openrouter_reasoning_capabilities(item) if not caps_by_id: return None _save_reasoning_caps_disk(url, caps_by_id) @@ -170,12 +151,8 @@ def _seed_reasoning_caps(url: str, items: Any) -> Optional[Caps]: def _fetch_reasoning_caps_catalog(url: str, timeout: float) -> Optional[Caps]: - """Fetch one OpenRouter-shaped ``/v1/models`` catalog → per-model caps. - - Returns None when the catalog is unreachable or has no usable entries, so callers remember the - failure and fall back rather than caching an empty result. Sends a User-Agent because the - Portal 403s anonymous catalog reads. - """ + """Fetch one OpenRouter-shaped ``/v1/models`` catalog → per-model caps; None when unreachable or + empty so callers remember the failure. Sends a User-Agent: the Portal 403s anonymous reads.""" m = _origin() headers = {"Accept": "application/json", "User-Agent": m._HERMES_USER_AGENT} try: @@ -193,11 +170,10 @@ def _fetch_reasoning_caps_catalog(url: str, timeout: float) -> Optional[Caps]: class _CapsSource: """One catalog's cache slots on ``hermes_cli.models`` plus how to name its URL. - ``cache``: model id → parsed caps, populated by one full-catalog fetch and kept for the process - lifetime (capabilities don't change). ``failed_at``: monotonic timestamp of the last FAILED - fetch; suppresses re-fetch storms from per-turn callers while the catalog is unreachable (60s, - mirrors the LM Studio/Ollama capability-probe caching). ``disk_checked`` / ``warm_started``: - once-per-process guards for the disk hydrate and the background warm. + ``cache``: model id → parsed caps from one full-catalog fetch, kept for the process lifetime. + ``failed_at``: monotonic timestamp of the last FAILED fetch; suppresses re-fetch storms from + per-turn callers for 60s (mirrors the LM Studio/Ollama probe caching). ``disk_checked`` / + ``warm_started``: once-per-process guards for the disk hydrate and the background warm. """ cache: str failed_at: str @@ -205,37 +181,40 @@ class _CapsSource: warm_started: str url: Callable[[], str] + def get(self, slot: str): + return getattr(_origin(), getattr(self, slot)) + + def set(self, slot: str, value) -> None: + setattr(_origin(), getattr(self, slot), value) + def _fetch_caps(src: _CapsSource, timeout: float = 6.0, *, force: bool = False) -> Optional[Caps]: """Fetch + cache the source's per-model caps. None (without poisoning the cache) when unreachable, so callers retry later and fall back meanwhile.""" - m = _origin() - cached = getattr(m, src.cache) + cached = src.get("cache") if cached is not None and not force: return cached - failed_at = getattr(m, src.failed_at) + failed_at = src.get("failed_at") if failed_at is not None and (time.monotonic() - failed_at) < 60: return None caps_by_id = _fetch_reasoning_caps_catalog(src.url(), timeout) if caps_by_id is None: - setattr(m, src.failed_at, time.monotonic()) + src.set("failed_at", time.monotonic()) return None - setattr(m, src.cache, caps_by_id) + src.set("cache", caps_by_id) return caps_by_id def _caps_cached(src: _CapsSource) -> Optional[Caps]: """Cache-only caps: memory, else the disk mirror. Never HTTP. - Guarded to one disk attempt per process: for the Portal, naming the catalog means resolving - credentials, which can itself reach the network to refresh a token — far too expensive for a - caller that runs every turn. + One disk attempt per process: for the Portal, naming the catalog means resolving credentials, + which can itself reach the network to refresh a token — too expensive for a per-turn caller. """ - m = _origin() - if getattr(m, src.cache) is None and not getattr(m, src.disk_checked): - setattr(m, src.disk_checked, True) - setattr(m, src.cache, _hydrate_reasoning_caps_from_disk(src.url(), lambda: _fetch_caps(src, force=True))) - return getattr(m, src.cache) + if src.get("cache") is None and not src.get("disk_checked"): + src.set("disk_checked", True) + src.set("cache", _hydrate_reasoning_caps_from_disk(src.url(), lambda: _fetch_caps(src, force=True))) + return src.get("cache") def _model_caps(src: _CapsSource, model_id: Optional[str], *, timeout: float, allow_fetch: bool) -> Optional[dict[str, Any]]: @@ -245,16 +224,13 @@ def _model_caps(src: _CapsSource, model_id: Optional[str], *, timeout: float, al caps_by_id = _caps_cached(src) if caps_by_id is None and allow_fetch: caps_by_id = _fetch_caps(src, timeout=timeout) - if caps_by_id is None: - return None - return caps_by_id.get(model) + return caps_by_id.get(model) if caps_by_id is not None else None def _warm_caps_async(src: _CapsSource) -> None: - m = _origin() - if getattr(m, src.warm_started) or _caps_cached(src) is not None: + if src.get("warm_started") or _caps_cached(src) is not None: return - setattr(m, src.warm_started, True) + src.set("warm_started", True) _warm_reasoning_caps_async(lambda: _fetch_caps(src, force=True)) @@ -275,11 +251,8 @@ _NOUS_CAPS = _CapsSource( def nous_catalog_url() -> str: - """The Portal ``/v1/models`` URL for the endpoint we actually talk to. - - Resolved through the ladder ``NOUS_INFERENCE_BASE_URL`` → resolved credential base → prod - rather than pinned to production, so a staging profile reads staging's capabilities. - """ + """The Portal ``/v1/models`` URL for the endpoint we actually talk to (``NOUS_INFERENCE_BASE_URL`` + → resolved credential base → prod), so a staging profile reads staging's capabilities.""" return f"{_origin()._resolve_nous_pricing_credentials()[1]}/v1/models" @@ -287,7 +260,6 @@ def openrouter_model_reasoning_capabilities( model_id: Optional[str], *, timeout: float = 6.0, allow_fetch: bool = False, ) -> Optional[dict[str, Any]]: """Live-catalog reasoning capabilities for an OpenRouter model (tri-state, see module doc). - CACHE-ONLY by default — safe on per-request hot paths (never blocks on HTTP).""" return _model_caps(_OPENROUTER_CAPS, model_id, timeout=timeout, allow_fetch=allow_fetch) From b30c4f16675b808bbb7885020f4611baa1dcf015 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:34:59 -0700 Subject: [PATCH 4/6] refactor(hermes_cli): fold match verdicts into _Match.verdict, ordered validation ladder table, tighter local/pricing bodies and docstrings --- hermes_cli/models_local.py | 101 ++++-------- hermes_cli/models_pricing.py | 142 ++++++---------- hermes_cli/models_validate.py | 293 ++++++++++++++++------------------ 3 files changed, 214 insertions(+), 322 deletions(-) diff --git a/hermes_cli/models_local.py b/hermes_cli/models_local.py index 51819a1f04..9de0a34f9f 100644 --- a/hermes_cli/models_local.py +++ b/hermes_cli/models_local.py @@ -91,13 +91,11 @@ def _ollama_host_from_env(env_host: str) -> str: def _get_ollama_base_url() -> str: - """Resolve the local Ollama-compatible endpoint URL. - - Precedence: explicit ``providers.ollama.base_url`` (wires local endpoints without changing the - active provider) → active ``model.base_url`` when the active provider is ollama, or custom AND - the endpoint actually serves ``/api/tags`` (otherwise the picker would probe an unrelated - endpoint and hide the local catalog) → ``OLLAMA_HOST`` → Ollama's local default. - """ + """Resolve the local Ollama-compatible endpoint URL: explicit ``providers.ollama.base_url`` + (wires local endpoints without changing the active provider) → active ``model.base_url`` when + the active provider is ollama, or custom AND the endpoint actually serves ``/api/tags`` + (otherwise the picker would probe an unrelated endpoint and hide the local catalog) → + ``OLLAMA_HOST`` → Ollama's local default.""" from hermes_cli.models import _get_model_config_dict, should_use_ollama_native_catalog configured = _configured_ollama_base_url() if configured: @@ -153,16 +151,10 @@ def _get_ollama_request_headers() -> dict[str, str]: 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. - - Configured headers apply only when *base_url* shares the configured Ollama root; an explicit - *api_key* replaces any configured Authorization variant rather than inheriting it. - """ +def _get_ollama_native_headers(base_url: Optional[str], *, api_key: Optional[str] = None) -> dict[str, str]: + """Ollama credentials and headers for one endpoint origin. Configured headers apply only when + *base_url* shares the configured Ollama root; an explicit *api_key* replaces any configured + Authorization variant rather than inheriting it.""" from hermes_cli.models import _get_ollama_request_headers configured_base = _configured_ollama_base_url() explicit_key = str(api_key or "").strip() @@ -230,12 +222,9 @@ def probe_ollama_local_models( 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 — Ollama's authoritative local catalog - (``/v1/models`` is not required for local servers). - """ + """Probe local Ollama-compatible models from native ``/api/tags`` (Ollama's authoritative local + catalog; ``/v1/models`` is not required for local servers). ``None`` when the endpoint cannot be + reached or returns malformed data; a list (possibly empty) when it was reachable.""" from hermes_cli.models import _HERMES_USER_AGENT, _get_ollama_base_url, _urlopen_model_catalog_request root = _root_for_ollama_native_api(base_url or _get_ollama_base_url()) if not root: @@ -305,14 +294,11 @@ def should_use_ollama_native_catalog( base_url: Optional[str], headers: Optional[dict[str, str]] = None, ) -> bool: - """Return True when model discovery should use local Ollama ``/api/tags``. - - Bare ``ollama`` is normalized to ``custom`` elsewhere so runtime paths share the OpenAI- - compatible client, but local Ollama's authoritative model list is ``/api/tags``. Use it when - the caller asked for Ollama explicitly, the base URL matches ``providers.ollama.base_url``, - or an ambiguous custom URL on Ollama's default port actually serves ``/api/tags``; other - custom endpoints keep the ``/models`` probe. - """ + """True when model discovery should use local Ollama ``/api/tags``: the caller asked for Ollama + explicitly, the base URL matches ``providers.ollama.base_url``, or an ambiguous custom URL on + Ollama's default port actually serves ``/api/tags``. (Bare ``ollama`` is normalized to + ``custom`` elsewhere so runtime paths share the OpenAI client, but ``/api/tags`` is the + authoritative local list; other custom endpoints keep the ``/models`` probe.)""" from hermes_cli.models import probe_ollama_local_models requested = str(provider or "").strip().lower() root = _root_for_ollama_native_api(base_url or "") @@ -383,11 +369,8 @@ def _ollama_local_catalog(force_refresh: bool) -> list[str]: def _lmstudio_server_root(base_url: Optional[str]) -> Optional[str]: - """LM Studio server root for native ``/api/v1`` endpoints. - - Users paste either the OpenAI runtime URL (``.../v1``) or the native prefix (``.../api`` / - ``.../api/v1``); native probes append ``/api/v1/...`` themselves, so strip all accepted forms. - """ + """LM Studio server root: users paste the OpenAI runtime URL (``.../v1``) or the native prefix + (``.../api``, ``.../api/v1``); native probes append ``/api/v1/...`` themselves.""" return _strip_suffixes((base_url or "").strip().rstrip("/"), ("/api/v1", "/api", "/v1")) or None @@ -406,10 +389,8 @@ def _lmstudio_fetch_raw_models( base_url: Optional[str] = None, timeout: float = 5.0, ) -> Optional[list[dict]]: - """Fetch the raw model list from LM Studio's ``/api/v1/models``. - - None on network errors / malformed payloads; raises ``AuthError`` on HTTP 401/403. - """ + """Raw model list from LM Studio's ``/api/v1/models``; None on network errors / malformed + payloads; raises ``AuthError`` on HTTP 401/403.""" from hermes_cli.models import _urlopen_model_catalog_request server_root = _lmstudio_server_root(base_url) if not server_root: @@ -461,12 +442,9 @@ def probe_lmstudio_models( base_url: Optional[str] = None, timeout: float = 5.0, ) -> Optional[list[str]]: - """Probe LM Studio's model listing. - - Returns chat-capable model keys — a valid empty list when the server is reachable but has no - non-embedding models; ``None`` on network errors, malformed responses, or bad base URLs. - Raises ``AuthError`` on HTTP 401/403 so token issues surface separately from reachability. - """ + """Chat-capable LM Studio model keys — a valid empty list when the server is reachable but has + no non-embedding models; ``None`` on network errors, malformed responses, or bad base URLs. + Raises ``AuthError`` on HTTP 401/403 so token issues surface separately from reachability.""" from hermes_cli.models import _lmstudio_fetch_raw_models raw_models = _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=timeout) if raw_models is None: @@ -532,14 +510,13 @@ def ensure_lmstudio_model_loaded( """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. - """ + caller supplied an explicit override; the returned context comes from LM Studio's echoed or + refreshed state.""" from hermes_cli.models import _urlopen_model_catalog_request def _result(context_length: Optional[int], *, load_attempted: bool = False, rejected: bool = False): - value = LMStudioLoadResult(context_length, load_attempted, rejected) - return value if return_load_result else context_length + result = LMStudioLoadResult(context_length, load_attempted, rejected) + return result if return_load_result else context_length server_root = _lmstudio_server_root(base_url) if not server_root: @@ -548,9 +525,7 @@ def ensure_lmstudio_model_loaded( explicit_context = _positive_int(target_context_length) if target_context_length is not None and explicit_context is None: return _result(None) - - raw_models = _lmstudio_raw_models_or_none(api_key, base_url, 10) - target_entry = _lmstudio_entry_for(raw_models, model) if raw_models is not None else None + target_entry = _lmstudio_entry_for(_lmstudio_raw_models_or_none(api_key, base_url, 10) or [], model) if target_entry is None: return _result(None) @@ -569,12 +544,11 @@ def ensure_lmstudio_model_loaded( load_payload: dict[str, Any] = {"model": model, "echo_load_config": True} if explicit_context is not None: load_payload["context_length"] = explicit_context - load_headers = {**_lmstudio_request_headers(api_key), "Content-Type": "application/json"} try: load_request = urllib.request.Request( server_root + "/api/v1/models/load", data=json.dumps(load_payload).encode(), - headers=load_headers, + headers={**_lmstudio_request_headers(api_key), "Content-Type": "application/json"}, method="POST", ) with _urlopen_model_catalog_request(load_request, timeout=timeout) as resp: @@ -622,12 +596,9 @@ def ollama_model_supports_thinking( api_key: Optional[str] = None, timeout: float = 5.0, ) -> Optional[bool]: - """Tri-state: True if an Ollama (Cloud or local) model advertises ``thinking``. - - Probes native ``/api/show`` ``capabilities`` — the authoritative source, since OpenAI-compat - ``/v1/models`` omits it. False when the probe succeeded without it; None when the probe failed - so the caller picks the fallback (treated as "don't emit"). - """ + """Tri-state: True if an Ollama (Cloud or local) model advertises ``thinking`` in native + ``/api/show`` ``capabilities`` (authoritative; OpenAI-compat ``/v1/models`` omits it), False + when the probe succeeded without it, None when it failed (caller treats as "don't emit").""" import httpx server_url = (base_url or "").strip().rstrip("/") @@ -714,11 +685,7 @@ def fetch_ollama_cloud_models( api_key = api_key or os.getenv("OLLAMA_API_KEY", "") base_url = base_url or os.getenv("OLLAMA_BASE_URL", "") or "https://ollama.com/v1" - - live_models: list[str] = [] - if api_key: - live_models = fetch_api_models(api_key, base_url, timeout=8.0) or [] - + live_models = (fetch_api_models(api_key, base_url, timeout=8.0) or []) if api_key else [] mdev_models: list[str] = [] try: from agent.models_dev import list_agentic_models diff --git a/hermes_cli/models_pricing.py b/hermes_cli/models_pricing.py index 8b4298ef61..6f095e2a40 100644 --- a/hermes_cli/models_pricing.py +++ b/hermes_cli/models_pricing.py @@ -48,12 +48,9 @@ def _cache_catalog( 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 for a catalog whose contents depend on - server-side state the client cannot observe (an org's model policy can change while a long- - lived process holds the entry). - """ + """Cache a catalog result, giving an empty one an expiry. *ttl_seconds* expires a non-empty + result too — only for a catalog whose contents depend on server-side state the client cannot + observe (an org's model policy can change while a long-lived process holds the entry).""" from hermes_cli.models import _pricing_cache, _pricing_cache_retry_after _pricing_cache[cache_key] = result if not result: @@ -82,12 +79,9 @@ def _pricing_auth_fingerprint(api_key: str | None) -> str: 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 (callers hold no credential) newest first, skipping - expired entries, so a rotated credential does not keep answering from its predecessor's catalog. - """ + """Pricing already cached for *base_url* (with or without ``/v1``), or ``{}``; never fetches. + Prefers an authenticated catalog, scanning newest first (callers hold no credential) and + skipping expired entries so a rotated credential does not answer from its predecessor's.""" from hermes_cli.models import _pricing_cache root = _strip_v1((base_url or "").rstrip("/")) authed_prefix = root + _PRICING_AUTH_KEY_PREFIX @@ -104,12 +98,9 @@ def _strip_v1(url: str) -> str: def _format_price_per_mtok(per_token_str: str) -> str: - """Per-token price string → human-friendly $/Mtok string. - - Always 2 decimals so right-justified prices align on the decimal point. Sub-cent prices (deep- - discount cache-hit promos) widen precision until the value shows, keep one extra digit and trim - trailing zeros instead of collapsing to "$0.00". - """ + """Per-token price string → $/Mtok string. Always 2 decimals so right-justified prices align; + sub-cent prices (deep-discount cache-hit promos) widen precision until the value shows, keep + one extra digit and trim trailing zeros instead of collapsing to "$0.00".""" try: val = float(per_token_str) except (TypeError, ValueError): @@ -147,19 +138,12 @@ def _sale_pct(current: Any, original: Any) -> int | None: return int(round((1.0 - (cur / orig)) * 100)) -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; callers - gate on the provider and 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. Free / $0 models get a flat 100% off, with "was" prices only when the gateway served - an original (a natively-free stealth model gets bare "-100%" chrome). - """ +def compute_sale_discount(prompt: str, completion: str, original: Any) -> tuple[int, str, str] | None: + """Sale chrome from gateway ``pricing.original`` (Nous Portal only; callers gate on the provider + and opted in via ``include_sale_original=True``): ``(discount_percent, was_prompt_raw, + was_completion_raw)`` when ``original`` is a dict and the current prompt (fallback: completion) + rate is strictly below the original. Free / $0 models get a flat 100% off, with "was" prices + only when the gateway served an original (a natively-free stealth model gets bare "-100%").""" orig_dict = original if isinstance(original, dict) else {} was_prompt = orig_dict.get("prompt") was_completion = orig_dict.get("completion") @@ -224,13 +208,10 @@ def fetch_models_with_pricing( 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, ...}}``. - - 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). *include_sale_original* (Nous Portal only) copies the gateway's pre-discount - ``pricing.original`` rates through as a nested ``original`` dict for sale chrome. - """ + """Fetch ``/v1/models`` (any OpenRouter-compatible endpoint) → ``{model_id: {prompt, completion, + ...}}``, cached per *base_url* and per credential so one caller's catalog never answers + another's read. *include_sale_original* (Nous Portal only) copies the gateway's pre-discount + ``pricing.original`` rates through as a nested ``original`` dict for sale chrome.""" from hermes_cli.models import _HERMES_USER_AGENT url_root = (base_url or "").rstrip("/") cache_key = url_root + _pricing_auth_fingerprint(api_key) @@ -247,9 +228,8 @@ def fetch_models_with_pricing( if payload is None: return _cache_catalog(cache_key, {}) - # Same document the reasoning-capability fetch would pull, and every picker/pricing surface - # goes through here — mirror it so a later hot-path lookup (and the next process) has an - # answer without its own round-trip. + # Same document the reasoning-capability fetch would pull — 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]] = {} @@ -273,11 +253,7 @@ def fetch_models_with_pricing( 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]]: +def fetch_ai_gateway_pricing(timeout: float = 8.0, *, force_refresh: bool = False) -> dict[str, dict[str, str]]: """Vercel AI Gateway /v1/models pricing, translating its ``input`` / ``output`` field names to the picker's ``prompt`` / ``completion`` (cache read/write names already match).""" from hermes_constants import AI_GATEWAY_BASE_URL @@ -311,21 +287,16 @@ _DEFAULT_NOUS_INFERENCE_BASE = "https://inference-api.nousresearch.com" def _resolve_nous_pricing_credentials() -> tuple[str, str]: """``(api_key, base_url)`` for Nous Portal pricing; base_url is the bare origin (no ``/v1``). - - Base URL precedence mirrors runtime credential resolution: ``NOUS_INFERENCE_BASE_URL`` env - override (staging / preview) → resolved runtime credential ``base_url`` → production default. - Without the override a staging profile's sale ``pricing.original`` would never reach the - pickers (prod has no ``original`` field). - """ + Precedence mirrors runtime credential resolution: ``NOUS_INFERENCE_BASE_URL`` (staging / + preview) → resolved credential ``base_url`` → production default. Without the override a + staging profile's sale ``pricing.original`` would never reach the pickers.""" 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 = "" + api_key = creds_base = "" try: from hermes_cli.auth import resolve_nous_runtime_credentials @@ -335,7 +306,6 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]: creds_base = (creds.get("base_url", "") or "").strip() except Exception: pass - base_url = (env_base or creds_base or _DEFAULT_NOUS_INFERENCE_BASE).rstrip("/") if base_url.endswith("/v1"): base_url = base_url[:-3] @@ -361,14 +331,10 @@ def _fetch_nous_pricing(api_key: str, base_url: str, *, force_refresh: bool) -> 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`` (leave the caller's list alone) covers the - three states that cannot narrow it: no policy (or a token too old to say), an anonymous read - whose catalog is unfiltered, and an empty read (a fetch failure, not an org that may reach - nothing). - """ + """The Nous model ids the caller's org may reach (keys of an authenticated ``GET /v1/models``, + which omits policy-blocked rows), or ``None`` to not filter: no policy (or a token too old to + say), an anonymous read (unfiltered catalog), or an empty read (a fetch failure, not an org + that may reach nothing).""" from hermes_cli.models import _resolve_nous_pricing_credentials try: from hermes_cli.nous_account import nous_policy_present @@ -395,15 +361,12 @@ def restrict_to_nous_policy( *, rescue_empty: bool = False, ) -> list[str]: - """*model_ids* narrowed to *allowed*, preserving the caller's order. - - A ``:free`` sibling is kept when its base model is reachable, mirroring the gateway, which - admits a row when any of its requestable ids passes. Prefer over-listing: that costs a 403 from - the authoritative gate, while hiding a row the gate would serve is unrecoverable client-side. - *rescue_empty*: an allowlist naming only models the curated manifest lacks would leave an - empty picker — worse than no filter — so return the allowlist itself. Opt-in per list: an - already-empty list (a paid tier's gated models) means "nothing to gate", not "nothing survived". - """ + """*model_ids* narrowed to *allowed*, preserving order. A ``:free`` sibling is kept when its + base model is reachable (the gateway admits a row when any requestable id passes); over-listing + costs a 403 from the authoritative gate, hiding a servable row is unrecoverable client-side. + *rescue_empty*: an allowlist naming only models the curated manifest lacks would leave an empty + picker — worse than no filter — so return the allowlist itself. Opt-in per list: an already- + empty list (a paid tier's gated models) means "nothing to gate", not "nothing survived".""" 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] @@ -437,13 +400,9 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d return fetcher(force_refresh=force_refresh) if fetcher else {} -def _fireworks_pricing_from_models_dev( - *, - force_refresh: bool = False, -) -> dict[str, dict[str, str]]: - """Fireworks picker pricing from the models.dev registry cache — no dedicated network fetch: - ``fetch_models_dev()`` already keeps a shared in-memory + disk cache (1h TTL), so this is a - pure dict transform with no per-render network call.""" +def _fireworks_pricing_from_models_dev(*, force_refresh: bool = False) -> dict[str, dict[str, str]]: + """Fireworks picker pricing from the models.dev registry cache (``fetch_models_dev()`` keeps a + shared in-memory + disk cache, 1h TTL) — a pure dict transform, no per-render network call.""" cache_key = "models.dev/fireworks" if not force_refresh: cached = _cached_catalog(cache_key) @@ -471,14 +430,9 @@ def _fireworks_pricing_from_models_dev( return _cache_catalog(cache_key, result) -def _fetch_novita_pricing( - timeout: float = 8.0, - *, - force_refresh: bool = False, -) -> dict[str, dict[str, str]]: - """NovitaAI /v1/models pricing. Novita reports per-million-token prices in units of 0.0001 USD; - converted to the per-token strings the shared formatter expects. Cached on the resolved base - URL so menu renders don't re-hit the network.""" +def _fetch_novita_pricing(timeout: float = 8.0, *, force_refresh: bool = False) -> dict[str, dict[str, str]]: + """NovitaAI /v1/models pricing (per-million prices in units of 0.0001 USD → per-token strings), + cached on the resolved base URL so menu renders don't re-hit the network.""" from hermes_cli.models import _HERMES_USER_AGENT api_key = os.getenv("NOVITA_API_KEY", "").strip() if not api_key: @@ -509,14 +463,10 @@ def _fetch_novita_pricing( return _cache_catalog(cache_key, result) -def _fetch_deepinfra_pricing( - timeout: float = 5.0, - *, - force_refresh: bool = False, -) -> dict[str, dict[str, str]]: - """Picker-shape pricing for DeepInfra chat models: ``input_tokens`` / ``output_tokens`` / - ``cache_read_tokens`` in $/MTok → per-token ``prompt`` / ``completion`` / ``input_cache_read``. - Cached via the models-by-tag helper so repeated picker renders are free.""" +def _fetch_deepinfra_pricing(timeout: float = 5.0, *, force_refresh: bool = False) -> dict[str, dict[str, str]]: + """DeepInfra chat-model pricing: ``input_tokens`` / ``output_tokens`` / ``cache_read_tokens`` in + $/MTok → per-token ``prompt`` / ``completion`` / ``input_cache_read`` (cached by the by-tag + helper).""" from hermes_cli.models import _fetch_deepinfra_models_by_tag items = _fetch_deepinfra_models_by_tag("chat", timeout=timeout, force_refresh=force_refresh) result: dict[str, dict[str, str]] = {} diff --git a/hermes_cli/models_validate.py b/hermes_cli/models_validate.py index 6393d03aaa..2bb8b0dca8 100644 --- a/hermes_cli/models_validate.py +++ b/hermes_cli/models_validate.py @@ -1,14 +1,11 @@ """Validate a requested ``/model`` value against the active provider's catalog. -Split out of ``hermes_cli.models``; :func:`validate_requested_model` is re-imported there, so -``hermes_cli.models.validate_requested_model`` keeps resolving. Every catalog fetcher this module -calls is looked up on ``hermes_cli.models`` at call time (``_m.``), so existing -``patch("hermes_cli.models.")`` mocks keep intercepting. +Split out of ``hermes_cli.models``; :func:`validate_requested_model` is re-imported there. Every +catalog fetcher this module calls is looked up on ``hermes_cli.models`` at call time (``_m.``) +so existing ``patch("hermes_cli.models.")`` mocks keep intercepting. -Every provider branch returns one of four verdict shapes (see :func:`_verdict`) or ``None`` to -mean "not decided here — keep walking the ladder". The ladder ORDER is behavior: moa → whitespace -→ OpenRouter preset parse → LM Studio → Ollama native → custom → codex/xai static → MiniMax → -Anthropic native → Anthropic Messages → live listing → Bedrock → curated-catalog fallback. +Every provider branch returns a verdict dict (see :func:`_verdict`) or ``None`` for "not decided +here — keep walking the ladder". The ladder ORDER is behavior (see ``_LADDER``). """ from __future__ import annotations @@ -16,7 +13,7 @@ from __future__ import annotations import re from dataclasses import dataclass from difflib import get_close_matches -from typing import Any, Optional +from typing import Any, Callable, Optional from utils import base_url_host_matches @@ -25,8 +22,6 @@ from utils import base_url_host_matches def _verdict(accepted: bool, persist: bool, recognized: bool, message: Optional[str], corrected_model: Optional[str] = None) -> dict[str, Any]: - """Build the verdict dict. ``corrected_model`` is only present when set (key order matters - to nobody, but keep it identical to the historical literals anyway).""" out: dict[str, Any] = {"accepted": accepted, "persist": persist, "recognized": recognized} if corrected_model is not None: out["corrected_model"] = corrected_model @@ -38,6 +33,10 @@ def _accept() -> dict[str, Any]: return _verdict(True, True, True, None) +def _accept_with_note(message: str) -> dict[str, Any]: + return _verdict(True, True, True, message) + + def _reject(message: str) -> dict[str, Any]: return _verdict(False, False, False, message) @@ -60,6 +59,16 @@ class _Match: corrected: Optional[str] = None suggestion_text: str = "" + def verdict(self, req: "_Request", *, keep_suffix: bool = False) -> Optional[dict[str, Any]]: + """Accept on exact, auto-correct on a near-typo (re-attaching a preserved ``@preset/`` + suffix when *keep_suffix*), else None so the branch composes its own message.""" + if self.exact: + return _accept() + if self.corrected: + corrected = req.with_preset_suffix(self.corrected) if keep_suffix else self.corrected + return _corrected(req.requested, corrected) + return None + def _match_in_catalog( query: str, @@ -71,37 +80,31 @@ def _match_in_catalog( suggest_cutoff: float = 0.5, suggest_label: str = "Similar models", ) -> _Match: - """The shared ladder: exact membership → typo auto-correct (cutoff .9) → suggestion text. - - ``case_insensitive`` matches on lower-cased ids and maps results back to the catalog's - spelling (MiniMax ships mixed-case ids). ``suggest_query`` overrides the string the - suggestion search uses (some branches search on the raw request, not the lookup form). - """ - candidates = list(candidates) + """Shared ladder: exact membership → typo auto-correct (cutoff .9) → suggestion text. + ``case_insensitive`` matches lower-cased ids and maps results back to the catalog's spelling + (MiniMax ships mixed-case ids). ``suggest_query`` overrides the string the suggestion search + uses (some branches search on the raw request, not the lookup form).""" + pool = list(candidates) + display = None + if suggest_query is None: + suggest_query = query if case_insensitive: - display = {c.lower(): c for c in candidates} + display = {c.lower(): c for c in pool} pool = list(display) - query = query.lower() - suggest_query = query if suggest_query is None else suggest_query.lower() - else: - display = None - pool = candidates - suggest_query = query if suggest_query is None else suggest_query + query, suggest_query = query.lower(), suggest_query.lower() def _show(cid: str) -> str: return display[cid] if display is not None else cid if query in set(pool): return _Match(exact=True) - if auto_correct: - auto = get_close_matches(query, pool, n=1, cutoff=0.9) - if auto: - return _Match(corrected=_show(auto[0])) + auto = get_close_matches(query, pool, n=1, cutoff=0.9) if auto_correct else [] + if auto: + return _Match(corrected=_show(auto[0])) suggestions = get_close_matches(suggest_query, pool, n=3, cutoff=suggest_cutoff) - text = "" - if suggestions: - text = f"\n {suggest_label}: " + ", ".join(f"`{_show(s)}`" for s in suggestions) - return _Match(suggestion_text=text) + if not suggestions: + return _Match() + return _Match(suggestion_text=f"\n {suggest_label}: " + ", ".join(f"`{_show(s)}`" for s in suggestions)) # ── Request context ────────────────────────────────────────────────────── @@ -125,19 +128,25 @@ class _Request: # ── Provider branches (None = not decided here) ───────────────────────── -def _validate_moa(requested: str) -> dict[str, Any]: +def _validate_moa(req: _Request) -> dict[str, Any]: try: from hermes_cli.config import load_config from hermes_cli.moa_config import normalize_moa_config cfg = normalize_moa_config(load_config().get("moa") or {}) - if requested in cfg["presets"]: + if req.requested in cfg["presets"]: return _accept() - return _reject(f"MoA preset `{requested}` was not found. Run `hermes moa list`.") + return _reject(f"MoA preset `{req.requested}` was not found. Run `hermes moa list`.") except Exception as exc: return _reject(f"Could not read MoA presets: {exc}") +def _reject_whitespace(req: _Request) -> Optional[dict[str, Any]]: + if any(ch.isspace() for ch in req.requested): + return _reject("Model names cannot contain spaces.") + return None + + def _parse_openrouter_preset(req: _Request) -> Optional[dict[str, Any]]: """OpenRouter presets are account-scoped, so ``@preset/`` never appears in the public /v1/models listing. A bare preset is accepted unverified; ``@preset/`` validates @@ -185,34 +194,29 @@ def _validate_lmstudio(req: _Request) -> dict[str, Any]: return _reject(f"Model `{req.requested}` was not found in LM Studio's model listing.") -def _ollama_probe_headers(req: _Request) -> dict[str, str]: - """Headers for the Ollama native probe. +def _drop_authorization(headers: dict[str, str]) -> None: + for key in tuple(headers): + if key.lower() == "authorization": + del headers[key] - Configured ``providers.ollama.extra_headers`` are only applied when the probed endpoint is - the configured one (never leak them to a different host). Caller headers win; a caller - ``api_key`` becomes the Authorization header unless the caller already sent one. - """ + +def _ollama_probe_headers(req: _Request) -> dict[str, str]: + """Headers for the Ollama native probe. Configured ``providers.ollama.extra_headers`` apply only + when the probed endpoint is the configured one (never leak them to a different host). Caller + headers win; a caller ``api_key`` becomes the Authorization header unless the caller sent one.""" from hermes_cli import models as _m from hermes_cli.models_local import _configured_ollama_base_url configured_base = _configured_ollama_base_url() - configured_allowed = not ( - configured_base and not _m._same_ollama_native_root(req.base_url or "", configured_base) - ) + configured_allowed = not configured_base or _m._same_ollama_native_root(req.base_url or "", configured_base) + configured = _m._get_ollama_native_headers(req.base_url, api_key=req.api_key) if configured_allowed else {} if req.headers is None: - return _m._get_ollama_native_headers(req.base_url, api_key=req.api_key) if configured_allowed else {} - out: dict[str, str] = {} - if configured_allowed: - out.update(_m._get_ollama_native_headers(req.base_url, api_key=req.api_key)) - for key in tuple(out): - if key.lower() == "authorization": - del out[key] + return configured + out = dict(configured) + _drop_authorization(out) out.update(req.headers) - caller_has_authorization = any(key.lower() == "authorization" for key in req.headers) - if req.api_key and not caller_has_authorization: - for key in tuple(out): - if key.lower() == "authorization": - del out[key] + if req.api_key and not any(key.lower() == "authorization" for key in req.headers): + _drop_authorization(out) out["Authorization"] = f"Bearer {req.api_key}" return out @@ -230,12 +234,9 @@ def _validate_ollama_native(req: _Request) -> Optional[dict[str, Any]]: return None models = _m.probe_ollama_local_models(req.base_url, headers=headers) if models is None: - # A failed native probe is not authoritative; fall back to the OpenAI-compatible - # catalog before accepting blindly. + # A failed native probe is not authoritative; fall back to the OpenAI-compatible catalog. models = _m.probe_api_models( - req.api_key, - _m._normalize_openai_base_url(req.base_url), - request_headers=headers, + req.api_key, _m._normalize_openai_base_url(req.base_url), request_headers=headers, ).get("models") if models is None: return _soft_accept( @@ -258,18 +259,15 @@ def _validate_custom(req: _Request) -> dict[str, Any]: from hermes_cli import models as _m # Probe with the auth shape the api_mode expects. - if req.api_mode == "anthropic_messages": - probe = _m.probe_api_models(req.api_key, req.base_url, api_mode=req.api_mode, - request_headers=req.headers) - else: - probe = _m.probe_api_models(req.api_key, req.base_url, request_headers=req.headers) + anthropic_style = req.api_mode == "anthropic_messages" + probe_kwargs = {"api_mode": req.api_mode} if anthropic_style else {} + probe = _m.probe_api_models(req.api_key, req.base_url, request_headers=req.headers, **probe_kwargs) api_models = probe.get("models") if api_models is not None: match = _match_in_catalog(req.lookup, api_models, suggest_query=req.requested) - if match.exact: - return _accept() - if match.corrected: - return _corrected(req.requested, match.corrected) + verdict = match.verdict(req) + if verdict is not None: + return verdict message = ( f"Note: `{req.requested}` was not found in this custom endpoint's model listing " f"({probe.get('probed_url')}). It may still work if the server supports hidden or aliased models." @@ -286,7 +284,7 @@ def _validate_custom(req: _Request) -> dict[str, Any]: f"Note: could not reach this custom endpoint's model listing at `{probe.get('probed_url')}`. " f"Hermes will still save `{req.requested}`, but the endpoint should expose `/models` for verification." ) - if req.api_mode == "anthropic_messages": + if anthropic_style: message += ( "\n Many Anthropic-compatible proxies do not implement the Models API " "(GET /v1/models). The model name has been accepted without verification." @@ -294,7 +292,7 @@ def _validate_custom(req: _Request) -> dict[str, Any]: if probe.get("suggested_base_url"): message += f"\n If this server expects `/v1`, try base URL: `{probe.get('suggested_base_url')}`" # Anthropic-style proxies routinely lack /v1/models, so only they are accepted unverified. - return _verdict(req.api_mode == "anthropic_messages", True, False, message) + return _verdict(anthropic_style, True, False, message) def _static_catalog(normalized: str) -> list[str]: @@ -339,10 +337,9 @@ def _validate_static_catalog(req: _Request) -> Optional[dict[str, Any]]: if not catalog: return None match = _match_in_catalog(req.lookup, catalog) - if match.exact: - return _accept() - if match.corrected: - return _corrected(req.requested, match.corrected) + verdict = match.verdict(req) + if verdict is not None: + return verdict label = _STATIC_LABELS[req.normalized] # Plausibility gate: the soft-accept exists for entitlement-gated *hidden* slugs the curated # listing hasn't caught up with — always the provider's own family (gpt-* / grok-*). An @@ -373,11 +370,7 @@ def _validate_minimax(req: _Request) -> Optional[dict[str, Any]]: if not catalog: return None match = _match_in_catalog(req.lookup, catalog, case_insensitive=True) - if match.exact: - return _accept() - if match.corrected: - return _corrected(req.requested, match.corrected) - return _soft_accept( + return match.verdict(req) or _soft_accept( f"Note: `{req.requested}` was not found in the MiniMax catalog." f"{match.suggestion_text}" "\n MiniMax does not expose a /models endpoint, so Hermes cannot verify the model name." @@ -387,21 +380,17 @@ def _validate_minimax(req: _Request) -> Optional[dict[str, Any]]: def _validate_anthropic(req: _Request) -> Optional[dict[str, Any]]: """Native Anthropic: /v1/models needs x-api-key (or OAuth Bearer) + anthropic-version, so the - generic Bearer probe 401s — use the native fetcher. Returns None (fall through to the generic - ladder) when no token is resolvable or the network failed.""" + generic Bearer probe 401s — use the native fetcher. None (fall through) when no token is + resolvable or the network failed.""" from hermes_cli import models as _m models = _m._fetch_anthropic_models(base_url=req.base_url or None, api_key=req.api_key or None) if models is None: return None match = _match_in_catalog(req.lookup, models, suggest_query=req.requested) - if match.exact: - return _accept() - if match.corrected: - return _corrected(req.requested, match.corrected) # Accept anyway — Anthropic gates newer/preview models (snapshot IDs, early access) behind # accounts even though they aren't listed on /v1/models. - return _soft_accept( + return match.verdict(req) or _soft_accept( f"Note: `{req.requested}` was not found in Anthropic's /v1/models listing. " f"It may still work if you have early-access or snapshot IDs." f"{match.suggestion_text}" @@ -414,13 +403,8 @@ def _validate_anthropic_messages(req: _Request) -> dict[str, Any]: from hermes_cli import models as _m models = _m.fetch_api_models(req.api_key, req.base_url, api_mode=req.api_mode) - if models is not None: - match = _match_in_catalog(req.lookup, models) - if match.exact: - return _accept() - if match.corrected: - return _corrected(req.requested, match.corrected) - return _soft_accept( + verdict = _match_in_catalog(req.lookup, models).verdict(req) if models is not None else None + return verdict or _soft_accept( f"Note: could not verify `{req.requested}` against this endpoint's " f"model listing. Many Anthropic-compatible proxies do not " f"implement GET /v1/models. The model name has been accepted " @@ -471,9 +455,9 @@ def _validate_live_listing(req: _Request) -> Optional[dict[str, Any]]: return _accept() # Listed but not found: the account may reach models absent from the public listing # (e.g. Z.AI Pro/Max plans use glm-5 on coding endpoints) — warn but allow where plausible. - if match.corrected: - corrected = req.with_preset_suffix(match.corrected) - return _corrected(req.requested, corrected) + verdict = match.verdict(req, keep_suffix=True) + if verdict is not None: + return verdict # Curated-catalog soft-accept: providers omit valid models from live listings (stale cache, # partial rollout, gated previews). EXCEPTION: official OpenAI hosts (canonical + data- # residency regional) — their listing is access-scoped and authoritative, so an absent model @@ -487,16 +471,18 @@ def _validate_live_listing(req: _Request) -> Optional[dict[str, Any]]: if not listing_authoritative and _m._model_in_provider_catalog( (variant_base or req.lookup).lower(), _m._provider_keys(req.normalized) ): - return _verdict(True, True, True, - f"Note: `{req.requested}` was not found in the live /v1/models listing " - f"but exists in the curated catalog — accepted.") + return _accept_with_note( + f"Note: `{req.requested}` was not found in the live /v1/models listing " + f"but exists in the curated catalog — accepted." + ) # Nous: the Portal's recommended-models feed can list a model before the curated list or the # docs-hosted manifest catches up; `hermes chat` already accepts those at model-list build # time, so mirror that source of truth for per-message /model validation. if req.normalized == "nous" and req.lookup.lower() in _nous_portal_recommended_names(): - return _verdict(True, True, True, - f"Note: `{req.requested}` was not found in the live /v1/models " - f"listing but is a current Nous Portal recommendation — accepted.") + return _accept_with_note( + f"Note: `{req.requested}` was not found in the live /v1/models " + f"listing but is a current Nous Portal recommendation — accepted." + ) return _reject( f"Model `{req.requested}` was not found in this provider's model listing." f"{match.suggestion_text}" @@ -526,10 +512,9 @@ def _validate_bedrock(req: _Request) -> Optional[dict[str, Any]]: def _validate_catalog_fallback(req: _Request) -> dict[str, Any]: - """The /models probe was unreachable: validate against the curated ``provider_model_ids()`` - list so gateway /model switches keep working while a provider's endpoint is down (otherwise - switch_model() would fail and the gateway never writes the session override). No catalog at - all → accept with a warning.""" + """/models unreachable: validate against the curated ``provider_model_ids()`` list so gateway + /model switches keep working while a provider's endpoint is down (otherwise switch_model() would + fail and the gateway never writes the session override). No catalog → accept with a warning.""" from hermes_cli import models as _m label = _m._PROVIDER_LABELS.get(req.normalized, req.normalized) @@ -547,10 +532,7 @@ def _validate_catalog_fallback(req: _Request) -> dict[str, Any]: variant_base = _m._openrouter_variant_base(req.lookup) if variant_base is not None and variant_base.lower() in {m.lower() for m in catalog}: return _accept() - if match.corrected: - corrected = req.with_preset_suffix(match.corrected) - return _corrected(req.requested, corrected) - return _soft_accept( + return match.verdict(req, keep_suffix=True) or _soft_accept( f"Note: `{req.requested}` was not found in the {label} curated catalog " f"and the /models endpoint was unreachable.{match.suggestion_text}" f"\n The model may still work if it exists on the provider." @@ -559,6 +541,36 @@ def _validate_catalog_fallback(req: _Request) -> dict[str, Any]: # ── Orchestrator ───────────────────────────────────────────────────────── +def _is_custom(req: _Request) -> bool: + return req.normalized == "custom" or req.normalized.startswith("custom:") + + +def _for(*providers: str) -> Callable[[_Request], bool]: + return lambda req: req.normalized in providers + + +# (gate, branch): the branch runs when the gate passes; the first non-None verdict wins. ORDER IS +# BEHAVIOR: moa → whitespace → OpenRouter preset parse → LM Studio → Ollama native → custom → +# codex/xai static → MiniMax → Anthropic native → Anthropic Messages → live listing → Bedrock → +# curated-catalog fallback (always decides). +_LADDER: tuple[tuple[Callable[[_Request], bool], Callable[[_Request], Optional[dict[str, Any]]]], ...] = ( + (_for("moa"), _validate_moa), + (lambda req: True, _reject_whitespace), + (_for("openrouter"), _parse_openrouter_preset), + (_for("lmstudio"), _validate_lmstudio), + (lambda req: True, _validate_ollama_native), + (_is_custom, _validate_custom), + (_for("openai-codex", "xai-oauth"), _validate_static_catalog), + (_for("minimax", "minimax-cn"), _validate_minimax), + (_for("anthropic"), _validate_anthropic), + (lambda req: req.api_mode == "anthropic_messages", _validate_anthropic_messages), + (lambda req: True, _validate_live_listing), + # API unreachable — accept and persist, but warn so typos don't silently break things. + (_for("bedrock"), _validate_bedrock), + (lambda req: True, _validate_catalog_fallback), +) + + def validate_requested_model( model_name: str, provider: Optional[str], @@ -568,13 +580,9 @@ def validate_requested_model( api_mode: Optional[str] = None, headers: Optional[dict[str, str]] = None, ) -> dict[str, Any]: - """Validate a ``/model`` value for the active provider. - - Returns a dict with: - accepted: whether the CLI should switch to the requested model now - - persist: whether it is safe to save to config - recognized: whether it matched a known provider - catalog - message: optional warning / guidance for the user (- corrected_model: when a typo - was auto-corrected). - """ + """Validate a ``/model`` value for the active provider → dict with ``accepted`` (switch now), + ``persist`` (safe to save to config), ``recognized`` (matched a known provider catalog), + ``message`` (optional warning / guidance) and ``corrected_model`` when a typo was fixed.""" from hermes_cli import models as _m requested = (model_name or "").strip() @@ -587,43 +595,10 @@ def validate_requested_model( if not requested: return _reject("Model name cannot be empty.") - if normalized == "moa": - return _validate_moa(requested) - if any(ch.isspace() for ch in requested): - return _reject("Model names cannot contain spaces.") - req = _Request(requested, lookup, provider, normalized, api_key, base_url, api_mode, headers) - if normalized == "openrouter": - verdict = _parse_openrouter_preset(req) - if verdict is not None: - return verdict - if normalized == "lmstudio": - return _validate_lmstudio(req) - verdict = _validate_ollama_native(req) - if verdict is not None: - return verdict - if normalized == "custom" or normalized.startswith("custom:"): - return _validate_custom(req) - if normalized in {"openai-codex", "xai-oauth"}: - verdict = _validate_static_catalog(req) - if verdict is not None: - return verdict - if normalized in {"minimax", "minimax-cn"}: - verdict = _validate_minimax(req) - if verdict is not None: - return verdict - if normalized == "anthropic": - verdict = _validate_anthropic(req) - if verdict is not None: - return verdict - if api_mode == "anthropic_messages": - return _validate_anthropic_messages(req) - verdict = _validate_live_listing(req) - if verdict is not None: - return verdict - # API unreachable — accept and persist, but warn so typos don't silently break things. - if normalized == "bedrock": - verdict = _validate_bedrock(req) - if verdict is not None: - return verdict - return _validate_catalog_fallback(req) + for gate, branch in _LADDER: + if gate(req): + verdict = branch(req) + if verdict is not None: + return verdict + raise AssertionError("unreachable: _validate_catalog_fallback always decides") From c64a1fbd6caceefca811507cd4abed12ae6d2eca Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:57:37 -0700 Subject: [PATCH 5/6] refactor(hermes_cli): repack literal tables, one-line trivial branches, share _drop_authorization --- hermes_cli/models_catalog_static.py | 171 ++++++++++++---------------- hermes_cli/models_local.py | 25 +--- hermes_cli/models_pricing.py | 13 +-- hermes_cli/models_validate.py | 95 +++++----------- 4 files changed, 115 insertions(+), 189 deletions(-) diff --git a/hermes_cli/models_catalog_static.py b/hermes_cli/models_catalog_static.py index c1b3f118e5..9e001d7709 100644 --- a/hermes_cli/models_catalog_static.py +++ b/hermes_cli/models_catalog_static.py @@ -26,18 +26,16 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ for mid in ( "anthropic/claude-fable-5.1", "anthropic/claude-fable-5", "anthropic/claude-opus-5", "anthropic/claude-opus-5-fast", "anthropic/claude-opus-4.8", "anthropic/claude-opus-4.8-fast", - "anthropic/claude-sonnet-5", "anthropic/claude-haiku-4.5", "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/gemini-3.1-pro-preview", "google/gemini-3.8-flash", "google/gemini-3.7-flash", - "x-ai/grok-4.6", "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-pro-0813", - "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-flash-0731", "qwen/qwen3.8-max", - "qwen/qwen3.8-flash", "moonshotai/kimi-k3", "minimax/minimax-m3", "z-ai/glm-5.3", + "anthropic/claude-sonnet-5", "anthropic/claude-haiku-4.5", "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/gemini-3.1-pro-preview", + "google/gemini-3.8-flash", "google/gemini-3.7-flash", "x-ai/grok-4.6", "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-pro-0813", "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-flash-0731", + "qwen/qwen3.8-max", "qwen/qwen3.8-flash", "moonshotai/kimi-k3", "minimax/minimax-m3", "z-ai/glm-5.3", "z-ai/glm-5.3-flash", "z-ai/glm-5.2", "xiaomi/mimo-v2.5-pro", "tencent/hy4-preview", "tencent/hy3", - "stepfun/step-3.7-flash", "nvidia/nemotron-3-super-120b-a12b", "meta/muse-spark-1.2", - "sakana/fugu-ultra", "openrouter/pareto-code", "thinkingmachines/inkling:free", - "thinkingmachines/inkling-small:free", "minimax/minimax-m3:free", "z-ai/glm-5.2:free", - "poolside/laguna-s-2.1:free", "poolside/laguna-xs-2.1:free", + "stepfun/step-3.7-flash", "nvidia/nemotron-3-super-120b-a12b", "meta/muse-spark-1.2", "sakana/fugu-ultra", + "openrouter/pareto-code", "thinkingmachines/inkling:free", "thinkingmachines/inkling-small:free", + "minimax/minimax-m3:free", "z-ai/glm-5.2:free", "poolside/laguna-s-2.1:free", "poolside/laguna-xs-2.1:free", "nvidia/nemotron-3-super-120b-a12b:free", "nvidia/nemotron-3-ultra-550b-a55b:free", "nvidia/nemotron-3.5-lightning:free", ) @@ -45,8 +43,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ # OpenRouter entries the Nous Portal does not carry (routing/fast variants, free tier). _OPENROUTER_ONLY = { - "anthropic/claude-opus-5-fast", "anthropic/claude-opus-4.8-fast", "meta/muse-spark-1.2", - "openrouter/pareto-code", + "anthropic/claude-opus-5-fast", "anthropic/claude-opus-4.8-fast", "meta/muse-spark-1.2", "openrouter/pareto-code", } @@ -124,8 +121,7 @@ def _xai_curated_models() -> list[str]: # Native OpenAI Chat Completions (api.openai.com); also the head of the Copilot list. _OPENAI_CHAT_MODELS = [ - "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", + "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", ] _MINIMAX_MODELS = ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"] _TENCENT_MODELS = ["hy4-preview", "hy3", "hy3-preview"] @@ -143,9 +139,8 @@ _ALIBABA_CODING_PLAN_MODELS = [ ] # Verified against a live Token Plan subscription (key tier ``sk-sp-...``). _ALIBABA_TOKEN_PLAN_MODELS = [ - "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", + "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", ] _XAI_MODELS = _xai_curated_models() @@ -164,13 +159,11 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "xai-oauth": list(_XAI_MODELS), "copilot-acp": ["copilot-acp"], "copilot": _OPENAI_CHAT_MODELS + [ - "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", + "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", + "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", @@ -184,9 +177,8 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "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-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", @@ -194,8 +186,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { ], "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", + "kimi-k3", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview", "kimi-k2-0905-preview", ], "minimax": list(_MINIMAX_MODELS), "minimax-oauth": ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"], @@ -219,19 +210,18 @@ _PROVIDER_MODELS: dict[str, list[str]] = { # _LIVE_FIRST_PICKER_PROVIDERS, so this is a discovery floor: live entries lead in the picker # and stale curated names never pollute the top. "x-preview-f-free" = "Ox Alpha" stealth model. "opencode-zen": [ - "x-preview-f-free", "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", + "x-preview-f-free", "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", ], # OpenCode keyless free tier — OFFLINE FLOOR only. provider_model_ids("opencode-free") # revalidates live against GET /zen/v1/models and filters to the anonymous tier, so this list @@ -269,11 +259,10 @@ _PROVIDER_MODELS: dict[str, list[str]] = { # Static fallback when live discovery (ListFoundationModels + ListInferenceProfiles) is # unavailable. Inference-profile IDs (us.*) because 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.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 models depend on the user's endpoint configuration. @@ -329,8 +318,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [ProviderEntry(*row) for row in ( ("huggingface", "Hugging Face", "Hugging Face Inference Providers"), ("gemini", "Google AI Studio", "Google AI Studio (Native Gemini API)"), ("vertex", "Google Vertex AI", "Google Vertex AI (Gemini via GCP; OAuth2 service account or ADC, GCP billing/quotas)"), - ("deepseek", "DeepSeek", "DeepSeek (V3, R1, coder, direct API)"), - ("xai", "xAI", "xAI Grok (Direct API)"), + ("deepseek", "DeepSeek", "DeepSeek (V3, R1, coder, direct API)"), ("xai", "xAI", "xAI Grok (Direct API)"), ("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu direct API)"), ("kimi-coding", "Kimi / Kimi Coding Plan", "Kimi Coding Plan (api.kimi.com & Moonshot API)"), ("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Domestic direct API)"), @@ -446,34 +434,31 @@ _PROVIDER_ALIASES = dict(( ("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"), + ("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"), + ("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"), )) @@ -545,9 +530,8 @@ _OPENAI_FAST_MODE_PREFIXES: tuple[str, ...] = ("gpt-", "o1", "o3", "o4") # /models are the subscription-tier source of truth), and providers with dedicated live-endpoint # branches (copilot, anthropic, ai-gateway, ollama-cloud, custom, stepfun, openai-codex). _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", + "opencode-go", "opencode-zen", "deepseek", "kilocode", "fireworks", "mistral", "togetherai", "cohere", + "perplexity", "groq", "nvidia", "huggingface", "zai", "gemini", "google", "xai", "xai-oauth", }) @@ -561,24 +545,19 @@ _KEYLESS_STABLE_CACHE_PROVIDERS = frozenset({"opencode-free"}) # Claude IDs use hyphens (Anthropic native) but Copilot's API only accepts dot-notation, so a # copilot + hyphenated default would otherwise hit HTTP 400 "model_not_supported". _COPILOT_MODEL_ALIASES = dict(( - ("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"), ("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-4-6", "claude-sonnet-4.6"), - ("anthropic/claude-sonnet-4-0", "claude-sonnet-4"), - ("anthropic/claude-sonnet-4-5", "claude-sonnet-4.5"), + ("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"), + ("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-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"), )) diff --git a/hermes_cli/models_local.py b/hermes_cli/models_local.py index 9de0a34f9f..858df73b18 100644 --- a/hermes_cli/models_local.py +++ b/hermes_cli/models_local.py @@ -112,11 +112,8 @@ def _get_ollama_base_url() -> str: return model_base except (OSError, RuntimeError, TypeError, ValueError): pass - env_host = os.getenv("OLLAMA_HOST", "").strip() - if env_host: - return _ollama_host_from_env(env_host) - return "http://localhost:11434" + return _ollama_host_from_env(env_host) if env_host else "http://localhost:11434" def _api_key_from_provider_config(entry: dict, *env_keys: str) -> str: @@ -239,7 +236,6 @@ def probe_ollama_local_models( if time.monotonic() - failed_at < _OLLAMA_LOCAL_PROBE_FAILURE_TTL: return None _OLLAMA_LOCAL_PROBE_FAILURE_CACHE.pop(failure_key, None) - try: request_headers = {"User-Agent": _HERMES_USER_AGENT, **(headers or {})} req = urllib.request.Request(root.rstrip("/") + "/api/tags", headers=request_headers) @@ -354,10 +350,7 @@ def _ollama_local_catalog(force_refresh: bool) -> list[str]: base_url = _get_ollama_base_url() headers = _get_ollama_native_headers(base_url) if should_use_ollama_native_catalog("ollama", base_url, headers=headers): - if headers: - native_models = fetch_ollama_local_models(base_url, headers=headers) - else: - native_models = fetch_ollama_local_models(base_url) + native_models = fetch_ollama_local_models(base_url, headers=headers) if headers else 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 [] @@ -375,13 +368,10 @@ def _lmstudio_server_root(base_url: Optional[str]) -> Optional[str]: def _lmstudio_request_headers(api_key: Optional[str] = None) -> dict: - """Build HTTP headers for LM Studio native API requests.""" + """HTTP headers for LM Studio native API requests.""" from hermes_cli.models import _HERMES_USER_AGENT - headers = {"User-Agent": _HERMES_USER_AGENT} token = str(api_key or "").strip() - if token: - headers["Authorization"] = f"Bearer {token}" - return headers + return {"User-Agent": _HERMES_USER_AGENT, **({"Authorization": f"Bearer {token}"} if token else {})} def _lmstudio_fetch_raw_models( @@ -609,9 +599,8 @@ def ollama_model_supports_thinking( 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: + with httpx.Client(timeout=timeout, headers={"Authorization": f"Bearer {token}"} if token else {}) as client: resp = client.post(f"{server_url}/api/show", json={"name": bare_model}) if resp.status_code != 200: return None @@ -646,9 +635,7 @@ def _load_ollama_cloud_cache(*, ignore_ttl: bool = False) -> Optional[dict]: try: data = _read_json_cache(_ollama_cloud_cache_path()) - if data is None: - return None - models = data.get("models") + models = data.get("models") if data is not None else None if not (isinstance(models, list) and models): return None if not ignore_ttl and (time.time() - data.get("cached_at", 0)) > _OLLAMA_CLOUD_CACHE_TTL: diff --git a/hermes_cli/models_pricing.py b/hermes_cli/models_pricing.py index 6f095e2a40..2bebefb5db 100644 --- a/hermes_cli/models_pricing.py +++ b/hermes_cli/models_pricing.py @@ -234,18 +234,14 @@ def fetch_models_with_pricing( result: dict[str, dict[str, Any]] = {} for item in payload.get("data", []): - mid = item.get("id") - pricing = item.get("pricing") + mid, pricing = item.get("id"), item.get("pricing") if mid and isinstance(pricing, dict): entry = _pricing_entry(pricing) # Sale chrome is Nous Portal-only; never copy pricing.original for other catalogs. original = pricing.get("original") if include_sale_original else None if isinstance(original, dict): - orig_entry = { - key: str(original[key]) - for key in ("prompt", "completion", "input_cache_read", "input_cache_write") - if original.get(key) not in (None, "") - } + orig_entry = {key: str(original[key]) for key in ("prompt", "completion", "input_cache_read", "input_cache_write") + if original.get(key) not in (None, "")} if orig_entry.get("prompt") or orig_entry.get("completion"): entry["original"] = orig_entry result[mid] = entry @@ -270,8 +266,7 @@ def fetch_ai_gateway_pricing(timeout: float = 8.0, *, force_refresh: bool = Fals result: dict[str, dict[str, str]] = {} for item in _catalog_items(payload): - mid = item.get("id") - pricing = item.get("pricing") + mid, pricing = item.get("id"), item.get("pricing") if mid and isinstance(pricing, dict): result[mid] = _pricing_entry(pricing, "input", "output") return _cache_catalog(cache_key, result) diff --git a/hermes_cli/models_validate.py b/hermes_cli/models_validate.py index 2bb8b0dca8..18192737a5 100644 --- a/hermes_cli/models_validate.py +++ b/hermes_cli/models_validate.py @@ -160,11 +160,8 @@ def _parse_openrouter_preset(req: _Request) -> Optional[dict[str, Any]]: else: preset_base, preset_slug = req.requested.split(marker, 1) if re.fullmatch(r"[A-Za-z0-9._~-]+", preset_slug) is None: - return _reject( - "OpenRouter preset slugs must be non-empty URL-safe " - "identifiers using only letters, digits, '.', '_', " - "'~', or '-'." - ) + return _reject("OpenRouter preset slugs must be non-empty URL-safe identifiers using only " + "letters, digits, '.', '_', '~', or '-'.") req.preset_suffix = f"{marker}{preset_slug}" if not preset_base: return _soft_accept(None) @@ -185,27 +182,19 @@ def _validate_lmstudio(req: _Request) -> dict[str, Any]: if models is None: return _reject(f"Could not reach LM Studio's `/api/v1/models` to validate `{req.requested}`.") if not models: - return _reject( - f"LM Studio is reachable but no chat-capable models are loaded. " - f"Load `{req.requested}` in LM Studio (Developer tab → Load Model) and try again." - ) + return _reject("LM Studio is reachable but no chat-capable models are loaded. " + f"Load `{req.requested}` in LM Studio (Developer tab → Load Model) and try again.") if req.lookup in set(models): return _accept() return _reject(f"Model `{req.requested}` was not found in LM Studio's model listing.") -def _drop_authorization(headers: dict[str, str]) -> None: - for key in tuple(headers): - if key.lower() == "authorization": - del headers[key] - - def _ollama_probe_headers(req: _Request) -> dict[str, str]: """Headers for the Ollama native probe. Configured ``providers.ollama.extra_headers`` apply only when the probed endpoint is the configured one (never leak them to a different host). Caller headers win; a caller ``api_key`` becomes the Authorization header unless the caller sent one.""" from hermes_cli import models as _m - from hermes_cli.models_local import _configured_ollama_base_url + from hermes_cli.models_local import _configured_ollama_base_url, _drop_authorization configured_base = _configured_ollama_base_url() configured_allowed = not configured_base or _m._same_ollama_native_root(req.base_url or "", configured_base) @@ -243,8 +232,7 @@ def _validate_ollama_native(req: _Request) -> Optional[dict[str, Any]]: f"Note: could not reach this Ollama endpoint's `/api/tags` model listing to validate `{req.requested}`. " "Hermes will save the model name, but local Ollama model discovery could not verify it." ) - match = _match_in_catalog(req.lookup, models, auto_correct=False, - suggest_label="Similar local Ollama models") + match = _match_in_catalog(req.lookup, models, auto_correct=False, suggest_label="Similar local Ollama models") if match.exact: return _accept() empty_hint = " No models are currently listed by `/api/tags`." if not models else "" @@ -274,10 +262,8 @@ def _validate_custom(req: _Request) -> dict[str, Any]: f"{match.suggestion_text}" ) if probe.get("used_fallback"): - message += ( - f"\n Endpoint verification succeeded after trying `{probe.get('resolved_base_url')}`. " - f"Consider saving that as your base URL." - ) + message += (f"\n Endpoint verification succeeded after trying `{probe.get('resolved_base_url')}`. " + "Consider saving that as your base URL.") return _soft_accept(message) message = ( @@ -285,10 +271,8 @@ def _validate_custom(req: _Request) -> dict[str, Any]: f"Hermes will still save `{req.requested}`, but the endpoint should expose `/models` for verification." ) if anthropic_style: - message += ( - "\n Many Anthropic-compatible proxies do not implement the Models API " - "(GET /v1/models). The model name has been accepted without verification." - ) + message += ("\n Many Anthropic-compatible proxies do not implement the Models API (GET /v1/models). " + "The model name has been accepted without verification.") if probe.get("suggested_base_url"): message += f"\n If this server expects `/v1`, try base URL: `{probe.get('suggested_base_url')}`" # Anthropic-style proxies routinely lack /v1/models, so only they are accepted unverified. @@ -328,11 +312,9 @@ def _validate_static_catalog(req: _Request) -> Optional[dict[str, Any]]: return _accept() base_guess = req.lookup[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)] return _reject( - f"`{req.requested}` is not a valid large-context variant — " - f"`{base_guess}` enforces the standard 272K window on " - f"Codex, so no `-900k` option exists for it. Pick the " - f"base model, or a verified variant from the `/model` " - f"picker (e.g. `gpt-5.6-sol-900k`)." + f"`{req.requested}` is not a valid large-context variant — `{base_guess}` enforces the " + "standard 272K window on Codex, so no `-900k` option exists for it. Pick the base model, " + "or a verified variant from the `/model` picker (e.g. `gpt-5.6-sol-900k`)." ) if not catalog: return None @@ -349,12 +331,9 @@ def _validate_static_catalog(req: _Request) -> Optional[dict[str, Any]]: lower = req.lookup.strip().lower() if prefixes and not any(lower.startswith(p) for p in prefixes): return _reject( - f"`{req.requested}` doesn't look like a {label} model " - f"and isn't in its listing, so it was not accepted. If it " - f"belongs to another configured provider, switch with " - f"`--provider ` (or select it from the `/model` " - f"picker)." - f"{match.suggestion_text}" + f"`{req.requested}` doesn't look like a {label} model and isn't in its listing, so it was not " + "accepted. If it belongs to another configured provider, switch with `--provider ` " + f"(or select it from the `/model` picker).{match.suggestion_text}" ) return _soft_accept( f"Note: `{req.requested}` was not found in the {label} model listing. " @@ -405,10 +384,9 @@ def _validate_anthropic_messages(req: _Request) -> dict[str, Any]: models = _m.fetch_api_models(req.api_key, req.base_url, api_mode=req.api_mode) verdict = _match_in_catalog(req.lookup, models).verdict(req) if models is not None else None return verdict or _soft_accept( - f"Note: could not verify `{req.requested}` against this endpoint's " - f"model listing. Many Anthropic-compatible proxies do not " - f"implement GET /v1/models. The model name has been accepted " - f"without verification." + f"Note: could not verify `{req.requested}` against this endpoint's model listing. Many " + "Anthropic-compatible proxies do not implement GET /v1/models. The model name has been accepted " + "without verification." ) @@ -437,12 +415,9 @@ def _validate_live_listing(req: _Request) -> Optional[dict[str, Any]]: if api_models is None: return None if req.normalized == "gemini": - # Gemini's OpenAI-compat listing prefixes ids with "models/"; curated list and user - # input use the bare id, so strip before comparing. - api_models = [ - m[len("models/"):] if isinstance(m, str) and m.startswith("models/") else m - for m in api_models - ] + # Gemini's OpenAI-compat listing prefixes ids with "models/"; curated list and user input + # use the bare id, so strip before comparing. + api_models = [m[len("models/"):] if isinstance(m, str) and m.startswith("models/") else m for m in api_models] match = _match_in_catalog(req.lookup, api_models) if match.exact: return _accept() @@ -471,22 +446,15 @@ def _validate_live_listing(req: _Request) -> Optional[dict[str, Any]]: if not listing_authoritative and _m._model_in_provider_catalog( (variant_base or req.lookup).lower(), _m._provider_keys(req.normalized) ): - return _accept_with_note( - f"Note: `{req.requested}` was not found in the live /v1/models listing " - f"but exists in the curated catalog — accepted." - ) + return _accept_with_note(f"Note: `{req.requested}` was not found in the live /v1/models listing " + "but exists in the curated catalog — accepted.") # Nous: the Portal's recommended-models feed can list a model before the curated list or the # docs-hosted manifest catches up; `hermes chat` already accepts those at model-list build # time, so mirror that source of truth for per-message /model validation. if req.normalized == "nous" and req.lookup.lower() in _nous_portal_recommended_names(): - return _accept_with_note( - f"Note: `{req.requested}` was not found in the live /v1/models " - f"listing but is a current Nous Portal recommendation — accepted." - ) - return _reject( - f"Model `{req.requested}` was not found in this provider's model listing." - f"{match.suggestion_text}" - ) + return _accept_with_note(f"Note: `{req.requested}` was not found in the live /v1/models listing " + "but is a current Nous Portal recommendation — accepted.") + return _reject(f"Model `{req.requested}` was not found in this provider's model listing.{match.suggestion_text}") def _validate_bedrock(req: _Request) -> Optional[dict[str, Any]]: @@ -497,8 +465,7 @@ def _validate_bedrock(req: _Request) -> Optional[dict[str, Any]]: region = resolve_bedrock_runtime_region() discovered_ids = {m["id"] for m in discover_bedrock_models(region)} - match = _match_in_catalog(req.requested, list(discovered_ids), auto_correct=False, - suggest_cutoff=0.4) + match = _match_in_catalog(req.requested, list(discovered_ids), auto_correct=False, suggest_cutoff=0.4) if match.exact: return _accept() # Still accept (custom inference profiles / cross-account access), but warn. @@ -520,10 +487,8 @@ def _validate_catalog_fallback(req: _Request) -> dict[str, Any]: label = _m._PROVIDER_LABELS.get(req.normalized, req.normalized) catalog = _static_catalog(req.normalized) if not catalog: - return _soft_accept( - f"Note: could not reach the {label} API to validate `{req.requested}`. " - f"If the service isn't down, this model may not be valid." - ) + return _soft_accept(f"Note: could not reach the {label} API to validate `{req.requested}`. " + "If the service isn't down, this model may not be valid.") match = _match_in_catalog(req.lookup, catalog, case_insensitive=True) if match.exact: return _accept() From 315da6f7f6fcb6f2c891e99e5a2e078b82e142e4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:04:49 -0700 Subject: [PATCH 6/6] refactor(hermes_cli): tighten reasoning-caps helpers --- hermes_cli/models_reasoning_caps.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/hermes_cli/models_reasoning_caps.py b/hermes_cli/models_reasoning_caps.py index ac26c6b3e9..5d7738d622 100644 --- a/hermes_cli/models_reasoning_caps.py +++ b/hermes_cli/models_reasoning_caps.py @@ -32,7 +32,6 @@ Caps = dict[str, Optional[dict[str, Any]]] def _origin(): from hermes_cli import models - return models @@ -56,14 +55,8 @@ def parse_openrouter_reasoning_capabilities(item: Any) -> Optional[dict[str, Any raw_efforts = reasoning.get("supported_efforts") efforts: Optional[list[str]] = None 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": reasoning.get("mandatory") is True, - } + efforts = list(dict.fromkeys(str(e).strip().lower() for e in raw_efforts if str(e).strip())) + return {"supports_reasoning": True, "supported_efforts": efforts, "mandatory": reasoning.get("mandatory") is True} # ── Disk mirror ──────────────────────────────────────────────────────── @@ -83,7 +76,6 @@ def _reasoning_caps_disk_path() -> Path: def _read_reasoning_caps_disk() -> dict[str, Any]: from hermes_cli.models import _read_json_cache - return _read_json_cache(_reasoning_caps_disk_path()) or {} @@ -103,7 +95,6 @@ def _load_reasoning_caps_disk(url: str) -> tuple[Optional[Caps], float]: def _save_reasoning_caps_disk(url: str, caps: Caps) -> None: """Merge *url*'s catalog into the shared disk mirror, atomically.""" from hermes_cli.models import _write_json_cache - try: data = _origin()._read_reasoning_caps_disk() data[url] = {"ts": time.time(), "caps": caps} @@ -154,9 +145,8 @@ def _fetch_reasoning_caps_catalog(url: str, timeout: float) -> Optional[Caps]: """Fetch one OpenRouter-shaped ``/v1/models`` catalog → per-model caps; None when unreachable or empty so callers remember the failure. Sends a User-Agent: the Portal 403s anonymous reads.""" m = _origin() - headers = {"Accept": "application/json", "User-Agent": m._HERMES_USER_AGENT} try: - req = urllib.request.Request(url, headers=headers) + req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": m._HERMES_USER_AGENT}) with m._urlopen_model_catalog_request(req, timeout=timeout) as resp: payload = json.loads(resp.read().decode()) except Exception: