| """ |
| 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"] |
|
|
| |
| GITHUB_MODELS_BASE_URL = COPILOT_BASE_URL |
| GITHUB_MODELS_CATALOG_URL = COPILOT_MODELS_URL |
|
|
| |
| 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 |
|
|
|
|
| |
| _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. |
| """ |
| |
| _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", |
| ] |
| |
| 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, []) |
| |
| 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: |
| |
| |
| |
| 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) |
|
|
| |
| live = provider_model_ids(normalized) |
| if live: |
| return [(m, "") for m in live] |
|
|
| |
| 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() |
|
|
| |
| |
| |
| |
| |
| 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 = {"nous", "openrouter"} |
|
|
| |
| current_models = _PROVIDER_MODELS.get(current_provider, []) |
| if any(name_lower == m.lower() for m in current_models): |
| return None |
|
|
| |
| 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: |
| |
| 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) |
|
|
| |
| or_slug = _find_openrouter_slug(name) |
| if or_slug: |
| return ("openrouter", or_slug) |
| |
| |
| return (direct_match, name) |
|
|
| |
| |
| or_slug = _find_openrouter_slug(name) |
| if or_slug: |
| if current_provider != "openrouter": |
| return ("openrouter", or_slug) |
| |
| if or_slug != name: |
| return ("openrouter", or_slug) |
| return None |
|
|
| 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 |
|
|
| |
| for mid, _ in OPENROUTER_MODELS: |
| if name_lower == mid.lower(): |
| return mid |
|
|
| |
| 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: |
| 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: |
| |
| 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")] |
| |
| return sorted(models, key=lambda m: ( |
| "opus" not in m, |
| "sonnet" not in m, |
| "haiku" not in m, |
| m, |
| )) |
| 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" |
|
|
| |
| if _should_use_copilot_responses_api(normalized): |
| return "codex_responses" |
|
|
| |
| 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() |
| } |
| |
| 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, |
| } |
|
|
| |
| api_models = fetch_api_models(api_key, base_url) |
|
|
| if api_models is not None: |
| if requested_for_lookup in set(api_models): |
| |
| return { |
| "accepted": True, |
| "persist": True, |
| "recognized": True, |
| "message": None, |
| } |
| else: |
| |
| |
| |
| |
| 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}" |
| ), |
| } |
|
|
| |
| |
| 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." |
| ), |
| } |
|
|