"""Per-provider model name normalization.""" from __future__ import annotations import re from 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({ "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-oauth", "minimax-cn", "alibaba", "qwen-oauth", "xiaomi", "arcee", "ollama-cloud", "nebius-token-factory", "custom", "gemini", "xai", }) # Providers whose API serves ``vendor/model`` ids but whose endpoint can also # front arbitrary self-hosted models, so a bare name cannot be prefixed # blindly. A bare id is repaired only when the curated catalogue for that # provider holds exactly one entry ending in ``/`` — a lookup, not a # guess. NVIDIA NIM is the case in hand: build.nvidia.com serves # ``nvidia/nemotron-…`` (and third-party ``z-ai/glm-…``), while the same # provider id also points at local NIM containers with their own naming. # Without this repair a bare ``nemotron-3-ultra-550b-a55b`` reaches the API # and returns a bare ``404 page not found`` that never names the model (#78796). _CATALOGUE_PREFIX_REPAIR_PROVIDERS: frozenset[str] = frozenset({ "nvidia", }) # 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 direct API only accepts first-class V-series IDs after the # 2026-07-24 cut-off. Legacy aliases and fuzzy names are remapped here so # saved configs / picker leftovers cannot keep sending retired IDs. _DEEPSEEK_REASONER_KEYWORDS: frozenset[str] = frozenset({ "reasoner", "r1", "think", "reasoning", "cot", }) # Retired on 2026-07-24 15:59 UTC. Official docs: both aliases mapped to # deepseek-v4-flash (chat = non-thinking, reasoner = thinking). Thinking # mode itself is controlled by extra_body.thinking on the DeepSeek profile. _DEEPSEEK_RETIRED_ALIASES: frozenset[str] = frozenset({ "deepseek-chat", "deepseek-reasoner", }) _DEEPSEEK_CANONICAL_MODELS: frozenset[str] = frozenset({ "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. Retired aliases ``deepseek-chat``/``deepseek-reasoner`` and known canonicals map as expected; anything matching ``deepseek-v...`` passes through so future V-series ids work without a release; reasoner keywords and everything else fall back to ``deepseek-v4-flash``. """ bare = _strip_vendor_prefix(model_name).lower() # Retired aliases must rewrite — DeepSeek returns HTTP 400 after the # 2026-07-24 cut-off if these IDs are sent on the wire. if bare in _DEEPSEEK_RETIRED_ALIASES: return "deepseek-v4-flash" 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-v4-flash" return "deepseek-v4-flash" # --------------------------------------------------------------------------- # Helper utilities # --------------------------------------------------------------------------- def _strip_vendor_prefix(model_name: str) -> str: """Remove a ``vendor/`` prefix if present.""" 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.""" 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. Prevents arbitrary slash-bearing ids from being mangled on native providers while still repairing config values like ``zai/glm-5.1`` for ``zai``. ``custom`` is a bucket, not a vendor: an alias that merely resolves to it (e.g. ``ollama``) may be a real routing prefix required by a proxy such as LiteLLM, so only a literal ``custom/`` prefix is treated as redundant. """ 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_target = _normalize_provider_alias(target_provider) if normalized_target == "custom": if prefix.strip().lower() == "custom": return remainder.strip() return model_name normalized_prefix = _normalize_provider_alias(prefix) 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.""" 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. For aggregators that require ``vendor/model``. Names already containing ``/`` or with no detectable vendor are returned unchanged (the aggregator may still accept them). """ if "/" in model_name: return model_name vendor = detect_vendor(model_name) if vendor: return f"{vendor}/{model_name}" return model_name def _repair_prefix_from_catalogue(model_name: str, provider: str) -> str: """Restore a dropped ``vendor/`` prefix using the provider's catalogue. Unlike :func:`_prepend_vendor`, this never guesses from the model's name shape — it only repairs a bare id that matches **exactly one** curated entry for this provider modulo the prefix. """ if "/" in model_name: return model_name try: from hermes_cli.models import _PROVIDER_MODELS except Exception: return model_name catalogue = _PROVIDER_MODELS.get(provider) or [] # Compare against the catalogue's own suffix, tag included: a bare # ``…:free`` id must resolve to the ``:free`` entry, not its paid sibling. needle = model_name.strip().lower() matches = { entry for entry in catalogue if "/" in entry and entry.split("/", 1)[1].strip().lower() == needle } if len(matches) == 1: return matches.pop() return model_name def suggest_prefixed_model_id(provider: str, model_name: str) -> Optional[str]: """Return the prefixed catalogue id for a bare *model_name*, if unambiguous. Diagnostic counterpart to :func:`_repair_prefix_from_catalogue`, used to explain a provider's content-free 404 when the configured id lost its ``vendor/`` prefix. Returns ``None`` when the name already has a prefix, the provider has no catalogue, or nothing matches — so callers stay silent rather than guess. """ name = (model_name or "").strip() if not name or "/" in name: return None try: canonical = _normalize_provider_alias(provider) except Exception: return None repaired = _repair_prefix_from_catalogue(name, canonical) return repaired if repaired != name else None # --------------------------------------------------------------------------- # 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. Primary entry point for model-name normalisation. Accepts bare, vendor-prefixed or native ids; ``target_provider`` should already be normalised via ``normalize_provider()``. Never raises — always returns a best-effort string. """ 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 / OpenCode Go: flat-namespace resellers. # Their /v1/models API returns bare IDs only (no vendor prefix), and # the inference endpoint rejects vendor-prefixed names with HTTP 401 # "Model not supported". Strip ANY leading ``vendor/`` so config # entries like ``minimax/minimax-m2.7`` or ``deepseek/deepseek-v4-flash`` # — commonly copied from aggregator slugs into fallback_model lists — # resolve to bare ``minimax-m2.7`` / ``deepseek-v4-flash`` the API # actually serves. See PR reviewing opencode-go fallback 401s. --- from hermes_cli.models import opencode_provider_family _oc_family = opencode_provider_family(provider) if _oc_family is not None: if "/" in name: _, bare_after_slash = name.split("/", 1) name = bare_after_slash.strip() or name if _oc_family == "opencode-zen" and name.lower().startswith("claude-"): return _dots_to_hyphens(name) return name # --- 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 # --- Catalogue-backed prefix repair: restore a dropped ``vendor/`` on a # bare id that matches exactly one curated entry. Unknown names (a # local NIM container, a proxied model) pass through untouched. --- if provider in _CATALOGUE_PREFIX_REPAIR_PROVIDERS: return _repair_prefix_from_catalogue(name, provider) # --- 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 # ---------------------------------------------------------------------------