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
- 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
Open PRs0
Fixes on record
-
#5983 fix: provider/model resolution — salvage 4 community PRs + MiniMax aux URL fix
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.pyShow 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
files:
hermes_cli/auth.py,hermes_cli/model_switch.py,tests/hermes_cli/test_overlay_slug_resolution.pyShow 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
files:
agent/credential_pool.py,tests/agent/test_credential_pool.pyShow 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.00means 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