Files
hermes-agent/hermes_cli/models_validate.py
teknium1 ce6f064ec3 fix(cli): model-switch validation trusts a profile-owned catalog
A plugin whose `fetch_models` (or `models_url`) IS its catalog was validated
against the generic `{base_url}/v1/models` listing; when that endpoint 200s
with a different product catalog (a relay serving a subscription SKU set,
#101705) every model the `/model` picker offered was rejected with
suggestions from another vendor. `_validate_live_listing` now accepts an
exact match against `provider_model_ids()` — the picker's list — before the
generic probe, only for profiles that own their catalog; everything else
falls through unchanged.

Part of #116408
Part of #101705
2026-09-19 20:56:31 -07:00

612 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Validate a requested ``/model`` value against the active provider's catalog.
Split out of ``hermes_cli.models``. Catalog fetchers defined in ``hermes_cli.models`` are looked up
there at call time (``_m.<name>``) so ``patch("hermes_cli.models.<name>")`` mocks keep intercepting;
local-server probes are looked up on ``hermes_cli.models_local`` (``_ml.<name>``) the same way.
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
import re
from dataclasses import dataclass
from difflib import get_close_matches
from typing import Any, Callable, Optional
from utils import base_url_host_matches
from hermes_constants import openrouter_variant_base
# ── Verdicts ─────────────────────────────────────────────────────────────
def _verdict(accepted: bool, persist: bool, recognized: bool, message: Optional[str]) -> dict[str, Any]:
return {"accepted": accepted, "persist": persist, "recognized": recognized, "message": message}
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)
def _soft_accept(message: Optional[str]) -> dict[str, Any]:
"""Accept + persist an unrecognized name, with a warning."""
return _verdict(True, True, False, message)
# ── Catalog matching ─────────────────────────────────────────────────────
@dataclass
class _Match:
exact: bool = False
suggestion_text: str = ""
def verdict(self, req: "_Request") -> Optional[dict[str, Any]]:
"""Accept on exact membership, else None so the branch composes its own message."""
return _accept() if self.exact else None
def _match_in_catalog(
query: str,
candidates,
*,
case_insensitive: bool = False,
suggest_query: Optional[str] = None,
suggest_cutoff: float = 0.5,
suggest_label: str = "Similar models",
) -> _Match:
"""Shared ladder: exact membership → suggestion text. Never rewrites the id: a requested model
that is merely CLOSE to a catalog entry is the user's selection (a newer release the listing
lacks, a dated snapshot, a qualifier) and goes to the wire verbatim — fuzzy "auto-correction"
swapped `deepseek-v4.1-flash` for `deepseek-v4-flash`, `gemini-3.8-flash` for `gemini-3.6-flash`
and `model:nitro` for `model` under the user's own label. The vendor's 400 names the valid ids.
``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 pool}
pool = list(display)
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)
suggestions = get_close_matches(suggest_query, pool, n=3, cutoff=suggest_cutoff)
if not suggestions:
return _Match()
return _Match(suggestion_text=f"\n {suggest_label}: " + ", ".join(f"`{_show(s)}`" for s in suggestions))
# ── Request context ──────────────────────────────────────────────────────
@dataclass
class _Request:
requested: str
lookup: str # id used for catalog membership (copilot-normalized / preset base)
provider: Optional[str] # raw caller value (Ollama checks look at this, not ``normalized``)
normalized: str
api_key: Optional[str]
base_url: Optional[str]
api_mode: Optional[str]
headers: Optional[dict[str, str]]
# ── Provider branches (None = not decided here) ─────────────────────────
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 req.requested in cfg["presets"]:
return _accept()
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/<slug>`` never appears in the public
/v1/models listing. A bare preset is accepted unverified; ``<model>@preset/<slug>`` validates
the base model; the full id (suffix included) goes to the wire. OpenRouter validates the slug
at request time."""
marker = "@preset/"
if marker not in req.requested:
return None
if req.requested.count(marker) != 1:
preset_slug, preset_base = "", req.requested
else:
preset_base, preset_slug = req.requested.split(marker, 1)
if re.fullmatch(r"[A-Za-z0-9._~-]+", preset_slug) is None:
return _reject("OpenRouter preset slugs must be non-empty URL-safe identifiers using only "
"letters, digits, '.', '_', '~', or '-'.")
if not preset_base:
return _soft_accept(None)
req.lookup = preset_base
return None
def _validate_lmstudio(req: _Request) -> dict[str, Any]:
from hermes_cli import models_local as _ml
from hermes_cli.auth import AuthError
# probe_lmstudio_models distinguishes None (unreachable / malformed) from [] (reachable,
# nothing chat-capable loaded); fetch_lmstudio_models collapses both to [].
try:
models = _ml.probe_lmstudio_models(api_key=req.api_key, base_url=req.base_url)
except AuthError as exc:
return _reject(f"{exc} Set `LM_API_KEY` (or update it) to match the server's bearer token.")
if models is None:
return _reject(f"Could not reach LM Studio's `/api/v1/models` to validate `{req.requested}`.")
if not models:
return _reject("LM Studio is reachable but no chat-capable models are loaded. "
f"Load `{req.requested}` in LM Studio (Developer tab → Load Model) and try again.")
if req.lookup in set(models):
return _accept()
return _reject(f"Model `{req.requested}` was not found in LM Studio's model listing.")
def _ollama_probe_headers(req: _Request) -> dict[str, str]:
"""Headers for the Ollama native probe. Configured ``providers.ollama.extra_headers`` 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 import models_local as _ml
from hermes_cli.models_local import _configured_ollama_base_url, _drop_authorization
configured_base = _configured_ollama_base_url()
configured_allowed = not configured_base or _ml._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 configured
out = dict(configured)
_drop_authorization(out)
out.update(req.headers)
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
def _validate_ollama_native(req: _Request) -> Optional[dict[str, Any]]:
"""Runs for EVERY provider: the native ``/api/tags`` catalog is used whenever the endpoint
looks like a local Ollama server. Also resolves ``base_url`` for the raw ``ollama`` provider,
which later branches (custom) rely on."""
from hermes_cli import models as _m
from hermes_cli import models_local as _ml
if str(req.provider or "").strip().lower() == "ollama" and not req.base_url:
req.base_url = _m._get_ollama_base_url()
headers = _ollama_probe_headers(req)
if not _ml.should_use_ollama_native_catalog(req.provider, req.base_url, headers=headers):
return None
models = _ml.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.
models = _m.probe_api_models(
req.api_key, _ml._normalize_openai_base_url(req.base_url), request_headers=headers,
).get("models")
if models is None:
return _soft_accept(
f"Note: could not reach this Ollama endpoint's `/api/tags` model listing to validate `{req.requested}`. "
"Hermes will save the model name, but local Ollama model discovery could not verify it."
)
match = _match_in_catalog(req.lookup, models, suggest_label="Similar local Ollama models")
if match.exact:
return _accept()
empty_hint = " No models are currently listed by `/api/tags`." if not models else ""
return _soft_accept(
f"Note: `{req.requested}` was not found in this Ollama endpoint's `/api/tags` model listing."
f"{empty_hint} It may still work if the server supports hidden or aliased models."
f"{match.suggestion_text}"
)
def _validate_custom(req: _Request) -> dict[str, Any]:
from hermes_cli import models as _m
# Probe with the auth shape the api_mode expects.
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)
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."
f"{match.suggestion_text}"
)
if probe.get("used_fallback"):
message += (f"\n Endpoint verification succeeded after trying `{probe.get('resolved_base_url')}`. "
"Consider saving that as your base URL.")
return _soft_accept(message)
# Many OpenAI-compatible and Anthropic-compatible proxies (DashScope coding plan, Cline,
# MiniMax) never implement GET /models; /chat/completions works fine. Rejecting the switch
# here bricked `/model` for them (#12220), so both chat modes persist the name unverified.
accepted = req.api_mode in ("chat_completions", "anthropic_messages")
message = f"Note: could not reach this custom endpoint's model listing at `{probe.get('probed_url')}`. "
if accepted:
message += (f"`{req.requested}` was accepted without verification — if this endpoint does not "
"serve it, inference will fail; check the provider's model catalog or the model name.")
else:
message += f"`{req.requested}` was not saved; the endpoint should expose `/models` for verification."
if probe.get("suggested_base_url"):
message += f"\n If this server expects `/v1`, try base URL: `{probe.get('suggested_base_url')}`"
return _verdict(accepted, True, False, message)
def _static_catalog(normalized: str) -> list[str]:
from hermes_cli import models as _m
try:
return _m.provider_model_ids(normalized)
except Exception:
return []
_STATIC_FAMILY_PREFIXES = {
# Plausibility gate (#45006): the soft-accept (#16172 / #19729) exists for entitlement-gated *hidden*
# slugs the curated listing hasn't caught up with — but those are always the provider's own family
# (openai-codex -> gpt-*; xai-oauth -> grok-*). Accepting an unrelated typed name (e.g. `qwen3.5-4b`,
# `llama-3.1-8b`) here turns what should be an actionable "did you mean --provider <x>?" error into a
# confusing success that 400s on the next turn. Only soft- accept names that share the provider's family
# prefix; reject the rest with guidance to pin the right provider.
"openai-codex": ("gpt-", "codex-", "o1", "o3", "o4"),
"xai-oauth": ("grok-",),
}
_STATIC_LABELS = {"openai-codex": "OpenAI Codex", "xai-oauth": "xAI Grok OAuth (SuperGrok / Premium+)"}
def _family_head(model_id: str) -> str:
"""Vendor family token of a model id: ``gpt-5.5`` → ``gpt``, ``claude-opus-5`` → ``claude``."""
return re.split(r"[-./:]", model_id.strip().lower(), maxsplit=1)[0]
def static_model_provider_conflict(model_name: str, provider: Optional[str], *, limit: int = 5) -> Optional[dict[str, Any]]:
"""Offline model×provider coherence from the curated catalogs only (no network: this runs on
``session.create``). ``None`` = coherent or undecidable — custom / aggregator / catalog-less
providers, names in the provider's own family (a newer ``gpt-*`` the curated list lacks) and
names no vendor lists (hidden or preview slugs) stay permissive. A conflict is a name outside
the provider's family that another native vendor's catalog lists — or any foreign-family name
on the OAuth catalogs with a strict family gate (``_STATIC_FAMILY_PREFIXES``) (#96817)."""
from hermes_cli import models as _m
requested = (model_name or "").strip()
normalized = _m.normalize_provider(provider)
catalog = list(_m._PROVIDER_MODELS.get(normalized, ()))
if not requested or not catalog or normalized == "moa" or normalized in _m._AGGREGATOR_PROVIDERS:
return None
if _m._model_in_provider_catalog(requested.lower(), _m._provider_keys(normalized)):
return None
if _family_head(requested) in {_family_head(m) for m in catalog}:
return None
strict = normalized in _STATIC_FAMILY_PREFIXES
if not strict and next(_m._static_catalog_matches(requested, normalized), None) is None:
return None
suggestions = get_close_matches(requested, catalog, n=limit, cutoff=0.4) or catalog[:limit]
label = _m._PROVIDER_LABELS.get(normalized, normalized)
return {
"model": requested, "provider": normalized, "suggestions": suggestions,
"message": (f"Model `{requested}` is not served by provider `{normalized}` ({label}). "
f"Closest {label} models: " + ", ".join(f"`{s}`" for s in suggestions) + "."),
}
def _validate_static_catalog(req: _Request) -> Optional[dict[str, Any]]:
"""openai-codex / xai-oauth: no /v1/models probing — validate against the curated catalog.
Returns None (fall through) when the catalog is empty."""
catalog = _static_catalog(req.normalized)
if req.normalized == "openai-codex":
from agent.model_metadata import CODEX_CONTEXT_VARIANT_SUFFIX, is_codex_context_variant
# Ineligible ``-900k`` aliases must be rejected BEFORE the hidden-slug soft-accept:
# the suffix is a Hermes picker convention, so an unknown `*-900k` can never be a real
# hidden provider slug — soft-accepting one silently runs at 272K on a different model.
if req.lookup.strip().lower().endswith(CODEX_CONTEXT_VARIANT_SUFFIX) and req.lookup not in set(catalog):
if is_codex_context_variant(req.lookup):
# Valid variant a stale catalog hasn't synthesized yet.
return _accept()
base_guess = req.lookup[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)]
return _reject(
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
match = _match_in_catalog(req.lookup, catalog)
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
# unrelated name (`qwen3.5-4b`) would turn an actionable "did you mean --provider <x>?" into
# a confusing success that 400s on the next turn, so reject it with guidance instead.
prefixes = _STATIC_FAMILY_PREFIXES.get(req.normalized, ())
lower = req.lookup.strip().lower()
if prefixes and not any(lower.startswith(p) for p in prefixes):
return _reject(
f"`{req.requested}` doesn't look like a {label} model and isn't in its listing, so it was not "
"accepted. If it belongs to another configured provider, switch with `--provider <slug>` "
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. "
"It may still work if your account has access to a newer or hidden model ID."
f"{match.suggestion_text}"
)
def _validate_minimax(req: _Request) -> Optional[dict[str, Any]]:
"""MiniMax has no /models endpoint — static catalog, case-insensitive (ids like MiniMax-M2.7).
Returns None when the catalog is empty."""
catalog = _static_catalog(req.normalized)
if not catalog:
return None
match = _match_in_catalog(req.lookup, catalog, case_insensitive=True)
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."
"\n The model may still work if it exists on the server."
)
def _validate_anthropic(req: _Request) -> Optional[dict[str, Any]]:
"""Native Anthropic: /v1/models needs x-api-key (or OAuth Bearer) + anthropic-version, so the
generic Bearer probe 401s — use the native fetcher. 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)
# Accept anyway — Anthropic gates newer/preview models (snapshot IDs, early access) behind
# accounts even though they aren't listed on /v1/models.
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}"
)
def _validate_anthropic_messages(req: _Request) -> dict[str, Any]:
"""Anthropic Messages transport: probe /v1/models and soft-accept either way, but say which
happened — a proxy that never implemented the listing is a different situation from a reachable
listing that simply doesn't name the slug (vendors alias ids: ``kimi-k3`` is served as ``k3``)."""
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 None:
return _soft_accept(
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."
)
# Vendor alias pairs sit below the default 0.5 similarity cutoff (kimi-k3 vs k3 ≈ 0.44).
match = _match_in_catalog(req.lookup, models, case_insensitive=True, suggest_query=req.requested,
suggest_cutoff=0.4)
return match.verdict(req) or _soft_accept(
f"Note: `{req.requested}` is not named in this endpoint's model listing (it may still serve it "
f"under an alias).{match.suggestion_text}"
"\n The model name has been accepted without verification."
)
def _nous_portal_recommended_names() -> set[str]:
"""Lower-cased ids from the Portal's live recommended-models feed (empty on any failure)."""
from hermes_cli import models as _m
try:
payload = _m.fetch_nous_recommended_models(_m._resolve_nous_portal_url())
return {
name.lower()
for tier in ("freeRecommendedModels", "paidRecommendedModels")
for entry in (payload.get(tier) or [])
if (name := _m._extract_model_name(entry))
}
except Exception:
return set()
def _profile_owns_catalog(normalized: str) -> bool:
"""True when the registered profile's catalog is not the generic ``{base_url}/models`` listing —
it overrides ``fetch_models`` or points ``models_url`` elsewhere — so that listing is not
authoritative for it (a relay may 200 with a different product catalog, #101705)."""
from providers import get_provider_profile
from providers.base import ProviderProfile
profile = get_provider_profile(normalized)
return profile is not None and (
type(profile).fetch_models is not ProviderProfile.fetch_models or bool(profile.models_url))
def _validate_live_listing(req: _Request) -> Optional[dict[str, Any]]:
"""Generic live /v1/models probe. Returns None when the API was unreachable (the caller then
tries Bedrock discovery / the curated catalog). A profile that owns its catalog is validated
against that catalog (``provider_model_ids`` — the picker's list) before the generic listing."""
from hermes_cli import models as _m
if _profile_owns_catalog(req.normalized) and _match_in_catalog(req.lookup, _static_catalog(req.normalized)).exact:
return _accept()
api_models = _m.fetch_api_models(req.api_key, req.base_url)
if api_models is None:
return None
if req.normalized == "gemini":
# Gemini's OpenAI-compat listing prefixes ids with "models/"; curated list and user input
# use the bare id, so strip before comparing.
api_models = [m[len("models/"):] if isinstance(m, str) and m.startswith("models/") else m for m in api_models]
match = _match_in_catalog(req.lookup, api_models)
if match.exact:
return _accept()
# OpenRouter routing variants (":nitro", ":floor", ...) are request-time modifiers, not
# catalog entries — validate the BASE but keep the suffixed id.
variant_base = openrouter_variant_base(req.lookup) if req.normalized == "openrouter" else None
if variant_base is not None and variant_base in set(api_models):
return _accept()
# Listed but not found: the account may reach models absent from the public listing
# (e.g. Z.AI Pro/Max plans use glm-5 on coding endpoints) — warn but allow where plausible.
# Curated-catalog soft-accept: providers omit valid models from live listings (stale cache,
# partial rollout, gated previews). EXCEPTION: official OpenAI hosts (canonical + data-
# residency regional) — their listing is access-scoped and authoritative, so an absent model
# is one this key CANNOT serve; a soft-accept would 400 at first use. Custom OpenAI-compatible
# proxies keep the fallback.
listing_authoritative = False
if req.normalized in ("openai", "openai-api"):
from hermes_cli.providers import is_official_openai_host
listing_authoritative = is_official_openai_host(req.base_url)
if not listing_authoritative and _m._model_in_provider_catalog(
(variant_base or req.lookup).lower(), _m._provider_keys(req.normalized)
):
return _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 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]]:
"""Bedrock's runtime URL has no /models; discovery goes through the AWS control plane
(ListFoundationModels + ListInferenceProfiles). Any failure falls through (None)."""
try:
from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_runtime_region
region = resolve_bedrock_runtime_region()
discovered_ids = {m["id"] for m in discover_bedrock_models(region)}
match = _match_in_catalog(req.requested, list(discovered_ids), suggest_cutoff=0.4)
if match.exact:
return _accept()
# Still accept (custom inference profiles / cross-account access), but warn.
return _soft_accept(
f"Note: `{req.requested}` was not found in Bedrock model discovery for {region}. "
f"It may still work with custom inference profiles or cross-account access."
f"{match.suggestion_text}"
)
except Exception:
return None
def _validate_catalog_fallback(req: _Request) -> dict[str, Any]:
"""/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)
catalog = _static_catalog(req.normalized)
if not catalog:
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()
# Same OpenRouter routing-variant rule as the live-listing path.
if req.normalized == "openrouter":
variant_base = openrouter_variant_base(req.lookup)
if variant_base is not None and variant_base.lower() in {m.lower() for m in catalog}:
return _accept()
return _soft_accept(
f"Note: `{req.requested}` was not found in the {label} curated catalog "
f"and the /models endpoint was unreachable.{match.suggestion_text}"
f"\n The model may still work if it exists on the provider."
)
# ── Orchestrator ─────────────────────────────────────────────────────────
def _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],
*,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
api_mode: Optional[str] = None,
headers: Optional[dict[str, str]] = None,
) -> dict[str, Any]:
"""Validate a ``/model`` value for the active provider → dict with ``accepted`` (switch now),
``persist`` (safe to save to config), ``recognized`` (matched a known provider catalog),
``message`` (optional warning / guidance). The requested id is never rewritten: what the user
selected is what the wire sees."""
from hermes_cli import models as _m
requested = (model_name or "").strip()
normalized = _m.normalize_provider(provider)
if normalized == "openrouter" and base_url and not base_url_host_matches(base_url, "openrouter.ai"):
normalized = "custom"
lookup = requested
if normalized == "copilot":
lookup = _m.normalize_copilot_model_id(requested, api_key=api_key) or requested
if not requested:
return _reject("Model name cannot be empty.")
req = _Request(requested, lookup, provider, normalized, api_key, base_url, api_mode, headers)
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")