OAuth and overlay providers missing from /model picker

NousResearch/hermes-agent · cluster 5223 · gateway

Data as of

Nothing open on GitHub. Fix merged in #5983. Check it covers your case.

The short answer

0open issues
0open PRs
11items grouped
Known?
Yes. 11 related items grouped here, 4 issues already closed.
Fix event
A fix was merged to main in PR #5983. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #7373, #9810. That is separate from the fix event.
Still open
Nothing open on GitHub.

Multiple issues and fixes addressing OAuth-based providers (Copilot, Codex, Nous, Qwen) not appearing in the /model picker, caused by import errors, wrong key lookups, slug mismatches, and fallback auth-store problems. Related fixes also touch provider alias resolution and credential detection across CLI and gateway contexts.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #5983 fix: provider/model resolution — salvage 4 community PRs + MiniMax aux URL fix

    merged to main

    files: agent/auxiliary_client.py, agent/model_metadata.py, hermes_cli/model_switch.py, run_agent.py, tests/agent/test_auxiliary_client.py, tests/agent/test_minimax_auxiliary_url.py, tests/test_ollama_num_ctx.py

    Show the diff
    • agent/auxiliary_client.py
      @@ -59,6 +59,41 @@
       
       logger = logging.getLogger(__name__)
       
      +_PROVIDER_ALIASES = {
      +    "google": "gemini",
      +    "google-gemini": "gemini",
      +    "google-ai-studio": "gemini",
      +    "glm": "zai",
      +    "z-ai": "zai",
      +    "z.ai": "zai",
      +    "zhipu": "zai",
      +    "kimi": "kimi-coding",
      +    "moonshot": "kimi-coding",
      +    "minimax-china": "minimax-cn",
      +    "minimax_cn": "minimax-cn",
      +    "claude": "anthropic",
      +    "claude-code": "anthropic",
      +}
      +
      +
      +def _normalize_aux_provider(provider: Optional[str], *, for_vision: bool = False) -> str:
      +    normalized = (provider or "auto").strip().lower()
      +    if normalized.startswith("custom:"):
      +        suffix = normalized.split(":", 1)[1].strip()
      +        if not suffix:
      +            return "custom"
      +        normalized = suffix if not for_vision else "custom"
      +    if normalized == "codex":
      +        return "openai-codex"
      +    if normalized == "main":
      +        # Resolve to the user's actual main provider so named custom providers
      +        # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly.
      +        main_prov = _read_main_provider()
      +        if main_prov and main_prov not in ("auto", "main", ""):
      +            return main_prov
      +        return "custom"
      +    return _PROVIDER_ALIASES.get(normalized, normalized)
      +
       # Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
       _API_KEY_PROVIDER_AUX_MODELS: [redacted:secret-assignment] str] = {
           "gemini": "gemini-3-flash-preview",
      @@ -106,6 +141,23 @@
       _CODEX_AUX_BASE_URL = "https://chatgpt.com/backend-api/codex"
       
       
      +def _to_openai_base_url(base_url: str) -> str:
      +    """Normalize an Anthropic-style base URL to OpenAI-compatible format.
      +
      +    Some providers (MiniMax, MiniMax-CN) expose an ``/anthropic`` endpoint for
      +    the Anthropic Messages API and a separate ``/v1`` endpoint for OpenAI chat
      +    completions.  The auxiliary client uses the OpenAI SDK, so it must hit the
      +    ``/v1`` surface.  Passing the raw ``inference_base_url`` causes requests to
      +    land on ``/anthropic/chat/completions`` — a 404.
      +    """
      +    url = str(base_url or "").strip().rstrip("/")
      +    if url.endswith("/anthropic"):
      +        rewritten = url[: -len("/anthropic")] + "/v1"
      +        logger.debug("Auxiliary client: rewrote base URL %s → %s", url, rewritten)
      +        return rewritten
      +    return url
      +
      +
       def _select_pool_entry(provider: str) -> Tuple[bool, Optional[Any]]:
           """Return (pool_exists_for_provider, selected_entry)."""
           try:
      @@ -635,7 +687,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
                   if not api_key:
                       continue
       
      -            base_url = _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
      +            base_url = _to_openai_base_url(
      +                _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
      +            )
                   model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default")
                   logger.debug("Auxiliary text client: %s (%s) via pool", pconfig.name, model)
                   extra = {}
      @@ -652,7 +706,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
               if not api_key:
                   continue
       
      -        base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
      +        base_url = _to_openai_base_url(
      +            str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
      +        )
               model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default")
               logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
               extra = {}
      @@ -778,7 +834,7 @@ def _read_main_provider() -> str:
               if isinstance(model_cfg, dict):
                   provider = model_cfg.get("provider", "")
                   if isinstance(provider, str) and provider.strip():
      -                return provider.strip().lower()
      +                return _normalize_aux_provider(provider)
           except Exception:
               pass
           return ""
      @@ -1140,17 +1196,7 @@ def resolve_provider_client(
               (client, resolved_model) or (None, None) if auth is unavailable.
           """
           # Normalise aliases
      -    provider = (provider or "auto").strip().lower()
      -    if provider == "codex":
      -        provider = "openai-codex"
      -    if provider == "main":
      -        # Resolve to the user's actual main provider so named custom providers
      -        # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly.
      -        main_prov = _read_main_provider()
      -        if main_prov and main_prov not in ("auto", "main", ""):
      -            provider = main_prov
      -        else:
      -            provider = "custom"
      +    provider = _normalize_aux_provider(provider)
       
           # ── Auto: try all providers in priority order ────────────────────
           if provider == "auto":
      @@ -1300,7 +1346,9 @@ def resolve_provider_client(
                                provider, ", ".join(tried_sources))
                   return None, None
       
      -        base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
      +        base_url = _to_openai_base_url(
      +            str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
      +        )
       
               default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
               final_model = model or default_model
      @@ -1384,17 +1432,7 @@ def get_async_text_auxiliary_client(task: str = ""):
       
       
       def _normalize_vision_provider(provider: Optional[str]) -> str:
      -    provider = (provider or "auto").strip().lower()
      -    if provider == "codex":
      -        return "openai-codex"
      -    if provider == "main":
      -        # Resolve to actual main provider — named custom providers and
      -        # non-aggregator providers need to pass through as their real name.
      -        main_prov = _read_main_provider()
      -        if main_prov and main_prov not in ("auto", "main", ""):
      -            return main_prov
      -        return "custom"
      -    return provider
      +    return _normalize_aux_provider(provider, for_vision=True)
       
       
       def _resolve_strict_vision_backend(provider: str) -> Tuple[Optional[Any], Optional[str]]:
    • agent/model_metadata.py
      @@ -611,6 +611,59 @@ def _model_id_matches(candidate_id: str, lookup_model: str) -> bool:
           return False
       
       
      +def query_ollama_num_ctx(model: str, base_url: str) -> Optional[int]:
      +    """Query an Ollama server for the model's context length.
      +
      +    Returns the model's maximum context from GGUF metadata via ``/api/show``,
      +    or the explicit ``num_ctx`` from the Modelfile if set.  Returns None if
      +    the server is unreachable or not Ollama.
      +
      +    This is the value that should be passed as ``num_ctx`` in Ollama chat
      +    requests to override the default 2048.
      +    """
      +    import httpx
      +
      +    bare_model = _strip_provider_prefix(model)
      +    server_url = base_url.rstrip("/")
      +    if server_url.endswith("/v1"):
      +        server_url = server_url[:-3]
      +
      +    try:
      +        server_type = detect_local_server_type(base_url)
      +    except Exception:
      +        return None
      +    if server_type != "ollama":
      +        return None
      +
      +    try:
      +        with httpx.Client(timeout=3.0) as client:
      +            resp = client.post(f"{server_url}/api/show", json={"name": bare_model})
      +            if resp.status_code != 200:
      +                return None
      +            data = resp.json()
      +
      +            # Prefer explicit num_ctx from Modelfile parameters (user override)
      +            params = data.get("parameters", "")
      +            if "num_ctx" in params:
      +                for line in params.split("\n"):
      +                    if "num_ctx" in line:
      +                        parts = line.strip().split()
      +                        if len(parts) >= 2:
      +                            try:
      +                                return int(parts[-1])
      +                            except ValueError:
      +                                pass
      +
      +            # Fall back to GGUF model_info context_length (training max)
      +            model_info = data.get("model_info", {})
      +            for key, value in model_info.items():
      +                if "context_length" in key and isinstance(value, (int, float)):
      +                    return int(value)
      +    except Exception:
      +        pass
      +    return None
      +
      +
       def _query_local_context_length(model: str, base_url: str) -> Optional[int]:
           """Query a local server for the model's context length."""
           import httpx
    • hermes_cli/model_switch.py
      @@ -791,12 +791,12 @@ def list_authenticated_providers(
               if overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"):
                   # These use auth stores, not env vars — check for auth.json entries
                   try:
      -                from hermes_cli.auth import _read_auth_store
      -                store = _read_auth_store()
      -                if store and pid in store:
      +                from hermes_cli.auth import _load_auth_store
      +                store = _load_auth_store()
      +                if store and (pid in store.get("providers", {}) or pid in store.get("credential_pool", {})):
                           has_creds = True
      -            except Exception:
      -                pass
      +            except Exception as exc:
      +                logger.debug("Auth store check failed for %s: %s", pid, exc)
               if not has_creds:
                   continue
       
    • run_agent.py
      @@ -85,6 +85,7 @@
           estimate_tokens_rough, estimate_messages_tokens_rough, estimate_request_tokens_rough,
           get_next_probe_tier, parse_context_limit_from_error,
           save_context_length, is_local_endpoint,
      +    query_ollama_num_ctx,
       )
       from agent.context_compressor import ContextCompressor
       from agent.subdirectory_hints import SubdirectoryHintTracker
      @@ -1216,6 +1217,33 @@ def __init__(
               self.session_cost_status = "unknown"
               self.session_cost_source = "none"
               
      +        # ── Ollama num_ctx injection ──
      +        # Ollama defaults to 2048 context regardless of the model's capabilities.
      +        # When running against an Ollama server, detect the model's max context
      +        # and pass num_ctx on every chat request so the full window is used.
      +        # User override: set model.ollama_num_ctx in config.yaml to cap VRAM use.
      +        self._ollama_num_ctx: int | None = None
      +        _ollama_num_ctx_override = None
      +        if isinstance(_model_cfg, dict):
      +            _ollama_num_ctx_override = _model_cfg.get("ollama_num_ctx")
      +        if _ollama_num_ctx_override is not None:
      +            try:
      +                self._ollama_num_ctx = int(_ollama_num_ctx_override)
      +            except (TypeError, ValueError):
      +                logger.debug("Invalid ollama_num_ctx config value: %r", _ollama_num_ctx_override)
      +        if self._ollama_num_ctx is None and self.base_url and is_local_endpoint(self.base_url):
      +            try:
      +                _detected = query_ollama_num_ctx(self.model, self.base_url)
      +                if _detected and _detected > 0:
      +                    self._ollama_num_ctx = _detected
      +            except Exception as exc:
      +                logger.debug("Ollama num_ctx detection failed: %s", exc)
      +        if self._ollama_num_ctx and not self.quiet_mode:
      +            logger.info(
      +                "Ollama num_ctx: will request %d tokens (model max from /api/show)",
      +                self._ollama_num_ctx,
      +            )
      +
               if not self.quiet_mode:
                   if compression_enabled:
                       print(f"📊 Context limit: {self.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {self.context_compressor.threshold_tokens:,})")
      @@ -5456,6 +5484,15 @@ def _build_api_kwargs(self, api_messages: list) -> dict:
               if _is_nous:
                   extra_body["tags"] = ["product=hermes-agent"]
       
      +        # Ollama num_ctx: override the 2048 default so the model actually
      +        # uses the context window it was trained for.  Passed via the OpenAI
      +        # SDK's extra_body → options.num_ctx, which Ollama's OpenAI-compat
      +        # endpoint forwards to the runner as --ctx-size.
      +        if self._ollama_num_ctx:
      +            options = extra_body.get("options", {})
      +            options["num_ctx"] = self._ollama_num_ctx
      +            extra_body["options"] = options
      +
               if extra_body:
                   api_kwargs["extra_body"] = extra_body
       
    • tests/agent/test_auxiliary_client.py
      @@ -471,6 +471,23 @@ def test_explicit_zai(self, monkeypatch):
                   client, model = resolve_provider_client("zai")
                   assert client is not None
       
      +    def test_explicit_google_alias_uses_gemini_credentials(self):
      +        """provider='google' should route through the gemini API-key provider."""
      +        with (
      +            patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={
      +                "api_key": "gemini-key",
      +                "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
      +            }),
      +            patch("agent.auxiliary_client.OpenAI") as mock_openai,
      +        ):
      +            mock_openai.return_value = MagicMock()
      +            client, model = resolve_provider_client("google", model="gemini-3.1-pro-preview")
      +
      +        assert client is not None
      +        assert model == "gemini-3.1-pro-preview"
      +        assert mock_openai.call_args.kwargs["api_key"] == "gemini-key"
      +        assert mock_openai.call_args.kwargs["base_url"] == "https://generativelanguage.googleapis.com/v1beta/openai"
      +
           def test_explicit_unknown_returns_none(self, monkeypatch):
               """Unknown provider should return None."""
               client, model = resolve_provider_client("nonexistent-provider")
      @@ -822,6 +839,31 @@ def test_vision_uses_nous_when_available(self, monkeypatch):
               assert model == "google/gemini-3-flash-preview"
               assert client is not None
       
      +    def test_vision_config_google_provider_uses_gemini_credentials(self, monkeypatch):
      +        config = {
      +            "auxiliary": {
      +                "vision": {
      +                    "provider": "google",
      +                    "model": "gemini-3.1-pro-preview",
      +                }
      +            }
      +        }
      +        monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
      +        with (
      +            patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={
      +                "api_key": "gemini-key",
      +                "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
      +            }),
      +            patch("agent.auxiliary_client.OpenAI") as mock_openai,
      +        ):
      +            resolved_provider, client, model = resolve_vision_provider_client()
      +
      +        assert resolved_provider == "gemini"
      +        assert client is not None
      +        assert model == "gemini-3.1-pro-preview"
      +        assert mock_openai.call_args.kwargs["api_key"] == "gemini-key"
      +        assert mock_openai.call_args.kwargs["base_url"] == "https://generativelanguage.googleapis.com/v1beta/openai"
      +
           def test_vision_forced_main_uses_custom_endpoint(self, monkeypatch):
               """When explicitly forced to 'main', vision CAN use custom endpoint."""
               config = {
    • tests/agent/test_minimax_auxiliary_url.py
      @@ -0,0 +1,42 @@
      +"""Tests for MiniMax auxiliary client URL normalization.
      +
      +MiniMax and MiniMax-CN set inference_base_url to the /anthropic path.
      +The auxiliary client uses the OpenAI SDK, which needs /v1 instead.
      +"""
      +
      +import sys
      +import os
      +
      +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
      +
      +from agent.auxiliary_client import _to_openai_base_url
      +
      +
      +class TestToOpenaiBaseUrl:
      +    def test_minimax_global_anthropic_suffix_replaced(self):
      +        assert _to_openai_base_url("https://api.minimax.io/anthropic") == "https://api.minimax.io/v1"
      +
      +    def test_minimax_cn_anthropic_suffix_replaced(self):
      +        assert _to_openai_base_url("https://api.minimaxi.com/anthropic") == "https://api.minimaxi.com/v1"
      +
      +    def test_trailing_slash_stripped_before_replace(self):
      +        assert _to_openai_base_url("https://api.minimax.io/anthropic/") == "https://api.minimax.io/v1"
      +
      +    def test_v1_url_unchanged(self):
      +        assert _to_openai_base_url("https://api.openai.com/v1") == "https://api.openai.com/v1"
      +
      +    def test_openrouter_url_unchanged(self):
      +        assert _to_openai_base_url("https://openrouter.ai/api/v1") == "https://openrouter.ai/api/v1"
      +
      +    def test_anthropic_domain_unchanged(self):
      +        """api.anthropic.com doesn't end with /anthropic — should be untouched."""
      +        assert _to_openai_base_url("https://api.anthropic.com") == "https://api.anthropic.com"
      +
      +    def test_anthropic_in_subpath_unchanged(self):
      +        assert _to_openai_base_url("https://example.com/anthropic/extra") == "https://example.com/anthropic/extra"
      +
      +    def test_empty_string(self):
      +        assert _to_openai_base_url("") == ""
      +
      +    def test_none(self):
      +        assert _to_openai_base_url(None) == ""
    • tests/test_ollama_num_ctx.py
      @@ -0,0 +1,135 @@
      +"""Tests for Ollama num_ctx context length detection and injection.
      +
      +Covers:
      +  agent/model_metadata.py — query_ollama_num_ctx()
      +  run_agent.py — _ollama_num_ctx detection + extra_body injection
      +"""
      +
      +from unittest.mock import patch, MagicMock
      +
      +import pytest
      +
      +from agent.model_metadata import query_ollama_num_ctx
      +
      +
      +# ═══════════════════════════════════════════════════════════════════════
      +# Level 1: query_ollama_num_ctx — Ollama API interaction
      +# ═══════════════════════════════════════════════════════════════════════
      +
      +
      +def _mock_httpx_client(show_response_data, status_code=200):
      +    """Create a mock httpx.Client context manager that returns given /api/show data."""
      +    mock_resp = MagicMock(status_code=status_code)
      +    mock_resp.json.return_value = show_response_data
      +    mock_client = MagicMock()
      +    mock_client.post.return_value = mock_resp
      +    mock_ctx = MagicMock()
      +    mock_ctx.__enter__ = MagicMock(return_value=mock_client)
      +    mock_ctx.__exit__ = MagicMock(return_value=False)
      +    return mock_ctx, mock_client
      +
      +
      +class TestQueryOllamaNumCtx:
      +    """Test the Ollama /api/show context length query."""
      +
      +    def test_returns_context_from_model_info(self):
      +        """Should extract context_length from GGUF model_info metadata."""
      +        show_data = {
      +            "model_info": {"llama.context_length": 131072},
      +            "parameters": "",
      +        }
      +        mock_ctx, _ = _mock_httpx_client(show_data)
      +
      +        with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
      +            # httpx is imported inside the function — patch the module import
      +            import httpx
      +            with patch.object(httpx, "Client", return_value=mock_ctx):
      +                result = query_ollama_num_ctx("llama3.1:8b", "http://localhost:11434/v1")
      +
      +        assert result == 131072
      +
      +    def test_prefers_explicit_num_ctx_from_modelfile(self):
      +        """If the Modelfile sets num_ctx explicitly, that should take priority."""
      +        show_data = {
      +            "model_info": {"llama.context_length": 131072},
      +            "parameters": "num_ctx 32768\ntemperature 0.7",
      +        }
      +        mock_ctx, _ = _mock_httpx_client(show_data)
      +
      +        with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
      +            import httpx
      +            with patch.object(httpx, "Client", return_value=mock_ctx):
      +                result = query_ollama_num_ctx("custom-model", "http://localhost:11434")
      +
      +        assert result == 32768
      +
      +    def test_returns_none_for_non_ollama_server(self):
      +        """Should return None if the server is not Ollama."""
      +        with patch("agent.model_metadata.detect_local_server_type", return_value="lm-studio"):
      +            result = query_ollama_num_ctx("model", "http://localhost:1234")
      +        assert result is None
      +
      +    def test_returns_none_on_connection_error(self):
      +        """Should return None if the server is unreachable."""
      +        with patch("agent.model_metadata.detect_local_server_type", side_effect=Exception("timeout")):
      +            result = query_ollama_num_ctx("model", "http://localhost:11434")
      +        assert result is None
      +
      +    def test_returns_none_on_404(self):
      +        """Should return None if the model is not found."""
      +        mock_ctx, _ = _mock_httpx_client({}, status_code=404)
      +
      +        with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
      +            import httpx
      +            with patch.object(httpx, "Client", return_value=mock_ctx):
      +                result = query_ollama_num_ctx("nonexistent", "http://localhost:11434")
      +
      +        assert result is None
      +
      +    def test_strips_provider_prefix(self):
      +        """Should strip 'local:' prefix from model name before querying."""
      +        show_data = {
      +            "model_info": {"qwen2.context_length": 32768},
      +            "parameters": "",
      +        }
      +        mock_ctx, mock_client = _mock_httpx_client(show_data)
      +
      +        with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
      +            import httpx
      +            with patch.object(httpx, "Client", return_value=mock_ctx):
      +                result = query_ollama_num_ctx("local:qwen2.5:7b", "http://localhost:11434/v1")
      +
      +        # Verify the post was called with stripped name (no "local:" prefix)
      +        call_args = mock_client.post.call_args
      +        assert call_args[1]["json"]["name"] == "qwen2.5:7b" or call_args[0][1] is not None
      +        assert result == 32768
      +
      +    def test_handles_qwen2_architecture_key(self):
      +        """Different model architectures use different key prefixes in model_info."""
      +        show_data = {
      +            "model_info": {"qwen2.context_length": 65536},
      +            "parameters": "",
      +        }
      +        mock_ctx, _ = _mock_httpx_client(show_data)
      +
      +        with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
      +            import httpx
      +            with patch.object(httpx, "Client", return_value=mock_ctx):
      +                result = query_ollama_num_ctx("qwen2.5:32b", "http://localhost:11434")
      +
      +        assert result == 65536
      +
      +    def test_returns_none_when_model_info_empty(self):
      +        """Should return None if model_info has no context_length key."""
      +        show_data = {
      +            "model_info": {"llama.embedding_length": 4096},
      +            "parameters": "",
      +        }
      +        mock_ctx, _ = _mock_httpx_client(show_data)
      +
      +        with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
      +            import httpx
      +            with patch.object(httpx, "Client", return_value=mock_ctx):
      +                result = query_ollama_num_ctx("model", "http://localhost:11434")
      +
      +        assert result is None
  • #7373 fix: resolve overlay provider slug mismatch in /model picker

    merged to main

    files: hermes_cli/auth.py, hermes_cli/model_switch.py, tests/hermes_cli/test_overlay_slug_resolution.py

    Show the diff
    • hermes_cli/auth.py
      @@ -890,7 +890,7 @@ def resolve_provider(
           _PROVIDER_ALIASES = {
               "glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai",
               "google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini",
      -        "kimi": "kimi-coding", "moonshot": "kimi-coding",
      +        "kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding",
               "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
               "claude": "anthropic", "claude-code": "anthropic",
               "github": "copilot", "github-copilot": "copilot",
    • hermes_cli/model_switch.py
      @@ -812,45 +812,66 @@ def list_authenticated_providers(
           # --- 2. Check Hermes-only providers (nous, openai-codex, copilot, opencode-go) ---
           from hermes_cli.providers import HERMES_OVERLAYS
           from hermes_cli.auth import PROVIDER_REGISTRY as _auth_registry
      +
      +    # Build reverse mapping: models.dev ID → Hermes provider ID.
      +    # HERMES_OVERLAYS keys may be models.dev IDs (e.g. "github-copilot")
      +    # while _PROVIDER_MODELS and config.yaml use Hermes IDs ("copilot").
      +    _mdev_to_hermes = {v: k for k, v in PROVIDER_TO_MODELS_DEV.items()}
      +
           for pid, overlay in HERMES_OVERLAYS.items():
               if pid in seen_slugs:
                   continue
      +
      +        # Resolve Hermes slug — e.g. "github-copilot" → "copilot"
      +        hermes_slug = _mdev_to_hermes.get(pid, pid)
      +        if hermes_slug in seen_slugs:
      +            continue
      +
               # Check if credentials exist
               has_creds = False
               if overlay.extra_env_vars:
                   has_creds = any(os.environ.get(ev) for ev in overlay.extra_env_vars)
               # Also check api_key_env_vars from PROVIDER_REGISTRY for api_key auth_type
               if not has_creds and overlay.auth_type == "api_key":
      -            pcfg = _auth_registry.get(pid)
      -            if pcfg and pcfg.api_key_env_vars:
      -                has_creds = any(os.environ.get(ev) for ev in pcfg.api_key_env_vars)
      -        if overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"):
      +            for _key in (pid, hermes_slug):
      +                pcfg = _auth_registry.get(_key)
      +                if pcfg and pcfg.api_key_env_vars:
      +                    if any(os.environ.get(ev) for ev in pcfg.api_key_env_vars):
      +                        has_creds = True
      +                        break
      +        if not has_creds and overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"):
                   # These use auth stores, not env vars — check for auth.json entries
                   try:
                       from hermes_cli.auth import _load_auth_store
                       store = _load_auth_store()
      -                if store and (pid in store.get("providers", {}) or pid in store.get("credential_pool", {})):
      +                providers_store = store.get("providers", {})
      +                pool_store = store.get("credential_pool", {})
      +                if store and (
      +                    pid in providers_store or hermes_slug in providers_store
      +                    or pid in pool_store or hermes_slug in pool_store
      +                ):
                           has_creds = True
                   except Exception as exc:
                       logger.debug("Auth store check failed for %s: %s", pid, exc)
               if not has_creds:
                   continue
       
      -        # Use curated list
      -        model_ids = curated.get(pid, [])
      +        # Use curated list — look up by Hermes slug, fall back to overlay key
      +        model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
               total = len(model_ids)
               top = model_ids[:max_models]
       
               results.append({
      -            "slug": pid,
      -            "name": get_label(pid),
      -            "is_current": pid == current_provider,
      +            "slug": hermes_slug,
      +            "name": get_label(hermes_slug),
      +            "is_current": hermes_slug == current_provider or pid == current_provider,
                   "is_user_defined": False,
                   "models": top,
                   "total_models": total,
                   "source": "hermes",
               })
               seen_slugs.add(pid)
      +        seen_slugs.add(hermes_slug)
       
           # --- 3. User-defined endpoints from config ---
           if user_providers and isinstance(user_providers, dict):
    • tests/hermes_cli/test_overlay_slug_resolution.py
      @@ -0,0 +1,83 @@
      +"""Test that overlay providers with mismatched models.dev keys resolve correctly.
      +
      +HERMES_OVERLAYS keys may be models.dev IDs (e.g. "github-copilot") while
      +_PROVIDER_MODELS and config.yaml use Hermes IDs ("copilot").  The slug
      +resolution in list_authenticated_providers() Section 2 must bridge this gap.
      +
      +Covers: #5223, #6492
      +"""
      +
      +import json
      +import os
      +from unittest.mock import patch
      +
      +import pytest
      +
      +from hermes_cli.model_switch import list_authenticated_providers
      +
      +
      +# -- Copilot slug resolution (env var path) ----------------------------------
      +
      [redacted:email](os.environ, {"COPILOT_GITHUB_TOKEN": "fake-ghu"}, clear=False)
      +def test_copilot_uses_hermes_slug():
      +    """github-copilot overlay should resolve to slug='copilot' with curated models."""
      +    providers = list_authenticated_providers(current_provider="copilot")
      +
      +    copilot = next((p for p in providers if p["slug"] == "copilot"), None)
      +    assert copilot is not None, "copilot should appear when COPILOT_GITHUB_TOKEN is set"
      +    assert copilot["total_models"] > 0, "copilot should have curated models"
      +    assert copilot["is_current"] is True
      +
      +    # Must NOT appear under the models.dev key
      +    gh_copilot = next((p for p in providers if p["slug"] == "github-copilot"), None)
      +    assert gh_copilot is None, "github-copilot slug should not appear (resolved to copilot)"
      +
      +
      [redacted:email](os.environ, {"COPILOT_GITHUB_TOKEN": "fake-ghu"}, clear=False)
      +def test_copilot_no_duplicate_entries():
      +    """Copilot must appear only once — not as both 'copilot' (section 1) and 'github-copilot' (section 2)."""
      +    providers = list_authenticated_providers(current_provider="copilot")
      +
      +    copilot_slugs = [p["slug"] for p in providers if "copilot" in p["slug"]]
      +    # Should have at most one copilot entry (may also have copilot-acp if creds exist)
      +    copilot_main = [s for s in copilot_slugs if s == "copilot"]
      +    assert len(copilot_main) == 1, f"Expected exactly one 'copilot' entry, got {copilot_main}"
      +
      +
      +# -- kimi-for-coding alias in auth.py ----------------------------------------
      +
      +def test_kimi_for_coding_alias():
      +    """resolve_provider('kimi-for-coding') should return 'kimi-coding'."""
      +    from hermes_cli.auth import resolve_provider
      +
      +    result = resolve_provider("kimi-for-coding")
      +    assert result == "kimi-coding"
      +
      +
      +# -- Generic slug mismatch providers -----------------------------------------
      +
      [redacted:email](os.environ, {"KIMI_API_KEY": "fake-key"}, clear=False)
      +def test_kimi_for_coding_overlay_uses_hermes_slug():
      +    """kimi-for-coding overlay should resolve to slug='kimi-coding'."""
      +    providers = list_authenticated_providers(current_provider="kimi-coding")
      +
      +    kimi = next((p for p in providers if p["slug"] == "kimi-coding"), None)
      +    assert kimi is not None, "kimi-coding should appear when KIMI_API_KEY is set"
      +    assert kimi["is_current"] is True
      +
      +    # Must NOT appear under the models.dev key
      +    kimi_mdev = next((p for p in providers if p["slug"] == "kimi-for-coding"), None)
      +    assert kimi_mdev is None, "kimi-for-coding slug should not appear (resolved to kimi-coding)"
      +
      +
      [redacted:email](os.environ, {"KILOCODE_API_KEY": "fake-key"}, clear=False)
      +def test_kilo_overlay_uses_hermes_slug():
      +    """kilo overlay should resolve to slug='kilocode'."""
      +    providers = list_authenticated_providers(current_provider="kilocode")
      +
      +    kilo = next((p for p in providers if p["slug"] == "kilocode"), None)
      +    assert kilo is not None, "kilocode should appear when KILOCODE_API_KEY is set"
      +    assert kilo["is_current"] is True
      +
      +    kilo_mdev = next((p for p in providers if p["slug"] == "kilo"), None)
      +    assert kilo_mdev is None, "kilo slug should not appear (resolved to kilocode)"
  • #9810 fix: detect copilot and qwen-oauth providers in /model picker

    merged to main

    files: agent/credential_pool.py, tests/agent/test_credential_pool.py

    Show the diff
    • agent/credential_pool.py
      @@ -1152,6 +1152,59 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
                       },
                   )
       
      +    elif provider == "copilot":
      +        # Copilot tokens are resolved dynamically via `gh auth token` or
      +        # env vars (COPILOT_GITHUB_TOKEN / GH_TOKEN).  They don't live in
      +        # the auth store or credential pool, so we resolve them here.
      +        try:
      +            from hermes_cli.copilot_auth import resolve_copilot_token
      +            token, source = resolve_copilot_token()
      +            if token:
      +                source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}"
      +                active_sources.add(source_name)
      +                changed |= _upsert_entry(
      +                    entries,
      +                    provider,
      +                    source_name,
      +                    {
      +                        "source": source_name,
      +                        "auth_type": AUTH_TYPE_API_KEY,
      +                        "access_token": token,
      +                        "label": source,
      +                    },
      +                )
      +        except Exception as exc:
      +            logger.debug("Copilot token seed failed: %s", exc)
      +
      +    elif provider == "qwen-oauth":
      +        # Qwen OAuth tokens live in ~/.qwen/oauth_creds.json, written by
      +        # the Qwen CLI (`qwen auth qwen-oauth`).  They aren't in the
      +        # Hermes auth store or env vars, so resolve them here.
      +        # Use refresh_if_expiring=False to avoid network calls during
      +        # pool loading / provider discovery.
      +        try:
      +            from hermes_cli.auth import resolve_qwen_runtime_credentials
      +            creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)
      +            token = [redacted:secret-assignment]"api_key", "")
      +            if token:
      +                source_name = creds.get("source", "qwen-cli")
      +                active_sources.add(source_name)
      +                changed |= _upsert_entry(
      +                    entries,
      +                    provider,
      +                    source_name,
      +                    {
      +                        "source": source_name,
      +                        "auth_type": AUTH_TYPE_OAUTH,
      +                        "access_token": token,
      +                        "expires_at_ms": creds.get("expires_at_ms"),
      +                        "base_url": creds.get("base_url", ""),
      +                        "label": creds.get("auth_file", source_name),
      +                    },
      +                )
      +        except Exception as exc:
      +            logger.debug("Qwen OAuth token seed failed: %s", exc)
      +
           elif provider == "openai-codex":
               state = _load_provider_state(auth_store, "openai-codex")
               tokens = [redacted:secret-assignment]"tokens") if isinstance(state, dict) else None
    • tests/agent/test_credential_pool.py
      @@ -1071,3 +1071,88 @@ def test_load_pool_does_not_seed_claude_code_when_anthropic_not_configured(tmp_p
       
           # Should NOT have seeded the claude_code entry
           assert pool.entries() == []
      +
      +
      +def test_load_pool_seeds_copilot_via_gh_auth_token(tmp_path, monkeypatch):
      +    """Copilot credentials from `gh auth token` should be seeded into the pool."""
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    _write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
      +
      +    monkeypatch.setattr(
      +        "hermes_cli.copilot_auth.resolve_copilot_token",
      +        lambda: ("gho_fake_token_abc123", "gh auth token"),
      +    )
      +
      +    from agent.credential_pool import load_pool
      +    pool = load_pool("copilot")
      +
      +    assert pool.has_credentials()
      +    entries = pool.entries()
      +    assert len(entries) == 1
      +    assert entries[0].source == "gh_cli"
      +    assert entries[0].access_token == "gho_fake_token_abc123"
      +
      +
      +def test_load_pool_does_not_seed_copilot_when_no_token(tmp_path, monkeypatch):
      +    """Copilot pool should be empty when resolve_copilot_token() returns nothing."""
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    _write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
      +
      +    monkeypatch.setattr(
      +        "hermes_cli.copilot_auth.resolve_copilot_token",
      +        lambda: ("", ""),
      +    )
      +
      +    from agent.credential_pool import load_pool
      +    pool = load_pool("copilot")
      +
      +    assert not pool.has_credentials()
      +    assert pool.entries() == []
      +
      +
      +def test_load_pool_seeds_qwen_oauth_via_cli_tokens(tmp_path, monkeypatch):
      +    """Qwen OAuth credentials from ~/.qwen/oauth_creds.json should be seeded into the pool."""
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    _write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
      +
      +    monkeypatch.setattr(
      +        "hermes_cli.auth.resolve_qwen_runtime_credentials",
      +        lambda **kw: {
      +            "provider": "qwen-oauth",
      +            "base_url": "https://portal.qwen.ai/v1",
      +            "api_key": "qwen_fake_token_xyz",
      +            "source": "qwen-cli",
      +            "expires_at_ms": 1900000000000,
      +            "auth_file": str(tmp_path / ".qwen" / "oauth_creds.json"),
      +        },
      +    )
      +
      +    from agent.credential_pool import load_pool
      +    pool = load_pool("qwen-oauth")
      +
      +    assert pool.has_credentials()
      +    entries = pool.entries()
      +    assert len(entries) == 1
      +    assert entries[0].source == "qwen-cli"
      +    assert entries[0].access_token == "qwen_fake_token_xyz"
      +
      +
      +def test_load_pool_does_not_seed_qwen_oauth_when_no_token(tmp_path, monkeypatch):
      +    """Qwen OAuth pool should be empty when no CLI credentials exist."""
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    _write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
      +
      +    from hermes_cli.auth import AuthError
      +
      +    monkeypatch.setattr(
      +        "hermes_cli.auth.resolve_qwen_runtime_credentials",
      +        lambda **kw: (_ for _ in ()).throw(
      +            AuthError("Qwen CLI credentials not found.", provider="qwen-oauth", code="qwen_auth_missing")
      +        ),
      +    )
      +
      +    from agent.credential_pool import load_pool
      +    pool = load_pool("qwen-oauth")
      +
      +    assert not pool.has_credentials()
      +    assert pool.entries() == []

4 PRs closed without merging

From the close comments: merged via another change 3 · duplicate 1

  • #5911 fix: OAuth providers missing from /model picker · merged via another change → #5983
  • #5945 fix(cli): OAuth providers missing from /model picker — wrong import and key lookup · duplicate → #8224
  • #6287 fix(auth): add kimi-for-coding alias to _PROVIDER_ALIASES · merged via another change → #7373
  • #9767 fix: copilot model not show in /model when copilot provider fallback to gh auth token · merged via another change → #9810
Closed issues · 4
  • Closed#5223Bug: /model gateway command omits Hermes-overlay providers (e.g. openai-codex)
  • Closed#5910OAuth providers (Nous, Codex) missing from /model picker — ImportError silently caught
  • Closed#9768Copilot provider missing from /model picker when using gh auth token
  • Closed#12058[Bug]: OpenAI Codex OAuth works in CLI, but Telegram gateway replies No Codex credentials stored
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.17: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#5983, #7373, #9810
Closed issues
4
Attempts
4 PRs closed without merging
Fix link checked
14 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.