# NousResearch/hermes-agent · OAuth and overlay providers missing from /model picker

## Open now

- 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.

## TL;DR

- 11 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 3 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #5983. Verify it addresses your case.
- **Recommended:** read PR #5983 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-7373,pr-9810`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Closed / duplicate issues

- #5223: Bug: /model gateway command omits Hermes-overlay providers (e.g. openai-codex)
- #5910: OAuth providers (Nous, Codex) missing from /model picker — ImportError silently caught
- #9768: Copilot provider missing from /model picker when using gh auth token
- #12058: \[Bug\]:  OpenAI Codex OAuth works in CLI, but Telegram gateway replies No Codex credentials stored

## Prior & competing PRs

- **#5983: fix: provider/model resolution — salvage 4 community PRs + MiniMax aux URL fix**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `agent/auxiliary_client.py`

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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 = {}
    ```

    ```diff
    @@ -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 = {}
    ```

    ```diff
    @@ -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 ""
    ```

    ```diff
    @@ -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":
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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:,})")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/auth.py`, `hermes_cli/model_switch.py`, `tests/hermes_cli/test_overlay_slug_resolution.py`
  - fix hunks:
  - `hermes_cli/auth.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/credential_pool.py`, `tests/agent/test_credential_pool.py`
  - fix hunks:
  - `agent/credential_pool.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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() == []
    ```
- **#5911: fix: OAuth providers missing from /model picker**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#5983`
- **#5945: fix(cli): OAuth providers missing from /model picker — wrong import and key lookup**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#8224`
- **#6287: fix(auth): add kimi-for-coding alias to _PROVIDER_ALIASES**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#7373`
- **#9767: fix: copilot model not show in /model when copilot provider fallback to gh auth token**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#9810`

## Fix status

- Merged to main in PR #5983 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-14`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
