Files
hermes-agent/hermes_cli/providers.py
Hermes Agent 70f7fa05ca fix(providers): keep llamacpp as the model table's managed-runtime id
Mapping the llamacpp aliases to custom in hermes_cli.models sent the
managed local runtime's /model validation down the custom branch before
the staged-library check, so a downloaded-but-not-running GGUF lost its
recognized verdict and an unstaged one was accepted. Keep local and vllm
on custom there, leave the llamacpp aliases on their runtime id, and
move the orphaned 'local' display label to 'custom'.

The parity test now reads the aliases from the custom provider profile
instead of a hand-written list.
2026-09-25 14:25:16 -05:00

573 lines
33 KiB
Python

"""Single source of truth for provider identity in Hermes Agent."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from utils import base_url_host_matches, base_url_hostname
logger = logging.getLogger(__name__)
# -- Hermes overlay: metadata models.dev doesn't provide ----------------------
@dataclass(frozen=True)
class HermesOverlay:
"""Hermes-specific provider metadata layered on top of models.dev."""
transport: str = "openai_chat" # openai_chat | anthropic_messages | codex_responses
is_aggregator: bool = False
auth_type: str = "api_key" # api_key | oauth_device_code | oauth_external | external_process
extra_env_vars: Tuple[str, ...] = () # env vars models.dev doesn't list
base_url_override: str = "" # override if models.dev URL is wrong/missing
base_url_env_var: str = "" # env var for user-custom base URL
HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
"moa": HermesOverlay(auth_type="virtual", base_url_override="moa://local"),
"openrouter": HermesOverlay(is_aggregator=True, base_url_env_var="OPENROUTER_BASE_URL"),
"nous": HermesOverlay(auth_type="oauth_device_code", base_url_override="https://inference-api.nousresearch.com/v1"),
"openai-codex": HermesOverlay(transport="codex_responses", auth_type="oauth_external",
base_url_override="https://chatgpt.com/backend-api/codex"),
"openai-api": HermesOverlay(transport="codex_responses", base_url_override="https://api.openai.com/v1",
base_url_env_var="OPENAI_BASE_URL"),
"xai-oauth": HermesOverlay(transport="codex_responses", auth_type="oauth_external",
base_url_override="https://api.x.ai/v1", base_url_env_var="XAI_BASE_URL"),
"qwen-oauth": HermesOverlay(auth_type="oauth_external", base_url_override="https://portal.qwen.ai/v1",
base_url_env_var="HERMES_QWEN_BASE_URL"),
"lmstudio": HermesOverlay(extra_env_vars=("LM_API_KEY",), base_url_override="http://127.0.0.1:1234/v1",
base_url_env_var="LM_BASE_URL"),
"copilot-acp": HermesOverlay(transport="codex_responses", auth_type="external_process",
base_url_override="acp://copilot", base_url_env_var="COPILOT_ACP_BASE_URL"),
"github-copilot": HermesOverlay(extra_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN")),
"anthropic": HermesOverlay(transport="anthropic_messages", extra_env_vars=("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN")),
"zai": HermesOverlay(extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), base_url_env_var="GLM_BASE_URL"),
"kimi-for-coding": HermesOverlay(base_url_env_var="KIMI_BASE_URL"),
"stepfun": HermesOverlay(extra_env_vars=("STEPFUN_API_KEY",),
base_url_override="https://api.stepfun.ai/step_plan/v1",
base_url_env_var="STEPFUN_BASE_URL"),
"minimax": HermesOverlay(transport="anthropic_messages", base_url_env_var="MINIMAX_BASE_URL"),
"minimax-oauth": HermesOverlay(transport="anthropic_messages", auth_type="oauth_external",
base_url_override="https://api.minimax.io/anthropic"),
"minimax-cn": HermesOverlay(transport="anthropic_messages", base_url_env_var="MINIMAX_CN_BASE_URL"),
"deepseek": HermesOverlay(base_url_env_var="DEEPSEEK_BASE_URL"),
"alibaba": HermesOverlay(base_url_env_var="DASHSCOPE_BASE_URL"),
"alibaba-coding-plan": HermesOverlay(base_url_env_var="ALIBABA_CODING_PLAN_BASE_URL"),
"vercel": HermesOverlay(is_aggregator=True),
"opencode": HermesOverlay(is_aggregator=True, base_url_env_var="OPENCODE_ZEN_BASE_URL"),
"opencode-go": HermesOverlay(is_aggregator=True, base_url_env_var="OPENCODE_GO_BASE_URL"),
"kilo": HermesOverlay(is_aggregator=True, base_url_env_var="KILOCODE_BASE_URL"),
"huggingface": HermesOverlay(is_aggregator=True, base_url_env_var="HF_BASE_URL"),
"novita": HermesOverlay(is_aggregator=True, base_url_env_var="NOVITA_BASE_URL"),
"xai": HermesOverlay(transport="codex_responses", base_url_override="https://api.x.ai/v1", base_url_env_var="XAI_BASE_URL"),
"nvidia": HermesOverlay(base_url_override="https://integrate.api.nvidia.com/v1", base_url_env_var="NVIDIA_BASE_URL"),
"xiaomi": HermesOverlay(base_url_env_var="XIAOMI_BASE_URL"),
"tencent-tokenhub": HermesOverlay(base_url_env_var="TOKENHUB_BASE_URL"),
"tencent-tokenplan": HermesOverlay(transport="anthropic_messages",
base_url_override="https://api.lkeap.cloud.tencent.com/plan/anthropic",
base_url_env_var="TOKENPLAN_BASE_URL"),
"arcee": HermesOverlay(base_url_override="https://api.arcee.ai/api/v1", base_url_env_var="ARCEE_BASE_URL"),
"gmi": HermesOverlay(extra_env_vars=("GMI_API_KEY",), base_url_override="https://api.gmi-serving.com/v1",
base_url_env_var="GMI_BASE_URL"),
"fireworks": HermesOverlay(extra_env_vars=("FIREWORKS_API_KEY",),
base_url_override="https://api.fireworks.ai/inference/v1"),
"actual": HermesOverlay(transport="chat_completions", extra_env_vars=("ACTUAL_API_KEY",),
base_url_override="https://api.actual.inc/v1", base_url_env_var="ACTUAL_BASE_URL"),
"upstage": HermesOverlay(extra_env_vars=("UPSTAGE_API_KEY",), base_url_override="https://api.upstage.ai/v1",
base_url_env_var="UPSTAGE_BASE_URL"),
"nebius-token-factory": HermesOverlay(extra_env_vars=("NEBIUS_API_KEY", "NEBIUS_TOKEN_FACTORY_API_KEY"),
base_url_override="https://api.tokenfactory.nebius.com/v1",
base_url_env_var="NEBIUS_BASE_URL"),
"ollama-cloud": HermesOverlay(base_url_override="https://ollama.com/v1", base_url_env_var="OLLAMA_BASE_URL"),
# Azure Foundry serves OpenAI- and Anthropic-style endpoints; transport comes from model.api_mode.
"azure-foundry": HermesOverlay(base_url_env_var="AZURE_FOUNDRY_BASE_URL"),
"bedrock": HermesOverlay(transport="bedrock_converse", auth_type="aws_sdk"),
# Vertex is OAuth2 (service-account JSON / ADC), resolved by agent/vertex_adapter.py. Without an
# overlay get_provider("vertex") is None and auxiliary_client._preserve_provider_with_base_url
# would treat a Vertex MoA slot as an unknown custom endpoint, losing the identity
# _refresh_provider_credentials() needs to re-mint an expired token on 401.
"vertex": HermesOverlay(auth_type="vertex"),
}
# -- Resolved provider -------------------------------------------------------
@dataclass
class ProviderDef:
"""Complete provider definition — merged from models.dev + overlay + user config."""
id: str
name: str
transport: str # openai_chat | anthropic_messages | codex_responses
api_key_env_vars: Tuple[str, ...] # all env vars to check for API key
base_url: str = ""
base_url_env_var: str = ""
is_aggregator: bool = False
auth_type: str = "api_key"
doc: str = ""
source: str = "" # "models.dev", "hermes", "user-config"
# -- Aliases: human-friendly / legacy names grouped by canonical (models.dev where possible) id;
# ``ALIASES`` is the inverted lookup table. ---------------------------------------------------
_ALIAS_GROUPS: Dict[str, Tuple[str, ...]] = {
"openrouter": ("openai",), "zai": ("glm", "z-ai", "z.ai", "zhipu"), "xai": ("x-ai", "x.ai", "grok"),
"xai-oauth": ("grok-oauth", "xai-oauth", "x-ai-oauth", "xai-grok-oauth"),
"nvidia": ("nim", "nvidia-nim", "build-nvidia", "nemotron"),
"kimi-for-coding": ("kimi", "kimi-coding", "kimi-coding-cn", "moonshot"),
"stepfun": ("step", "stepfun-coding-plan"), "minimax-cn": ("minimax-china", "minimax_cn"),
"anthropic": ("claude", "claude-code"), "github-copilot": ("copilot", "github"),
"copilot-acp": ("github-copilot-acp",), "openai-codex": ("chatgpt", "chatgpt-codex"),
"vercel": ("ai-gateway", "aigateway", "vercel-ai-gateway"),
"opencode": ("opencode-zen", "zen"), "opencode-go": ("go", "opencode-go-sub"), "kilo": ("kilocode", "kilo-code", "kilo-gateway"),
"deepseek": ("deep-seek",), "alibaba": ("dashscope", "aliyun", "qwen", "alibaba-cloud"),
"alibaba-coding-plan": ("alibaba_coding", "alibaba-coding", "alibaba_coding_plan"),
"huggingface": ("hf", "hugging-face", "huggingface-hub"), "novita": ("novita-ai", "novitaai"),
"xiaomi": ("mimo", "xiaomi-mimo"), "tencent-tokenhub": ("tencent", "tokenhub", "tencent-cloud", "tencentmaas"),
"tencent-tokenplan": ("tokenplan", "tencent-lkeap"),
"bedrock": ("aws", "aws-bedrock", "amazon-bedrock", "amazon"), "arcee": ("arcee-ai", "arceeai"),
"gmi": ("gmi-cloud", "gmicloud"), "fireworks": ("fireworks-ai", "fw"), "upstage": ("solar",),
"actual": ("actual-computer", "actualcomputer", "aci"),
"nebius-token-factory": ("nebius", "nebius-tokenfactory", "nebius-tf", "token-factory", "tokenfactory"),
"lmstudio": ("lmstudio", "lm-studio", "lm_studio"),
# Local OpenAI-compatible servers route through the generic "custom" provider,
# matching hermes_cli.auth and hermes_cli.models so every layer agrees. Issue #62213.
"custom": ("ollama", "local", "vllm", "llamacpp", "llama.cpp", "llama-cpp"),
}
ALIASES: Dict[str, str] = {alias: canon for canon, aliases in _ALIAS_GROUPS.items() for alias in aliases}
# -- Display labels for providers not in the models.dev catalog ---------------
_LABEL_OVERRIDES: Dict[str, str] = {
"moa": "Mixture of Agents", "nous": "Nous Portal", "openai-codex": "ChatGPT or Codex Subscription",
"copilot-acp": "GitHub Copilot ACP", "stepfun": "StepFun Step Plan", "xiaomi": "Xiaomi MiMo", "gmi": "GMI Cloud",
"upstage": "Upstage Solar", "actual": "Actual Computer", "tencent-tokenhub": "Tencent TokenHub",
"nebius-token-factory": "Nebius Token Factory", "tencent-tokenplan": "Tencent TokenPlan", "lmstudio": "LM Studio",
"custom": "Custom endpoint", "bedrock": "AWS Bedrock", "vertex": "Google Vertex AI", "ollama-cloud": "Ollama Cloud",
"xai-oauth": "xAI Grok OAuth (SuperGrok / Premium+)",
}
# -- Transport → API mode mapping ---------------------------------------------
TRANSPORT_TO_API_MODE: Dict[str, str] = {
"openai_chat": "chat_completions", "anthropic_messages": "anthropic_messages",
"codex_responses": "codex_responses", "bedrock_converse": "bedrock_converse",
}
# -- Helper functions ---------------------------------------------------------
def normalize_provider(name: str) -> str:
"""Resolve aliases and normalise casing to a canonical provider id."""
key = name.strip().lower()
return ALIASES.get(key, key)
def is_actual_route(provider: str = "", base_url: str = "") -> bool:
"""Identify Actual by provider/alias or its hosted endpoint, including custom routes."""
return (
normalize_provider(provider or "") == "actual"
or base_url_hostname(base_url) == "api.actual.inc"
)
def _models_dev_info(canonical: str, allow_network: bool = True):
"""models.dev entry or None. Single-arg call on the default path: test sites monkeypatch
``get_provider_info`` with single-arg lambdas."""
try:
from agent.models_dev import get_provider_info as _mdev_provider
return _mdev_provider(canonical) if allow_network else _mdev_provider(canonical, allow_network=False)
except Exception:
return None
def _overlay_pdef(canonical, ov: HermesOverlay, name, env_vars, base_url, doc, source) -> ProviderDef:
return ProviderDef(id=canonical, name=name, transport=ov.transport, api_key_env_vars=env_vars, base_url=base_url,
base_url_env_var=ov.base_url_env_var, is_aggregator=ov.is_aggregator, auth_type=ov.auth_type, doc=doc,
source=source)
def get_provider(name: str, *, allow_network: bool = True) -> Optional[ProviderDef]:
"""Look up a built-in provider by id or alias: models.dev catalog merged with the Hermes overlay;
Hermes-only overlay (nous, openai-codex, …); plugin provider profiles with a concrete endpoint."""
canonical = normalize_provider(name)
mdev_info = _models_dev_info(canonical, allow_network)
overlay = HERMES_OVERLAYS.get(canonical)
if mdev_info is not None:
ov = overlay or HermesOverlay()
env_vars = list(mdev_info.env)
for ev in ov.extra_env_vars:
if ev not in env_vars:
env_vars.append(ev)
return _overlay_pdef(canonical, ov, mdev_info.name, tuple(env_vars), ov.base_url_override or mdev_info.api,
mdev_info.doc, "models.dev")
if overlay is not None:
return _overlay_pdef(canonical, overlay, _LABEL_OVERRIDES.get(canonical, canonical), overlay.extra_env_vars,
overlay.base_url_override, "", "hermes")
# Plugin-registered profiles (plugins/model-providers/<name>/) absent from models.dev and
# HERMES_OVERLAYS would otherwise be "Unknown provider" in /model, --provider and model-switch
# even though the picker lists them. Only profiles with a literal or env-configured endpoint
# resolve at this rung: placeholder profiles like ``custom`` (aliases ollama/local/vllm) ship
# an empty base_url and are completed by config.yaml custom_providers — resolving them would
# preempt resolve_provider_full's custom step and collapse keyed ``custom:<name>`` ids to bare
# custom. Profiles whose endpoint is minted at runtime resolve at the END of
# resolve_provider_full, after every user-configured rung.
pdef = _plugin_profile_pdef(canonical)
if pdef is None or not (pdef.base_url or (pdef.auth_type == "api_key" and pdef.api_key_env_vars and pdef.base_url_env_var)):
return None
return pdef
def _plugin_profile_pdef(name: str) -> Optional[ProviderDef]:
"""The registered ``ProviderProfile`` for *name* (or one of its aliases) as a ProviderDef; the
id is the profile's canonical name so an alias switch persists and resolves credentials under
the same identity as the profile itself. URL-shaped env vars are the endpoint, not the key."""
try:
from providers import get_provider_profile as _profile
prof = _profile(name)
except Exception:
return None
if prof is None:
return None
env_vars = tuple(prof.env_vars or ())
url_vars = tuple(v for v in env_vars if v.endswith(("_BASE_URL", "_URL")))
key_vars = tuple(v for v in env_vars if v not in url_vars)
api_mode_to_transport = {v: k for k, v in TRANSPORT_TO_API_MODE.items()}
# A mode outside the reverse table is a plugin-registered dialect: keep its name so
# ``determine_api_mode`` can check the transport registry instead of degrading it.
mode = (prof.api_mode or "").strip()
return ProviderDef(id=prof.name, name=prof.display_name or prof.name or name,
transport=api_mode_to_transport.get(mode, mode or "openai_chat"),
api_key_env_vars=key_vars, base_url=(prof.base_url or "").strip(),
base_url_env_var=next(iter(url_vars), ""),
auth_type=prof.auth_type or "api_key", source="plugin-profile")
def get_label(provider_id: str) -> str:
"""Human-readable display name: label override, else models.dev name, else the id."""
canonical = normalize_provider(provider_id)
if canonical in _LABEL_OVERRIDES:
return _LABEL_OVERRIDES[canonical]
pdef = get_provider(canonical)
return pdef.name if pdef else canonical
def is_aggregator(provider: str) -> bool:
"""Return True when the provider is a multi-model aggregator."""
provider_norm = normalize_provider(provider or "")
if provider_norm.startswith("custom:"):
return True
pdef = get_provider(provider_norm)
return pdef.is_aggregator if pdef else False
# Flat-namespace resellers (opencode-go, opencode-zen) are flagged ``is_aggregator=True`` because
# their live ``/v1/models`` returns bare model IDs ("deepseek-v4-flash") rather than
# ``vendor/model`` routing slugs — model_switch searches their flat catalog on that flag. But they
# are NOT routing aggregators: every listed model is first-party under their own subscription, so
# picker dedup (build_models_payload) must not strip a reseller's "minimax-m3" just because a
# user's custom proxy serves a same-named model. Normalized ids: "opencode-zen" -> "opencode".
_FLAT_NAMESPACE_RESELLERS: frozenset[str] = frozenset({"opencode-go", "opencode"})
def is_routing_aggregator(provider: str) -> bool:
"""True only for TRUE routing aggregators (OpenRouter, named ``custom:*`` proxies) — excludes
flat-namespace resellers whose catalog is first-party. Use for "would selecting this model
silently re-route away from the intended provider?" (picker dedup)."""
provider_norm = normalize_provider(provider or "")
if provider_norm in _FLAT_NAMESPACE_RESELLERS:
return False
return is_aggregator(provider_norm)
def is_official_openai_host(base_url: str) -> bool:
"""True when *base_url* points at OpenAI's official API host family. Hostname-parsed matching
only — never substring — so lookalike hosts (``api.openai.com.attacker.test``) and path-segment
spoofs (``proxy.test/api.openai.com/v1``) are rejected; a genuine ``*.api.openai.com``
subdomain requires control of openai.com DNS.
A genuine ``*.api.openai.com`` subdomain requires control of openai.com DNS, so the dot-suffix match
does not reopen the #32243 spoofing hole. Delegates to ``utils.base_url_host_matches``, which owns the
exact-or-dot-suffix hostname contract (userinfo/port stripped, lowercased, trailing dot removed) — one
implementation, not two.
"""
return base_url_host_matches(base_url, "api.openai.com")
# Exact hostnames that are Responses-API-native: api.meta.ai only achieves prompt-cache hits on
# Responses with prompt_cache_retention (chat/completions stays cache-cold); api.router.com (Ramp
# Router) keeps reasoning validation/summaries and prompt caching on /v1/responses and serves
# /v1/chat/completions as a minimal shim.
_RESPONSES_NATIVE_HOSTS: frozenset[str] = frozenset({"api.meta.ai", "api.router.com"})
def host_mandated_api_mode(base_url: str = "") -> Optional[str]:
"""Return the wire protocol a specific endpoint *requires*, or None. Some hosts accept exactly
one API mode (api.openai.com 400s chat/completions for reasoning models with tools); these are
*mandatory*: a session carrying a stale api_mode (a /model switch that kept the previous
provider's ``chat_completions``) must be overridden, not merely filled in when empty.
Exact-hostname matching only — never substring — so lookalike hosts and path-segment spoofs are
not treated as the real endpoint."""
if not base_url:
return None
url_lower = base_url.rstrip("/").lower()
hostname = base_url_hostname(base_url)
if hostname == "api.actual.inc":
return "chat_completions"
# Exact-hostname matching only — never bare substring — so lookalike hosts
# (api.openai.com.attacker.test) and path-segment spoofs (proxy.test/api.openai.com/v1) are NOT treated
# as the real endpoint. (#32243)
if hostname == "api.kimi.com" and "/coding" in url_lower:
return "anthropic_messages"
if hostname == "api.anthropic.com" or url_lower.endswith("/anthropic"):
return "anthropic_messages"
# Official OpenAI host family (canonical + us./eu. data-residency hosts) mandates Responses;
# the shared predicate keeps this in lockstep with catalog filtering and listing authority.
if is_official_openai_host(base_url) or hostname in _RESPONSES_NATIVE_HOSTS:
# Ramp Router (api.router.com) is Responses-native: reasoning-effort validation, reasoning
# summaries, and prompt caching live on /v1/responses, and /v1/chat/completions is only a minimal
# compatibility shim (docs.router.com/api/endpoint). Exact-hostname match per #32243.
return "codex_responses"
if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"):
return "bedrock_converse"
return None
def nous_api_mode(model: str = "") -> str:
"""Wire protocol for a Nous Portal model. Portal serves its ``anthropic/*`` catalog on a native
Messages route alongside OpenAI-compatible chat/completions for everything else.
``anthropic/*`` rides chat/completions by default for now (``nous.anthropic_wire``). Measured
2026-09-06, 20 concurrent sessions x 6 tool calls on Fable 5.1, same account and hour: the
native route re-wrote the previous turn on 14-20% of consecutive calls (4 runs; the cache read
stopped at the prior breakpoint with byte-identical prefixes), chat/completions 0 of 320 pairs.
That is 15-20% of a fan-out's cache-write bill. The cause is inside the portal's native route
(NousResearch/api#227 carries the diagnostics); flip the default back to ``native`` when it is
fixed. Cost of ``chat``: prior-turn thinking travels as OpenAI-style reasoning fields instead of
signed native blocks, and cache_control scopes are translated by the portal's adapter.
Empty/unknown model defaults to ``chat_completions`` (the historical Nous transport)."""
if str(model or "").strip().lower().startswith("anthropic/"):
# ``auto`` starts on chat too: it is safe on every upstream, and ``agent/nous_wire.py``
# promotes the session to native from the first response when the upstream allows it.
return "anthropic_messages" if _nous_anthropic_wire() == "native" else "chat_completions"
return "chat_completions"
def _nous_anthropic_wire() -> str:
"""``nous.anthropic_wire``: ``"chat"`` (default), ``"native"``, or ``"auto"`` (chat, then per-session
promotion decided from the first response; see ``agent/nous_wire.py``). Anything else reads as ``chat``."""
try:
from hermes_cli.config import load_config_readonly
value = str(((load_config_readonly().get("nous") or {}).get("anthropic_wire")) or "chat").strip().lower()
except Exception:
return "chat"
return value if value in ("native", "auto") else "chat"
def determine_api_mode(provider: str, base_url: str = "", model: str = "") -> str:
"""API mode (wire protocol) for a provider/endpoint: host-mandated mode, then Nous dual-wire
(model-derived — the overlay alone says openai_chat and would pin Claude on the wrong wire),
then the known provider's transport, then bedrock, else ``chat_completions``."""
if is_actual_route(provider, base_url):
return "chat_completions"
mandated = host_mandated_api_mode(base_url)
if mandated is not None:
return mandated
if (provider or "").strip().lower() in {"nous", "nous-portal", "nousresearch"}:
return nous_api_mode(model)
pdef = get_provider(provider)
if pdef is not None:
if pdef.transport in TRANSPORT_TO_API_MODE:
return TRANSPORT_TO_API_MODE[pdef.transport]
# A plugin profile's transport IS its api_mode when a plugin registered that dialect.
from agent.transports import registered_api_modes
return pdef.transport if pdef.transport in registered_api_modes() else "chat_completions"
if provider == "bedrock":
return "bedrock_converse"
return "chat_completions"
# -- Provider from user config ------------------------------------------------
def _user_pdef(pid: str, name: str, base_url: str, key_env: str, transport: str = "openai_chat") -> ProviderDef:
"""``source="user-config"`` ProviderDef shared by ``providers:`` and ``custom_providers:`` entries."""
return ProviderDef(id=pid, name=name, transport=transport, api_key_env_vars=(key_env,) if key_env else (),
base_url=base_url, is_aggregator=False, auth_type="api_key", source="user-config")
def resolve_user_provider(name: str, user_config: Dict[str, Any]) -> Optional[ProviderDef]:
"""Resolve a provider from the user's config.yaml ``providers:`` section."""
entry = user_config.get(name) if isinstance(user_config, dict) and user_config else None
if not isinstance(entry, dict):
return None
return _user_pdef(name, entry.get("name", "") or name,
entry.get("api", "") or entry.get("url", "") or entry.get("base_url", "") or "",
entry.get("key_env") or entry.get("api_key_env") or "",
entry.get("transport", "openai_chat") or "openai_chat")
def custom_provider_slug(display_name: str, provider_key: str = "") -> str:
"""Stable ``custom:`` identity for a configured provider: keyed ``providers:`` entries use their
config key (survives display-name changes); legacy ``custom_providers:`` entries have no key,
so their normalized display name is the identity."""
identity = str(provider_key or "").strip() or str(display_name or "").strip()
normalized = identity.lower().replace(" ", "-")
return normalized if normalized.startswith("custom:") else f"custom:{normalized}"
def custom_provider_aliases(display_name: str, provider_key: str = "") -> frozenset[str]:
"""Return every current and legacy identity accepted for one endpoint."""
aliases: set[str] = set()
for value in (display_name, provider_key):
raw = str(value or "").strip().lower()
if not raw:
continue
normalized = raw.replace(" ", "-")
aliases.update({raw, normalized, custom_provider_slug(normalized)})
if normalized.startswith("custom:"):
suffix = normalized.split(":", 1)[1]
if suffix:
aliases.update({suffix, f"custom:{normalized}"})
return frozenset(aliases)
def resolve_custom_provider(name: str, custom_providers: Optional[List[Dict[str, Any]]]) -> Optional[ProviderDef]:
"""Resolve a provider from the user's config.yaml ``custom_providers`` list. A stored bare
``"custom"`` (corrupt state from a prior model-switch bug) falls back to the first valid entry
so existing configs self-heal."""
requested = (name or "").strip().lower()
if not requested or not custom_providers or not isinstance(custom_providers, list):
return None
first_valid: Optional[ProviderDef] = None
# If the stored provider is the bare string "custom" (corrupt state from a prior model-switch bug), fall
# back to the first custom provider entry so existing configs self-heal. (GH #17478)
for entry in custom_providers:
if not isinstance(entry, dict):
continue
display_name = (entry.get("name") or "").strip()
api_url = (entry.get("base_url", "") or entry.get("url", "") or entry.get("api", "") or "").strip()
if not display_name or not api_url:
continue
provider_key = (entry.get("provider_key") or "").strip()
pdef = _user_pdef(custom_provider_slug(display_name, provider_key), display_name, api_url,
(entry.get("key_env") or "").strip())
if first_valid is None:
first_valid = pdef
if requested in custom_provider_aliases(display_name, provider_key):
return pdef
if requested == "custom" and first_valid:
return first_valid
return None
def _lossy_alias_registry_pdef(raw: str, canonical: str) -> Optional[ProviderDef]:
"""Exact Hermes registry ids win over LOSSY alias collapsing (kimi-coding-cn must stay distinct
from kimi-coding instead of collapsing through the shared models.dev alias "kimi-for-coding").
A collapse is lossy only when MULTIPLE registry providers normalize to the same canonical name;
single-entry rewrites ("copilot" -> "github-copilot") are correct routing and keep resolving
through the built-in chain so overlay transports apply."""
try:
from hermes_cli.auth import PROVIDER_REGISTRY as _AUTH_PROVIDER_REGISTRY
_pcfg = _AUTH_PROVIDER_REGISTRY.get(raw)
if _pcfg is None:
return None
if sum(1 for _rid in _AUTH_PROVIDER_REGISTRY if normalize_provider(_rid) == canonical) > 1:
return ProviderDef(id=_pcfg.id, name=_pcfg.name, transport="openai_chat",
api_key_env_vars=tuple(_pcfg.api_key_env_vars or ()), base_url=_pcfg.inference_base_url or "",
source="hermes-auth-registry")
except Exception:
pass
return None
# The local llama.cpp runtime's provider id + aliases: ONE definition, shared by the resolver rung
# below and the picker's Local row (``hermes_cli/inventory.py``) — the two drifting apart is what
# made the row's own id unresolvable.
LLAMACPP_PROVIDER_ID = "llamacpp"
LLAMACPP_ALIASES: Tuple[str, ...] = (LLAMACPP_PROVIDER_ID, "llama.cpp", "llama-cpp")
def _has_staged_local_models() -> bool:
"""True when GGUFs are staged under the Hermes home's ``models/`` — the model the picker's Local
row offers, which the runtime seam serves by booting/attaching a server on selection."""
try:
from hermes_cli.local_runtime.bootstrap import staged_model_ids
return bool(staged_model_ids())
except Exception:
return False
def _llamacpp_pdef() -> Optional[ProviderDef]:
"""The llamacpp aliases are a real provider whenever the managed server (or a detected external
one) resolves — reachability is the credential — OR a model is staged for the runtime to serve.
The picker's Local row is built from staged GGUFs and is deliberately offline-first (selection
starts the server through the runtime seam), so requiring a live endpoint before admitting the id
made that row offer a provider the resolver rejected ("Unknown provider 'llamacpp'"). Without
this rung model-switch rejected the very provider the Local Models 'Use' flow writes to config."""
try:
from hermes_cli.config import load_config_readonly
from hermes_cli.local_runtime.endpoint import resolve_llamacpp_endpoint
endpoint = resolve_llamacpp_endpoint(config=load_config_readonly(), wait_for_boot_s=0)
except Exception:
endpoint = None
if not endpoint and not _has_staged_local_models():
return None
return ProviderDef(id=LLAMACPP_PROVIDER_ID, name="Local", transport="openai_chat", api_key_env_vars=(),
base_url=(endpoint or {}).get("base_url", ""), source="local-runtime")
def resolve_provider_full(name: str, user_providers: Optional[Dict[str, Any]] = None,
custom_providers: Optional[List[Dict[str, Any]]] = None) -> Optional[ProviderDef]:
"""Full resolution chain: user ``providers.<raw name>`` -> lossy-alias registry id -> built-in
(models.dev + overlays) -> user providers (canonical, then raw) -> ``custom_providers`` ->
managed llamacpp -> models.dev directly. User-defined ``providers.<name>`` is tried FIRST on
the raw (pre-alias) name: a configured ``providers.openai`` pointing at api.openai.com must not
be hijacked by the legacy "openai" -> "openrouter" alias."""
canonical = normalize_provider(name)
raw = name.strip().lower()
if user_providers:
user_pdef = resolve_user_provider(raw, user_providers)
if user_pdef is not None:
return user_pdef
if canonical != raw:
pdef = _lossy_alias_registry_pdef(raw, canonical)
if pdef is not None:
return pdef
pdef = get_provider(canonical)
if pdef is not None:
if pdef.source == "plugin-profile" and user_providers:
user_pdef = resolve_user_provider(pdef.id, user_providers)
if user_pdef is not None:
return user_pdef
return pdef
if user_providers:
for candidate in (canonical, raw):
user_pdef = resolve_user_provider(candidate, user_providers)
if user_pdef is not None:
return user_pdef
custom_pdef = resolve_custom_provider(name, custom_providers)
if custom_pdef is not None:
return custom_pdef
if raw in LLAMACPP_ALIASES:
pdef = _llamacpp_pdef()
if pdef is not None:
return pdef
try:
mdev_info = _models_dev_info(canonical)
if mdev_info is not None:
return ProviderDef(id=canonical, name=mdev_info.name, transport="openai_chat", api_key_env_vars=mdev_info.env,
base_url=mdev_info.api, source="models.dev")
except Exception:
pass
# Plugin profiles whose endpoint is minted at runtime (empty base_url, e.g. a token exchange
# that also returns the host) are still real providers: /model --provider, the model picker
# and `hermes model` must not reject them as unknown. Last rung, so every user-configured
# entry above wins; the bare ``custom`` placeholder is excluded because model-switch completes
# it from the current endpoint (see get_provider).
pdef = _plugin_profile_pdef(canonical)
return pdef if pdef is not None and pdef.id != "custom" else None