{"repo":"NousResearch/hermes-agent","public_id":"29285","display_label":"auth provider priority ordering fixes and bugs","summary":"These issues and pull requests concern a bug where auth.json's active_provider field silently overrides explicit provider configuration from config.yaml, environment variables, or model config. The work involves adjusting provider resolution precedence so that explicit intent takes priority over stale or implicit OAuth state.","subsystem":"agent","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53931},"resolution":{"state":"partially_resolved","by":"pr-53931"},"freshness":{"state":"verified","last_verified_at":"2026-06-28T02:49:03+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2552380952380952},"fix_edges":[{"issue_number":29285,"pr_number":53931,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":29285,"title":"Bug: auth.json active_provider silently overrides model.provider in config.yaml at runtime","url":null,"state":"closed"},{"kind":"issue","number":65706,"title":"OpenRouter API key never clears auth.json active_provider — app stays on Nous Portal","url":null,"state":"closed"},{"kind":"pr","number":29615,"title":"fix(auth): config.yaml model.provider takes precedence over auth.json…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53931","number":53931}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":29809,"title":"fix(auth): lower active_provider priority below env var API keys in resolve_provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#29615","number":29615},{"kind":"pr","value":"#29285","number":29285}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":37706,"title":"fix(provider): warn when model config dict omits provider instead of silently routing to auto (#29285)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53931","number":53931},{"kind":"pr","value":"#29285","number":29285}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":50950,"title":"fix(auth): explicit provider intent beats stale OAuth active_provider (#29285)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53931","number":53931},{"kind":"pr","value":"#29615","number":29615},{"kind":"pr","value":"#29809","number":29809}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":53931,"title":"fix(auth): explicit provider intent beats stale OAuth active_provider (#29285)","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","infographic/pr-29285-provider-precedence/infographic.png","tests/hermes_cli/test_provider_precedence.py"],"fix_hunks":[{"file":"hermes_cli/auth.py","hunks":["@@ -1519,12 +1519,16 @@ def resolve_provider(\n     \"\"\"\n     Determine which inference provider to use.\n \n-    Priority (when requested=\"auto\" or None):\n-    1. active_provider in auth.json with valid credentials\n-    2. Explicit CLI api_key/base_url -> \"openrouter\"\n-    3. OPENAI_API_KEY or OPENROUTER_API_KEY env vars -> \"openrouter\"\n-    4. Provider-specific API keys (GLM, Kimi, MiniMax) -> that provider\n-    5. Fallback: \"openrouter\"\n+    Priority (when requested=\"auto\" or None) — explicit user intent wins over a\n+    stale logged-in OAuth provider (#29285):\n+    1. Explicit CLI api_key/base_url -> \"openrouter\"\n+    2. config.yaml `model.provider`\n+    3. OPENAI_API_KEY / OPENROUTER_API_KEY env vars -> \"openrouter\"\n+    4. OpenRouter credential pool\n+    5. Provider-specific API keys (GLM, Kimi, MiniMax, ...) -> that provider\n+    6. auth.json `active_provider` (logged-in OAuth) — last-resort fallback\n+    7. AWS Bedrock credential chain\n+    8. Error (no provider configured)\n     \"\"\"\n     normalized = (requested or \"auto\").strip().lower()\n ","@@ -1596,16 +1600,26 @@ def resolve_provider(\n     if explicit_api_key or explicit_base_url:\n         return \"openrouter\"\n \n-    # Check auth store for an active OAuth provider\n+    # Provider precedence for the auto-path (#29285): explicit user intent must\n+    # win over a stale logged-in OAuth `active_provider`. Order matches the\n+    # docstring: 1. explicit CLI creds  2. config.yaml `model.provider`\n+    # 3. OPENAI/OPENROUTER env keys  4. OpenRouter pool  5. provider-specific\n+    # env keys  6. auth.json `active_provider` (OAuth)  7. Bedrock  8. error.\n+    # The normal chat/gateway path resolves config.provider upstream in\n+    # resolve_requested_provider() before ever reaching \"auto\"; this duplicate\n+    # check is the safety net for the lone direct caller (main.py resolve_provider\n+    # (\"auto\")) and any future bypass of that stage.\n+    _model_cfg: Any = None\n     try:\n-        auth_store = _load_auth_store()\n-        active = auth_store.get(\"active_provider\")\n-        if active and active in PROVIDER_REGISTRY:\n-            status = get_auth_status(active)\n-            if status.get(\"logged_in\"):\n-                return active\n+        from hermes_cli.config import load_config\n+\n+        _model_cfg = (load_config() or {}).get(\"model\")\n+        if isinstance(_model_cfg, dict):\n+            _cfg_provider = _model_cfg.get(\"provider\")\n+            if isinstance(_cfg_provider, str) and _cfg_provider.strip().lower() in PROVIDER_REGISTRY:\n+                return _cfg_provider.strip().lower()\n     except Exception as e:\n-        logger.debug(\"Could not detect active auth provider: %s\", e)\n+        logger.debug(\"Could not read config.yaml model.provider for auto-resolution: %s\", e)\n \n     if has_usable_secret(os.getenv(\"OPENAI_API_KEY\")) or has_usable_secret(os.getenv(\"OPENROUTER_API_KEY\")):\n         return \"openrouter\"","@@ -1625,6 +1639,18 @@ def resolve_provider(\n     except Exception as e:\n         logger.debug(\"Could not check OpenRouter credential pool: %s\", e)\n \n+    # Determine the logged-in OAuth provider up front so the env-key loop below\n+    # can WARN when an exported API key preempts it (#29285 transparency). The\n+    # actual OAuth fallback (tier 6) still happens later if nothing else matches.\n+    _oauth_active: Optional[str] = None\n+    try:\n+        _store = _load_auth_store()\n+        _maybe = _store.get(\"active_provider\")\n+        if _maybe and _maybe in PROVIDER_REGISTRY and get_auth_status(_maybe).get(\"logged_in\"):\n+            _oauth_active = _maybe\n+    except Exception as e:\n+        logger.debug(\"Could not pre-read active auth provider: %s\", e)\n+\n     # Auto-detect API-key providers by checking their env vars\n     for pid, pconfig in PROVIDER_REGISTRY.items():\n         if pconfig.auth_type != \"api_key\":","@@ -1639,8 +1665,37 @@ def resolve_provider(\n             continue\n         for env_var in pconfig.api_key_env_vars:\n             if has_usable_secret(os.getenv(env_var, \"\")):\n+                # An exported API key now wins over a logged-in OAuth provider\n+                # (the #29285 fix). Surface that so a user who deliberately uses\n+                # OAuth but has a stale key in ~/.hermes/.env isn't silently\n+                # switched without knowing why.\n+                if _oauth_active and _oauth_active != pid:\n+                    logger.warning(\n+                        \"Provider resolved to %r via %s, preempting your \"\n+                        \"logged-in OAuth provider %r. If you meant to use the \"\n+                        \"OAuth login, unset %s or set `model.provider` \"\n+                        \"explicitly.\",\n+                        pid, env_var, _oauth_active, env_var,\n+                    )\n                 return pid\n \n+    # Logged-in OAuth provider (auth.json `active_provider`) — a LAST-RESORT\n+    # fallback, chosen only when the user expressed no other preference above.\n+    # Previously this sat ABOVE the env-var/config checks, so a stale OAuth\n+    # login silently overrode an explicit `model.provider` or an exported API\n+    # key (#29285). Demoted here so explicit intent always wins.\n+    if _oauth_active:\n+        # Surface the silent-override case the issue reported: a populated\n+        # `model` config that lacks a `provider` key falls through to OAuth.\n+        if isinstance(_model_cfg, dict) and _model_cfg and not _model_cfg.get(\"provider\"):\n+            logger.warning(\n+                \"Provider resolved to logged-in OAuth provider %r because \"\n+                \"config.yaml `model` has no `provider` key. If you meant a \"\n+                \"different provider, set `model.provider` explicitly.\",\n+                _oauth_active,\n+            )\n+        return _oauth_active\n+\n     # AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars).\n     # This runs after API-key providers so explicit keys always win.\n     try:"]},{"file":"tests/hermes_cli/test_provider_precedence.py","hunks":["@@ -0,0 +1,117 @@\n+\"\"\"Regression tests for #29285 — provider precedence in resolve_provider(\"auto\").\n+\n+Explicit user intent (config.yaml model.provider, env-var API keys) must win\n+over a stale logged-in OAuth `active_provider` in auth.json. Before the fix,\n+`active_provider` sat above the env/config checks and silently overrode an\n+explicit choice — e.g. a user OAuth-logged-into Anthropic but with\n+OPENAI_API_KEY exported (or model.provider set) got routed to Anthropic.\n+\"\"\"\n+import pytest\n+\n+from hermes_cli.auth import resolve_provider, AuthError\n+\n+\n+def _login(monkeypatch, provider_id):\n+    \"\"\"Simulate a logged-in OAuth active_provider in auth.json.\"\"\"\n+    monkeypatch.setattr(\"hermes_cli.auth._load_auth_store\",\n+                        lambda: {\"active_provider\": provider_id})\n+    monkeypatch.setattr(\"hermes_cli.auth.get_auth_status\",\n+                        lambda p: {\"logged_in\": p == provider_id})\n+\n+\n+def _config(monkeypatch, model_cfg):\n+    monkeypatch.setattr(\"hermes_cli.config.load_config\", lambda: {\"model\": model_cfg})\n+\n+\n+def _no_aws(monkeypatch):\n+    # Neutralize any ambient AWS creds so Bedrock auto-detect can't interfere.\n+    monkeypatch.setattr(\"agent.bedrock_adapter.has_aws_credentials\", lambda: False)\n+\n+\n+def _clear_provider_env(monkeypatch):\n+    for var in (\"OPENAI_API_KEY\", \"OPENROUTER_API_KEY\", \"GLM_API_KEY\", \"ZAI_API_KEY\",\n+                \"KIMI_API_KEY\", \"MINIMAX_API_KEY\", \"HERMES_INFERENCE_PROVIDER\"):\n+        monkeypatch.delenv(var, raising=False)\n+\n+\n+class TestProviderPrecedence:\n+    def test_config_provider_beats_stale_oauth(self, monkeypatch):\n+        \"\"\"config.yaml model.provider wins over a logged-in OAuth active_provider.\"\"\"\n+        _clear_provider_env(monkeypatch)\n+        _no_aws(monkeypatch)\n+        _login(monkeypatch, \"anthropic\")           # stale OAuth login\n+        _config(monkeypatch, {\"provider\": \"zai\", \"default\": \"glm-4.6\"})\n+        assert resolve_provider(\"auto\") == \"zai\"\n+\n+    def test_env_key_beats_stale_oauth(self, monkeypatch):\n+        \"\"\"An exported provider API key wins over a logged-in OAuth active_provider.\"\"\"\n+        _clear_provider_env(monkeypatch)\n+        _no_aws(monkeypatch)\n+        _login(monkeypatch, \"anthropic\")\n+        _config(monkeypatch, {\"default\": \"some-model\"})  # dict, NO provider key\n+        monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-test-key\")\n+        assert resolve_provider(\"auto\") == \"openrouter\"\n+\n+    def test_provider_specific_env_key_beats_stale_oauth(self, monkeypatch):\n+        \"\"\"A provider-specific env key (GLM) wins over a logged-in OAuth provider.\"\"\"\n+        _clear_provider_env(monkeypatch)\n+        _no_aws(monkeypatch)\n+        _login(monkeypatch, \"anthropic\")\n+        _config(monkeypatch, {})\n+        monkeypatch.setenv(\"GLM_API_KEY\", \"test-glm-key\")\n+        assert resolve_provider(\"auto\") == \"zai\"\n+\n+    def test_oauth_used_as_last_resort(self, monkeypatch):\n+        \"\"\"With NO config provider and NO env keys, the logged-in OAuth provider\n+        is still used (it's the last-resort fallback, not removed).\"\"\"\n+        _clear_provider_env(monkeypatch)\n+        _no_aws(monkeypatch)\n+        _login(monkeypatch, \"anthropic\")\n+        _config(monkeypatch, {})  # empty model config, no provider\n+        assert resolve_provider(\"auto\") == \"anthropic\"\n+\n+    def test_explicit_request_unaffected(self, monkeypatch):\n+        \"\"\"An explicit requested provider short-circuits everything.\"\"\"\n+        _clear_provider_env(monkeypatch)\n+        _login(monkeypatch, \"anthropic\")\n+        assert resolve_provider(\"zai\") == \"zai\"\n+\n+    def test_warns_on_silent_oauth_fallthrough(self, monkeypatch, caplog):\n+        \"\"\"A populated model dict lacking `provider` that falls through to OAuth\n+        emits a WARN so the silent override is visible (#29285).\"\"\"\n+        import logging\n+        _clear_provider_env(monkeypatch)\n+        _no_aws(monkeypatch)\n+        _login(monkeypatch, \"anthropic\")\n+        _config(monkeypatch, {\"default\": \"claude-x\"})  # populated, no provider\n+        with caplog.at_level(logging.WARNING, logger=\"hermes_cli.auth\"):\n+            assert resolve_provider(\"auto\") == \"anthropic\"\n+        assert any(\"no `provider` key\" in r.message for r in caplog.records)\n+\n+    def test_warns_when_env_key_preempts_oauth(self, monkeypatch, caplog):\n+        \"\"\"When an exported API key preempts a logged-in OAuth provider, a WARN\n+        makes the silent routing switch visible (#29285).\"\"\"\n+        import logging\n+        _clear_provider_env(monkeypatch)\n+        _no_aws(monkeypatch)\n+        _login(monkeypatch, \"anthropic\")           # OAuth into anthropic\n+        _config(monkeypatch, {})\n+        monkeypatch.setenv(\"GLM_API_KEY\", \"test-glm-key\")  # unrelated key present\n+        with caplog.at_level(logging.WARNING, logger=\"hermes_cli.auth\"):\n+            assert resolve_provider(\"auto\") == \"zai\"\n+        assert any(\"preempting your\" in r.message for r in caplog.records)\n+\n+    def test_openrouter_pool_beats_stale_oauth(self, monkeypatch):\n+        \"\"\"An OpenRouter credential-pool entry (no env var) wins over a logged-in\n+        OAuth provider — the pool rung sits above OAuth (#42130 + #29285).\"\"\"\n+        _clear_provider_env(monkeypatch)\n+        _no_aws(monkeypatch)\n+        _login(monkeypatch, \"anthropic\")\n+        _config(monkeypatch, {})\n+\n+        class _Pool:\n+            def has_credentials(self):\n+                return True\n+\n+        monkeypatch.setattr(\"agent.credential_pool.load_pool\", lambda name: _Pool())\n+        assert resolve_provider(\"auto\") == \"openrouter\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}