From 204e9190c41ff89d452f46fbe8292d3384873f4b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:59:50 -0700 Subject: [PATCH] fix: consolidate provider lists into single CANONICAL_PROVIDERS source of truth (#9237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate hardcoded provider lists (/model, /provider, hermes model) diverged over time, causing providers to be missing from some commands. - Create CANONICAL_PROVIDERS in hermes_cli/models.py as the single source of truth for all provider identity, labels, and TUI ordering - Derive _PROVIDER_LABELS and list_available_providers() from canonical list - Add step 2b in list_authenticated_providers() to cross-check canonical list — catches providers with credentials that weren't found via PROVIDER_TO_MODELS_DEV or HERMES_OVERLAYS mappings - Derive hermes model TUI provider menus from canonical list - Add deepseek and xai as first-class providers (were missing from TUI) - Add grok/x-ai/x.ai aliases for xai provider Fixes: /model command not showing all providers that hermes model shows --- hermes_cli/main.py | 56 +++------------------- hermes_cli/model_switch.py | 59 +++++++++++++++++++++++ hermes_cli/models.py | 95 ++++++++++++++++++++++++-------------- 3 files changed, 126 insertions(+), 84 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 97281d5a9..fadb42771 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1034,29 +1034,9 @@ def select_provider_and_model(args=None): if active == "openrouter" and get_env_value("OPENAI_BASE_URL"): active = "custom" - provider_labels = { - "openrouter": "OpenRouter", - "nous": "Nous Portal", - "openai-codex": "OpenAI Codex", - "qwen-oauth": "Qwen OAuth", - "copilot-acp": "GitHub Copilot ACP", - "copilot": "GitHub Copilot", - "anthropic": "Anthropic", - "gemini": "Google AI Studio", - "zai": "Z.AI / GLM", - "kimi-coding": "Kimi / Moonshot", - "kimi-coding-cn": "Kimi / Moonshot (China)", - "minimax": "MiniMax", - "minimax-cn": "MiniMax (China)", - "opencode-zen": "OpenCode Zen", - "opencode-go": "OpenCode Go", - "ai-gateway": "AI Gateway", - "kilocode": "Kilo Code", - "alibaba": "Alibaba Cloud (DashScope)", - "huggingface": "Hugging Face", - "xiaomi": "Xiaomi MiMo", - "custom": "Custom endpoint", - } + from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS + + provider_labels = dict(_PROVIDER_LABELS) # derive from canonical list active_label = provider_labels.get(active, active) if active else "none" print() @@ -1065,31 +1045,9 @@ def select_provider_and_model(args=None): print() # Step 1: Provider selection — top providers shown first, rest behind "More..." - top_providers = [ - ("nous", "Nous Portal (Nous Research subscription)"), - ("openrouter", "OpenRouter (100+ models, pay-per-use)"), - ("anthropic", "Anthropic (Claude models — API key or Claude Code)"), - ("openai-codex", "OpenAI Codex"), - ("qwen-oauth", "Qwen OAuth (reuses local Qwen CLI login)"), - ("copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"), - ("huggingface", "Hugging Face Inference Providers (20+ open models)"), - ] - - extended_providers = [ - ("copilot-acp", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"), - ("gemini", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"), - ("zai", "Z.AI / GLM (Zhipu AI direct API)"), - ("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"), - ("kimi-coding-cn", "Kimi / Moonshot China (Moonshot CN direct API)"), - ("minimax", "MiniMax (global direct API)"), - ("minimax-cn", "MiniMax China (domestic direct API)"), - ("kilocode", "Kilo Code (Kilo Gateway API)"), - ("opencode-zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"), - ("opencode-go", "OpenCode Go (open models, $10/month subscription)"), - ("ai-gateway", "AI Gateway (Vercel — 200+ models, pay-per-use)"), - ("alibaba", "Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"), - ("xiaomi", "Xiaomi MiMo (MiMo-V2 models — pro, omni, flash)"), - ] + # Derived from CANONICAL_PROVIDERS (single source of truth) + top_providers = [(p.slug, p.tui_desc) for p in CANONICAL_PROVIDERS if p.tier == "top"] + extended_providers = [(p.slug, p.tui_desc) for p in CANONICAL_PROVIDERS if p.tier == "extended"] def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: custom_provider_map = {} @@ -1207,7 +1165,7 @@ def select_provider_and_model(args=None): _model_flow_anthropic(config, current_model) elif selected_provider == "kimi-coding": _model_flow_kimi(config, current_model) - elif selected_provider in ("gemini", "zai", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface", "xiaomi"): + elif selected_provider in ("gemini", "deepseek", "xai", "zai", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface", "xiaomi"): _model_flow_api_key_provider(config, selected_provider, current_model) # ── Post-switch cleanup: clear stale OPENAI_BASE_URL ────────────── diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 443321b8c..45dced9c2 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -935,6 +935,65 @@ def list_authenticated_providers( seen_slugs.add(pid) seen_slugs.add(hermes_slug) + # --- 2b. Cross-check canonical provider list --- + # Catches providers that are in CANONICAL_PROVIDERS but weren't found + # in PROVIDER_TO_MODELS_DEV or HERMES_OVERLAYS (keeps /model in sync + # with `hermes model`). + try: + from hermes_cli.models import CANONICAL_PROVIDERS as _canon_provs + except ImportError: + _canon_provs = [] + + for _cp in _canon_provs: + if _cp.slug in seen_slugs: + continue + + # Check credentials via PROVIDER_REGISTRY (auth.py) + _cp_config = _auth_registry.get(_cp.slug) + _cp_has_creds = False + if _cp_config and _cp_config.api_key_env_vars: + _cp_has_creds = any(os.environ.get(ev) for ev in _cp_config.api_key_env_vars) + # Also check auth store and credential pool + if not _cp_has_creds: + try: + from hermes_cli.auth import _load_auth_store + _cp_store = _load_auth_store() + _cp_providers_store = _cp_store.get("providers", {}) + _cp_pool_store = _cp_store.get("credential_pool", {}) + if _cp_store and ( + _cp.slug in _cp_providers_store + or _cp.slug in _cp_pool_store + ): + _cp_has_creds = True + except Exception: + pass + if not _cp_has_creds: + try: + from agent.credential_pool import load_pool + _cp_pool = load_pool(_cp.slug) + if _cp_pool.has_credentials(): + _cp_has_creds = True + except Exception: + pass + + if not _cp_has_creds: + continue + + _cp_model_ids = curated.get(_cp.slug, []) + _cp_total = len(_cp_model_ids) + _cp_top = _cp_model_ids[:max_models] + + results.append({ + "slug": _cp.slug, + "name": _cp.label, + "is_current": _cp.slug == current_provider, + "is_user_defined": False, + "models": _cp_top, + "total_models": _cp_total, + "source": "canonical", + }) + seen_slugs.add(_cp.slug) + # --- 3. User-defined endpoints from config --- if user_providers and isinstance(user_providers, dict): for ep_name, ep_cfg in user_providers.items(): diff --git a/hermes_cli/models.py b/hermes_cli/models.py index c3f1408d1..041a4a79f 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -12,7 +12,7 @@ import os import urllib.request import urllib.error from difflib import get_close_matches -from typing import Any, Optional +from typing import Any, NamedTuple, Optional COPILOT_BASE_URL = "https://api.githubcopilot.com" COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models" @@ -485,30 +485,55 @@ def check_nous_free_tier() -> bool: return False # default to paid on error — don't block users -_PROVIDER_LABELS = { - "openrouter": "OpenRouter", - "openai-codex": "OpenAI Codex", - "copilot-acp": "GitHub Copilot ACP", - "nous": "Nous Portal", - "copilot": "GitHub Copilot", - "gemini": "Google AI Studio", - "zai": "Z.AI / GLM", - "kimi-coding": "Kimi / Moonshot", - "kimi-coding-cn": "Kimi / Moonshot (China)", - "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)", - "qwen-oauth": "Qwen OAuth (Portal)", - "huggingface": "Hugging Face", - "xiaomi": "Xiaomi MiMo", - "custom": "Custom endpoint", -} +# --------------------------------------------------------------------------- +# Canonical provider list — single source of truth for provider identity. +# Every code path that lists, displays, or iterates providers derives from +# this list: hermes model, /model, /provider, list_authenticated_providers. +# +# Fields: +# slug — internal provider ID (used in config.yaml, --provider flag) +# label — short display name +# tier — "top" (shown first) or "extended" (behind "More...") +# tui_desc — longer description for the `hermes model` interactive picker +# --------------------------------------------------------------------------- + +class ProviderEntry(NamedTuple): + slug: str + label: str + tier: str # "top" or "extended" + tui_desc: str # detailed description for `hermes model` TUI + + +CANONICAL_PROVIDERS: list[ProviderEntry] = [ + # -- Top tier (shown by default) -- + ProviderEntry("nous", "Nous Portal", "top", "Nous Portal (Nous Research subscription)"), + ProviderEntry("openrouter", "OpenRouter", "top", "OpenRouter (100+ models, pay-per-use)"), + ProviderEntry("anthropic", "Anthropic", "top", "Anthropic (Claude models — API key or Claude Code)"), + ProviderEntry("openai-codex", "OpenAI Codex", "top", "OpenAI Codex"), + ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "top", "Qwen OAuth (reuses local Qwen CLI login)"), + ProviderEntry("copilot", "GitHub Copilot", "top", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"), + ProviderEntry("huggingface", "Hugging Face", "top", "Hugging Face Inference Providers (20+ open models)"), + # -- Extended tier (behind "More..." in hermes model) -- + ProviderEntry("copilot-acp", "GitHub Copilot ACP", "extended", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"), + ProviderEntry("gemini", "Google AI Studio", "extended", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"), + ProviderEntry("deepseek", "DeepSeek", "extended", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"), + ProviderEntry("xai", "xAI", "extended", "xAI (Grok models — direct API)"), + ProviderEntry("zai", "Z.AI / GLM", "extended", "Z.AI / GLM (Zhipu AI direct API)"), + ProviderEntry("kimi-coding", "Kimi / Moonshot", "extended", "Kimi / Moonshot (Moonshot AI direct API)"), + ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "extended", "Kimi / Moonshot China (Moonshot CN direct API)"), + ProviderEntry("minimax", "MiniMax", "extended", "MiniMax (global direct API)"), + ProviderEntry("minimax-cn", "MiniMax (China)", "extended", "MiniMax China (domestic direct API)"), + ProviderEntry("kilocode", "Kilo Code", "extended", "Kilo Code (Kilo Gateway API)"), + ProviderEntry("opencode-zen", "OpenCode Zen", "extended", "OpenCode Zen (35+ curated models, pay-as-you-go)"), + ProviderEntry("opencode-go", "OpenCode Go", "extended", "OpenCode Go (open models, $10/month subscription)"), + ProviderEntry("ai-gateway", "AI Gateway", "extended", "AI Gateway (Vercel — 200+ models, pay-per-use)"), + ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","extended", "Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"), + ProviderEntry("xiaomi", "Xiaomi MiMo", "extended", "Xiaomi MiMo (MiMo-V2 models — pro, omni, flash)"), +] + +# Derived dicts — used throughout the codebase +_PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS} +_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider _PROVIDER_ALIASES = { "glm": "zai", @@ -553,6 +578,9 @@ _PROVIDER_ALIASES = { "huggingface-hub": "huggingface", "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", + "grok": "xai", + "x-ai": "xai", + "x.ai": "xai", } @@ -845,23 +873,20 @@ def list_available_providers() -> list[dict[str, str]]: Each dict has ``id``, ``label``, and ``aliases``. Checks which providers have valid credentials configured. + + Derives the provider list from :data:`CANONICAL_PROVIDERS` (single + source of truth shared with ``hermes model``, ``/model``, etc.). """ - # Canonical providers in display order - _PROVIDER_ORDER = [ - "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", - "gemini", "huggingface", - "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba", - "qwen-oauth", "xiaomi", - "opencode-zen", "opencode-go", - "ai-gateway", "deepseek", "custom", - ] + # Derive display order from canonical list + custom + provider_order = [p.slug for p in CANONICAL_PROVIDERS] + ["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: + for pid in provider_order: label = _PROVIDER_LABELS.get(pid, pid) alias_list = aliases_for.get(pid, []) # Check if this provider has credentials available