{"repo":"NousResearch/hermes-agent","public_id":"5223","display_label":"OAuth and overlay providers missing from /model picker","summary":"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.","subsystem":"gateway","counts":{"members":11,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":3,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":5983},"resolution":{"state":"partially_resolved","by":"pr-7373,pr-9810"},"freshness":{"state":"stale","last_verified_at":"2026-04-14T18:16:26+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1650909090909091},"fix_edges":[{"issue_number":2708,"pr_number":5983,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":5223,"pr_number":7373,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":5781,"pr_number":5983,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":5910,"pr_number":5983,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":5910,"pr_number":7373,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":9768,"pr_number":9810,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":5223,"title":"Bug: /model gateway command omits Hermes-overlay providers (e.g. openai-codex)","url":null,"state":"closed"},{"kind":"issue","number":5910,"title":"OAuth providers (Nous, Codex) missing from /model picker — ImportError silently caught","url":null,"state":"closed"},{"kind":"issue","number":9768,"title":"Copilot provider missing from /model picker when using gh auth token","url":null,"state":"closed"},{"kind":"issue","number":12058,"title":"[Bug]:  OpenAI Codex OAuth works in CLI, but Telegram gateway replies No Codex credentials stored","url":null,"state":"closed"},{"kind":"pr","number":5911,"title":"fix: OAuth providers missing from /model picker","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5983","number":5983}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5945,"title":"fix(cli): OAuth providers missing from /model picker — wrong import and key lookup","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8224","number":8224}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":5983,"title":"fix: provider/model resolution — salvage 4 community PRs + MiniMax aux URL fix","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"agent/auxiliary_client.py","hunks":["@@ -59,6 +59,41 @@\n \n logger = logging.getLogger(__name__)\n \n+_PROVIDER_ALIASES = {\n+    \"google\": \"gemini\",\n+    \"google-gemini\": \"gemini\",\n+    \"google-ai-studio\": \"gemini\",\n+    \"glm\": \"zai\",\n+    \"z-ai\": \"zai\",\n+    \"z.ai\": \"zai\",\n+    \"zhipu\": \"zai\",\n+    \"kimi\": \"kimi-coding\",\n+    \"moonshot\": \"kimi-coding\",\n+    \"minimax-china\": \"minimax-cn\",\n+    \"minimax_cn\": \"minimax-cn\",\n+    \"claude\": \"anthropic\",\n+    \"claude-code\": \"anthropic\",\n+}\n+\n+\n+def _normalize_aux_provider(provider: Optional[str], *, for_vision: bool = False) -> str:\n+    normalized = (provider or \"auto\").strip().lower()\n+    if normalized.startswith(\"custom:\"):\n+        suffix = normalized.split(\":\", 1)[1].strip()\n+        if not suffix:\n+            return \"custom\"\n+        normalized = suffix if not for_vision else \"custom\"\n+    if normalized == \"codex\":\n+        return \"openai-codex\"\n+    if normalized == \"main\":\n+        # Resolve to the user's actual main provider so named custom providers\n+        # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly.\n+        main_prov = _read_main_provider()\n+        if main_prov and main_prov not in (\"auto\", \"main\", \"\"):\n+            return main_prov\n+        return \"custom\"\n+    return _PROVIDER_ALIASES.get(normalized, normalized)\n+\n # Default auxiliary models for direct API-key providers (cheap/fast for side tasks)\n _API_KEY_PROVIDER_AUX_MODELS: [redacted:secret-assignment] str] = {\n     \"gemini\": \"gemini-3-flash-preview\",","@@ -106,6 +141,23 @@\n _CODEX_AUX_BASE_URL = \"https://chatgpt.com/backend-api/codex\"\n \n \n+def _to_openai_base_url(base_url: str) -> str:\n+    \"\"\"Normalize an Anthropic-style base URL to OpenAI-compatible format.\n+\n+    Some providers (MiniMax, MiniMax-CN) expose an ``/anthropic`` endpoint for\n+    the Anthropic Messages API and a separate ``/v1`` endpoint for OpenAI chat\n+    completions.  The auxiliary client uses the OpenAI SDK, so it must hit the\n+    ``/v1`` surface.  Passing the raw ``inference_base_url`` causes requests to\n+    land on ``/anthropic/chat/completions`` — a 404.\n+    \"\"\"\n+    url = str(base_url or \"\").strip().rstrip(\"/\")\n+    if url.endswith(\"/anthropic\"):\n+        rewritten = url[: -len(\"/anthropic\")] + \"/v1\"\n+        logger.debug(\"Auxiliary client: rewrote base URL %s → %s\", url, rewritten)\n+        return rewritten\n+    return url\n+\n+\n def _select_pool_entry(provider: str) -> Tuple[bool, Optional[Any]]:\n     \"\"\"Return (pool_exists_for_provider, selected_entry).\"\"\"\n     try:","@@ -635,7 +687,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:\n             if not api_key:\n                 continue\n \n-            base_url = _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url\n+            base_url = _to_openai_base_url(\n+                _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url\n+            )\n             model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, \"default\")\n             logger.debug(\"Auxiliary text client: %s (%s) via pool\", pconfig.name, model)\n             extra = {}","@@ -652,7 +706,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:\n         if not api_key:\n             continue\n \n-        base_url = str(creds.get(\"base_url\", \"\")).strip().rstrip(\"/\") or pconfig.inference_base_url\n+        base_url = _to_openai_base_url(\n+            str(creds.get(\"base_url\", \"\")).strip().rstrip(\"/\") or pconfig.inference_base_url\n+        )\n         model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, \"default\")\n         logger.debug(\"Auxiliary text client: %s (%s)\", pconfig.name, model)\n         extra = {}","@@ -778,7 +834,7 @@ def _read_main_provider() -> str:\n         if isinstance(model_cfg, dict):\n             provider = model_cfg.get(\"provider\", \"\")\n             if isinstance(provider, str) and provider.strip():\n-                return provider.strip().lower()\n+                return _normalize_aux_provider(provider)\n     except Exception:\n         pass\n     return \"\"","@@ -1140,17 +1196,7 @@ def resolve_provider_client(\n         (client, resolved_model) or (None, None) if auth is unavailable.\n     \"\"\"\n     # Normalise aliases\n-    provider = (provider or \"auto\").strip().lower()\n-    if provider == \"codex\":\n-        provider = \"openai-codex\"\n-    if provider == \"main\":\n-        # Resolve to the user's actual main provider so named custom providers\n-        # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly.\n-        main_prov = _read_main_provider()\n-        if main_prov and main_prov not in (\"auto\", \"main\", \"\"):\n-            provider = main_prov\n-        else:\n-            provider = \"custom\"\n+    provider = _normalize_aux_provider(provider)\n \n     # ── Auto: try all providers in priority order ────────────────────\n     if provider == \"auto\":","@@ -1300,7 +1346,9 @@ def resolve_provider_client(\n                          provider, \", \".join(tried_sources))\n             return None, None\n \n-        base_url = str(creds.get(\"base_url\", \"\")).strip().rstrip(\"/\") or pconfig.inference_base_url\n+        base_url = _to_openai_base_url(\n+            str(creds.get(\"base_url\", \"\")).strip().rstrip(\"/\") or pconfig.inference_base_url\n+        )\n \n         default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, \"\")\n         final_model = model or default_model","@@ -1384,17 +1432,7 @@ def get_async_text_auxiliary_client(task: str = \"\"):\n \n \n def _normalize_vision_provider(provider: Optional[str]) -> str:\n-    provider = (provider or \"auto\").strip().lower()\n-    if provider == \"codex\":\n-        return \"openai-codex\"\n-    if provider == \"main\":\n-        # Resolve to actual main provider — named custom providers and\n-        # non-aggregator providers need to pass through as their real name.\n-        main_prov = _read_main_provider()\n-        if main_prov and main_prov not in (\"auto\", \"main\", \"\"):\n-            return main_prov\n-        return \"custom\"\n-    return provider\n+    return _normalize_aux_provider(provider, for_vision=True)\n \n \n def _resolve_strict_vision_backend(provider: str) -> Tuple[Optional[Any], Optional[str]]:"]},{"file":"agent/model_metadata.py","hunks":["@@ -611,6 +611,59 @@ def _model_id_matches(candidate_id: str, lookup_model: str) -> bool:\n     return False\n \n \n+def query_ollama_num_ctx(model: str, base_url: str) -> Optional[int]:\n+    \"\"\"Query an Ollama server for the model's context length.\n+\n+    Returns the model's maximum context from GGUF metadata via ``/api/show``,\n+    or the explicit ``num_ctx`` from the Modelfile if set.  Returns None if\n+    the server is unreachable or not Ollama.\n+\n+    This is the value that should be passed as ``num_ctx`` in Ollama chat\n+    requests to override the default 2048.\n+    \"\"\"\n+    import httpx\n+\n+    bare_model = _strip_provider_prefix(model)\n+    server_url = base_url.rstrip(\"/\")\n+    if server_url.endswith(\"/v1\"):\n+        server_url = server_url[:-3]\n+\n+    try:\n+        server_type = detect_local_server_type(base_url)\n+    except Exception:\n+        return None\n+    if server_type != \"ollama\":\n+        return None\n+\n+    try:\n+        with httpx.Client(timeout=3.0) as client:\n+            resp = client.post(f\"{server_url}/api/show\", json={\"name\": bare_model})\n+            if resp.status_code != 200:\n+                return None\n+            data = resp.json()\n+\n+            # Prefer explicit num_ctx from Modelfile parameters (user override)\n+            params = data.get(\"parameters\", \"\")\n+            if \"num_ctx\" in params:\n+                for line in params.split(\"\\n\"):\n+                    if \"num_ctx\" in line:\n+                        parts = line.strip().split()\n+                        if len(parts) >= 2:\n+                            try:\n+                                return int(parts[-1])\n+                            except ValueError:\n+                                pass\n+\n+            # Fall back to GGUF model_info context_length (training max)\n+            model_info = data.get(\"model_info\", {})\n+            for key, value in model_info.items():\n+                if \"context_length\" in key and isinstance(value, (int, float)):\n+                    return int(value)\n+    except Exception:\n+        pass\n+    return None\n+\n+\n def _query_local_context_length(model: str, base_url: str) -> Optional[int]:\n     \"\"\"Query a local server for the model's context length.\"\"\"\n     import httpx"]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -791,12 +791,12 @@ def list_authenticated_providers(\n         if overlay.auth_type in (\"oauth_device_code\", \"oauth_external\", \"external_process\"):\n             # These use auth stores, not env vars — check for auth.json entries\n             try:\n-                from hermes_cli.auth import _read_auth_store\n-                store = _read_auth_store()\n-                if store and pid in store:\n+                from hermes_cli.auth import _load_auth_store\n+                store = _load_auth_store()\n+                if store and (pid in store.get(\"providers\", {}) or pid in store.get(\"credential_pool\", {})):\n                     has_creds = True\n-            except Exception:\n-                pass\n+            except Exception as exc:\n+                logger.debug(\"Auth store check failed for %s: %s\", pid, exc)\n         if not has_creds:\n             continue\n "]},{"file":"run_agent.py","hunks":["@@ -85,6 +85,7 @@\n     estimate_tokens_rough, estimate_messages_tokens_rough, estimate_request_tokens_rough,\n     get_next_probe_tier, parse_context_limit_from_error,\n     save_context_length, is_local_endpoint,\n+    query_ollama_num_ctx,\n )\n from agent.context_compressor import ContextCompressor\n from agent.subdirectory_hints import SubdirectoryHintTracker","@@ -1216,6 +1217,33 @@ def __init__(\n         self.session_cost_status = \"unknown\"\n         self.session_cost_source = \"none\"\n         \n+        # ── Ollama num_ctx injection ──\n+        # Ollama defaults to 2048 context regardless of the model's capabilities.\n+        # When running against an Ollama server, detect the model's max context\n+        # and pass num_ctx on every chat request so the full window is used.\n+        # User override: set model.ollama_num_ctx in config.yaml to cap VRAM use.\n+        self._ollama_num_ctx: int | None = None\n+        _ollama_num_ctx_override = None\n+        if isinstance(_model_cfg, dict):\n+            _ollama_num_ctx_override = _model_cfg.get(\"ollama_num_ctx\")\n+        if _ollama_num_ctx_override is not None:\n+            try:\n+                self._ollama_num_ctx = int(_ollama_num_ctx_override)\n+            except (TypeError, ValueError):\n+                logger.debug(\"Invalid ollama_num_ctx config value: %r\", _ollama_num_ctx_override)\n+        if self._ollama_num_ctx is None and self.base_url and is_local_endpoint(self.base_url):\n+            try:\n+                _detected = query_ollama_num_ctx(self.model, self.base_url)\n+                if _detected and _detected > 0:\n+                    self._ollama_num_ctx = _detected\n+            except Exception as exc:\n+                logger.debug(\"Ollama num_ctx detection failed: %s\", exc)\n+        if self._ollama_num_ctx and not self.quiet_mode:\n+            logger.info(\n+                \"Ollama num_ctx: will request %d tokens (model max from /api/show)\",\n+                self._ollama_num_ctx,\n+            )\n+\n         if not self.quiet_mode:\n             if compression_enabled:\n                 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:\n         if _is_nous:\n             extra_body[\"tags\"] = [\"product=hermes-agent\"]\n \n+        # Ollama num_ctx: override the 2048 default so the model actually\n+        # uses the context window it was trained for.  Passed via the OpenAI\n+        # SDK's extra_body → options.num_ctx, which Ollama's OpenAI-compat\n+        # endpoint forwards to the runner as --ctx-size.\n+        if self._ollama_num_ctx:\n+            options = extra_body.get(\"options\", {})\n+            options[\"num_ctx\"] = self._ollama_num_ctx\n+            extra_body[\"options\"] = options\n+\n         if extra_body:\n             api_kwargs[\"extra_body\"] = extra_body\n "]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -471,6 +471,23 @@ def test_explicit_zai(self, monkeypatch):\n             client, model = resolve_provider_client(\"zai\")\n             assert client is not None\n \n+    def test_explicit_google_alias_uses_gemini_credentials(self):\n+        \"\"\"provider='google' should route through the gemini API-key provider.\"\"\"\n+        with (\n+            patch(\"hermes_cli.auth.resolve_api_key_provider_credentials\", return_value={\n+                \"api_key\": \"gemini-key\",\n+                \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n+            }),\n+            patch(\"agent.auxiliary_client.OpenAI\") as mock_openai,\n+        ):\n+            mock_openai.return_value = MagicMock()\n+            client, model = resolve_provider_client(\"google\", model=\"gemini-3.1-pro-preview\")\n+\n+        assert client is not None\n+        assert model == \"gemini-3.1-pro-preview\"\n+        assert mock_openai.call_args.kwargs[\"api_key\"] == \"gemini-key\"\n+        assert mock_openai.call_args.kwargs[\"base_url\"] == \"https://generativelanguage.googleapis.com/v1beta/openai\"\n+\n     def test_explicit_unknown_returns_none(self, monkeypatch):\n         \"\"\"Unknown provider should return None.\"\"\"\n         client, model = resolve_provider_client(\"nonexistent-provider\")","@@ -822,6 +839,31 @@ def test_vision_uses_nous_when_available(self, monkeypatch):\n         assert model == \"google/gemini-3-flash-preview\"\n         assert client is not None\n \n+    def test_vision_config_google_provider_uses_gemini_credentials(self, monkeypatch):\n+        config = {\n+            \"auxiliary\": {\n+                \"vision\": {\n+                    \"provider\": \"google\",\n+                    \"model\": \"gemini-3.1-pro-preview\",\n+                }\n+            }\n+        }\n+        monkeypatch.setattr(\"hermes_cli.config.load_config\", lambda: config)\n+        with (\n+            patch(\"hermes_cli.auth.resolve_api_key_provider_credentials\", return_value={\n+                \"api_key\": \"gemini-key\",\n+                \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n+            }),\n+            patch(\"agent.auxiliary_client.OpenAI\") as mock_openai,\n+        ):\n+            resolved_provider, client, model = resolve_vision_provider_client()\n+\n+        assert resolved_provider == \"gemini\"\n+        assert client is not None\n+        assert model == \"gemini-3.1-pro-preview\"\n+        assert mock_openai.call_args.kwargs[\"api_key\"] == \"gemini-key\"\n+        assert mock_openai.call_args.kwargs[\"base_url\"] == \"https://generativelanguage.googleapis.com/v1beta/openai\"\n+\n     def test_vision_forced_main_uses_custom_endpoint(self, monkeypatch):\n         \"\"\"When explicitly forced to 'main', vision CAN use custom endpoint.\"\"\"\n         config = {"]},{"file":"tests/agent/test_minimax_auxiliary_url.py","hunks":["@@ -0,0 +1,42 @@\n+\"\"\"Tests for MiniMax auxiliary client URL normalization.\n+\n+MiniMax and MiniMax-CN set inference_base_url to the /anthropic path.\n+The auxiliary client uses the OpenAI SDK, which needs /v1 instead.\n+\"\"\"\n+\n+import sys\n+import os\n+\n+sys.path.insert(0, os.path.join(os.path.dirname(__file__), \"..\", \"..\"))\n+\n+from agent.auxiliary_client import _to_openai_base_url\n+\n+\n+class TestToOpenaiBaseUrl:\n+    def test_minimax_global_anthropic_suffix_replaced(self):\n+        assert _to_openai_base_url(\"https://api.minimax.io/anthropic\") == \"https://api.minimax.io/v1\"\n+\n+    def test_minimax_cn_anthropic_suffix_replaced(self):\n+        assert _to_openai_base_url(\"https://api.minimaxi.com/anthropic\") == \"https://api.minimaxi.com/v1\"\n+\n+    def test_trailing_slash_stripped_before_replace(self):\n+        assert _to_openai_base_url(\"https://api.minimax.io/anthropic/\") == \"https://api.minimax.io/v1\"\n+\n+    def test_v1_url_unchanged(self):\n+        assert _to_openai_base_url(\"https://api.openai.com/v1\") == \"https://api.openai.com/v1\"\n+\n+    def test_openrouter_url_unchanged(self):\n+        assert _to_openai_base_url(\"https://openrouter.ai/api/v1\") == \"https://openrouter.ai/api/v1\"\n+\n+    def test_anthropic_domain_unchanged(self):\n+        \"\"\"api.anthropic.com doesn't end with /anthropic — should be untouched.\"\"\"\n+        assert _to_openai_base_url(\"https://api.anthropic.com\") == \"https://api.anthropic.com\"\n+\n+    def test_anthropic_in_subpath_unchanged(self):\n+        assert _to_openai_base_url(\"https://example.com/anthropic/extra\") == \"https://example.com/anthropic/extra\"\n+\n+    def test_empty_string(self):\n+        assert _to_openai_base_url(\"\") == \"\"\n+\n+    def test_none(self):\n+        assert _to_openai_base_url(None) == \"\""]},{"file":"tests/test_ollama_num_ctx.py","hunks":["@@ -0,0 +1,135 @@\n+\"\"\"Tests for Ollama num_ctx context length detection and injection.\n+\n+Covers:\n+  agent/model_metadata.py — query_ollama_num_ctx()\n+  run_agent.py — _ollama_num_ctx detection + extra_body injection\n+\"\"\"\n+\n+from unittest.mock import patch, MagicMock\n+\n+import pytest\n+\n+from agent.model_metadata import query_ollama_num_ctx\n+\n+\n+# ═══════════════════════════════════════════════════════════════════════\n+# Level 1: query_ollama_num_ctx — Ollama API interaction\n+# ═══════════════════════════════════════════════════════════════════════\n+\n+\n+def _mock_httpx_client(show_response_data, status_code=200):\n+    \"\"\"Create a mock httpx.Client context manager that returns given /api/show data.\"\"\"\n+    mock_resp = MagicMock(status_code=status_code)\n+    mock_resp.json.return_value = show_response_data\n+    mock_client = MagicMock()\n+    mock_client.post.return_value = mock_resp\n+    mock_ctx = MagicMock()\n+    mock_ctx.__enter__ = MagicMock(return_value=mock_client)\n+    mock_ctx.__exit__ = MagicMock(return_value=False)\n+    return mock_ctx, mock_client\n+\n+\n+class TestQueryOllamaNumCtx:\n+    \"\"\"Test the Ollama /api/show context length query.\"\"\"\n+\n+    def test_returns_context_from_model_info(self):\n+        \"\"\"Should extract context_length from GGUF model_info metadata.\"\"\"\n+        show_data = {\n+            \"model_info\": {\"llama.context_length\": 131072},\n+            \"parameters\": \"\",\n+        }\n+        mock_ctx, _ = _mock_httpx_client(show_data)\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"ollama\"):\n+            # httpx is imported inside the function — patch the module import\n+            import httpx\n+            with patch.object(httpx, \"Client\", return_value=mock_ctx):\n+                result = query_ollama_num_ctx(\"llama3.1:8b\", \"http://localhost:11434/v1\")\n+\n+        assert result == 131072\n+\n+    def test_prefers_explicit_num_ctx_from_modelfile(self):\n+        \"\"\"If the Modelfile sets num_ctx explicitly, that should take priority.\"\"\"\n+        show_data = {\n+            \"model_info\": {\"llama.context_length\": 131072},\n+            \"parameters\": \"num_ctx 32768\\ntemperature 0.7\",\n+        }\n+        mock_ctx, _ = _mock_httpx_client(show_data)\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"ollama\"):\n+            import httpx\n+            with patch.object(httpx, \"Client\", return_value=mock_ctx):\n+                result = query_ollama_num_ctx(\"custom-model\", \"http://localhost:11434\")\n+\n+        assert result == 32768\n+\n+    def test_returns_none_for_non_ollama_server(self):\n+        \"\"\"Should return None if the server is not Ollama.\"\"\"\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"lm-studio\"):\n+            result = query_ollama_num_ctx(\"model\", \"http://localhost:1234\")\n+        assert result is None\n+\n+    def test_returns_none_on_connection_error(self):\n+        \"\"\"Should return None if the server is unreachable.\"\"\"\n+        with patch(\"agent.model_metadata.detect_local_server_type\", side_effect=Exception(\"timeout\")):\n+            result = query_ollama_num_ctx(\"model\", \"http://localhost:11434\")\n+        assert result is None\n+\n+    def test_returns_none_on_404(self):\n+        \"\"\"Should return None if the model is not found.\"\"\"\n+        mock_ctx, _ = _mock_httpx_client({}, status_code=404)\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"ollama\"):\n+            import httpx\n+            with patch.object(httpx, \"Client\", return_value=mock_ctx):\n+                result = query_ollama_num_ctx(\"nonexistent\", \"http://localhost:11434\")\n+\n+        assert result is None\n+\n+    def test_strips_provider_prefix(self):\n+        \"\"\"Should strip 'local:' prefix from model name before querying.\"\"\"\n+        show_data = {\n+            \"model_info\": {\"qwen2.context_length\": 32768},\n+            \"parameters\": \"\",\n+        }\n+        mock_ctx, mock_client = _mock_httpx_client(show_data)\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"ollama\"):\n+            import httpx\n+            with patch.object(httpx, \"Client\", return_value=mock_ctx):\n+                result = query_ollama_num_ctx(\"local:qwen2.5:7b\", \"http://localhost:11434/v1\")\n+\n+        # Verify the post was called with stripped name (no \"local:\" prefix)\n+        call_args = mock_client.post.call_args\n+        assert call_args[1][\"json\"][\"name\"] == \"qwen2.5:7b\" or call_args[0][1] is not None\n+        assert result == 32768\n+\n+    def test_handles_qwen2_architecture_key(self):\n+        \"\"\"Different model architectures use different key prefixes in model_info.\"\"\"\n+        show_data = {\n+            \"model_info\": {\"qwen2.context_length\": 65536},\n+            \"parameters\": \"\",\n+        }\n+        mock_ctx, _ = _mock_httpx_client(show_data)\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"ollama\"):\n+            import httpx\n+            with patch.object(httpx, \"Client\", return_value=mock_ctx):\n+                result = query_ollama_num_ctx(\"qwen2.5:32b\", \"http://localhost:11434\")\n+\n+        assert result == 65536\n+\n+    def test_returns_none_when_model_info_empty(self):\n+        \"\"\"Should return None if model_info has no context_length key.\"\"\"\n+        show_data = {\n+            \"model_info\": {\"llama.embedding_length\": 4096},\n+            \"parameters\": \"\",\n+        }\n+        mock_ctx, _ = _mock_httpx_client(show_data)\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"ollama\"):\n+            import httpx\n+            with patch.object(httpx, \"Client\", return_value=mock_ctx):\n+                result = query_ollama_num_ctx(\"model\", \"http://localhost:11434\")\n+\n+        assert result is None\n"]}]},{"kind":"pr","number":6287,"title":"fix(auth): add kimi-for-coding alias to _PROVIDER_ALIASES","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7373","number":7373}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7373,"title":"fix: resolve overlay provider slug mismatch in /model picker","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/auth.py","hermes_cli/model_switch.py","tests/hermes_cli/test_overlay_slug_resolution.py"],"fix_hunks":[{"file":"hermes_cli/auth.py","hunks":["@@ -890,7 +890,7 @@ def resolve_provider(\n     _PROVIDER_ALIASES = {\n         \"glm\": \"zai\", \"z-ai\": \"zai\", \"z.ai\": \"zai\", \"zhipu\": \"zai\",\n         \"google\": \"gemini\", \"google-gemini\": \"gemini\", \"google-ai-studio\": \"gemini\",\n-        \"kimi\": \"kimi-coding\", \"moonshot\": \"kimi-coding\",\n+        \"kimi\": \"kimi-coding\", \"kimi-for-coding\": \"kimi-coding\", \"moonshot\": \"kimi-coding\",\n         \"minimax-china\": \"minimax-cn\", \"minimax_cn\": \"minimax-cn\",\n         \"claude\": \"anthropic\", \"claude-code\": \"anthropic\",\n         \"github\": \"copilot\", \"github-copilot\": \"copilot\","]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -812,45 +812,66 @@ def list_authenticated_providers(\n     # --- 2. Check Hermes-only providers (nous, openai-codex, copilot, opencode-go) ---\n     from hermes_cli.providers import HERMES_OVERLAYS\n     from hermes_cli.auth import PROVIDER_REGISTRY as _auth_registry\n+\n+    # Build reverse mapping: models.dev ID → Hermes provider ID.\n+    # HERMES_OVERLAYS keys may be models.dev IDs (e.g. \"github-copilot\")\n+    # while _PROVIDER_MODELS and config.yaml use Hermes IDs (\"copilot\").\n+    _mdev_to_hermes = {v: k for k, v in PROVIDER_TO_MODELS_DEV.items()}\n+\n     for pid, overlay in HERMES_OVERLAYS.items():\n         if pid in seen_slugs:\n             continue\n+\n+        # Resolve Hermes slug — e.g. \"github-copilot\" → \"copilot\"\n+        hermes_slug = _mdev_to_hermes.get(pid, pid)\n+        if hermes_slug in seen_slugs:\n+            continue\n+\n         # Check if credentials exist\n         has_creds = False\n         if overlay.extra_env_vars:\n             has_creds = any(os.environ.get(ev) for ev in overlay.extra_env_vars)\n         # Also check api_key_env_vars from PROVIDER_REGISTRY for api_key auth_type\n         if not has_creds and overlay.auth_type == \"api_key\":\n-            pcfg = _auth_registry.get(pid)\n-            if pcfg and pcfg.api_key_env_vars:\n-                has_creds = any(os.environ.get(ev) for ev in pcfg.api_key_env_vars)\n-        if overlay.auth_type in (\"oauth_device_code\", \"oauth_external\", \"external_process\"):\n+            for _key in (pid, hermes_slug):\n+                pcfg = _auth_registry.get(_key)\n+                if pcfg and pcfg.api_key_env_vars:\n+                    if any(os.environ.get(ev) for ev in pcfg.api_key_env_vars):\n+                        has_creds = True\n+                        break\n+        if not has_creds and overlay.auth_type in (\"oauth_device_code\", \"oauth_external\", \"external_process\"):\n             # These use auth stores, not env vars — check for auth.json entries\n             try:\n                 from hermes_cli.auth import _load_auth_store\n                 store = _load_auth_store()\n-                if store and (pid in store.get(\"providers\", {}) or pid in store.get(\"credential_pool\", {})):\n+                providers_store = store.get(\"providers\", {})\n+                pool_store = store.get(\"credential_pool\", {})\n+                if store and (\n+                    pid in providers_store or hermes_slug in providers_store\n+                    or pid in pool_store or hermes_slug in pool_store\n+                ):\n                     has_creds = True\n             except Exception as exc:\n                 logger.debug(\"Auth store check failed for %s: %s\", pid, exc)\n         if not has_creds:\n             continue\n \n-        # Use curated list\n-        model_ids = curated.get(pid, [])\n+        # Use curated list — look up by Hermes slug, fall back to overlay key\n+        model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])\n         total = len(model_ids)\n         top = model_ids[:max_models]\n \n         results.append({\n-            \"slug\": pid,\n-            \"name\": get_label(pid),\n-            \"is_current\": pid == current_provider,\n+            \"slug\": hermes_slug,\n+            \"name\": get_label(hermes_slug),\n+            \"is_current\": hermes_slug == current_provider or pid == current_provider,\n             \"is_user_defined\": False,\n             \"models\": top,\n             \"total_models\": total,\n             \"source\": \"hermes\",\n         })\n         seen_slugs.add(pid)\n+        seen_slugs.add(hermes_slug)\n \n     # --- 3. User-defined endpoints from config ---\n     if user_providers and isinstance(user_providers, dict):"]},{"file":"tests/hermes_cli/test_overlay_slug_resolution.py","hunks":["@@ -0,0 +1,83 @@\n+\"\"\"Test that overlay providers with mismatched models.dev keys resolve correctly.\n+\n+HERMES_OVERLAYS keys may be models.dev IDs (e.g. \"github-copilot\") while\n+_PROVIDER_MODELS and config.yaml use Hermes IDs (\"copilot\").  The slug\n+resolution in list_authenticated_providers() Section 2 must bridge this gap.\n+\n+Covers: #5223, #6492\n+\"\"\"\n+\n+import json\n+import os\n+from unittest.mock import patch\n+\n+import pytest\n+\n+from hermes_cli.model_switch import list_authenticated_providers\n+\n+\n+# -- Copilot slug resolution (env var path) ----------------------------------\n+\n[redacted:email](os.environ, {\"COPILOT_GITHUB_TOKEN\": \"fake-ghu\"}, clear=False)\n+def test_copilot_uses_hermes_slug():\n+    \"\"\"github-copilot overlay should resolve to slug='copilot' with curated models.\"\"\"\n+    providers = list_authenticated_providers(current_provider=\"copilot\")\n+\n+    copilot = next((p for p in providers if p[\"slug\"] == \"copilot\"), None)\n+    assert copilot is not None, \"copilot should appear when COPILOT_GITHUB_TOKEN is set\"\n+    assert copilot[\"total_models\"] > 0, \"copilot should have curated models\"\n+    assert copilot[\"is_current\"] is True\n+\n+    # Must NOT appear under the models.dev key\n+    gh_copilot = next((p for p in providers if p[\"slug\"] == \"github-copilot\"), None)\n+    assert gh_copilot is None, \"github-copilot slug should not appear (resolved to copilot)\"\n+\n+\n[redacted:email](os.environ, {\"COPILOT_GITHUB_TOKEN\": \"fake-ghu\"}, clear=False)\n+def test_copilot_no_duplicate_entries():\n+    \"\"\"Copilot must appear only once — not as both 'copilot' (section 1) and 'github-copilot' (section 2).\"\"\"\n+    providers = list_authenticated_providers(current_provider=\"copilot\")\n+\n+    copilot_slugs = [p[\"slug\"] for p in providers if \"copilot\" in p[\"slug\"]]\n+    # Should have at most one copilot entry (may also have copilot-acp if creds exist)\n+    copilot_main = [s for s in copilot_slugs if s == \"copilot\"]\n+    assert len(copilot_main) == 1, f\"Expected exactly one 'copilot' entry, got {copilot_main}\"\n+\n+\n+# -- kimi-for-coding alias in auth.py ----------------------------------------\n+\n+def test_kimi_for_coding_alias():\n+    \"\"\"resolve_provider('kimi-for-coding') should return 'kimi-coding'.\"\"\"\n+    from hermes_cli.auth import resolve_provider\n+\n+    result = resolve_provider(\"kimi-for-coding\")\n+    assert result == \"kimi-coding\"\n+\n+\n+# -- Generic slug mismatch providers -----------------------------------------\n+\n[redacted:email](os.environ, {\"KIMI_API_KEY\": \"fake-key\"}, clear=False)\n+def test_kimi_for_coding_overlay_uses_hermes_slug():\n+    \"\"\"kimi-for-coding overlay should resolve to slug='kimi-coding'.\"\"\"\n+    providers = list_authenticated_providers(current_provider=\"kimi-coding\")\n+\n+    kimi = next((p for p in providers if p[\"slug\"] == \"kimi-coding\"), None)\n+    assert kimi is not None, \"kimi-coding should appear when KIMI_API_KEY is set\"\n+    assert kimi[\"is_current\"] is True\n+\n+    # Must NOT appear under the models.dev key\n+    kimi_mdev = next((p for p in providers if p[\"slug\"] == \"kimi-for-coding\"), None)\n+    assert kimi_mdev is None, \"kimi-for-coding slug should not appear (resolved to kimi-coding)\"\n+\n+\n[redacted:email](os.environ, {\"KILOCODE_API_KEY\": \"fake-key\"}, clear=False)\n+def test_kilo_overlay_uses_hermes_slug():\n+    \"\"\"kilo overlay should resolve to slug='kilocode'.\"\"\"\n+    providers = list_authenticated_providers(current_provider=\"kilocode\")\n+\n+    kilo = next((p for p in providers if p[\"slug\"] == \"kilocode\"), None)\n+    assert kilo is not None, \"kilocode should appear when KILOCODE_API_KEY is set\"\n+    assert kilo[\"is_current\"] is True\n+\n+    kilo_mdev = next((p for p in providers if p[\"slug\"] == \"kilo\"), None)\n+    assert kilo_mdev is None, \"kilo slug should not appear (resolved to kilocode)\"\n"]}]},{"kind":"pr","number":9767,"title":"fix: copilot model not show in /model when copilot provider fallback to gh auth token","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9810","number":9810}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":9810,"title":"fix: detect copilot and qwen-oauth providers in /model picker","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/credential_pool.py","tests/agent/test_credential_pool.py"],"fix_hunks":[{"file":"agent/credential_pool.py","hunks":["@@ -1152,6 +1152,59 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup\n                 },\n             )\n \n+    elif provider == \"copilot\":\n+        # Copilot tokens are resolved dynamically via `gh auth token` or\n+        # env vars (COPILOT_GITHUB_TOKEN / GH_TOKEN).  They don't live in\n+        # the auth store or credential pool, so we resolve them here.\n+        try:\n+            from hermes_cli.copilot_auth import resolve_copilot_token\n+            token, source = resolve_copilot_token()\n+            if token:\n+                source_name = \"gh_cli\" if \"gh\" in source.lower() else f\"env:{source}\"\n+                active_sources.add(source_name)\n+                changed |= _upsert_entry(\n+                    entries,\n+                    provider,\n+                    source_name,\n+                    {\n+                        \"source\": source_name,\n+                        \"auth_type\": AUTH_TYPE_API_KEY,\n+                        \"access_token\": token,\n+                        \"label\": source,\n+                    },\n+                )\n+        except Exception as exc:\n+            logger.debug(\"Copilot token seed failed: %s\", exc)\n+\n+    elif provider == \"qwen-oauth\":\n+        # Qwen OAuth tokens live in ~/.qwen/oauth_creds.json, written by\n+        # the Qwen CLI (`qwen auth qwen-oauth`).  They aren't in the\n+        # Hermes auth store or env vars, so resolve them here.\n+        # Use refresh_if_expiring=False to avoid network calls during\n+        # pool loading / provider discovery.\n+        try:\n+            from hermes_cli.auth import resolve_qwen_runtime_credentials\n+            creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)\n+            token = [redacted:secret-assignment]\"api_key\", \"\")\n+            if token:\n+                source_name = creds.get(\"source\", \"qwen-cli\")\n+                active_sources.add(source_name)\n+                changed |= _upsert_entry(\n+                    entries,\n+                    provider,\n+                    source_name,\n+                    {\n+                        \"source\": source_name,\n+                        \"auth_type\": AUTH_TYPE_OAUTH,\n+                        \"access_token\": token,\n+                        \"expires_at_ms\": creds.get(\"expires_at_ms\"),\n+                        \"base_url\": creds.get(\"base_url\", \"\"),\n+                        \"label\": creds.get(\"auth_file\", source_name),\n+                    },\n+                )\n+        except Exception as exc:\n+            logger.debug(\"Qwen OAuth token seed failed: %s\", exc)\n+\n     elif provider == \"openai-codex\":\n         state = _load_provider_state(auth_store, \"openai-codex\")\n         tokens = [redacted:secret-assignment]\"tokens\") if isinstance(state, dict) else None"]},{"file":"tests/agent/test_credential_pool.py","hunks":["@@ -1071,3 +1071,88 @@ def test_load_pool_does_not_seed_claude_code_when_anthropic_not_configured(tmp_p\n \n     # Should NOT have seeded the claude_code entry\n     assert pool.entries() == []\n+\n+\n+def test_load_pool_seeds_copilot_via_gh_auth_token(tmp_path, monkeypatch):\n+    \"\"\"Copilot credentials from `gh auth token` should be seeded into the pool.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    _write_auth_store(tmp_path, {\"version\": 1, \"credential_pool\": {}})\n+\n+    monkeypatch.setattr(\n+        \"hermes_cli.copilot_auth.resolve_copilot_token\",\n+        lambda: (\"gho_fake_token_abc123\", \"gh auth token\"),\n+    )\n+\n+    from agent.credential_pool import load_pool\n+    pool = load_pool(\"copilot\")\n+\n+    assert pool.has_credentials()\n+    entries = pool.entries()\n+    assert len(entries) == 1\n+    assert entries[0].source == \"gh_cli\"\n+    assert entries[0].access_token == \"gho_fake_token_abc123\"\n+\n+\n+def test_load_pool_does_not_seed_copilot_when_no_token(tmp_path, monkeypatch):\n+    \"\"\"Copilot pool should be empty when resolve_copilot_token() returns nothing.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    _write_auth_store(tmp_path, {\"version\": 1, \"credential_pool\": {}})\n+\n+    monkeypatch.setattr(\n+        \"hermes_cli.copilot_auth.resolve_copilot_token\",\n+        lambda: (\"\", \"\"),\n+    )\n+\n+    from agent.credential_pool import load_pool\n+    pool = load_pool(\"copilot\")\n+\n+    assert not pool.has_credentials()\n+    assert pool.entries() == []\n+\n+\n+def test_load_pool_seeds_qwen_oauth_via_cli_tokens(tmp_path, monkeypatch):\n+    \"\"\"Qwen OAuth credentials from ~/.qwen/oauth_creds.json should be seeded into the pool.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    _write_auth_store(tmp_path, {\"version\": 1, \"credential_pool\": {}})\n+\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth.resolve_qwen_runtime_credentials\",\n+        lambda **kw: {\n+            \"provider\": \"qwen-oauth\",\n+            \"base_url\": \"https://portal.qwen.ai/v1\",\n+            \"api_key\": \"qwen_fake_token_xyz\",\n+            \"source\": \"qwen-cli\",\n+            \"expires_at_ms\": 1900000000000,\n+            \"auth_file\": str(tmp_path / \".qwen\" / \"oauth_creds.json\"),\n+        },\n+    )\n+\n+    from agent.credential_pool import load_pool\n+    pool = load_pool(\"qwen-oauth\")\n+\n+    assert pool.has_credentials()\n+    entries = pool.entries()\n+    assert len(entries) == 1\n+    assert entries[0].source == \"qwen-cli\"\n+    assert entries[0].access_token == \"qwen_fake_token_xyz\"\n+\n+\n+def test_load_pool_does_not_seed_qwen_oauth_when_no_token(tmp_path, monkeypatch):\n+    \"\"\"Qwen OAuth pool should be empty when no CLI credentials exist.\"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    _write_auth_store(tmp_path, {\"version\": 1, \"credential_pool\": {}})\n+\n+    from hermes_cli.auth import AuthError\n+\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth.resolve_qwen_runtime_credentials\",\n+        lambda **kw: (_ for _ in ()).throw(\n+            AuthError(\"Qwen CLI credentials not found.\", provider=\"qwen-oauth\", code=\"qwen_auth_missing\")\n+        ),\n+    )\n+\n+    from agent.credential_pool import load_pool\n+    pool = load_pool(\"qwen-oauth\")\n+\n+    assert not pool.has_credentials()\n+    assert pool.entries() == []\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}