The validation ladder had no llamacpp branch: a switch to a freshly downloaded local model fell through to the generic live-listing probe, which hard-rejects when the spawn-only /v1/models listing has not learned the new file yet — so the Local Models Use button and the composer picker could never succeed for a non-catalog model. The managed runtime now validates against the staged library on disk (the source of truth for what the user downloaded), case-insensitively; ids that were never staged keep the live-listing verdict. The activate flow's self-heal had the same blind spot: its rescan only ran when this process supervised the server, but ensure_local_runtime returns None when another process owns it — precisely the desktop situation after a download job bounced the router once. Probe the live listing through the persisted endpoint and bounce the router when it lacks the model.
633 lines
32 KiB
Python
633 lines
32 KiB
Python
"""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_managed_local(req: _Request) -> Optional[dict[str, Any]]:
|
||
"""The managed llama.cpp runtime: the staged library on disk is the source of truth, not the
|
||
live listing. The router's model list is spawn-only (a GGUF landed after its start is
|
||
invisible to GET /models until a bounce), so validating a freshly downloaded model against
|
||
the live listing rejects the very file the user just staged — the Local Models "Use" flow
|
||
and the composer picker could never succeed for a non-catalog model. A staged id accepts
|
||
(case-insensitive: typing matches the file name, the router registers the preset id);
|
||
anything else falls through to the live listing, which stays authoritative for ids that
|
||
were never downloaded here."""
|
||
from hermes_cli.local_runtime.bootstrap import staged_model_ids
|
||
|
||
staged = {sid.lower() for sid in staged_model_ids()}
|
||
if req.lookup.strip().lower() in staged:
|
||
return _accept_with_note(
|
||
f"Note: `{req.requested}` was not found in the live /v1/models listing "
|
||
"but is downloaded in the managed local-models library — accepted."
|
||
)
|
||
return None
|
||
|
||
|
||
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 → managed local (staged library) → 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("llamacpp", "llama.cpp", "llama-cpp"), _validate_managed_local),
|
||
(_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")
|