Something went wrong. Try again.
The agent that grows with you
Something went wrong. Try again.
16 kB · 465 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466"""Per-provider model name normalization.
Different LLM providers expect model identifiers in different formats:
- **Aggregators** (OpenRouter, Nous, AI Gateway, Kilo Code) need ``vendor/model`` slugs like ``anthropic/claude-sonnet-4.6``.- **Anthropic** native API expects bare names with dots replaced by hyphens: ``claude-sonnet-4-6``.- **Copilot** expects bare names *with* dots preserved: ``claude-sonnet-4.6``.- **OpenCode Zen** preserves dots for GPT/GLM/Gemini/Kimi/MiniMax-style model IDs, but Claude still uses hyphenated native names like ``claude-sonnet-4-6``.- **OpenCode Go** preserves dots in model names: ``minimax-m2.7``.- **DeepSeek** accepts ``deepseek-chat`` (V3), ``deepseek-reasoner`` (R1-family), and the first-class V-series IDs (``deepseek-v4-pro``, ``deepseek-v4-flash``, and any future ``deepseek-v<N>-*``). Older Hermes revisions folded every non-reasoner input into ``deepseek-chat``, which on aggregators routes to V3 — so a user picking V4 Pro was silently downgraded.- **Custom** and remaining providers pass the name through as-is.
This module centralises that translation so callers can simply write::
api_model = normalize_model_for_provider(user_input, provider)
Inspired by Clawdbot's ``normalizeAnthropicModelId`` pattern."""
from __future__ import annotations
import refrom typing import Optional
# ---------------------------------------------------------------------------# Vendor prefix mapping# ---------------------------------------------------------------------------# Maps the first hyphen-delimited token of a bare model name to the vendor# slug used by aggregator APIs (OpenRouter, Nous, etc.).## Example: "claude-sonnet-4.6" -> first token "claude" -> vendor "anthropic"# -> aggregator slug: "anthropic/claude-sonnet-4.6"
_VENDOR_PREFIXES: dict[str, str] = { "claude": "anthropic", "gpt": "openai", "o1": "openai", "o3": "openai", "o4": "openai", "gemini": "google", "gemma": "google", "deepseek": "deepseek", "glm": "z-ai", "kimi": "moonshotai", "minimax": "minimax", "grok": "x-ai", "qwen": "qwen", "mimo": "xiaomi", "trinity": "arcee-ai", "nemotron": "nvidia", "llama": "meta-llama", "step": "stepfun", "trinity": "arcee-ai",}
# Providers whose APIs consume vendor/model slugs._AGGREGATOR_PROVIDERS: frozenset[str] = frozenset({ "openrouter", "nous", "ai-gateway", "kilocode",})
# Providers that want bare names with dots replaced by hyphens._DOT_TO_HYPHEN_PROVIDERS: frozenset[str] = frozenset({ "anthropic",})
# Providers that want bare names with dots preserved._STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({ "copilot", "copilot-acp", "openai-codex",})
# Providers whose native naming is authoritative -- pass through unchanged._AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({ "gemini", "huggingface",})
# Direct providers that accept bare native names but should repair a matching# provider/ prefix when users copy the aggregator form into config.yaml._MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({ "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "alibaba", "qwen-oauth", "xiaomi", "arcee", "ollama-cloud", "custom",})
# Providers whose APIs require lowercase model IDs. Xiaomi's# ``api.xiaomimimo.com`` rejects mixed-case names like ``MiMo-V2.5-Pro``# that users might copy from marketing docs — it only accepts# ``mimo-v2.5-pro``. After stripping a matching provider prefix, these# providers also get ``.lower()`` applied._LOWERCASE_MODEL_PROVIDERS: frozenset[str] = frozenset({ "xiaomi",})
# ---------------------------------------------------------------------------# DeepSeek special handling# ---------------------------------------------------------------------------# DeepSeek's API only recognises exactly two model identifiers. We map# common aliases and patterns to the canonical names.
_DEEPSEEK_REASONER_KEYWORDS: frozenset[str] = frozenset({ "reasoner", "r1", "think", "reasoning", "cot",})
_DEEPSEEK_CANONICAL_MODELS: frozenset[str] = frozenset({ "deepseek-chat", # V3 on DeepSeek direct and most aggregators "deepseek-reasoner", # R1-family reasoning model "deepseek-v4-pro", # V4 Pro — first-class model ID "deepseek-v4-flash", # V4 Flash — first-class model ID})
# First-class V-series IDs (``deepseek-v4-pro``, ``deepseek-v4-flash``,# future ``deepseek-v5-*``, dated variants like ``deepseek-v4-flash-20260423``).# Verified empirically 2026-04-24: DeepSeek's Chat Completions API returns# ``provider: DeepSeek`` / ``model: deepseek-v4-flash-20260423`` when called# with ``model=deepseek/deepseek-v4-flash``, so these names are not aliases# of ``deepseek-chat`` and must not be folded into it._DEEPSEEK_V_SERIES_RE = re.compile(r"^deepseek-v\d+([-.].+)?$")
def _normalize_for_deepseek(model_name: str) -> str: """Map a model input to a DeepSeek-accepted identifier.
Rules: - Already a known canonical (``deepseek-chat``/``deepseek-reasoner``/ ``deepseek-v4-pro``/``deepseek-v4-flash``) -> pass through. - Matches the V-series pattern ``deepseek-v<digit>...`` -> pass through (covers future ``deepseek-v5-*`` and dated variants without a release). - Contains a reasoner keyword (r1, think, reasoning, cot, reasoner) -> ``deepseek-reasoner``. - Everything else -> ``deepseek-chat``.
Args: model_name: The bare model name (vendor prefix already stripped).
Returns: A DeepSeek-accepted model identifier. """ bare = _strip_vendor_prefix(model_name).lower()
if bare in _DEEPSEEK_CANONICAL_MODELS: return bare
# V-series first-class IDs (v4-pro, v4-flash, future v5-*, dated variants) if _DEEPSEEK_V_SERIES_RE.match(bare): return bare
# Check for reasoner-like keywords anywhere in the name for keyword in _DEEPSEEK_REASONER_KEYWORDS: if keyword in bare: return "deepseek-reasoner"
return "deepseek-chat"
# ---------------------------------------------------------------------------# Helper utilities# ---------------------------------------------------------------------------
def _strip_vendor_prefix(model_name: str) -> str: """Remove a ``vendor/`` prefix if present.
Examples::
>>> _strip_vendor_prefix("anthropic/claude-sonnet-4.6") 'claude-sonnet-4.6' >>> _strip_vendor_prefix("claude-sonnet-4.6") 'claude-sonnet-4.6' >>> _strip_vendor_prefix("meta-llama/llama-4-scout") 'llama-4-scout' """ if "/" in model_name: return model_name.split("/", 1)[1] return model_name
def _dots_to_hyphens(model_name: str) -> str: """Replace dots with hyphens in a model name.
Anthropic's native API uses hyphens where marketing names use dots: ``claude-sonnet-4.6`` -> ``claude-sonnet-4-6``. """ return model_name.replace(".", "-")
def _normalize_provider_alias(provider_name: str) -> str: """Resolve provider aliases to Hermes' canonical ids.""" raw = (provider_name or "").strip().lower() if not raw: return raw try: from hermes_cli.models import normalize_provider
return normalize_provider(raw) except Exception: return raw
def _strip_matching_provider_prefix(model_name: str, target_provider: str) -> str: """Strip ``provider/`` only when the prefix matches the target provider.
This prevents arbitrary slash-bearing model IDs from being mangled on native providers while still repairing manual config values like ``zai/glm-5.1`` for the ``zai`` provider. """ if "/" not in model_name: return model_name
prefix, remainder = model_name.split("/", 1) if not prefix.strip() or not remainder.strip(): return model_name
normalized_prefix = _normalize_provider_alias(prefix) normalized_target = _normalize_provider_alias(target_provider) if normalized_prefix and normalized_prefix == normalized_target: return remainder.strip() return model_name
def detect_vendor(model_name: str) -> Optional[str]: """Detect the vendor slug from a bare model name.
Uses the first hyphen-delimited token of the model name to look up the corresponding vendor in ``_VENDOR_PREFIXES``. Also handles case-insensitive matching and special patterns.
Args: model_name: A model name, optionally already including a ``vendor/`` prefix. If a prefix is present it is used directly.
Returns: The vendor slug (e.g. ``"anthropic"``, ``"openai"``) or ``None`` if no vendor can be confidently detected.
Examples::
>>> detect_vendor("claude-sonnet-4.6") 'anthropic' >>> detect_vendor("gpt-5.4-mini") 'openai' >>> detect_vendor("anthropic/claude-sonnet-4.6") 'anthropic' >>> detect_vendor("my-custom-model") """ name = model_name.strip() if not name: return None
# If there's already a vendor/ prefix, extract it if "/" in name: return name.split("/", 1)[0].lower() or None
name_lower = name.lower()
# Try first hyphen-delimited token (exact match) first_token = name_lower.split("-")[0] if first_token in _VENDOR_PREFIXES: return _VENDOR_PREFIXES[first_token]
# Handle patterns where the first token includes version digits, # e.g. "qwen3.5-plus" -> first token "qwen3.5", but prefix is "qwen" for prefix, vendor in _VENDOR_PREFIXES.items(): if name_lower.startswith(prefix): return vendor
return None
def _prepend_vendor(model_name: str) -> str: """Prepend the detected ``vendor/`` prefix if missing.
Used for aggregator providers that require ``vendor/model`` format. If the name already contains a ``/``, it is returned as-is. If no vendor can be detected, the name is returned unchanged (aggregators may still accept it or return an error).
Examples::
>>> _prepend_vendor("claude-sonnet-4.6") 'anthropic/claude-sonnet-4.6' >>> _prepend_vendor("anthropic/claude-sonnet-4.6") 'anthropic/claude-sonnet-4.6' >>> _prepend_vendor("my-custom-thing") 'my-custom-thing' """ if "/" in model_name: return model_name
vendor = detect_vendor(model_name) if vendor: return f"{vendor}/{model_name}" return model_name
# ---------------------------------------------------------------------------# Main normalisation entry point# ---------------------------------------------------------------------------
def normalize_model_for_provider(model_input: str, target_provider: str) -> str: """Translate a model name into the format the target provider's API expects.
This is the primary entry point for model name normalisation. It accepts any user-facing model identifier and transforms it for the specific provider that will receive the API call.
Args: model_input: The model name as provided by the user or config. Can be bare (``"claude-sonnet-4.6"``), vendor-prefixed (``"anthropic/claude-sonnet-4.6"``), or already in native format (``"claude-sonnet-4-6"``). target_provider: The canonical Hermes provider id, e.g. ``"openrouter"``, ``"anthropic"``, ``"copilot"``, ``"deepseek"``, ``"custom"``. Should already be normalised via ``hermes_cli.models.normalize_provider()``.
Returns: The model identifier string that the target provider's API expects.
Raises: No exceptions -- always returns a best-effort string.
Examples::
>>> normalize_model_for_provider("claude-sonnet-4.6", "openrouter") 'anthropic/claude-sonnet-4.6'
>>> normalize_model_for_provider("anthropic/claude-sonnet-4.6", "anthropic") 'claude-sonnet-4-6'
>>> normalize_model_for_provider("anthropic/claude-sonnet-4.6", "copilot") 'claude-sonnet-4.6'
>>> normalize_model_for_provider("openai/gpt-5.4", "copilot") 'gpt-5.4'
>>> normalize_model_for_provider("claude-sonnet-4.6", "opencode-zen") 'claude-sonnet-4-6'
>>> normalize_model_for_provider("minimax-m2.5-free", "opencode-zen") 'minimax-m2.5-free'
>>> normalize_model_for_provider("deepseek-v3", "deepseek") 'deepseek-chat'
>>> normalize_model_for_provider("deepseek-r1", "deepseek") 'deepseek-reasoner'
>>> normalize_model_for_provider("my-model", "custom") 'my-model'
>>> normalize_model_for_provider("claude-sonnet-4.6", "zai") 'claude-sonnet-4.6'
>>> normalize_model_for_provider("MiMo-V2.5-Pro", "xiaomi") 'mimo-v2.5-pro' """ name = (model_input or "").strip() if not name: return name
provider = _normalize_provider_alias(target_provider)
# --- Aggregators: need vendor/model format --- if provider in _AGGREGATOR_PROVIDERS: return _prepend_vendor(name)
# --- OpenCode Zen: Claude stays hyphenated; other models keep dots --- if provider == "opencode-zen": bare = _strip_matching_provider_prefix(name, provider) if "/" in bare: return bare if bare.lower().startswith("claude-"): return _dots_to_hyphens(bare) return bare
# --- Anthropic: strip matching provider prefix, dots -> hyphens --- if provider in _DOT_TO_HYPHEN_PROVIDERS: bare = _strip_matching_provider_prefix(name, provider) if "/" in bare: return bare return _dots_to_hyphens(bare)
# --- Copilot / Copilot ACP: delegate to the Copilot-specific # normalizer. It knows about the alias table (vendor-prefix # stripping for Anthropic/OpenAI, dash-to-dot repair for Claude) # and live-catalog lookups. Without this, vendor-prefixed or # dash-notation Claude IDs survive to the Copilot API and hit # HTTP 400 "model_not_supported". See issue #6879. if provider in {"copilot", "copilot-acp"}: try: from hermes_cli.models import normalize_copilot_model_id
normalized = normalize_copilot_model_id(name) if normalized: return normalized except Exception: # Fall through to the generic strip-vendor behaviour below # if the Copilot-specific path is unavailable for any reason. pass
# --- Copilot / Copilot ACP / openai-codex fallback: # strip matching provider prefix, keep dots --- if provider in _STRIP_VENDOR_ONLY_PROVIDERS: stripped = _strip_matching_provider_prefix(name, provider) if stripped == name and name.startswith("openai/"): # openai-codex maps openai/gpt-5.4 -> gpt-5.4 return name.split("/", 1)[1] return stripped
# --- DeepSeek: map to one of two canonical names --- if provider == "deepseek": bare = _strip_matching_provider_prefix(name, provider) if "/" in bare: return bare return _normalize_for_deepseek(bare)
# --- Direct providers: repair matching provider prefixes only --- if provider in _MATCHING_PREFIX_STRIP_PROVIDERS: result = _strip_matching_provider_prefix(name, provider) # Some providers require lowercase model IDs (e.g. Xiaomi's API # rejects "MiMo-V2.5-Pro" but accepts "mimo-v2.5-pro"). if provider in _LOWERCASE_MODEL_PROVIDERS: result = result.lower() return result
# --- Authoritative native providers: preserve user-facing slugs as-is --- if provider in _AUTHORITATIVE_NATIVE_PROVIDERS: return name
# --- Custom & all others: pass through as-is --- return name
# ---------------------------------------------------------------------------# Batch / convenience helpers# ---------------------------------------------------------------------------