""" Canonical model catalogs and lightweight validation helpers. Add, remove, or reorder entries here — both `hermes setup` and `hermes` provider-selection will pick up the change automatically. """ from __future__ import annotations import json import os import urllib.request import urllib.error from difflib import get_close_matches from typing import Any, Optional COPILOT_BASE_URL = "https://api.githubcopilot.com" COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models" COPILOT_EDITOR_VERSION = "vscode/1.104.1" COPILOT_REASONING_EFFORTS_GPT5 = ["minimal", "low", "medium", "high"] COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"] # Backward-compatible aliases for the earlier GitHub Models-backed Copilot work. GITHUB_MODELS_BASE_URL = COPILOT_BASE_URL GITHUB_MODELS_CATALOG_URL = COPILOT_MODELS_URL # (model_id, display description shown in menus) OPENROUTER_MODELS: list[tuple[str, str]] = [ ("anthropic/claude-opus-4.6", "recommended"), ("anthropic/claude-sonnet-4.5", ""), ("anthropic/claude-haiku-4.5", ""), ("openai/gpt-5.4", ""), ("openai/gpt-5.4-mini", ""), ("xiaomi/mimo-v2-pro", ""), ("openai/gpt-5.3-codex", ""), ("google/gemini-3-pro-preview", ""), ("google/gemini-3-flash-preview", ""), ("qwen/qwen3.5-plus-02-15", ""), ("qwen/qwen3.5-35b-a3b", ""), ("stepfun/step-3.5-flash", ""), ("minimax/minimax-m2.7", ""), ("minimax/minimax-m2.5", ""), ("z-ai/glm-5", ""), ("z-ai/glm-5-turbo", ""), ("moonshotai/kimi-k2.5", ""), ("x-ai/grok-4.20-beta", ""), ("nvidia/nemotron-3-super-120b-a12b", ""), ("nvidia/nemotron-3-super-120b-a12b:free", "free"), ("arcee-ai/trinity-large-preview:free", "free"), ("openai/gpt-5.4-pro", ""), ("openai/gpt-5.4-nano", ""), ] _PROVIDER_MODELS: dict[str, list[str]] = { "nous": [ "claude-opus-4-6", "claude-sonnet-4-6", "gpt-5.4", "gemini-3-flash", "gemini-3.0-pro-preview", "deepseek-v3.2", ], "openai-codex": [ "gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.1-codex-mini", "gpt-5.1-codex-max", ], "copilot-acp": [ "copilot-acp", ], "copilot": [ "gpt-5.4", "gpt-5.4-mini", "gpt-5-mini", "gpt-5.3-codex", "gpt-5.2-codex", "gpt-4.1", "gpt-4o", "gpt-4o-mini", "claude-opus-4.6", "claude-sonnet-4.6", "claude-sonnet-4.5", "claude-haiku-4.5", "gemini-2.5-pro", "grok-code-fast-1", ], "zai": [ "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash", ], "kimi-coding": [ "kimi-for-coding", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-thinking-turbo", "kimi-k2-turbo-preview", "kimi-k2-0905-preview", ], "minimax": [ "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1", ], "minimax-cn": [ "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1", ], "anthropic": [ "claude-opus-4-6", "claude-sonnet-4-6", "claude-opus-4-5-20251101", "claude-sonnet-4-5-20250929", "claude-opus-4-20250514", "claude-sonnet-4-20250514", "claude-haiku-4-5-20251001", ], "deepseek": [ "deepseek-chat", "deepseek-reasoner", ], "opencode-zen": [ "gpt-5.4-pro", "gpt-5.4", "gpt-5.3-codex", "gpt-5.3-codex-spark", "gpt-5.2", "gpt-5.2-codex", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5", "gpt-5-codex", "gpt-5-nano", "claude-opus-4-6", "claude-opus-4-5", "claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-sonnet-4", "claude-haiku-4-5", "claude-3-5-haiku", "gemini-3.1-pro", "gemini-3-pro", "gemini-3-flash", "minimax-m2.7", "minimax-m2.5", "minimax-m2.5-free", "minimax-m2.1", "glm-5", "glm-4.7", "glm-4.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2", "qwen3-coder", "big-pickle", ], "opencode-go": [ "glm-5", "kimi-k2.5", "minimax-m2.5", ], "ai-gateway": [ "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "anthropic/claude-sonnet-4.5", "anthropic/claude-haiku-4.5", "openai/gpt-5", "openai/gpt-4.1", "openai/gpt-4.1-mini", "google/gemini-3-pro-preview", "google/gemini-3-flash", "google/gemini-2.5-pro", "google/gemini-2.5-flash", "deepseek/deepseek-v3.2", ], "kilocode": [ "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview", ], "alibaba": [ "qwen3.5-plus", "qwen3-max", "qwen3-coder-plus", "qwen3-coder-next", "qwen-plus-latest", "qwen3.5-flash", "qwen-vl-max", ], } _PROVIDER_LABELS = { "openrouter": "OpenRouter", "openai-codex": "OpenAI Codex", "copilot-acp": "GitHub Copilot ACP", "nous": "Nous Portal", "copilot": "GitHub Copilot", "zai": "Z.AI / GLM", "kimi-coding": "Kimi / Moonshot", "minimax": "MiniMax", "minimax-cn": "MiniMax (China)", "anthropic": "Anthropic", "deepseek": "DeepSeek", "opencode-zen": "OpenCode Zen", "opencode-go": "OpenCode Go", "ai-gateway": "AI Gateway", "kilocode": "Kilo Code", "alibaba": "Alibaba Cloud (DashScope)", "custom": "Custom endpoint", } _PROVIDER_ALIASES = { "glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai", "github": "copilot", "github-copilot": "copilot", "github-models": "copilot", "github-model": "copilot", "github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp", "kimi": "kimi-coding", "moonshot": "kimi-coding", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", "claude": "anthropic", "claude-code": "anthropic", "deep-seek": "deepseek", "opencode": "opencode-zen", "zen": "opencode-zen", "go": "opencode-go", "opencode-go-sub": "opencode-go", "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway", "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode", "dashscope": "alibaba", "aliyun": "alibaba", "qwen": "alibaba", "alibaba-cloud": "alibaba", } def model_ids() -> list[str]: """Return just the OpenRouter model-id strings.""" return [mid for mid, _ in OPENROUTER_MODELS] def menu_labels() -> list[str]: """Return display labels like 'anthropic/claude-opus-4.6 (recommended)'.""" labels = [] for mid, desc in OPENROUTER_MODELS: labels.append(f"{mid} ({desc})" if desc else mid) return labels # All provider IDs and aliases that are valid for the provider:model syntax. _KNOWN_PROVIDER_NAMES: set[str] = ( set(_PROVIDER_LABELS.keys()) | set(_PROVIDER_ALIASES.keys()) | {"openrouter", "custom"} ) def list_available_providers() -> list[dict[str, str]]: """Return info about all providers the user could use with ``provider:model``. Each dict has ``id``, ``label``, and ``aliases``. Checks which providers have valid credentials configured. """ # Canonical providers in display order _PROVIDER_ORDER = [ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba", "opencode-zen", "opencode-go", "ai-gateway", "deepseek", "custom", ] # Build reverse alias map aliases_for: dict[str, list[str]] = {} for alias, canonical in _PROVIDER_ALIASES.items(): aliases_for.setdefault(canonical, []).append(alias) result = [] for pid in _PROVIDER_ORDER: label = _PROVIDER_LABELS.get(pid, pid) alias_list = aliases_for.get(pid, []) # Check if this provider has credentials available has_creds = False try: from hermes_cli.auth import get_auth_status, has_usable_secret if pid == "custom": custom_base_url = _get_custom_base_url() or os.getenv("OPENAI_BASE_URL", "") has_creds = bool(custom_base_url.strip()) elif pid == "openrouter": has_creds = has_usable_secret(os.getenv("OPENROUTER_API_KEY", "")) else: status = get_auth_status(pid) has_creds = bool(status.get("logged_in") or status.get("configured")) except Exception: pass result.append({ "id": pid, "label": label, "aliases": alias_list, "authenticated": has_creds, }) return result def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]: """Parse ``/model`` input into ``(provider, model)``. Supports ``provider:model`` syntax to switch providers at runtime:: openrouter:anthropic/claude-sonnet-4.5 → ("openrouter", "anthropic/claude-sonnet-4.5") nous:hermes-3 → ("nous", "hermes-3") anthropic/claude-sonnet-4.5 → (current_provider, "anthropic/claude-sonnet-4.5") gpt-5.4 → (current_provider, "gpt-5.4") The colon is only treated as a provider delimiter if the left side is a recognized provider name or alias. This avoids misinterpreting model names that happen to contain colons (e.g. ``anthropic/claude-3.5-sonnet:beta``). Returns ``(provider, model)`` where *provider* is either the explicit provider from the input or *current_provider* if none was specified. """ stripped = raw.strip() colon = stripped.find(":") if colon > 0: provider_part = stripped[:colon].strip().lower() model_part = stripped[colon + 1:].strip() if provider_part and model_part and provider_part in _KNOWN_PROVIDER_NAMES: # Support custom:name:model triple syntax for named custom # providers. ``custom:local:qwen`` → ("custom:local", "qwen"). # Single colon ``custom:qwen`` → ("custom", "qwen") as before. if provider_part == "custom" and ":" in model_part: second_colon = model_part.find(":") custom_name = model_part[:second_colon].strip() actual_model = model_part[second_colon + 1:].strip() if custom_name and actual_model: return (f"custom:{custom_name}", actual_model) return (normalize_provider(provider_part), model_part) return (current_provider, stripped) def _get_custom_base_url() -> str: """Get the custom endpoint base_url from config.yaml.""" try: from hermes_cli.config import load_config config = load_config() model_cfg = config.get("model", {}) if isinstance(model_cfg, dict): return str(model_cfg.get("base_url", "")).strip() except Exception: pass return "" def curated_models_for_provider(provider: Optional[str]) -> list[tuple[str, str]]: """Return ``(model_id, description)`` tuples for a provider's model list. Tries to fetch the live model list from the provider's API first, falling back to the static ``_PROVIDER_MODELS`` catalog if the API is unreachable. """ normalized = normalize_provider(provider) if normalized == "openrouter": return list(OPENROUTER_MODELS) # Try live API first (Codex, Nous, etc. all support /models) live = provider_model_ids(normalized) if live: return [(m, "") for m in live] # Fallback to static catalog models = _PROVIDER_MODELS.get(normalized, []) return [(m, "") for m in models] def detect_provider_for_model( model_name: str, current_provider: str, ) -> Optional[tuple[str, str]]: """Auto-detect the best provider for a model name. Returns ``(provider_id, model_name)`` — the model name may be remapped (e.g. bare ``deepseek-chat`` → ``deepseek/deepseek-chat`` for OpenRouter). Returns ``None`` when no confident match is found. Priority: 0. Bare provider name → switch to that provider's default model 1. Direct provider with credentials (highest) 2. Direct provider without credentials → remap to OpenRouter slug 3. OpenRouter catalog match """ name = (model_name or "").strip() if not name: return None name_lower = name.lower() # --- Step 0: bare provider name typed as model --- # If someone types `/model nous` or `/model anthropic`, treat it as a # provider switch and pick the first model from that provider's catalog. # Skip "custom" and "openrouter" — custom has no model catalog, and # openrouter requires an explicit model name to be useful. resolved_provider = _PROVIDER_ALIASES.get(name_lower, name_lower) if resolved_provider not in {"custom", "openrouter"}: default_models = _PROVIDER_MODELS.get(resolved_provider, []) if ( resolved_provider in _PROVIDER_LABELS and default_models and resolved_provider != normalize_provider(current_provider) ): return (resolved_provider, default_models[0]) # Aggregators list other providers' models — never auto-switch TO them _AGGREGATORS = {"nous", "openrouter"} # If the model belongs to the current provider's catalog, don't suggest switching current_models = _PROVIDER_MODELS.get(current_provider, []) if any(name_lower == m.lower() for m in current_models): return None # --- Step 1: check static provider catalogs for a direct match --- direct_match: Optional[str] = None for pid, models in _PROVIDER_MODELS.items(): if pid == current_provider or pid in _AGGREGATORS: continue if any(name_lower == m.lower() for m in models): direct_match = pid break if direct_match: # Check if we have credentials for this provider has_creds = False try: from hermes_cli.auth import PROVIDER_REGISTRY pconfig = PROVIDER_REGISTRY.get(direct_match) if pconfig: import os for env_var in pconfig.api_key_env_vars: if os.getenv(env_var, "").strip(): has_creds = True break except Exception: pass if has_creds: return (direct_match, name) # No direct creds — try to find this model on OpenRouter instead or_slug = _find_openrouter_slug(name) if or_slug: return ("openrouter", or_slug) # Still return the direct provider — credential resolution will # give a clear error rather than silently using the wrong provider return (direct_match, name) # --- Step 2: check OpenRouter catalog --- # First try exact match (handles provider/model format) or_slug = _find_openrouter_slug(name) if or_slug: if current_provider != "openrouter": return ("openrouter", or_slug) # Already on openrouter, just return the resolved slug if or_slug != name: return ("openrouter", or_slug) return None # already on openrouter with matching name return None def _find_openrouter_slug(model_name: str) -> Optional[str]: """Find the full OpenRouter model slug for a bare or partial model name. Handles: - Exact match: ``anthropic/claude-opus-4.6`` → as-is - Bare name: ``deepseek-chat`` → ``deepseek/deepseek-chat`` - Bare name: ``claude-opus-4.6`` → ``anthropic/claude-opus-4.6`` """ name_lower = model_name.strip().lower() if not name_lower: return None # Exact match (already has provider/ prefix) for mid, _ in OPENROUTER_MODELS: if name_lower == mid.lower(): return mid # Try matching just the model part (after the /) for mid, _ in OPENROUTER_MODELS: if "/" in mid: _, model_part = mid.split("/", 1) if name_lower == model_part.lower(): return mid return None def normalize_provider(provider: Optional[str]) -> str: """Normalize provider aliases to Hermes' canonical provider ids. Note: ``"auto"`` passes through unchanged — use ``hermes_cli.auth.resolve_provider()`` to resolve it to a concrete provider based on credentials and environment. """ normalized = (provider or "openrouter").strip().lower() return _PROVIDER_ALIASES.get(normalized, normalized) def provider_label(provider: Optional[str]) -> str: """Return a human-friendly label for a provider id or alias.""" original = (provider or "openrouter").strip() normalized = original.lower() if normalized == "auto": return "Auto" normalized = normalize_provider(normalized) return _PROVIDER_LABELS.get(normalized, original or "OpenRouter") def _resolve_copilot_catalog_api_key() -> str: """Best-effort GitHub token for fetching the Copilot model catalog.""" try: from hermes_cli.auth import resolve_api_key_provider_credentials creds = resolve_api_key_provider_credentials("copilot") return str(creds.get("api_key") or "").strip() except Exception: return "" def provider_model_ids(provider: Optional[str]) -> list[str]: """Return the best known model catalog for a provider. Tries live API endpoints for providers that support them (Codex, Nous), falling back to static lists. """ normalized = normalize_provider(provider) if normalized == "openrouter": return model_ids() if normalized == "openai-codex": from hermes_cli.codex_models import get_codex_model_ids return get_codex_model_ids() if normalized in {"copilot", "copilot-acp"}: try: live = _fetch_github_models(_resolve_copilot_catalog_api_key()) if live: return live except Exception: pass if normalized == "copilot-acp": return list(_PROVIDER_MODELS.get("copilot", [])) if normalized == "nous": # Try live Nous Portal /models endpoint try: from hermes_cli.auth import fetch_nous_models, resolve_nous_runtime_credentials creds = resolve_nous_runtime_credentials() if creds: live = fetch_nous_models(api_key=creds.get("api_key", ""), inference_base_url=creds.get("base_url", "")) if live: return live except Exception: pass if normalized == "anthropic": live = _fetch_anthropic_models() if live: return live if normalized == "ai-gateway": live = _fetch_ai_gateway_models() if live: return live if normalized == "custom": base_url = _get_custom_base_url() if base_url: # Try common API key env vars for custom endpoints api_key = ( os.getenv("CUSTOM_API_KEY", "") or os.getenv("OPENAI_API_KEY", "") or os.getenv("OPENROUTER_API_KEY", "") ) live = fetch_api_models(api_key, base_url) if live: return live return list(_PROVIDER_MODELS.get(normalized, [])) def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]: """Fetch available models from the Anthropic /v1/models endpoint. Uses resolve_anthropic_token() to find credentials (env vars or Claude Code auto-discovery). Returns sorted model IDs or None. """ try: from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token except ImportError: return None token = resolve_anthropic_token() if not token: return None headers: dict[str, str] = {"anthropic-version": "2023-06-01"} if _is_oauth_token(token): headers["Authorization"] = f"Bearer {token}" from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS) else: headers["x-api-key"] = token req = urllib.request.Request( "https://api.anthropic.com/v1/models", headers=headers, ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) models = [m["id"] for m in data.get("data", []) if m.get("id")] # Sort: latest/largest first (opus > sonnet > haiku, higher version first) return sorted(models, key=lambda m: ( "opus" not in m, # opus first "sonnet" not in m, # then sonnet "haiku" not in m, # then haiku m, # alphabetical within tier )) except Exception as e: import logging logging.getLogger(__name__).debug("Failed to fetch Anthropic models: %s", e) return None def _payload_items(payload: Any) -> list[dict[str, Any]]: if isinstance(payload, list): return [item for item in payload if isinstance(item, dict)] if isinstance(payload, dict): data = payload.get("data", []) if isinstance(data, list): return [item for item in data if isinstance(item, dict)] return [] def _extract_model_ids(payload: Any) -> list[str]: return [item.get("id", "") for item in _payload_items(payload) if item.get("id")] def copilot_default_headers() -> dict[str, str]: """Standard headers for Copilot API requests. Includes Openai-Intent and x-initiator headers that opencode and the Copilot CLI send on every request. """ try: from hermes_cli.copilot_auth import copilot_request_headers return copilot_request_headers(is_agent_turn=True) except ImportError: return { "Editor-Version": COPILOT_EDITOR_VERSION, "User-Agent": "HermesAgent/1.0", "Openai-Intent": "conversation-edits", "x-initiator": "agent", } def _copilot_catalog_item_is_text_model(item: dict[str, Any]) -> bool: model_id = str(item.get("id") or "").strip() if not model_id: return False if item.get("model_picker_enabled") is False: return False capabilities = item.get("capabilities") if isinstance(capabilities, dict): model_type = str(capabilities.get("type") or "").strip().lower() if model_type and model_type != "chat": return False supported_endpoints = item.get("supported_endpoints") if isinstance(supported_endpoints, list): normalized_endpoints = { str(endpoint).strip() for endpoint in supported_endpoints if str(endpoint).strip() } if normalized_endpoints and not normalized_endpoints.intersection( {"/chat/completions", "/responses", "/v1/messages"} ): return False return True def fetch_github_model_catalog( api_key: Optional[str] = None, timeout: float = 5.0 ) -> Optional[list[dict[str, Any]]]: """Fetch the live GitHub Copilot model catalog for this account.""" attempts: list[dict[str, str]] = [] if api_key: attempts.append({ **copilot_default_headers(), "Authorization": f"Bearer {api_key}", }) attempts.append(copilot_default_headers()) for headers in attempts: req = urllib.request.Request(COPILOT_MODELS_URL, headers=headers) try: with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) items = _payload_items(data) models: list[dict[str, Any]] = [] seen_ids: set[str] = set() for item in items: if not _copilot_catalog_item_is_text_model(item): continue model_id = str(item.get("id") or "").strip() if not model_id or model_id in seen_ids: continue seen_ids.add(model_id) models.append(item) if models: return models except Exception: continue return None def _is_github_models_base_url(base_url: Optional[str]) -> bool: normalized = (base_url or "").strip().rstrip("/").lower() return ( normalized.startswith(COPILOT_BASE_URL) or normalized.startswith("https://models.github.ai/inference") ) def _fetch_github_models(api_key: Optional[str] = None, timeout: float = 5.0) -> Optional[list[str]]: catalog = fetch_github_model_catalog(api_key=api_key, timeout=timeout) if not catalog: return None return [item.get("id", "") for item in catalog if item.get("id")] _COPILOT_MODEL_ALIASES = { "openai/gpt-5": "gpt-5-mini", "openai/gpt-5-chat": "gpt-5-mini", "openai/gpt-5-mini": "gpt-5-mini", "openai/gpt-5-nano": "gpt-5-mini", "openai/gpt-4.1": "gpt-4.1", "openai/gpt-4.1-mini": "gpt-4.1", "openai/gpt-4.1-nano": "gpt-4.1", "openai/gpt-4o": "gpt-4o", "openai/gpt-4o-mini": "gpt-4o-mini", "openai/o1": "gpt-5.2", "openai/o1-mini": "gpt-5-mini", "openai/o1-preview": "gpt-5.2", "openai/o3": "gpt-5.3-codex", "openai/o3-mini": "gpt-5-mini", "openai/o4-mini": "gpt-5-mini", "anthropic/claude-opus-4.6": "claude-opus-4.6", "anthropic/claude-sonnet-4.6": "claude-sonnet-4.6", "anthropic/claude-sonnet-4.5": "claude-sonnet-4.5", "anthropic/claude-haiku-4.5": "claude-haiku-4.5", } def _copilot_catalog_ids( catalog: Optional[list[dict[str, Any]]] = None, api_key: Optional[str] = None, ) -> set[str]: if catalog is None and api_key: catalog = fetch_github_model_catalog(api_key=api_key) if not catalog: return set() return { str(item.get("id") or "").strip() for item in catalog if str(item.get("id") or "").strip() } def normalize_copilot_model_id( model_id: Optional[str], *, catalog: Optional[list[dict[str, Any]]] = None, api_key: Optional[str] = None, ) -> str: raw = str(model_id or "").strip() if not raw: return "" catalog_ids = _copilot_catalog_ids(catalog=catalog, api_key=api_key) alias = _COPILOT_MODEL_ALIASES.get(raw) if alias: return alias candidates = [raw] if "/" in raw: candidates.append(raw.split("/", 1)[1].strip()) if raw.endswith("-mini"): candidates.append(raw[:-5]) if raw.endswith("-nano"): candidates.append(raw[:-5]) if raw.endswith("-chat"): candidates.append(raw[:-5]) seen: set[str] = set() for candidate in candidates: if not candidate or candidate in seen: continue seen.add(candidate) if candidate in _COPILOT_MODEL_ALIASES: return _COPILOT_MODEL_ALIASES[candidate] if candidate in catalog_ids: return candidate if "/" in raw: return raw.split("/", 1)[1].strip() return raw def _github_reasoning_efforts_for_model_id(model_id: str) -> list[str]: raw = (model_id or "").strip().lower() if raw.startswith(("openai/o1", "openai/o3", "openai/o4", "o1", "o3", "o4")): return list(COPILOT_REASONING_EFFORTS_O_SERIES) normalized = normalize_copilot_model_id(model_id).lower() if normalized.startswith("gpt-5"): return list(COPILOT_REASONING_EFFORTS_GPT5) return [] def _should_use_copilot_responses_api(model_id: str) -> bool: """Decide whether a Copilot model should use the Responses API. Replicates opencode's ``shouldUseCopilotResponsesApi`` logic: GPT-5+ models use Responses API, except ``gpt-5-mini`` which uses Chat Completions. All non-GPT models (Claude, Gemini, etc.) use Chat Completions. """ import re match = re.match(r"^gpt-(\d+)", model_id) if not match: return False major = int(match.group(1)) return major >= 5 and not model_id.startswith("gpt-5-mini") def copilot_model_api_mode( model_id: Optional[str], *, catalog: Optional[list[dict[str, Any]]] = None, api_key: Optional[str] = None, ) -> str: """Determine the API mode for a Copilot model. Uses the model ID pattern (matching opencode's approach) as the primary signal. Falls back to the catalog's ``supported_endpoints`` only for models not covered by the pattern check. """ normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key) if not normalized: return "chat_completions" # Primary: model ID pattern (matches opencode's shouldUseCopilotResponsesApi) if _should_use_copilot_responses_api(normalized): return "codex_responses" # Secondary: check catalog for non-GPT-5 models (Claude via /v1/messages, etc.) if catalog is None and api_key: catalog = fetch_github_model_catalog(api_key=api_key) if catalog: catalog_entry = next((item for item in catalog if item.get("id") == normalized), None) if isinstance(catalog_entry, dict): supported_endpoints = { str(endpoint).strip() for endpoint in (catalog_entry.get("supported_endpoints") or []) if str(endpoint).strip() } # For non-GPT-5 models, check if they only support messages API if "/v1/messages" in supported_endpoints and "/chat/completions" not in supported_endpoints: return "anthropic_messages" return "chat_completions" def github_model_reasoning_efforts( model_id: Optional[str], *, catalog: Optional[list[dict[str, Any]]] = None, api_key: Optional[str] = None, ) -> list[str]: """Return supported reasoning-effort levels for a Copilot-visible model.""" normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key) if not normalized: return [] catalog_entry = None if catalog is not None: catalog_entry = next((item for item in catalog if item.get("id") == normalized), None) elif api_key: fetched_catalog = fetch_github_model_catalog(api_key=api_key) if fetched_catalog: catalog_entry = next((item for item in fetched_catalog if item.get("id") == normalized), None) if catalog_entry is not None: capabilities = catalog_entry.get("capabilities") if isinstance(capabilities, dict): supports = capabilities.get("supports") if isinstance(supports, dict): efforts = supports.get("reasoning_effort") if isinstance(efforts, list): normalized_efforts = [ str(effort).strip().lower() for effort in efforts if str(effort).strip() ] return list(dict.fromkeys(normalized_efforts)) return [] legacy_capabilities = { str(capability).strip().lower() for capability in catalog_entry.get("capabilities", []) if str(capability).strip() } if "reasoning" not in legacy_capabilities: return [] return _github_reasoning_efforts_for_model_id(str(model_id or normalized)) def probe_api_models( api_key: Optional[str], base_url: Optional[str], timeout: float = 5.0, ) -> dict[str, Any]: """Probe an OpenAI-compatible ``/models`` endpoint with light URL heuristics.""" normalized = (base_url or "").strip().rstrip("/") if not normalized: return { "models": None, "probed_url": None, "resolved_base_url": "", "suggested_base_url": None, "used_fallback": False, } if _is_github_models_base_url(normalized): models = _fetch_github_models(api_key=api_key, timeout=timeout) return { "models": models, "probed_url": COPILOT_MODELS_URL, "resolved_base_url": COPILOT_BASE_URL, "suggested_base_url": None, "used_fallback": False, } if normalized.endswith("/v1"): alternate_base = normalized[:-3].rstrip("/") else: alternate_base = normalized + "/v1" candidates: list[tuple[str, bool]] = [(normalized, False)] if alternate_base and alternate_base != normalized: candidates.append((alternate_base, True)) tried: list[str] = [] headers: dict[str, str] = {} if api_key: headers["Authorization"] = f"Bearer {api_key}" if normalized.startswith(COPILOT_BASE_URL): headers.update(copilot_default_headers()) for candidate_base, is_fallback in candidates: url = candidate_base.rstrip("/") + "/models" tried.append(url) req = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) return { "models": [m.get("id", "") for m in data.get("data", [])], "probed_url": url, "resolved_base_url": candidate_base.rstrip("/"), "suggested_base_url": alternate_base if alternate_base != candidate_base else normalized, "used_fallback": is_fallback, } except Exception: continue return { "models": None, "probed_url": tried[-1] if tried else normalized.rstrip("/") + "/models", "resolved_base_url": normalized, "suggested_base_url": alternate_base if alternate_base != normalized else None, "used_fallback": False, } def _fetch_ai_gateway_models(timeout: float = 5.0) -> Optional[list[str]]: """Fetch available language models with tool-use from AI Gateway.""" api_key = os.getenv("AI_GATEWAY_API_KEY", "").strip() if not api_key: return None base_url = os.getenv("AI_GATEWAY_BASE_URL", "").strip() if not base_url: from hermes_constants import AI_GATEWAY_BASE_URL base_url = AI_GATEWAY_BASE_URL url = base_url.rstrip("/") + "/models" headers: dict[str, str] = {"Authorization": f"Bearer {api_key}"} req = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) return [ m["id"] for m in data.get("data", []) if m.get("id") and m.get("type") == "language" and "tool-use" in (m.get("tags") or []) ] except Exception: return None def fetch_api_models( api_key: Optional[str], base_url: Optional[str], timeout: float = 5.0, ) -> Optional[list[str]]: """Fetch the list of available model IDs from the provider's ``/models`` endpoint. Returns a list of model ID strings, or ``None`` if the endpoint could not be reached (network error, timeout, auth failure, etc.). """ return probe_api_models(api_key, base_url, timeout=timeout).get("models") def validate_requested_model( model_name: str, provider: Optional[str], *, api_key: Optional[str] = None, base_url: Optional[str] = None, ) -> dict[str, Any]: """ Validate a ``/model`` value for the active provider. Performs format checks first, then probes the live API to confirm the model actually exists. Returns a dict with: - accepted: whether the CLI should switch to the requested model now - persist: whether it is safe to save to config - recognized: whether it matched a known provider catalog - message: optional warning / guidance for the user """ requested = (model_name or "").strip() normalized = normalize_provider(provider) if normalized == "openrouter" and base_url and "openrouter.ai" not in base_url: normalized = "custom" requested_for_lookup = requested if normalized == "copilot": requested_for_lookup = normalize_copilot_model_id( requested, api_key=api_key, ) or requested if not requested: return { "accepted": False, "persist": False, "recognized": False, "message": "Model name cannot be empty.", } if any(ch.isspace() for ch in requested): return { "accepted": False, "persist": False, "recognized": False, "message": "Model names cannot contain spaces.", } if normalized == "custom": probe = probe_api_models(api_key, base_url) api_models = probe.get("models") if api_models is not None: if requested_for_lookup in set(api_models): return { "accepted": True, "persist": True, "recognized": True, "message": None, } suggestions = get_close_matches(requested, api_models, n=3, cutoff=0.5) suggestion_text = "" if suggestions: suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions) message = ( f"Note: `{requested}` was not found in this custom endpoint's model listing " f"({probe.get('probed_url')}). It may still work if the server supports hidden or aliased models." f"{suggestion_text}" ) if probe.get("used_fallback"): message += ( f"\n Endpoint verification succeeded after trying `{probe.get('resolved_base_url')}`. " f"Consider saving that as your base URL." ) return { "accepted": True, "persist": True, "recognized": False, "message": message, } message = ( f"Note: could not reach this custom endpoint's model listing at `{probe.get('probed_url')}`. " f"Hermes will still save `{requested}`, but the endpoint should expose `/models` for verification." ) if probe.get("suggested_base_url"): message += f"\n If this server expects `/v1`, try base URL: `{probe.get('suggested_base_url')}`" return { "accepted": True, "persist": True, "recognized": False, "message": message, } # Probe the live API to check if the model actually exists api_models = fetch_api_models(api_key, base_url) if api_models is not None: if requested_for_lookup in set(api_models): # API confirmed the model exists return { "accepted": True, "persist": True, "recognized": True, "message": None, } else: # API responded but model is not listed. Accept anyway — # the user may have access to models not shown in the public # listing (e.g. Z.AI Pro/Max plans can use glm-5 on coding # endpoints even though it's not in /models). Warn but allow. suggestions = get_close_matches(requested, api_models, n=3, cutoff=0.5) suggestion_text = "" if suggestions: suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions) return { "accepted": True, "persist": True, "recognized": False, "message": ( f"Note: `{requested}` was not found in this provider's model listing. " f"It may still work if your plan supports it." f"{suggestion_text}" ), } # api_models is None — couldn't reach API. Accept and persist, # but warn so typos don't silently break things. provider_label = _PROVIDER_LABELS.get(normalized, normalized) return { "accepted": True, "persist": True, "recognized": False, "message": ( f"Could not reach the {provider_label} API to validate `{requested}`. " f"If the service isn't down, this model may not be valid." ), }