""" Single source of truth for provider identity in Hermes Agent. Two data sources, merged at runtime: 1. **models.dev catalog** — 109+ providers with base URLs, env vars, display names, and full model metadata (context, cost, capabilities). This is the primary database. 2. **Hermes overlays** — transport type, auth patterns, aggregator flags, and additional env vars that models.dev doesn't track. Small dict, maintained here. 3. **User config** (``providers:`` section in config.yaml) — user-defined endpoints and overrides. Merged on top of everything else. Other modules import from this file. No parallel registries. """ from __future__ import annotations import logging import os from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) # -- Hermes overlay ---------------------------------------------------------- # Hermes-specific metadata that 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] = { "openrouter": HermesOverlay( transport="openai_chat", is_aggregator=True, extra_env_vars=("OPENAI_API_KEY",), base_url_env_var="OPENROUTER_BASE_URL", ), "nous": HermesOverlay( transport="openai_chat", 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", ), "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( transport="openai_chat", extra_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN"), ), "anthropic": HermesOverlay( transport="anthropic_messages", extra_env_vars=("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"), ), "zai": HermesOverlay( transport="openai_chat", extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), base_url_env_var="GLM_BASE_URL", ), "kimi-for-coding": HermesOverlay( transport="openai_chat", base_url_env_var="KIMI_BASE_URL", ), "minimax": HermesOverlay( transport="openai_chat", base_url_env_var="MINIMAX_BASE_URL", ), "minimax-cn": HermesOverlay( transport="openai_chat", base_url_env_var="MINIMAX_CN_BASE_URL", ), "deepseek": HermesOverlay( transport="openai_chat", base_url_env_var="DEEPSEEK_BASE_URL", ), "alibaba": HermesOverlay( transport="openai_chat", base_url_env_var="DASHSCOPE_BASE_URL", ), "vercel": HermesOverlay( transport="openai_chat", is_aggregator=True, ), "opencode": HermesOverlay( transport="openai_chat", is_aggregator=True, base_url_env_var="OPENCODE_ZEN_BASE_URL", ), "opencode-go": HermesOverlay( transport="openai_chat", is_aggregator=True, base_url_env_var="OPENCODE_GO_BASE_URL", ), "kilo": HermesOverlay( transport="openai_chat", is_aggregator=True, base_url_env_var="KILOCODE_BASE_URL", ), "huggingface": HermesOverlay( transport="openai_chat", is_aggregator=True, base_url_env_var="HF_BASE_URL", ), } # -- Resolved provider ------------------------------------------------------- # The merged result of models.dev + overlay + user config. @dataclass class ProviderDef: """Complete provider definition — merged from all sources.""" 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" @property def is_user_defined(self) -> bool: return self.source == "user-config" # -- Aliases ------------------------------------------------------------------ # Maps human-friendly / legacy names to canonical provider IDs. # Uses models.dev IDs where possible. ALIASES: Dict[str, str] = { # openrouter "openai": "openrouter", # bare "openai" → route through aggregator # zai "glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai", # kimi-for-coding (models.dev ID) "kimi": "kimi-for-coding", "kimi-coding": "kimi-for-coding", "moonshot": "kimi-for-coding", # minimax-cn "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", # anthropic "claude": "anthropic", "claude-code": "anthropic", # github-copilot (models.dev ID) "copilot": "github-copilot", "github": "github-copilot", "github-copilot-acp": "copilot-acp", # vercel (models.dev ID for AI Gateway) "ai-gateway": "vercel", "aigateway": "vercel", "vercel-ai-gateway": "vercel", # opencode (models.dev ID for OpenCode Zen) "opencode-zen": "opencode", "zen": "opencode", # opencode-go "go": "opencode-go", "opencode-go-sub": "opencode-go", # kilo (models.dev ID for KiloCode) "kilocode": "kilo", "kilo-code": "kilo", "kilo-gateway": "kilo", # deepseek "deep-seek": "deepseek", # alibaba "dashscope": "alibaba", "aliyun": "alibaba", "qwen": "alibaba", "alibaba-cloud": "alibaba", # huggingface "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", # Local server aliases → virtual "local" concept (resolved via user config) "lmstudio": "lmstudio", "lm-studio": "lmstudio", "lm_studio": "lmstudio", "ollama": "ollama-cloud", "vllm": "local", "llamacpp": "local", "llama.cpp": "local", "llama-cpp": "local", } # -- Display labels ----------------------------------------------------------- # Built dynamically from models.dev + overlays. Fallback for providers # not in the catalog. _LABEL_OVERRIDES: Dict[str, str] = { "nous": "Nous Portal", "openai-codex": "OpenAI Codex", "copilot-acp": "GitHub Copilot ACP", "local": "Local endpoint", } # -- Transport → API mode mapping --------------------------------------------- TRANSPORT_TO_API_MODE: Dict[str, str] = { "openai_chat": "chat_completions", "anthropic_messages": "anthropic_messages", "codex_responses": "codex_responses", } # -- Helper functions --------------------------------------------------------- def normalize_provider(name: str) -> str: """Resolve aliases and normalise casing to a canonical provider id. Returns the canonical id string. Does *not* validate that the id corresponds to a known provider. """ key = name.strip().lower() return ALIASES.get(key, key) def get_overlay(provider_id: str) -> Optional[HermesOverlay]: """Get Hermes overlay for a provider, if one exists.""" canonical = normalize_provider(provider_id) return HERMES_OVERLAYS.get(canonical) def get_provider(name: str) -> Optional[ProviderDef]: """Look up a provider by id or alias, merging all data sources. Resolution order: 1. Hermes overlays (for providers not in models.dev: nous, openai-codex, etc.) 2. models.dev catalog + Hermes overlay 3. User-defined providers from config (TODO: Phase 4) Returns a fully-resolved ProviderDef or None. """ canonical = normalize_provider(name) # Try to get models.dev data try: from agent.models_dev import get_provider_info as _mdev_provider mdev_info = _mdev_provider(canonical) except Exception: mdev_info = None overlay = HERMES_OVERLAYS.get(canonical) if mdev_info is not None: # Merge models.dev + overlay transport = overlay.transport if overlay else "openai_chat" is_agg = overlay.is_aggregator if overlay else False auth = overlay.auth_type if overlay else "api_key" base_url_env = overlay.base_url_env_var if overlay else "" base_url_override = overlay.base_url_override if overlay else "" # Combine env vars: models.dev env + hermes extra env_vars = list(mdev_info.env) if overlay and overlay.extra_env_vars: for ev in overlay.extra_env_vars: if ev not in env_vars: env_vars.append(ev) return ProviderDef( id=canonical, name=mdev_info.name, transport=transport, api_key_env_vars=tuple(env_vars), base_url=base_url_override or mdev_info.api, base_url_env_var=base_url_env, is_aggregator=is_agg, auth_type=auth, doc=mdev_info.doc, source="models.dev", ) if overlay is not None: # Hermes-only provider (not in models.dev) return ProviderDef( id=canonical, name=_LABEL_OVERRIDES.get(canonical, canonical), transport=overlay.transport, api_key_env_vars=overlay.extra_env_vars, base_url=overlay.base_url_override, base_url_env_var=overlay.base_url_env_var, is_aggregator=overlay.is_aggregator, auth_type=overlay.auth_type, source="hermes", ) return None def get_label(provider_id: str) -> str: """Get a human-readable display name for a provider.""" canonical = normalize_provider(provider_id) # Check label overrides first if canonical in _LABEL_OVERRIDES: return _LABEL_OVERRIDES[canonical] # Try models.dev pdef = get_provider(canonical) if pdef: return pdef.name return canonical # Build LABELS dict for backward compat def _build_labels() -> Dict[str, str]: """Build labels dict from overlays + overrides. Lazy, cached.""" labels: Dict[str, str] = {} for pid in HERMES_OVERLAYS: labels[pid] = get_label(pid) labels.update(_LABEL_OVERRIDES) return labels # Lazy-built on first access _labels_cache: Optional[Dict[str, str]] = None @property def LABELS() -> Dict[str, str]: """Backward-compatible labels dict.""" global _labels_cache if _labels_cache is None: _labels_cache = _build_labels() return _labels_cache # For direct import compat, expose as module-level dict # Built on demand by get_label() calls LABELS: Dict[str, str] = { # Static entries for backward compat — get_label() is the proper API "openrouter": "OpenRouter", "nous": "Nous Portal", "openai-codex": "OpenAI Codex", "copilot-acp": "GitHub Copilot ACP", "github-copilot": "GitHub Copilot", "anthropic": "Anthropic", "zai": "Z.AI / GLM", "kimi-for-coding": "Kimi / Moonshot", "minimax": "MiniMax", "minimax-cn": "MiniMax (China)", "deepseek": "DeepSeek", "alibaba": "Alibaba Cloud (DashScope)", "vercel": "Vercel AI Gateway", "opencode": "OpenCode Zen", "opencode-go": "OpenCode Go", "kilo": "Kilo Gateway", "huggingface": "Hugging Face", "local": "Local endpoint", "custom": "Custom endpoint", # Legacy Hermes IDs (point to same providers) "ai-gateway": "Vercel AI Gateway", "kilocode": "Kilo Gateway", "copilot": "GitHub Copilot", "kimi-coding": "Kimi / Moonshot", "opencode-zen": "OpenCode Zen", } def is_aggregator(provider: str) -> bool: """Return True when the provider is a multi-model aggregator.""" pdef = get_provider(provider) return pdef.is_aggregator if pdef else False def determine_api_mode(provider: str, base_url: str = "") -> str: """Determine the API mode (wire protocol) for a provider/endpoint. Resolution order: 1. Known provider → transport → TRANSPORT_TO_API_MODE. 2. URL heuristics for unknown / custom providers. 3. Default: 'chat_completions'. """ pdef = get_provider(provider) if pdef is not None: return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions") # URL-based heuristics for custom / unknown providers if base_url: url_lower = base_url.rstrip("/").lower() if url_lower.endswith("/anthropic") or "api.anthropic.com" in url_lower: return "anthropic_messages" if "api.openai.com" in url_lower: return "codex_responses" return "chat_completions" # -- Provider from 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. Args: name: Provider name as given by the user. user_config: The ``providers:`` dict from config.yaml. Returns: ProviderDef if found, else None. """ if not user_config or not isinstance(user_config, dict): return None entry = user_config.get(name) if not isinstance(entry, dict): return None # Extract fields display_name = entry.get("name", "") or name api_url = entry.get("api", "") or entry.get("url", "") or entry.get("base_url", "") or "" key_env = entry.get("key_env", "") or "" transport = entry.get("transport", "openai_chat") or "openai_chat" env_vars: List[str] = [] if key_env: env_vars.append(key_env) return ProviderDef( id=name, name=display_name, transport=transport, api_key_env_vars=tuple(env_vars), base_url=api_url, is_aggregator=False, auth_type="api_key", source="user-config", ) def resolve_provider_full( name: str, user_providers: Optional[Dict[str, Any]] = None, ) -> Optional[ProviderDef]: """Full resolution chain: built-in → models.dev → user config. This is the main entry point for --provider flag resolution. Args: name: Provider name or alias. user_providers: The ``providers:`` dict from config.yaml (optional). Returns: ProviderDef if found, else None. """ canonical = normalize_provider(name) # 1. Built-in (models.dev + overlays) pdef = get_provider(canonical) if pdef is not None: return pdef # 2. User-defined providers from config if user_providers: # Try canonical name user_pdef = resolve_user_provider(canonical, user_providers) if user_pdef is not None: return user_pdef # Try original name (in case alias didn't match) user_pdef = resolve_user_provider(name.strip().lower(), user_providers) if user_pdef is not None: return user_pdef # 3. Try models.dev directly (for providers not in our ALIASES) try: from agent.models_dev import get_provider_info as _mdev_provider mdev_info = _mdev_provider(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 return None