* feat(auth): Nous free tier core: anonymous identity minted on first use, welcome inference, shared-store scoping A fresh install with no provider sets up a free Nous identity (anonymous auth method of the nous provider) instead of forcing the setup wizard. The identity is persisted through the same path a real login uses, so the resolver ladder is unchanged. Two seams differ: token acquisition re-exchanges the anon credential (no refresh token), and routing pins the welcome host's single model nous/welcome. One identity per shared store; nous.guest: false turns the free tier off. * test(auth): free tier core contracts: lifecycle, resolver precedence, exchange seam, model pin * docs(user-guide): free tier and signing in New page explaining what a fresh install gets before any key or sign-in (free inference on nous/welcome plus connectors), how the free tier coexists with a user's own API key, how to sign in with hermes auth upgrade and keep connectors, how to turn the free tier off with nous.guest, what hermes logout does in each state, a troubleshooting table, and a plain privacy note. Wired into the Using Hermes sidebar. * fix(auth): logout leaves the free tier alone and clears the shared store for a real Nous account Logging out of the free tier is a no-op: it is not a login, so nothing is cleared and the user is told they were never signed in. Logging out of a real Nous account now also clears the cross-profile store, so a profile logout is not silently re-adopted on the next boot. * fix(model): switching off the free tier points at signing in, never hops providers * Name the free tier in the gateway startup notice and tell explicit-provider installs about it once * Render the Nous free tier as free tier on auth status, auth list, hermes status and portal info, short-circuit billing copy for it, and skip the keepalive when there is no refresh token * fix(auth): free tier is set up where nothing is configured: resolver last rung and first-run check Both the provider resolver's terminal rung and the CLI first-run check now try to set up the free tier before declaring nothing configured. On a fresh install the first command lands in chat on nous/welcome; a failed setup still falls through to the existing guidance. * Add hermes auth upgrade: sign the free tier into a Nous account while keeping its connectors The device-code flow runs as usual, with a promotion intent registered on the portal between the code request and the token poll so the account that approves the code inherits the free tier's connectors. The promotion status decides the outcome: only a completed one is followed by the token grant, which is persisted over the free-tier singleton and the shared store. Declined, superseded, retired and busy outcomes each print their own plain copy, and a retired identity is cleared so the next use sets up a fresh one. User-facing text never names the free tier's internals. * Show the Nous free tier as one picker row with nous/welcome and hide it when nous.guest is off * fix(auth): upgrade opens the consent page for this sign-in; one mint attempt per process; forced free tier wins the first-run check The browser leg of hermes auth upgrade now prints and opens the promotion claim URL with the claim code, not the generic device page. A failed mint is attempted once per process so several bootstrap sites cannot hit a closed gate or a 429 twice; a retired credential resets that so re-minting still happens. HERMES_FORCE_GUEST is honoured ahead of the first-run provider check. * fix(auth): pin the welcome model on the selected route, not on profile state; background setup retries after a failure A credential-pool entry can select a paid Nous key while the profile singleton is still the free tier. The model pin now keys on the resolved endpoint (welcome host) in agent init and /model, and the pin in model normalization is removed since it had no route to look at. A failed background identity setup releases its latch so a later attempt in the same process can try again. * fix(auth): decide the Nous model together with the route on every credential-pool swap The credential pool can move a Nous agent between the welcome host and the portal host after init. One helper, pin_model_for_route, now runs at init and inside every pool swap, so the welcome host always carries nous/welcome and a paid endpoint always keeps the caller's model. * fix(auth): apply the route model policy on every wire mode during a pool swap; release the setup latch if the thread cannot start * fix(auth): free-tier lifecycle takes profile then shared lock, reconciles with the shared store, persists the mint before exchanging, and clears only the identity that died The shared store is the identity of record for a Hermes root: a profile holding a stale free-tier identity adopts a sibling's newer sign-in instead of keeping the guest, and never overwrites the shared account. Locks are taken in the documented order (profile, then shared). A minted credential is stored as soon as create succeeds, so a rate-limited or timed-out exchange does not lose it and trigger a second mint. Retiring a dead credential removes only that credential from both stores. Guest exchange uses the resolver's canonical portal URL. * fix(auth): a credential rotation never rewrites the conversation model; connectors honour the off switch and replace a retired free-tier credential The welcome host serves one model, so a rotation onto it is refused for any conversation on another model instead of silently switching that conversation to nous/welcome (the model pin applies only when a route is first chosen). The connector token path now treats the free tier as absent when nous.guest is false, including cached tokens, and shares the one dead-credential rule with inference: a retired identity is replaced once rather than returning its stale token. * fix(auth): plain login never imports the free tier as OAuth credentials; the gateway startup line reads persisted state only A free-tier identity in the shared store is not an OAuth credential to offer for import; a real sign-in replaces it. The gateway's startup notice now answers provider precedence from persisted state (no token refresh at boot), so an expired free-tier token cannot stall the online message.
1545 lines
74 KiB
Python
1545 lines
74 KiB
Python
"""Shared model-switching logic for the CLI and gateway /model commands.
|
|
|
|
Pipeline: parse flags -> alias resolution -> provider resolution -> credential resolution ->
|
|
normalize model name -> metadata lookup -> build result. Provider switching uses ``--provider``
|
|
exclusively; colons are reserved for OpenRouter variant suffixes (``:free``, ``:extended``)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, NamedTuple, Optional
|
|
|
|
from hermes_cli.providers import (
|
|
ProviderDef, custom_provider_aliases, determine_api_mode, get_label, host_mandated_api_mode,
|
|
is_aggregator, resolve_provider_full)
|
|
from hermes_cli.model_normalize import normalize_model_for_provider
|
|
from agent.models_dev import (
|
|
ModelCapabilities, ModelInfo, get_model_capabilities, get_model_info, list_provider_models)
|
|
from utils import base_url_host_matches, base_url_hostname, base_url_origin
|
|
# Re-exported: callers/tests patch hermes_cli.model_switch.<name>.
|
|
from hermes_cli.model_switch_providers import list_authenticated_providers
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _declared_model_ids(value: Any) -> list[str]:
|
|
"""Configured model IDs from ``{"id": {...}}``, ``["a", "b"]``, ``[{"id"|"name": ...}]`` or ``"a"``."""
|
|
if isinstance(value, str):
|
|
candidates: Any = [value]
|
|
elif isinstance(value, dict):
|
|
# Pre-fix Hermes wrote sentinel keys inside the user-facing ``models`` mapping.
|
|
candidates = (k for k in value if k not in ("__explicit_model_allowlist__", "__discovered_model_catalog__"))
|
|
elif isinstance(value, (list, tuple)):
|
|
candidates = (_declared_item_id(item) if isinstance(item, dict) else item for item in value)
|
|
else:
|
|
return []
|
|
ids: list[str] = []
|
|
seen: set[str] = set()
|
|
for candidate in candidates:
|
|
if not isinstance(candidate, str):
|
|
continue # non-str items are dropped
|
|
model_id = candidate.strip()
|
|
if model_id and model_id.lower() not in seen:
|
|
seen.add(model_id.lower())
|
|
ids.append(model_id)
|
|
return ids
|
|
|
|
|
|
def _declared_item_id(item: dict) -> Any:
|
|
"""``id`` of a ``[{"id": ...}]`` entry, falling back to ``name`` when blank/missing."""
|
|
model_id = item.get("id")
|
|
return model_id if isinstance(model_id, str) and model_id.strip() else item.get("name")
|
|
|
|
|
|
def _entry_models_discovered(entry: Any) -> bool:
|
|
"""True when the entry's ``models`` mapping was auto-discovered by Hermes.
|
|
|
|
Current shape: entry-level ``models_discovered: true``. Older versions wrote an in-mapping
|
|
``__discovered_model_catalog__: true`` sentinel — accepted on read (the next save migrates it)."""
|
|
if not isinstance(entry, dict):
|
|
return False
|
|
models = entry.get("models")
|
|
return entry.get("models_discovered") is True or (
|
|
isinstance(models, dict) and models.get("__discovered_model_catalog__") is True)
|
|
|
|
|
|
def _models_config_is_allowlist(value: Any, discovered: bool = False) -> bool:
|
|
"""True when ``models:`` is an intentional ID allowlist.
|
|
|
|
A mapping like ``{model_id: {context_length: N}}`` is per-model *metadata* written by
|
|
``_save_custom_provider`` / the wizard, not a catalog narrow (treating it as one made GUI
|
|
pickers show only the saved default for keyless Ollama while the CLI live-probed). List and
|
|
string shapes remain allowlists for no-key endpoints; pin a dict catalog with
|
|
``discover_models: false``. A catalog Hermes itself persisted (``discovered``) is never a pin."""
|
|
if discovered:
|
|
return False
|
|
if isinstance(value, str):
|
|
return bool(value.strip())
|
|
if isinstance(value, (list, tuple)):
|
|
return bool(_declared_model_ids(value))
|
|
return False # None, dict (per-model metadata), or anything else
|
|
|
|
|
|
def _bare_custom_provider_def(current_base_url: str) -> Optional[ProviderDef]:
|
|
"""ProviderDef for a direct ``model.provider: custom`` endpoint."""
|
|
base_url = _clean(current_base_url)
|
|
if not base_url:
|
|
return None
|
|
return ProviderDef(
|
|
id="custom", name="Custom endpoint", transport="openai_chat", api_key_env_vars=(),
|
|
base_url=base_url, is_aggregator=False, auth_type="api_key", source="model-config")
|
|
|
|
|
|
# --- Non-agentic model warning
|
|
|
|
_HERMES_MODEL_WARNING = (
|
|
"Nous Research Hermes 3 & 4 models are NOT agentic and are not designed "
|
|
"for use with Hermes Agent. They lack the tool-calling capabilities "
|
|
"required for agent workflows. Consider using an agentic model instead "
|
|
"(Claude, GPT, Gemini, DeepSeek, etc.).")
|
|
|
|
# Match only the real Nous Research Hermes 3 / 4 chat families; a bare substring check
|
|
# false-positived on tool-capable local Modelfiles like ``hermes-brain:qwen3-14b-ctx16k``.
|
|
# match: NousResearch/Hermes-3-Llama-3.1-70B, hermes-4-405b, openrouter/hermes3:70b
|
|
# no match: hermes-brain:qwen3-14b-ctx16k, qwen3:14b, claude-opus-4-6
|
|
_NOUS_HERMES_NON_AGENTIC_RE = re.compile(r"(?:^|[/:])hermes[-_ ]?[34](?:[-_.:]|$)", re.IGNORECASE)
|
|
|
|
|
|
# Opaque proxy model IDs (Palantir Foundry: ``ri.language-model-service..language-model.<slug>``)
|
|
# are noise in status output; the provider_label already carries the routing context. Stripped
|
|
# for DISPLAY ONLY — never for wire-side comparison, persistence, config writes or alias lookup.
|
|
_OPAQUE_MODEL_PREFIXES: tuple[str, ...] = ("ri.language-model-service..language-model.",)
|
|
|
|
|
|
def format_model_for_display(model_name: str) -> str:
|
|
"""Human-friendly form of *model_name* for CLI status output (display only, never wire-side)."""
|
|
for prefix in _OPAQUE_MODEL_PREFIXES:
|
|
if model_name and model_name.startswith(prefix):
|
|
return model_name[len(prefix):] or model_name
|
|
return model_name
|
|
|
|
|
|
def is_nous_hermes_non_agentic(model_name: str) -> bool:
|
|
"""True if *model_name* is a real Nous Hermes 3/4 chat model (single owner; cli.py uses it too)."""
|
|
return bool(model_name and _NOUS_HERMES_NON_AGENTIC_RE.search(model_name))
|
|
|
|
|
|
def _check_hermes_model_warning(model_name: str) -> str:
|
|
"""Warning string if *model_name* is a Nous Hermes 3/4 chat model, else ""."""
|
|
return _HERMES_MODEL_WARNING if is_nous_hermes_non_agentic(model_name) else ""
|
|
|
|
|
|
# --- Model aliases -- short names -> (vendor, family) with NO version numbers,
|
|
# resolved dynamically against the live models.dev catalog.
|
|
|
|
class ModelIdentity(NamedTuple):
|
|
"""Vendor slug and family prefix used for catalog resolution."""
|
|
vendor: str
|
|
family: str
|
|
|
|
|
|
MODEL_ALIASES: dict[str, ModelIdentity] = {
|
|
"sonnet": ModelIdentity("anthropic", "claude-sonnet"),
|
|
"opus": ModelIdentity("anthropic", "claude-opus"),
|
|
"haiku": ModelIdentity("anthropic", "claude-haiku"),
|
|
"claude": ModelIdentity("anthropic", "claude"),
|
|
"gpt5": ModelIdentity("openai", "gpt-5"),
|
|
"gpt": ModelIdentity("openai", "gpt"),
|
|
"codex": ModelIdentity("openai", "codex"),
|
|
"o3": ModelIdentity("openai", "o3"),
|
|
"o4": ModelIdentity("openai", "o4"),
|
|
"gemini": ModelIdentity("google", "gemini"),
|
|
"deepseek": ModelIdentity("deepseek", "deepseek-chat"),
|
|
"grok": ModelIdentity("x-ai", "grok"),
|
|
"llama": ModelIdentity("meta-llama", "llama"),
|
|
"qwen": ModelIdentity("qwen", "qwen"),
|
|
"minimax": ModelIdentity("minimax", "minimax"),
|
|
"nemotron": ModelIdentity("nvidia", "nemotron"),
|
|
"kimi": ModelIdentity("moonshotai", "kimi"),
|
|
"glm": ModelIdentity("z-ai", "glm"),
|
|
"step": ModelIdentity("stepfun", "step"),
|
|
"mimo": ModelIdentity("xiaomi", "mimo"),
|
|
"trinity": ModelIdentity("arcee-ai", "trinity")}
|
|
|
|
|
|
# --- Direct aliases — exact model+provider+base_url for endpoints outside the
|
|
# models.dev catalog (Ollama Cloud, local servers). Checked BEFORE catalog
|
|
# resolution; loaded from config.yaml ``model_aliases:`` / ``model.aliases``.
|
|
|
|
class DirectAlias(NamedTuple):
|
|
"""Exact model mapping that bypasses catalog resolution.
|
|
|
|
``api_key`` / ``key_env`` carry the alias endpoint's OWN credential. Without them the switch
|
|
would keep the *default* provider's key, which 401s against the alias host and sends that
|
|
provider's secret to an unrelated third party. Both default so positional
|
|
``DirectAlias(model, provider, base_url)`` keeps working.
|
|
|
|
See #83612.
|
|
"""
|
|
model: str
|
|
provider: str
|
|
base_url: str
|
|
api_key: str = ""
|
|
key_env: str = ""
|
|
|
|
|
|
# Built-in direct aliases (extended via config.yaml model_aliases:)
|
|
_BUILTIN_DIRECT_ALIASES: dict[str, DirectAlias] = {}
|
|
|
|
|
|
def _clean(value: Any) -> str:
|
|
"""``str(value or "").strip()`` — the config-field normaliser used throughout this module."""
|
|
return str(value or "").strip()
|
|
|
|
# Merged dict (builtins + user config); populated by _load_direct_aliases()
|
|
DIRECT_ALIASES: dict[str, DirectAlias] = {}
|
|
|
|
|
|
def _load_direct_aliases() -> dict[str, DirectAlias]:
|
|
"""Load direct aliases from config.yaml.
|
|
|
|
``model_aliases:`` entries are dicts (``model``, ``provider``, ``base_url``, optional
|
|
``api_key`` — literal or ``"${VAR}"`` — / ``key_env``); with neither credential field the key
|
|
is resolved from the alias HOST, never from the previously active provider. ``model.aliases``
|
|
never overrides ``model_aliases``; its string entries (``ds-flash: deepseek/deepseek-v4-flash``)
|
|
take the provider from the ``provider/`` prefix, else the current provider.
|
|
|
|
See #83612.
|
|
"""
|
|
merged = dict(_BUILTIN_DIRECT_ALIASES)
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
cfg = load_config()
|
|
|
|
user_aliases = cfg.get("model_aliases")
|
|
if isinstance(user_aliases, dict):
|
|
for name, entry in user_aliases.items():
|
|
if isinstance(entry, dict) and entry.get("model", ""):
|
|
merged[name.strip().lower()] = DirectAlias(
|
|
model=entry.get("model", ""), provider=entry.get("provider", "custom"),
|
|
base_url=entry.get("base_url", ""), api_key=_clean(entry.get("api_key", "")),
|
|
key_env=_clean(entry.get("key_env", "")))
|
|
|
|
model_section = cfg.get("model", {})
|
|
simple_aliases = model_section.get("aliases") if isinstance(model_section, dict) else None
|
|
if isinstance(simple_aliases, dict):
|
|
current_provider = model_section.get("provider", "")
|
|
for name, value in simple_aliases.items():
|
|
key = name.strip().lower()
|
|
if not key or key in merged:
|
|
continue
|
|
if isinstance(value, dict):
|
|
model = _clean(value.get("model"))
|
|
if model:
|
|
merged[key] = DirectAlias(
|
|
model=model, provider=_clean(value.get("provider")) or current_provider or "custom",
|
|
base_url=_clean(value.get("base_url")))
|
|
elif isinstance(value, str) and value.strip():
|
|
val = value.strip()
|
|
provider, model = val.split("/", 1) if "/" in val else (current_provider, val)
|
|
merged[key] = DirectAlias(
|
|
model=model.strip(), provider=provider.strip() or current_provider, base_url="")
|
|
except Exception:
|
|
pass
|
|
return merged
|
|
|
|
|
|
# Identity of the config the cached aliases were built from. The cache is process-global but its
|
|
# source is profile-local: unkeyed, the first profile to resolve an alias would pin its definitions
|
|
# — and, since entries carry `api_key`, its credentials — for every later profile. Same shape
|
|
# `load_config()` keys on, so a profile switch (path) and a key rotation (mtime/size) both invalidate.
|
|
_DIRECT_ALIAS_IDENTITY: Optional[tuple] = None
|
|
# Copy of what the loader last produced. Callers and tests seed DIRECT_ALIASES both by rebinding
|
|
# and by editing in place, so only comparing against what we wrote tells our stale cache from
|
|
# someone else's contents.
|
|
_DIRECT_ALIAS_LOADED: Optional[dict] = None
|
|
|
|
|
|
def _direct_alias_source_identity() -> Optional[tuple]:
|
|
"""Identity of the active profile's alias source; None means "do not reuse the cache"."""
|
|
try:
|
|
from hermes_constants import get_config_path
|
|
path = get_config_path()
|
|
except Exception:
|
|
return None
|
|
try:
|
|
stat = path.stat()
|
|
except OSError:
|
|
# A missing config is still a definite identity for this profile.
|
|
return (str(path), None, None)
|
|
except Exception:
|
|
return None
|
|
return (str(path), stat.st_mtime_ns, stat.st_size)
|
|
|
|
|
|
def _ensure_direct_aliases() -> None:
|
|
"""Load direct aliases for the ACTIVE profile, caching per config identity.
|
|
|
|
Mutates DIRECT_ALIASES in place (never rebinds) so ``from ... import DIRECT_ALIASES``
|
|
references in callers stay valid."""
|
|
global _DIRECT_ALIAS_IDENTITY, _DIRECT_ALIAS_LOADED
|
|
identity = _direct_alias_source_identity()
|
|
if DIRECT_ALIASES and (
|
|
# Contents are not what we loaded — seeded or edited by a caller. Not ours to discard.
|
|
DIRECT_ALIASES != _DIRECT_ALIAS_LOADED
|
|
# Ours, and still the same config file at the same signature.
|
|
or (identity is not None and identity == _DIRECT_ALIAS_IDENTITY)):
|
|
return
|
|
loaded = _load_direct_aliases()
|
|
DIRECT_ALIASES.clear()
|
|
DIRECT_ALIASES.update(loaded)
|
|
_DIRECT_ALIAS_IDENTITY = identity
|
|
_DIRECT_ALIAS_LOADED = dict(loaded)
|
|
|
|
|
|
def direct_alias_api_key(alias: DirectAlias) -> str:
|
|
"""Resolve a direct alias's own credential, or "" when it has none.
|
|
|
|
Precedence: ``api_key: "${VAR}"`` (env indirection) > literal ``api_key`` > ``key_env``.
|
|
Env reads go through the per-profile secret scope: a raw ``os.environ`` read hands this
|
|
profile whatever key the process env holds — another profile's, under the multiplexed gateway."""
|
|
raw = (alias.api_key or "").strip()
|
|
if raw.startswith("${") and raw.endswith("}"):
|
|
return _scoped_key_env(raw[2:-1].strip())
|
|
if raw:
|
|
return raw
|
|
return _scoped_key_env((alias.key_env or "").strip())
|
|
|
|
|
|
def direct_alias_runtime_request(alias: DirectAlias) -> tuple[str, Optional[str]]:
|
|
"""``(requested_provider, explicit_api_key)`` for resolving *alias*.
|
|
|
|
Single owner of the invariant that a URL-bearing direct alias resolves its credential for
|
|
the alias HOST, never for its provider label: a label like ``anthropic`` on an unrelated URL
|
|
would otherwise reach that provider's explicit-runtime branch and put the live vendor token
|
|
on the foreign wire. Bare ``custom`` is host-gated, so an authoritative URL still resolves
|
|
its vendor key and a foreign one resolves none. An alias with no base_url keeps its label —
|
|
there is no foreign host, and the label is the only routing information.
|
|
|
|
See #28660.
|
|
"""
|
|
return ("custom" if alias.base_url else (alias.provider or "custom")), direct_alias_api_key(alias) or None
|
|
|
|
|
|
# Hosts where plaintext HTTP is not a downgrade — no network hop to intercept.
|
|
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "0.0.0.0"})
|
|
|
|
|
|
def _may_reuse_session_credential(session_base_url: str, alias_base_url: str) -> bool:
|
|
"""Whether the session's key may follow a switch to *alias_base_url*.
|
|
|
|
Same hostname is NOT sufficient: ``http://h`` and ``https://h:8443`` are different trust
|
|
boundaries, and an alias that drops the scheme would put a live bearer secret on the wire in
|
|
the clear. Require an identical (scheme, host, port) and refuse plaintext outside loopback."""
|
|
session = base_url_origin(session_base_url)
|
|
alias = base_url_origin(alias_base_url)
|
|
if not session[1] or session != alias:
|
|
return False
|
|
scheme, hostname, _ = alias
|
|
return scheme == "https" or hostname in _LOOPBACK_HOSTS
|
|
|
|
|
|
class StartupModelRoute(NamedTuple):
|
|
"""Model/provider pair resolved before an agent is constructed."""
|
|
model: str
|
|
provider: str = ""
|
|
base_url: str = ""
|
|
api_key: str = ""
|
|
|
|
|
|
def resolve_startup_model_route(
|
|
raw_model: str, *, explicit_provider: str = "", current_provider: str = "",
|
|
user_providers: Optional[dict] = None,
|
|
custom_providers: Optional[list] = None) -> Optional[StartupModelRoute]:
|
|
"""Resolve aliases and configured ``provider/model`` input at startup.
|
|
|
|
``HermesCLI`` is constructed before the interactive ``/model`` pipeline runs; resolving here
|
|
keeps startup from attaching the configured default provider to an explicitly requested
|
|
model. ``provider/model`` strings are consumed only for providers present in user config. When
|
|
``current_provider`` is a routing aggregator and the raw string is an aggregator-native slug
|
|
(``anthropic/claude-opus-4.6`` on OpenRouter) the input stays on the aggregator — a
|
|
``providers:`` block for the same vendor must not steal the route."""
|
|
raw = _clean(raw_model)
|
|
if not raw:
|
|
return None
|
|
|
|
_ensure_direct_aliases()
|
|
direct = DIRECT_ALIASES.get(raw.lower())
|
|
if direct is not None:
|
|
if explicit_provider:
|
|
# An explicit --provider wins over the alias's own label; the alias contributes
|
|
# model/base_url only.
|
|
return StartupModelRoute(model=direct.model, provider=explicit_provider, base_url=direct.base_url)
|
|
# Same owner as the interactive /model and oneshot paths: credential for the alias HOST.
|
|
# Resolve through the SAME owner the interactive /model and oneshot paths use: a URL-bearing alias
|
|
# must resolve its credential for the alias HOST, never for its provider label — a label like
|
|
# ``anthropic`` on a foreign URL would otherwise reach that provider's explicit-runtime branch and
|
|
# put the live vendor token on the foreign wire (#28660).
|
|
alias_provider, alias_key = direct_alias_runtime_request(direct)
|
|
return StartupModelRoute(
|
|
model=direct.model, provider=alias_provider, base_url=direct.base_url, api_key=alias_key or "")
|
|
|
|
if explicit_provider or "/" not in raw:
|
|
return None
|
|
prefix, model = (part.strip() for part in raw.split("/", 1))
|
|
if not prefix or not model:
|
|
return None
|
|
|
|
if current_provider:
|
|
try:
|
|
from hermes_cli.providers import is_routing_aggregator, normalize_provider as _norm_prov
|
|
if is_routing_aggregator(_norm_prov(current_provider)):
|
|
from hermes_cli.models import _find_openrouter_slug
|
|
if _find_openrouter_slug(raw):
|
|
return None
|
|
except Exception:
|
|
pass
|
|
|
|
configured = {str(name).strip().lower() for name in (user_providers or {}) if str(name).strip()}
|
|
configured.update(
|
|
f"custom:{entry.get('name', '').strip().lower()}"
|
|
for entry in (custom_providers or [])
|
|
if isinstance(entry, dict) and _clean(entry.get("name")))
|
|
try:
|
|
from hermes_cli.models import normalize_provider
|
|
canonical = normalize_provider(prefix)
|
|
except Exception:
|
|
canonical = prefix.lower()
|
|
|
|
if prefix.lower() in configured:
|
|
provider = prefix
|
|
elif canonical.lower() in configured:
|
|
provider = canonical
|
|
else:
|
|
return None
|
|
return None if is_aggregator(canonical) else StartupModelRoute(model=model, provider=provider)
|
|
|
|
|
|
# --- Result dataclasses
|
|
|
|
@dataclass
|
|
class ModelSwitchResult:
|
|
"""Result of a model switch attempt."""
|
|
success: bool
|
|
new_model: str = ""
|
|
target_provider: str = ""
|
|
provider_changed: bool = False
|
|
api_key: str = ""
|
|
base_url: str = ""
|
|
api_mode: str = ""
|
|
request_overrides: Optional[dict] = None
|
|
error_message: str = ""
|
|
warning_message: str = ""
|
|
provider_label: str = ""
|
|
resolved_via_alias: str = ""
|
|
capabilities: Optional[ModelCapabilities] = None
|
|
runtime_capabilities: Optional[dict[str, bool]] = None
|
|
model_info: Optional[ModelInfo] = None
|
|
is_global: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ModelFlagParseResult:
|
|
"""Parsed flags for a /model command."""
|
|
model_input: str
|
|
explicit_provider: str = ""
|
|
is_global: bool = False
|
|
force_refresh: bool = False
|
|
is_session: bool = False
|
|
is_once: bool = False
|
|
|
|
|
|
# --- Flag parsing
|
|
|
|
_BOOL_FLAGS = {"--global": "is_global", "--session": "is_session", "--refresh": "force_refresh", "--once": "is_once"}
|
|
|
|
|
|
def parse_model_flags_detailed(raw_args: str) -> ModelFlagParseResult:
|
|
"""Parse /model flags: ``--provider X``, ``--global``, ``--session``, ``--refresh``, ``--once``.
|
|
|
|
``--once`` is parsed here but interpreted by each caller (each frontend has its own
|
|
live-session restore hook). ``is_global`` / ``is_session`` are raw flag presences; the
|
|
effective persistence decision belongs to :func:`resolve_persist_behavior`."""
|
|
# Telegram/iOS auto-convert ``--`` to an em/en dash: normalize a single Unicode dash before
|
|
# a flag keyword.
|
|
raw_args = re.sub(r'[\u2012\u2013\u2014\u2015](provider|global|session|refresh|once)', r'--\1', raw_args)
|
|
|
|
# Hand-rolled: model IDs may contain colons/slashes and the historical parser did not
|
|
# require shell quoting.
|
|
flags = dict.fromkeys(_BOOL_FLAGS.values(), False)
|
|
explicit_provider = ""
|
|
filtered: list[str] = []
|
|
tokens = iter(raw_args.split())
|
|
for tok in tokens:
|
|
if tok in _BOOL_FLAGS:
|
|
flags[_BOOL_FLAGS[tok]] = True
|
|
elif tok == "--provider" and (value := next(tokens, None)) is not None:
|
|
explicit_provider = value
|
|
else:
|
|
filtered.append(tok) # a trailing bare ``--provider`` stays part of the model text
|
|
return ModelFlagParseResult(model_input=" ".join(filtered).strip(), explicit_provider=explicit_provider, **flags)
|
|
|
|
|
|
def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool, bool]:
|
|
"""Legacy 5-tuple ``(model_input, explicit_provider, is_global, force_refresh, is_session)``."""
|
|
p = parse_model_flags_detailed(raw_args)
|
|
return (p.model_input, p.explicit_provider, p.is_global, p.force_refresh, p.is_session)
|
|
|
|
|
|
def resolve_persist_behavior(
|
|
is_global: bool, is_session: bool, is_once: bool = False, explicit_provider: str = "") -> bool:
|
|
"""Decide whether a ``/model`` switch should persist to ``config.yaml``.
|
|
|
|
Order: ``--once`` / ``--session`` -> False; ``--global`` -> True; no default configured yet
|
|
(neither ``model.default`` nor ``model.provider`` — a fresh install's first pick) -> True, so
|
|
the pick does not evaporate into whatever ``*_API_KEY`` is lying around on the next launch;
|
|
``--provider`` without a persist flag -> False (exploratory); else
|
|
``model.persist_switch_by_default`` (default False). A flat-string ``model`` IS a configured
|
|
default; an unreadable config -> False.
|
|
|
|
1. ``--once`` explicitly opts out → ``False`` (next turn only). 2. ``--session`` explicitly opts out →
|
|
``False`` (this session only). 3. 4. Applies to every surface (CLI, gateway, Desktop picker) so no
|
|
client has to hardcode ``--global``. 5. Provider switches are typically exploratory — the user is trying
|
|
a different backend for this conversation, not reconfiguring the default. 6. Otherwise defer to
|
|
``model.persist_switch_by_default`` in ``config.yaml`` (defaults to ``False``: a plain ``/model <name>``
|
|
affects only the current session). Users who want the old persist-by-default behavior can set the key to
|
|
``true``; a one-off ``--global`` always persists. See #86414.
|
|
"""
|
|
if is_once or is_session:
|
|
return False
|
|
if is_global:
|
|
return True
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
model_cfg = load_config().get("model")
|
|
except Exception:
|
|
return False
|
|
if isinstance(model_cfg, dict):
|
|
if not (model_cfg.get("default") or model_cfg.get("provider")):
|
|
return True
|
|
if explicit_provider:
|
|
return False
|
|
return bool(model_cfg.get("persist_switch_by_default", False))
|
|
return not model_cfg
|
|
|
|
|
|
# --- Single-owner /model request parsing + effective-model resolution. Surfaces
|
|
# (cli.py, gateway/slash_commands.py, tui_gateway/server.py, api_server.py)
|
|
# map error codes to their own copy but never re-derive the semantics.
|
|
|
|
# Error codes emitted by parse_model_switch_args().
|
|
MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL = "once_with_global"
|
|
MODEL_SWITCH_ERR_ONCE_REQUIRES_TARGET = "once_requires_target"
|
|
|
|
# Canonical (surface-neutral) error copy. Surfaces prepend their own decoration (" ✗ " in the
|
|
# CLI, "❌ " in the gateway) but MUST NOT change the core sentence — it is shared user-visible copy.
|
|
MODEL_SWITCH_ERROR_TEXT = {
|
|
MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL: "/model --once cannot be combined with --global",
|
|
MODEL_SWITCH_ERR_ONCE_REQUIRES_TARGET: "/model --once requires a model or provider."}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ModelSwitchRequest:
|
|
"""A fully parsed /model command request.
|
|
|
|
``scope`` is the *requested* persistence scope from the flags alone: ``"once"`` |
|
|
``"session"`` | ``"global"`` | ``"default"`` (the effective decision then belongs to
|
|
:func:`resolve_persist_behavior`). ``errors`` carries ``MODEL_SWITCH_ERR_*`` codes rendered
|
|
via :data:`MODEL_SWITCH_ERROR_TEXT`. ``model_input`` keeps it a drop-in for
|
|
:class:`ModelFlagParseResult` consumers."""
|
|
raw: str
|
|
target: str
|
|
explicit_provider: str = ""
|
|
is_global: bool = False
|
|
is_session: bool = False
|
|
is_once: bool = False
|
|
force_refresh: bool = False
|
|
scope: str = "default"
|
|
errors: tuple = ()
|
|
|
|
@property
|
|
def model_input(self) -> str:
|
|
return self.target
|
|
|
|
def error_messages(self) -> list:
|
|
"""Canonical (undercorated) error strings for this request."""
|
|
return [MODEL_SWITCH_ERROR_TEXT[code] for code in self.errors]
|
|
|
|
|
|
def parse_model_switch_args(raw: str) -> ModelSwitchRequest:
|
|
"""The ONE parser for every /model surface: tokenization plus flag-conflict validation.
|
|
|
|
``--once`` + ``--global`` -> ``MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL``; ``--once`` with neither
|
|
a model nor ``--provider`` -> ``MODEL_SWITCH_ERR_ONCE_REQUIRES_TARGET``. Targets pass through
|
|
untouched (bare names, ``vendor/model``, ``vendor:model``) for :func:`switch_model`."""
|
|
raw = str(raw or "")
|
|
parsed = parse_model_flags_detailed(raw)
|
|
|
|
errors: list = []
|
|
if parsed.is_once and parsed.is_global:
|
|
errors.append(MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL)
|
|
if parsed.is_once and not parsed.model_input and not parsed.explicit_provider:
|
|
errors.append(MODEL_SWITCH_ERR_ONCE_REQUIRES_TARGET)
|
|
# First matching flag wins: once > session > global > default.
|
|
scope = next((name for name, on in (("once", parsed.is_once), ("session", parsed.is_session),
|
|
("global", parsed.is_global)) if on), "default")
|
|
return ModelSwitchRequest(
|
|
raw=raw, target=parsed.model_input, scope=scope, errors=tuple(errors),
|
|
**{f: getattr(parsed, f)
|
|
for f in ("explicit_provider", "is_global", "is_session", "is_once", "force_refresh")})
|
|
|
|
|
|
def _effective_model_candidate(value: Any) -> str:
|
|
"""Extract a model-name candidate from a str / dict / attr-object."""
|
|
if isinstance(value, str):
|
|
return value.strip()
|
|
if isinstance(value, dict):
|
|
return _clean(value.get("model"))
|
|
model_attr = getattr(value, "model", None)
|
|
return _clean(model_attr) if model_attr is not None else ""
|
|
|
|
|
|
def resolve_effective_model(
|
|
session_overrides: Any = None, channel_config: Any = None, global_config: Any = "") -> str:
|
|
"""Resolve the effective model: session override > channel > global.
|
|
|
|
Single owner of the precedence rule gateway/run.py and api_server.py each used to encode.
|
|
Each argument may be a model string, a dict with a ``"model"`` key, or an object with a
|
|
``.model`` attribute; empty entries fall through to the next tier."""
|
|
for tier in (session_overrides, channel_config, global_config):
|
|
candidate = _effective_model_candidate(tier)
|
|
if candidate:
|
|
return candidate
|
|
return ""
|
|
|
|
|
|
# --- Alias resolution
|
|
|
|
def _model_sort_key(model_id: str, prefix: str) -> tuple:
|
|
"""Sort key preferring higher versions after the family prefix, then ranked suffix tokens.
|
|
|
|
With prefix ``"mimo"``: ``mimo-v2.5-pro`` -> (-2.5, 0, 'pro'), ``mimo-v2.5`` -> (-2.5, 1, ''),
|
|
``mimo-v2-omni`` -> (-2.0, 1, 'omni')."""
|
|
# Strip the prefix (and optional "/" separator for aggregator slugs)
|
|
rest = model_id[len(prefix):].removeprefix("/").lstrip("-").strip()
|
|
nums, suffix_buf = _split_version_suffix(rest)
|
|
suffix = suffix_buf.lower().strip("-_.").strip()
|
|
|
|
# YYYYMMDD date stamps (claude-opus-4-20250514) are snapshot markers, not version components,
|
|
# and would dwarf real point versions; keep them as a trailing tiebreaker so bare IDs sort
|
|
# before their dated snapshots and newer snapshots before older. The 19_000_101 threshold
|
|
# reclassifies only 8-digit stamps (mistral-large-2411, gpt-4-0613 keep sorting as versions).
|
|
version_key = tuple(-n for n in nums if n < 19_000_101) # negate: higher sorts first
|
|
date_stamp = max((n for n in nums if n >= 19_000_101), default=0.0)
|
|
date_key = (0.0, 0.0) if date_stamp == 0.0 else (1.0, -date_stamp)
|
|
|
|
# Suffix quality: pro/max/plus/turbo (0) > no suffix / omni / flash / mini (1). "sol" is the
|
|
# flagship tier of the GPT-5.6 series (sol > terra > luna); without it `/model gpt` would
|
|
# tiebreak alphabetically onto luna, the cheapest. Revisit if a vendor ships a non-flagship "-sol".
|
|
suffix_rank = 0 if suffix in ("pro", "max", "plus", "turbo", "sol") else 1
|
|
return version_key + (suffix_rank, suffix) + date_key
|
|
|
|
|
|
def _split_version_suffix(rest: str) -> tuple[list[float], str]:
|
|
"""``"v2.5-pro"`` -> ``([2.5], "pro")``; ``"-omni"`` -> ``([], "omni")``.
|
|
|
|
Version tokens are ``v``-optional digit/dot runs separated by ``-``/``_``; a second dot inside
|
|
a run starts a new component; the first character that is neither starts the suffix."""
|
|
nums: list[float] = []
|
|
run, pos = "", 0
|
|
|
|
def _flush() -> None:
|
|
nonlocal run
|
|
try:
|
|
nums.append(float(run.rstrip(".")))
|
|
except ValueError:
|
|
pass
|
|
run = ""
|
|
|
|
while pos < len(rest):
|
|
ch = rest[pos]
|
|
if ch in "-_.":
|
|
pos += 1
|
|
continue
|
|
if not (ch in "vV" or ch.isdigit()):
|
|
break
|
|
if ch in "vV":
|
|
pos += 1
|
|
while pos < len(rest) and (rest[pos].isdigit() or rest[pos] == "."):
|
|
if rest[pos] == "." and "." in run:
|
|
_flush()
|
|
else:
|
|
run += rest[pos]
|
|
pos += 1
|
|
_flush()
|
|
if pos < len(rest) and rest[pos] not in "-_":
|
|
break
|
|
return nums, rest[pos:]
|
|
|
|
|
|
class AmbiguousAliasError(Exception):
|
|
"""Alias family-matches multiple catalog models; caller must disambiguate.
|
|
|
|
Raised by :func:`resolve_alias` instead of silently picking one via version-sort heuristics.
|
|
``candidates`` is sorted best-guess-first (see :func:`_model_sort_key`) for display only."""
|
|
def __init__(self, alias: str, provider: str, candidates: list[str]):
|
|
self.alias = alias
|
|
self.provider = provider
|
|
self.candidates = candidates
|
|
super().__init__(f"alias {alias!r} matches {len(candidates)} models on {provider}")
|
|
|
|
|
|
def _ambiguous_alias_message(err: "AmbiguousAliasError") -> str:
|
|
"""User-facing disambiguation list for an ambiguous alias."""
|
|
shown = err.candidates[:10]
|
|
lines = "\n".join(f" {i}. {m}" for i, m in enumerate(shown, 1))
|
|
hidden = len(err.candidates) - len(shown)
|
|
more = f"\n … and {hidden} more" if hidden > 0 else ""
|
|
return (
|
|
f"'{err.alias}' matches {len(err.candidates)} models on "
|
|
f"{err.provider} — not switching automatically:\n{lines}{more}\n"
|
|
f"Pick one with /model <exact-model-name>.")
|
|
|
|
|
|
def resolve_alias(raw_input: str, current_provider: str) -> Optional[tuple[str, str, str]]:
|
|
"""Resolve a short alias against the current provider's catalog.
|
|
|
|
Direct aliases (and reverse lookup by exact model id) win; then :data:`MODEL_ALIASES` is
|
|
matched against the provider's models.dev catalog by ``vendor/family`` prefix (``family``
|
|
for non-aggregators). Returns ``(provider, resolved_model_id, alias_name)`` or None; raises
|
|
:class:`AmbiguousAliasError` when several catalog models match."""
|
|
key = raw_input.strip().lower()
|
|
|
|
_ensure_direct_aliases()
|
|
direct = DIRECT_ALIASES.get(key)
|
|
if direct is not None:
|
|
return (direct.provider, direct.model, key)
|
|
|
|
# Reverse lookup so full names ("kimi-k2.5") route through direct aliases instead of
|
|
# falling through to the catalog/OpenRouter.
|
|
for alias_name, da in DIRECT_ALIASES.items():
|
|
if da.model.lower() == key:
|
|
return (da.provider, da.model, alias_name)
|
|
|
|
identity = MODEL_ALIASES.get(key)
|
|
if identity is None:
|
|
return None
|
|
|
|
vendor, family = identity
|
|
|
|
# models.dev catalog merged with static _PROVIDER_MODELS entries it may be missing.
|
|
catalog = list_provider_models(current_provider)
|
|
try:
|
|
from hermes_cli.models import _PROVIDER_MODELS
|
|
seen = {m.lower() for m in catalog}
|
|
catalog.extend(m for m in _PROVIDER_MODELS.get(current_provider, []) if m.lower() not in seen)
|
|
except Exception:
|
|
pass
|
|
|
|
prefix = f"{vendor}/{family}" if is_aggregator(current_provider) else family
|
|
matches = [mid for mid in catalog if mid.lower().startswith(prefix.lower())]
|
|
if not matches:
|
|
return None
|
|
|
|
# Version-sort for display, but NEVER silently pick among multiple candidates: the
|
|
# heuristics have repeatedly guessed wrong (dated snapshots outranking point releases,
|
|
# suffix tiebreaks landing on the cheapest tier).
|
|
matches.sort(key=lambda m: _model_sort_key(m, prefix))
|
|
if len(matches) > 1:
|
|
raise AmbiguousAliasError(key, current_provider, matches)
|
|
return (current_provider, matches[0], key)
|
|
|
|
|
|
def get_authenticated_provider_slugs(
|
|
current_provider: str = "", user_providers: dict = None, custom_providers: list | None = None
|
|
) -> list[str]:
|
|
"""Slugs of providers that have credentials (models.dev in-memory cache; no extra network cost)."""
|
|
try:
|
|
return [p["slug"] for p in list_authenticated_providers(
|
|
current_provider=current_provider, user_providers=user_providers,
|
|
custom_providers=custom_providers, max_models=0)]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _resolve_alias_fallback(
|
|
raw_input: str, authenticated_providers: list[str] = ()) -> Optional[tuple[str, str, str]]:
|
|
"""Resolve an alias on the user's authenticated providers (``("openrouter", "nous")`` when none given).
|
|
|
|
AmbiguousAliasError propagates: the alias exists on this provider, the user just has to
|
|
choose — trying the next provider would silently switch them somewhere they didn't ask for."""
|
|
results = (resolve_alias(raw_input, p) for p in authenticated_providers or ("openrouter", "nous"))
|
|
return next((r for r in results if r is not None), None)
|
|
|
|
|
|
def resolve_display_context_length(
|
|
model: str, provider: str, base_url: str = "", api_key: str = "",
|
|
model_info: Optional[ModelInfo] = None, custom_providers: list | None = None,
|
|
config_context_length: int | None = None, configured_model: str | None = None,
|
|
configured_provider: str | None = None,
|
|
configured_base_url: str | None = None) -> Optional[int]:
|
|
"""Context length to show in /model output.
|
|
|
|
models.dev reports per-vendor context but provider-enforced limits can be lower (Codex OAuth
|
|
caps gpt-5.5 at 272k), so ``agent.model_metadata.get_model_context_length`` is authoritative
|
|
(it also honors ``custom_providers[].models.<id>.context_length``); ``model_info.context_window``
|
|
is the fallback. A ``config_context_length`` pin is dropped when the route changed.
|
|
|
|
When ``custom_providers`` is provided, per-model ``context_length`` overrides from
|
|
``custom_providers[].models.<id>.context_length`` are honored — this closes #15779 where ``/model``
|
|
switch ignored user-set overrides.
|
|
"""
|
|
if config_context_length is not None and (configured_model or configured_provider or configured_base_url):
|
|
try:
|
|
from hermes_cli.route_identity import should_clear_context_pin
|
|
if should_clear_context_pin(
|
|
configured_model, model, configured_base_url, base_url, configured_provider, provider):
|
|
config_context_length = None
|
|
except Exception:
|
|
config_context_length = None
|
|
|
|
try:
|
|
from agent.model_metadata import get_model_context_length
|
|
ctx = get_model_context_length(
|
|
model, base_url=base_url or "", api_key=api_key or "", provider=provider or None,
|
|
custom_providers=custom_providers, config_context_length=config_context_length)
|
|
if ctx:
|
|
return int(ctx)
|
|
except Exception:
|
|
pass
|
|
if model_info is not None and model_info.context_window:
|
|
return int(model_info.context_window)
|
|
return None
|
|
|
|
|
|
async def resolve_display_context_length_async(model: str, provider: str, **kwargs) -> Optional[int]:
|
|
"""Thread-offloaded :func:`resolve_display_context_length` (same keyword arguments) — the sync
|
|
version runs blocking provider probes that async gateway handlers must not run on the loop."""
|
|
import asyncio
|
|
return await asyncio.to_thread(resolve_display_context_length, model, provider, **kwargs)
|
|
|
|
|
|
# --- Configured-provider detection for typed model names
|
|
|
|
def _configured_provider_matches(
|
|
model_name: str, user_providers: Optional[dict], custom_providers: Optional[list]
|
|
) -> dict[str, str]:
|
|
"""``{provider_slug: canonical_model_id}`` for every configured provider whose declared models
|
|
(``models``, ``model``, ``default_model`` — exact, case-insensitive, never fuzzy) contain
|
|
``model_name``, so a typed name routes to the provider that declares it instead of being
|
|
soft-accepted by the current provider (openai-codex) as an unknown hidden model.
|
|
|
|
Used by :func:`switch_model` to route a *typed* model name to the provider that actually declares it in
|
|
user/custom provider config, instead of leaving it on the current provider. See #45006.
|
|
"""
|
|
if not model_name or not model_name.strip():
|
|
return {}
|
|
target = model_name.strip().lower()
|
|
|
|
candidates: list[tuple[str, dict]] = []
|
|
if isinstance(user_providers, dict):
|
|
candidates += [(slug, cfg) for slug, cfg in user_providers.items()
|
|
if isinstance(slug, str) and isinstance(cfg, dict)]
|
|
candidates += [(f"custom:{e['name']}", e) for e in _custom_entries(custom_providers)
|
|
if isinstance(e.get("name"), str) and e["name"].strip()]
|
|
|
|
matches: dict[str, str] = {}
|
|
for slug, cfg in candidates:
|
|
hit = next((mid for key in ("models", "model", "default_model")
|
|
for mid in _declared_model_ids(cfg.get(key)) if mid.lower() == target), None)
|
|
if hit:
|
|
matches.setdefault(slug, hit) # first declaration wins
|
|
return matches
|
|
|
|
|
|
def _resolve_named_custom_model_id(model_name: str, target_provider: str, custom_providers: Optional[list]) -> str:
|
|
"""Map a picker-prefixed custom model selection (``prefix/model``) to its configured ID."""
|
|
provider = _clean(target_provider).lower()
|
|
if not provider.startswith("custom:") or "/" not in model_name:
|
|
return model_name
|
|
|
|
prefix, candidate = (part.strip() for part in model_name.split("/", 1))
|
|
if not prefix or not candidate:
|
|
return model_name
|
|
for entry in _custom_entries(custom_providers):
|
|
entry_slugs = _entry_aliases(entry)
|
|
if provider in entry_slugs and f"custom:{prefix.lower()}" in entry_slugs:
|
|
for model_id in _declared_model_ids(entry.get("models")):
|
|
if model_id.lower() == candidate.lower():
|
|
return model_id
|
|
return model_name
|
|
|
|
|
|
def _custom_entries(custom_providers: Any) -> list[dict]:
|
|
"""The dict-shaped entries of a ``custom_providers:`` list (anything else is ignored)."""
|
|
return [e for e in custom_providers if isinstance(e, dict)] if isinstance(custom_providers, list) else []
|
|
|
|
|
|
def _entry_aliases(entry: dict) -> frozenset[str]:
|
|
return custom_provider_aliases(str(entry.get("name") or ""), str(entry.get("provider_key") or ""))
|
|
|
|
|
|
# --- Core model-switching pipeline
|
|
|
|
def _entry_configured_key(cfg: dict, read_env) -> str:
|
|
"""Inline ``api_key`` (a ``${VAR}`` template resolves via *read_env*), else
|
|
``key_env``/``api_key_env`` via *read_env*."""
|
|
key = _clean(cfg.get("api_key", ""))
|
|
if key.startswith("${") and key.endswith("}"):
|
|
key = read_env(key[2:-1])
|
|
if not key:
|
|
key_env = _clean(cfg.get("key_env") or cfg.get("api_key_env"))
|
|
key = read_env(key_env) if key_env else ""
|
|
return key
|
|
|
|
|
|
def _ollama_configured_base() -> tuple[dict, str]:
|
|
from hermes_cli.models import _get_provider_config_dict
|
|
cfg = _get_provider_config_dict("ollama")
|
|
return cfg, _clean(cfg.get("base_url") or cfg.get("api") or cfg.get("url"))
|
|
|
|
|
|
def _unknown_provider_message(explicit_provider: str) -> str:
|
|
msg = (
|
|
f"Unknown provider '{explicit_provider}'. Check 'hermes model' for available "
|
|
f"providers, or define it in config.yaml under 'providers:'.")
|
|
try: # Surface common config issues that cause provider resolution failures
|
|
from hermes_cli.config import validate_config_structure
|
|
issues = validate_config_structure()
|
|
if issues:
|
|
msg += "\n\nRun 'hermes doctor' — config issues detected:" + "".join(f"\n • {ci.message}" for ci in issues[:3])
|
|
except Exception:
|
|
pass
|
|
return msg
|
|
|
|
|
|
def _aggregator_alias_error(
|
|
explicit_provider: str, target_provider: str, current_provider: str, user_providers, custom_providers,
|
|
) -> str:
|
|
"""Guard against silent aggregator hops: a vendor alias like bare "openai" resolves to an
|
|
aggregator ("openrouter"); if that aggregator has no credentials, refuse instead of switching
|
|
the user onto an unauthed endpoint (HTTP 401) and point at the real direct provider."""
|
|
from hermes_cli.models import _AGGREGATOR_PROVIDERS
|
|
from hermes_cli.providers import ALIASES
|
|
explicit_norm = explicit_provider.strip().lower()
|
|
alias_target = ALIASES.get(explicit_norm)
|
|
if not (
|
|
alias_target and alias_target == target_provider and target_provider != explicit_norm
|
|
and target_provider in _AGGREGATOR_PROVIDERS):
|
|
return ""
|
|
authed = get_authenticated_provider_slugs(
|
|
current_provider=current_provider, user_providers=user_providers, custom_providers=custom_providers)
|
|
if target_provider in authed:
|
|
return ""
|
|
suggestions = [s for s in authed if s.startswith(explicit_norm) and s != explicit_norm]
|
|
hint = f" Did you mean: {', '.join(suggestions)}?" if suggestions else ""
|
|
return (
|
|
f"Provider '{explicit_norm}' is an alias that routes "
|
|
f"through {get_label(target_provider)}, which "
|
|
f"has no credentials configured.{hint}")
|
|
|
|
|
|
def _aggregator_catalog_match(new_model: str, catalog: list) -> str | None:
|
|
"""Exact (case-insensitive) match on full id, then on the bare part after ``vendor/``."""
|
|
wanted = new_model.lower()
|
|
return next((mid for mid in catalog if mid.lower() == wanted), None) or next(
|
|
(mid for mid in catalog if "/" in mid and mid.split("/", 1)[1].lower() == wanted), None)
|
|
|
|
|
|
def _config_declares_model(
|
|
new_model: str, target_provider: str, base_url: str, user_providers, custom_providers) -> bool:
|
|
"""A model declared in the user's ``providers:``/``custom_providers:`` config is accepted even
|
|
when the remote /v1/models does not list it (cloud/aliased models). Custom entries match by
|
|
slug alias or by base_url."""
|
|
if user_providers:
|
|
from hermes_cli.config import is_provider_enabled
|
|
cfg = user_providers.get(target_provider)
|
|
if cfg is not None and is_provider_enabled(cfg) and new_model in _declared_model_ids(cfg.get("models", {})):
|
|
return True
|
|
for entry in _custom_entries(custom_providers):
|
|
if (target_provider.lower() in _entry_aliases(entry) or entry.get("base_url", "") == base_url) and (
|
|
new_model == entry.get("model", "") or new_model in _declared_model_ids(entry.get("models", {}))
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _apply_direct_alias_endpoint(st: "_Switch", da: DirectAlias) -> None:
|
|
"""Route a direct alias to its own base_url and decide its credential (mutates ``st``).
|
|
|
|
Credentials were resolved against the DEFAULT provider; carrying that key onto the alias
|
|
endpoint both 401s and ships the default provider's secret to an unrelated host. The alias's
|
|
own endpoint decides: its declared key; else the session key only for the SAME ORIGIN; else a
|
|
fresh resolution against the alias base_url (env-key fallbacks are host-gated: OLLAMA_API_KEY
|
|
resolves for ollama.com, OPENROUTER_API_KEY never reaches an unrelated host)."""
|
|
from hermes_cli.models_local import _same_ollama_native_root
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
alias_key = direct_alias_api_key(da)
|
|
same_host = _may_reuse_session_credential(st.base_url, da.base_url)
|
|
if alias_key:
|
|
st.base_url, st.api_key = da.base_url, alias_key
|
|
elif st.api_key and st.api_key != "no-key-required" and same_host:
|
|
# Same origin: the key is host-appropriate and re-resolving would only repeat the work.
|
|
st.base_url = da.base_url
|
|
else:
|
|
try:
|
|
req, explicit = direct_alias_runtime_request(da)
|
|
alias_runtime = resolve_runtime_provider(
|
|
requested=req, explicit_api_key=explicit, explicit_base_url=da.base_url, target_model=st.new_model)
|
|
except Exception:
|
|
alias_runtime = {}
|
|
st.base_url = alias_runtime.get("base_url", "") or da.base_url
|
|
# The resolver reports "no key found" as the `no-key-required` placeholder; normalise so
|
|
# a same-host credential still outranks it.
|
|
resolved_key = alias_runtime.get("api_key", "")
|
|
if resolved_key == "no-key-required":
|
|
resolved_key = ""
|
|
st.api_key = resolved_key or (st.api_key if same_host else "") or "no-key-required"
|
|
|
|
# providers.ollama refinement: pick up the configured key only for the configured native
|
|
# root; drop key and provider-level headers for any other origin. Skipped when the alias
|
|
# declared its own credential (explicit api_key/key_env outranks a provider-level config key).
|
|
if not alias_key and st.target_provider.strip().lower() == "ollama":
|
|
ollama_cfg, ollama_cfg_base = _ollama_configured_base()
|
|
if ollama_cfg_base and _same_ollama_native_root(st.base_url, ollama_cfg_base):
|
|
configured_key = _entry_configured_key(ollama_cfg, lambda n: os.environ.get(n, "").strip())
|
|
if configured_key:
|
|
st.api_key = configured_key
|
|
else:
|
|
# Different origin, or no configured root to safely associate the headers with.
|
|
st.validation_headers, st.suppress_ollama_headers, st.api_key = {}, True, "no-key-required"
|
|
st.api_key = st.api_key or "no-key-required"
|
|
st.api_mode = "" # clear so determine_api_mode re-detects from URL
|
|
|
|
|
|
def _moa_default_preset() -> str:
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
from hermes_cli.moa_config import normalize_moa_config
|
|
return normalize_moa_config(load_config().get("moa") or {})["default_preset"]
|
|
except Exception:
|
|
return "default"
|
|
|
|
|
|
@dataclass
|
|
class _Switch:
|
|
"""Mutable state threaded through the ``switch_model`` steps.
|
|
|
|
The routing steps settle ``target_provider`` / ``new_model`` / ``resolved_alias`` (and may
|
|
promote a config-routed ``providers.<slug>`` to ``explicit_provider`` so the credential step
|
|
resolves its block); the credential step fills ``api_key`` / ``base_url`` / ``api_mode`` /
|
|
``validation_headers``."""
|
|
raw_input: str
|
|
current_provider: str
|
|
current_model: str
|
|
current_base_url: str
|
|
current_api_key: str
|
|
is_global: bool
|
|
explicit_provider: str
|
|
user_providers: Optional[dict]
|
|
custom_providers: Optional[list]
|
|
new_model: str = ""
|
|
target_provider: str = ""
|
|
resolved_alias: str = ""
|
|
provider_label: str = ""
|
|
api_key: str = ""
|
|
base_url: str = ""
|
|
api_mode: str = ""
|
|
validation_headers: dict = field(default_factory=dict)
|
|
suppress_ollama_headers: bool = False
|
|
validation: dict = field(default_factory=dict)
|
|
|
|
def fail(self, message: str, **fields) -> ModelSwitchResult:
|
|
return ModelSwitchResult(success=False, is_global=self.is_global, error_message=message, **fields)
|
|
|
|
def fail_on_target(self, message: str) -> ModelSwitchResult:
|
|
"""Failure carrying the already-settled ``target_provider`` / ``provider_label``."""
|
|
return self.fail(message, target_provider=self.target_provider, provider_label=self.provider_label)
|
|
|
|
@property
|
|
def provider_changed(self) -> bool:
|
|
return self.target_provider != self.current_provider
|
|
|
|
def resolve_runtime(self, **kwargs) -> None:
|
|
"""Fill api_key / base_url / api_mode / validation_headers from ``resolve_runtime_provider``
|
|
for ``new_model``; headers keep their current value when the resolver returns none."""
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
rt = resolve_runtime_provider(target_model=self.new_model, **kwargs)
|
|
self.api_key, self.base_url = rt.get("api_key", ""), rt.get("base_url", "")
|
|
self.api_mode = rt.get("api_mode", "")
|
|
self.validation_headers = rt.get("extra_headers") or self.validation_headers
|
|
|
|
|
|
def _route_explicit_provider(st: _Switch) -> Optional[ModelSwitchResult]:
|
|
"""PATH A (``--provider`` given): resolve the provider, auto-detect a model from a local
|
|
endpoint when none was typed, then resolve the alias on the TARGET provider."""
|
|
pdef = resolve_provider_full(st.explicit_provider, st.user_providers, st.custom_providers)
|
|
if pdef is None and st.explicit_provider.strip().lower() == "custom":
|
|
pdef = _bare_custom_provider_def(st.current_base_url)
|
|
if pdef is None:
|
|
return st.fail(_unknown_provider_message(st.explicit_provider))
|
|
|
|
st.target_provider, st.provider_label = pdef.id, pdef.name # label is re-derived in the credential step
|
|
if st.target_provider == "moa" and not st.new_model:
|
|
st.new_model = _moa_default_preset()
|
|
|
|
agg_err = _aggregator_alias_error(
|
|
st.explicit_provider, st.target_provider, st.current_provider, st.user_providers, st.custom_providers)
|
|
if agg_err:
|
|
return st.fail_on_target(agg_err)
|
|
|
|
if not st.new_model:
|
|
if not pdef.base_url:
|
|
return st.fail_on_target(
|
|
f"Provider '{pdef.name}' has no base URL configured. "
|
|
f"Specify a model: /model <model-name> --provider {st.explicit_provider}")
|
|
from hermes_cli.runtime_provider import _auto_detect_local_model
|
|
st.new_model = _auto_detect_local_model(pdef.base_url)
|
|
if not st.new_model:
|
|
return st.fail_on_target(
|
|
f"No model detected on {pdef.name} ({pdef.base_url}). "
|
|
f"Specify the model explicitly: /model <model-name> --provider {st.explicit_provider}")
|
|
|
|
try:
|
|
alias_result = resolve_alias(st.new_model, st.target_provider)
|
|
except AmbiguousAliasError as err:
|
|
return st.fail(_ambiguous_alias_message(err), target_provider=st.target_provider)
|
|
if alias_result is not None:
|
|
_, st.new_model, st.resolved_alias = alias_result
|
|
return None
|
|
|
|
|
|
def _route_alias_fallback(st: _Switch, key: str) -> Optional[ModelSwitchResult]:
|
|
"""Step b: the alias exists but not on the current provider -> try the user's authenticated providers."""
|
|
authed = get_authenticated_provider_slugs(
|
|
current_provider=st.current_provider, user_providers=st.user_providers, custom_providers=st.custom_providers,
|
|
)
|
|
try:
|
|
fallback_result = _resolve_alias_fallback(st.raw_input, authed)
|
|
except AmbiguousAliasError as err:
|
|
return st.fail(_ambiguous_alias_message(err))
|
|
if fallback_result is None:
|
|
identity = MODEL_ALIASES[key]
|
|
return st.fail(
|
|
f"Alias '{key}' maps to {identity.vendor}/{identity.family} "
|
|
f"but no matching model was found in any provider catalog. "
|
|
f"Try specifying the full model name.")
|
|
st.target_provider, st.new_model, st.resolved_alias = fallback_result
|
|
logger.debug(
|
|
"Alias '%s' resolved via fallback to %s on %s", st.resolved_alias, st.new_model, st.target_provider)
|
|
return None
|
|
|
|
|
|
def _convert_vendor_colon_slug(st: _Switch) -> None:
|
|
"""Step c: on an aggregator, ``vendor:model`` -> ``vendor/model``. Only without a slash: with
|
|
one, the colon is a variant tag (:free, :extended, :fast) that must be preserved."""
|
|
raw_input = st.raw_input
|
|
colon_pos = raw_input.find(":")
|
|
cur_norm = str(st.current_provider).strip().lower()
|
|
if (
|
|
colon_pos > 0 and "/" not in raw_input and is_aggregator(st.current_provider)
|
|
and not cur_norm.startswith("custom") and cur_norm != "ollama"):
|
|
left = raw_input[:colon_pos].strip().lower()
|
|
right = raw_input[colon_pos + 1:].strip()
|
|
if left and right:
|
|
st.new_model = f"{left}/{right}"
|
|
logger.debug("Converted vendor:model '%s' to aggregator slug '%s'", raw_input, st.new_model)
|
|
|
|
|
|
def _route_configured_provider(st: _Switch) -> Optional[ModelSwitchResult] | bool:
|
|
"""Step d.5: a model declared in user/custom provider config routes there BEFORE
|
|
detect_provider_for_model() guesses from static catalogs and before a soft-accepting current
|
|
provider (openai-codex) can swallow it as an unknown hidden model. Returns a failure result,
|
|
``True`` when routed, else ``False``."""
|
|
cfg_matches = _configured_provider_matches(st.new_model, st.user_providers, st.custom_providers)
|
|
if not cfg_matches:
|
|
return False
|
|
if st.current_provider in cfg_matches:
|
|
st.new_model = cfg_matches[st.current_provider]
|
|
return True
|
|
match_slugs = sorted(cfg_matches)
|
|
if len(match_slugs) > 1:
|
|
return st.fail(
|
|
f"'{st.new_model}' is declared by multiple configured "
|
|
f"providers ({', '.join(match_slugs)}). Re-run with "
|
|
f"--provider <slug> to choose which one to use.")
|
|
st.target_provider = match_slugs[0]
|
|
st.new_model = cfg_matches[st.target_provider]
|
|
logger.debug("Configured-provider detection routed '%s' to %s", st.new_model, st.target_provider)
|
|
# providers.<slug> endpoints resolve in the credential block via resolve_user_provider(),
|
|
# which is gated on explicit_provider; custom:* slugs resolve at runtime directly.
|
|
if isinstance(st.user_providers, dict) and st.target_provider in st.user_providers:
|
|
st.explicit_provider = st.target_provider
|
|
return True
|
|
|
|
|
|
def _route_from_model_input(st: _Switch) -> Optional[ModelSwitchResult]:
|
|
"""PATH B (no ``--provider``): MoA preset / alias on the current provider (a) -> alias
|
|
fallback (b) or ``vendor:model`` conversion (c) -> aggregator catalog search (d) ->
|
|
configured-provider match (d.5) -> detect_provider_for_model() as last resort (e)."""
|
|
from hermes_cli.models import detect_provider_for_model
|
|
raw_input, current_provider = st.raw_input, st.current_provider
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
from hermes_cli.moa_config import exact_moa_preset_name, normalize_moa_config
|
|
moa_match = exact_moa_preset_name(normalize_moa_config(load_config().get("moa") or {}), raw_input)
|
|
except Exception:
|
|
moa_match = None # MoA config unreadable: fall through to plain alias resolution
|
|
if moa_match:
|
|
st.target_provider, st.new_model, st.resolved_alias = "moa", moa_match, ""
|
|
else:
|
|
try:
|
|
alias_result = resolve_alias(raw_input, current_provider)
|
|
except AmbiguousAliasError as err:
|
|
return st.fail(_ambiguous_alias_message(err))
|
|
if alias_result is not None:
|
|
st.target_provider, st.new_model, st.resolved_alias = alias_result
|
|
logger.debug("Alias '%s' resolved to %s on %s", st.resolved_alias, st.new_model, st.target_provider)
|
|
elif raw_input.strip().lower() in MODEL_ALIASES:
|
|
fail = _route_alias_fallback(st, raw_input.strip().lower())
|
|
if fail is not None:
|
|
return fail
|
|
else:
|
|
_convert_vendor_colon_slug(st)
|
|
|
|
# Step d: if the CURRENT provider's live catalog resolved the model, step e must not
|
|
# second-guess and switch providers — flat-namespace resellers (opencode-go/zen) return bare
|
|
# ids that coincidentally match native providers' static catalogs.
|
|
resolved_in_current_catalog = False
|
|
if is_aggregator(st.target_provider) and not st.resolved_alias:
|
|
catalog = list_provider_models(st.target_provider)
|
|
if catalog:
|
|
matched = _aggregator_catalog_match(st.new_model, catalog)
|
|
if matched is not None:
|
|
st.new_model, resolved_in_current_catalog = matched, True
|
|
|
|
# Steps d.5 / e only apply while the request is still unrouted on the current provider.
|
|
if st.resolved_alias or resolved_in_current_catalog or st.target_provider != current_provider:
|
|
return None
|
|
if current_provider == "nous":
|
|
# The welcome host serves nous/welcome only; a model outside it needs an account or a key.
|
|
# Never hop to another provider on the user's behalf here (there is no key to hop to).
|
|
from hermes_cli.anon_auth import GUEST_MODEL, route_is_welcome_host
|
|
if route_is_welcome_host(st.current_base_url) and st.new_model != GUEST_MODEL:
|
|
return st.fail(
|
|
f"{st.new_model} needs a Nous account or an API key. "
|
|
"Run `hermes auth upgrade` to sign in, or `hermes model` to pick another provider.")
|
|
config_routed = _route_configured_provider(st) # d.5 — deliberately NOT gated on ``not is_custom``
|
|
if isinstance(config_routed, ModelSwitchResult):
|
|
return config_routed
|
|
is_custom = (
|
|
current_provider in {"custom", "local"} or current_provider.startswith("custom:")
|
|
or base_url_hostname(st.current_base_url or "") in ("localhost", "127.0.0.1"))
|
|
if not config_routed and not is_custom: # e
|
|
detected = detect_provider_for_model(st.new_model, current_provider)
|
|
if detected:
|
|
st.target_provider, st.new_model = detected
|
|
return None
|
|
|
|
|
|
def _switch_provider_label(st: _Switch) -> str:
|
|
label = get_label(st.target_provider)
|
|
if st.target_provider == "custom" and st.current_base_url:
|
|
label = "Custom endpoint"
|
|
if st.target_provider.startswith("custom:"):
|
|
custom_pdef = resolve_provider_full(st.target_provider, st.user_providers, st.custom_providers)
|
|
if custom_pdef is not None:
|
|
label = custom_pdef.name
|
|
return label
|
|
|
|
|
|
def _creds_for_switched_provider(st: _Switch) -> Optional[ModelSwitchResult]:
|
|
"""Credentials when the provider changed or ``--provider`` was given.
|
|
|
|
``providers.<name>`` blocks carry their own base_url + transport + key reference;
|
|
resolve_runtime_provider() resolves by provider NAME and would re-resolve a block named
|
|
"openai" from scratch (or hop to an aggregator), so use the pdef's endpoint directly."""
|
|
user_pdef = None
|
|
explicit_norm = st.explicit_provider.strip().lower()
|
|
if st.explicit_provider and st.user_providers:
|
|
from hermes_cli.providers import resolve_user_provider
|
|
user_pdef = (resolve_user_provider(explicit_norm, st.user_providers)
|
|
or resolve_user_provider(st.target_provider, st.user_providers))
|
|
if user_pdef is not None and user_pdef.base_url:
|
|
ucfg = st.user_providers.get(explicit_norm) or st.user_providers.get(st.target_provider) or {}
|
|
# Key reads go through the per-profile secret scope (multiplexed gateway).
|
|
ukey = _entry_configured_key(ucfg, _scoped_key_env)
|
|
st.validation_headers = _extra_headers_from_config(ucfg)
|
|
try:
|
|
st.resolve_runtime(
|
|
requested=st.target_provider, explicit_api_key=ukey or None, explicit_base_url=user_pdef.base_url)
|
|
st.api_key, st.base_url = st.api_key or ukey, st.base_url or user_pdef.base_url
|
|
except Exception:
|
|
st.api_key, st.base_url, st.api_mode = ukey, user_pdef.base_url, ""
|
|
elif st.target_provider == "custom" and st.current_base_url:
|
|
st.api_key, st.base_url = st.current_api_key, st.current_base_url
|
|
st.api_mode = determine_api_mode(st.target_provider, st.base_url)
|
|
else:
|
|
try:
|
|
st.resolve_runtime(requested=st.target_provider)
|
|
except Exception as e:
|
|
return st.fail_on_target(f"Could not resolve credentials for provider '{st.provider_label}': {e}")
|
|
return None
|
|
|
|
|
|
def _creds_for_current_provider(st: _Switch) -> None:
|
|
"""Credentials when staying on the current provider. Mid-session ``/model <name>`` on a local
|
|
Ollama-compatible endpoint keeps the endpoint in use; re-resolving bare ``custom`` from config
|
|
can fall through to an unrelated default provider."""
|
|
from hermes_cli.models_local import _get_ollama_request_headers, _same_ollama_native_root
|
|
keep_current_ollama_endpoint = False
|
|
ollama_headers: dict[str, str] = {}
|
|
if st.current_provider == "custom" and st.current_base_url:
|
|
try:
|
|
from hermes_cli.models_local import should_use_ollama_native_catalog
|
|
ollama_headers = _get_ollama_request_headers()
|
|
_, configured_ollama_base = _ollama_configured_base()
|
|
# Provider-level Ollama headers only belong to the configured native root; without
|
|
# one there is no safe origin for them.
|
|
if not configured_ollama_base or not _same_ollama_native_root(st.current_base_url, configured_ollama_base):
|
|
ollama_headers = {}
|
|
st.suppress_ollama_headers = True
|
|
keep_current_ollama_endpoint = should_use_ollama_native_catalog(
|
|
st.current_provider, st.current_base_url, headers=ollama_headers)
|
|
except (ImportError, OSError, RuntimeError, TypeError, ValueError):
|
|
keep_current_ollama_endpoint = False
|
|
if keep_current_ollama_endpoint:
|
|
st.api_key = st.current_api_key or "no-key-required"
|
|
st.base_url = st.current_base_url
|
|
st.api_mode = determine_api_mode(st.current_provider, st.base_url)
|
|
st.validation_headers = ollama_headers
|
|
else:
|
|
try:
|
|
st.resolve_runtime(requested=st.current_provider)
|
|
except Exception:
|
|
pass
|
|
# Bare ``custom``/``local`` sessions whose base_url is session-only (not a trusted config
|
|
# ``model.base_url``) re-resolve to the OpenRouter DEFAULT — a host the user never picked
|
|
# (#74143). Keep the session endpoint + key then; a config-backed custom URL still wins so
|
|
# key/endpoint rotation is not pinned to a stale session.
|
|
if (
|
|
st.current_provider in {"custom", "local"} and st.current_base_url
|
|
and (not st.base_url or base_url_host_matches(st.base_url, "openrouter.ai"))
|
|
and not base_url_host_matches(st.current_base_url, "openrouter.ai")
|
|
):
|
|
st.base_url, st.api_key = st.current_base_url, st.current_api_key
|
|
st.api_mode = determine_api_mode(st.current_provider, st.base_url)
|
|
|
|
|
|
def _resolve_switch_credentials(st: _Switch) -> Optional[ModelSwitchResult]:
|
|
"""COMMON PATH part 1: credentials, direct-alias endpoint override, and the api_mode for the
|
|
final (provider, base_url) before validation."""
|
|
st.provider_label = _switch_provider_label(st)
|
|
st.api_key, st.base_url = st.current_api_key, st.current_base_url
|
|
if st.provider_changed or st.explicit_provider:
|
|
fail = _creds_for_switched_provider(st)
|
|
if fail is not None:
|
|
return fail
|
|
else:
|
|
_creds_for_current_provider(st)
|
|
|
|
# Direct alias override: use the alias's exact base_url if set.
|
|
if st.resolved_alias:
|
|
_ensure_direct_aliases()
|
|
da = DIRECT_ALIASES.get(st.resolved_alias)
|
|
if da is not None and da.base_url:
|
|
_apply_direct_alias_endpoint(st, da)
|
|
|
|
# Fills an empty mode (alias cleared it) and overrides a STALE mode carried from previous
|
|
# session state when the host mandates one wire protocol (e.g. gpt-5.x on api.openai.com
|
|
# would otherwise 400 on tools+reasoning).
|
|
from hermes_cli.providers import is_actual_route
|
|
mandated_mode = "chat_completions" if is_actual_route(st.target_provider, st.base_url) else host_mandated_api_mode(st.base_url)
|
|
if mandated_mode is not None:
|
|
st.api_mode = mandated_mode
|
|
st.api_mode = st.api_mode or determine_api_mode(st.target_provider, st.base_url)
|
|
return None
|
|
|
|
|
|
def _validate_switch(st: _Switch) -> Optional[ModelSwitchResult]:
|
|
"""COMMON PATH part 2: normalize the model name for the target provider, validate it, and
|
|
accept config-declared models the remote catalog lacks."""
|
|
from hermes_cli.models_local import _get_ollama_request_headers
|
|
from hermes_cli.models_validate import validate_requested_model
|
|
st.new_model = _resolve_named_custom_model_id(st.new_model, st.target_provider, st.custom_providers)
|
|
st.new_model = normalize_model_for_provider(st.new_model, st.target_provider)
|
|
|
|
if st.target_provider.strip().lower() == "ollama":
|
|
headers = {} if st.suppress_ollama_headers else (st.validation_headers or _get_ollama_request_headers())
|
|
else:
|
|
headers = st.validation_headers or (
|
|
_extra_headers_from_config(st.user_providers.get(st.target_provider))
|
|
if st.user_providers and st.target_provider in st.user_providers else None)
|
|
try:
|
|
validation = validate_requested_model(
|
|
st.new_model, st.target_provider, api_key=st.api_key, base_url=st.base_url,
|
|
api_mode=st.api_mode or None, headers=headers)
|
|
except Exception as e:
|
|
validation = {"accepted": False, "persist": False, "recognized": False,
|
|
"message": f"Could not validate `{st.new_model}`: {e}"}
|
|
|
|
if not validation.get("accepted"):
|
|
if not _config_declares_model(
|
|
st.new_model, st.target_provider, st.base_url, st.user_providers, st.custom_providers):
|
|
return st.fail(
|
|
validation.get("message", "Invalid model"),
|
|
new_model=st.new_model, target_provider=st.target_provider, provider_label=st.provider_label)
|
|
validation = {"accepted": True, "persist": True, "recognized": False, "message": validation.get("message", "")}
|
|
st.new_model = validation.get("corrected_model") or st.new_model
|
|
st.validation = validation
|
|
return None
|
|
|
|
|
|
def _copilot_api_mode(provider: str, model: str, api_key: str) -> str:
|
|
from hermes_cli.models import copilot_model_api_mode
|
|
return copilot_model_api_mode(model, api_key=api_key)
|
|
|
|
|
|
def _opencode_api_mode(provider: str, model: str, api_key: str) -> str:
|
|
# Re-derive api_mode from the effective model rather than the persisted api_mode: the opencode providers
|
|
# serve both anthropic_messages and chat_completions models, so the previous session's mode must not
|
|
# leak across /model switches. Refs #16878.
|
|
# opencode-zen/go must always re-derive api_mode from the target model (not the stale persisted
|
|
# api_mode), because the same provider serves both anthropic_messages (e.g. minimax-m2.7) and
|
|
# chat_completions (e.g. deepseek-v4-flash) and switching models via /model would otherwise carry the
|
|
# previous mode forward, stripping /v1 from base_url for chat_completions models and 404'ing. Refs
|
|
# #16878.
|
|
from hermes_cli.models import opencode_model_api_mode
|
|
return opencode_model_api_mode(provider, model)
|
|
|
|
|
|
def _nous_api_mode(provider: str, model: str, api_key: str) -> str:
|
|
# Portal serves anthropic/* on /v1/messages and everything else on /chat/completions;
|
|
# re-derive from the FINAL model so alias clears / empty fallbacks cannot leave Claude on the
|
|
# OpenAI wire.
|
|
from hermes_cli.providers import nous_api_mode
|
|
return nous_api_mode(model)
|
|
|
|
|
|
# Per-provider api_mode overrides applied after validation, keyed on the final target provider
|
|
# (the key sets are disjoint, so exactly one — or none — fires).
|
|
_PROVIDER_API_MODE_OVERRIDES: dict[str, Any] = {
|
|
**dict.fromkeys(("copilot", "github-copilot"), _copilot_api_mode),
|
|
**dict.fromkeys(("opencode-zen", "opencode-go", "opencode"), _opencode_api_mode),
|
|
**dict.fromkeys(("nous", "nous-portal", "nousresearch"), _nous_api_mode)}
|
|
|
|
|
|
def _build_switch_result(st: _Switch) -> ModelSwitchResult:
|
|
"""COMMON PATH part 3: final api_mode / base_url shaping, metadata, warnings."""
|
|
override = _PROVIDER_API_MODE_OVERRIDES.get(st.target_provider)
|
|
if override is not None:
|
|
st.api_mode = override(st.target_provider, st.new_model, st.api_key)
|
|
if not st.api_mode:
|
|
st.api_mode = determine_api_mode(st.target_provider, st.base_url, model=st.new_model)
|
|
|
|
# OpenCode base URLs end with /v1 for OpenAI-compatible models but the Anthropic SDK prepends
|
|
# its own /v1/messages: strip for anthropic_messages, re-append for
|
|
# chat_completions/codex_responses (mirrors resolve_runtime_provider).
|
|
from hermes_cli.models import normalize_opencode_base_url, opencode_provider_family
|
|
if opencode_provider_family(st.target_provider) is not None and isinstance(st.base_url, str):
|
|
st.base_url = normalize_opencode_base_url(st.target_provider, st.api_mode, st.base_url)
|
|
|
|
capabilities = get_model_capabilities(st.target_provider, st.new_model, allow_network=True)
|
|
from agent.native_compaction import resolve_native_compaction_capabilities
|
|
runtime_capabilities = resolve_native_compaction_capabilities(
|
|
model=st.new_model, base_url=st.base_url, provider=st.target_provider,
|
|
is_codex_backend=st.target_provider.strip().lower() == "openai-codex")
|
|
model_info = get_model_info(st.target_provider, st.new_model, allow_network=True)
|
|
|
|
warnings = [w for w in (st.validation.get("message"), _check_hermes_model_warning(st.new_model)) if w]
|
|
|
|
# Carry the switched provider's request_overrides (custom_providers ``extra_body`` such as
|
|
# chat_template_kwargs) so the gateway applies them like the default-provider path does.
|
|
request_overrides = None
|
|
try:
|
|
from hermes_cli.runtime_provider import _get_named_custom_provider, _custom_provider_request_overrides
|
|
cp_for_ro = _get_named_custom_provider(st.target_provider)
|
|
request_overrides = _custom_provider_request_overrides(cp_for_ro) or None if cp_for_ro else None
|
|
except Exception:
|
|
request_overrides = None
|
|
return ModelSwitchResult(
|
|
success=True, new_model=st.new_model, target_provider=st.target_provider,
|
|
provider_changed=st.provider_changed, api_key=st.api_key, base_url=st.base_url, api_mode=st.api_mode,
|
|
request_overrides=dict(request_overrides or {}), warning_message=" | ".join(warnings) if warnings else "",
|
|
provider_label=st.provider_label, resolved_via_alias=st.resolved_alias, capabilities=capabilities,
|
|
runtime_capabilities={
|
|
k: v for k, v in runtime_capabilities.items() if isinstance(k, str) and isinstance(v, bool)},
|
|
model_info=model_info, is_global=st.is_global)
|
|
|
|
|
|
def switch_model(
|
|
raw_input: str, current_provider: str, current_model: str, current_base_url: str = "",
|
|
current_api_key: str = "", is_global: bool = False, explicit_provider: str = "",
|
|
user_providers: dict = None, custom_providers: list | None = None) -> ModelSwitchResult:
|
|
"""Core model-switching pipeline shared between CLI and gateway.
|
|
|
|
Route (PATH A with ``--provider``, else PATH B) -> credentials -> validation -> result; each
|
|
step returns a failure :class:`ModelSwitchResult` to stop the chain, or ``None`` to continue.
|
|
``user_providers`` / ``custom_providers`` are the config.yaml ``providers:`` dict and
|
|
``custom_providers:`` list."""
|
|
st = _Switch(
|
|
raw_input=raw_input, current_provider=current_provider, current_model=current_model,
|
|
current_base_url=current_base_url, current_api_key=current_api_key, is_global=is_global,
|
|
explicit_provider=explicit_provider, user_providers=user_providers, custom_providers=custom_providers,
|
|
new_model=raw_input.strip(), target_provider=current_provider)
|
|
route = _route_explicit_provider if explicit_provider else _route_from_model_input
|
|
for step in (route, _resolve_switch_credentials, _validate_switch):
|
|
fail = step(st)
|
|
if fail is not None:
|
|
return fail
|
|
return _build_switch_result(st)
|
|
|
|
|
|
def _extra_headers_from_config(entry: Any) -> dict[str, str]:
|
|
if not isinstance(entry, dict):
|
|
return {}
|
|
from hermes_cli.config import normalize_extra_headers
|
|
return normalize_extra_headers(entry.get("extra_headers"))
|
|
|
|
|
|
def _scoped_key_env(name: str) -> str:
|
|
"""Read a provider key env var through the per-profile secret scope.
|
|
|
|
The multiplexed gateway installs a secret scope per turn; a raw ``os.environ`` read hands the
|
|
current profile whatever key happens to be in the process environment — another profile's.
|
|
Identical to ``os.getenv`` when multiplexing is off. A fail-closed ``UnscopedSecretError``
|
|
(multiplexing on, no scope installed) means "no credential visible for this profile here",
|
|
which is exactly how the picker already treats a missing key."""
|
|
try:
|
|
from agent.secret_scope import get_secret
|
|
return (get_secret(name, "") or "").strip() if name else ""
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
from typing import List # noqa: F401,E402
|
|
import http.client # noqa: F401,E402
|
|
import time # noqa: F401,E402
|
|
|
|
|
|
_PLUGIN_COMPAT_LAZY = {
|
|
'base_url_host_matches': ('utils', 'base_url_host_matches'),
|
|
'custom_provider_slug': ('hermes_cli.providers', 'custom_provider_slug'),
|
|
'list_picker_providers': ('hermes_cli.model_switch_providers', 'list_picker_providers'),
|
|
'prewarm_picker_cache_async': ('hermes_cli.model_switch_providers', 'prewarm_picker_cache_async'),
|
|
}
|
|
|
|
|
|
def __getattr__(name): # PEP 562 — lazy so no import cycles
|
|
target = _PLUGIN_COMPAT_LAZY.get(name)
|
|
if target is None:
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
import importlib
|
|
from hermes_cli.plugin_compat import warn_once
|
|
warn_once(__name__, name, *target)
|
|
return getattr(importlib.import_module(target[0]), target[1])
|
|
# ---- END PLUGIN-COMPAT ----
|