Based on #6079 by @tunamitom with critical fixes and comprehensive tests. Changes from #6079: - Fix: sanitization overwrite bug — Qwen message prep now runs AFTER codex field sanitization, not before (was silently discarding Qwen transforms) - Fix: missing try/except AuthError in runtime_provider.py — stale Qwen credentials now fall through to next provider on auto-detect - Fix: 'qwen' alias conflict — bare 'qwen' stays mapped to 'alibaba' (DashScope); use 'qwen-portal' or 'qwen-cli' for the OAuth provider - Fix: hardcoded ['coder-model'] replaced with live API fetch + curated fallback list (qwen3-coder-plus, qwen3-coder) - Fix: extract _is_qwen_portal() helper + _qwen_portal_headers() to replace 5 inline 'portal.qwen.ai' string checks and share headers between init and credential swap - Fix: add Qwen branch to _apply_client_headers_for_base_url for mid-session credential swaps - Fix: remove suspicious TypeError catch blocks around _prompt_provider_choice - Fix: handle bare string items in content lists (were silently dropped) - Fix: remove redundant dict() copies after deepcopy in message prep - Revert: unrelated ai-gateway test mock removal and model_switch.py comment deletion New tests (30 test functions): - _qwen_cli_auth_path, _read_qwen_cli_tokens (success + 3 error paths) - _save_qwen_cli_tokens (roundtrip, parent creation, permissions) - _qwen_access_token_is_expiring (5 edge cases: fresh, expired, within skew, None, non-numeric) - _refresh_qwen_cli_tokens (success, preserve old refresh, 4 error paths, default expires_in, disk persistence) - resolve_qwen_runtime_credentials (fresh, auto-refresh, force-refresh, missing token, env override) - get_qwen_auth_status (logged in, not logged in) - Runtime provider resolution (direct, pool entry, alias) - _build_api_kwargs (metadata, vl_high_resolution_images, message formatting, max_tokens suppression)
363 lines
11 KiB
Python
363 lines
11 KiB
Python
"""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** follows the same dot-to-hyphen convention as
|
|
Anthropic: ``claude-sonnet-4-6``.
|
|
- **OpenCode Go** preserves dots in model names: ``minimax-m2.7``.
|
|
- **DeepSeek** only accepts two model identifiers:
|
|
``deepseek-chat`` and ``deepseek-reasoner``.
|
|
- **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
|
|
|
|
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",
|
|
"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",
|
|
"opencode-zen",
|
|
})
|
|
|
|
# Providers that want bare names with dots preserved.
|
|
_STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({
|
|
"copilot",
|
|
"copilot-acp",
|
|
})
|
|
|
|
# Providers whose own naming is authoritative -- pass through unchanged.
|
|
_PASSTHROUGH_PROVIDERS: frozenset[str] = frozenset({
|
|
"gemini",
|
|
"zai",
|
|
"kimi-coding",
|
|
"minimax",
|
|
"minimax-cn",
|
|
"alibaba",
|
|
"qwen-oauth",
|
|
"huggingface",
|
|
"openai-codex",
|
|
"custom",
|
|
})
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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",
|
|
"deepseek-reasoner",
|
|
})
|
|
|
|
|
|
def _normalize_for_deepseek(model_name: str) -> str:
|
|
"""Map any model input to one of DeepSeek's two accepted identifiers.
|
|
|
|
Rules:
|
|
- Already ``deepseek-chat`` or ``deepseek-reasoner`` -> pass through.
|
|
- Contains any 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:
|
|
One of ``"deepseek-chat"`` or ``"deepseek-reasoner"``.
|
|
"""
|
|
bare = _strip_vendor_prefix(model_name).lower()
|
|
|
|
if bare in _DEEPSEEK_CANONICAL_MODELS:
|
|
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 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("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'
|
|
"""
|
|
name = (model_input or "").strip()
|
|
if not name:
|
|
return name
|
|
|
|
provider = (target_provider or "").strip().lower()
|
|
|
|
# --- Aggregators: need vendor/model format ---
|
|
if provider in _AGGREGATOR_PROVIDERS:
|
|
return _prepend_vendor(name)
|
|
|
|
# --- Anthropic / OpenCode: strip vendor, dots -> hyphens ---
|
|
if provider in _DOT_TO_HYPHEN_PROVIDERS:
|
|
bare = _strip_vendor_prefix(name)
|
|
return _dots_to_hyphens(bare)
|
|
|
|
# --- Copilot: strip vendor, keep dots ---
|
|
if provider in _STRIP_VENDOR_ONLY_PROVIDERS:
|
|
return _strip_vendor_prefix(name)
|
|
|
|
# --- DeepSeek: map to one of two canonical names ---
|
|
if provider == "deepseek":
|
|
return _normalize_for_deepseek(name)
|
|
|
|
# --- Custom & all others: pass through as-is ---
|
|
return name
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Batch / convenience helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def model_display_name(model_id: str) -> str:
|
|
"""Return a short, human-readable display name for a model id.
|
|
|
|
Strips the vendor prefix (if any) for a cleaner display in menus
|
|
and status bars, while preserving dots for readability.
|
|
|
|
Examples::
|
|
|
|
>>> model_display_name("anthropic/claude-sonnet-4.6")
|
|
'claude-sonnet-4.6'
|
|
>>> model_display_name("claude-sonnet-4-6")
|
|
'claude-sonnet-4-6'
|
|
"""
|
|
return _strip_vendor_prefix((model_id or "").strip())
|
|
|
|
|
|
def is_aggregator_provider(provider: str) -> bool:
|
|
"""Check if a provider is an aggregator that needs vendor/model format."""
|
|
return (provider or "").strip().lower() in _AGGREGATOR_PROVIDERS
|
|
|
|
|
|
def vendor_for_model(model_name: str) -> str:
|
|
"""Return the vendor slug for a model, or ``""`` if unknown.
|
|
|
|
Convenience wrapper around :func:`detect_vendor` that never returns
|
|
``None``.
|
|
"""
|
|
return detect_vendor(model_name) or ""
|