{"repo":"NousResearch/hermes-agent","public_id":"9813","display_label":"Anthropic OAuth token misclassification and auth fixes","summary":"These issues and PRs concern OAuth tokens being incorrectly classified or handled as API keys in the Anthropic provider, leading to wrong headers, 429s, and 401s. Several fixes address credential normalization, token detection, and OAuth support in model fetching.","subsystem":null,"counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15175},"resolution":{"state":"partially_resolved","by":"pr-63883"},"freshness":{"state":"verified","last_verified_at":"2026-07-13T17:50:49+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22476190476190475},"fix_edges":[{"issue_number":9813,"pr_number":15175,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":63737,"pr_number":63883,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":9813,"title":"CLAUDE_CODE_OAUTH_TOKEN can be misclassified as API key instead of OAuth","url":null,"state":"closed"},{"kind":"issue","number":47965,"title":"Anthropic provider fetch_models uses x-api-key only — OAuth tokens 401, breaks provider registration","url":null,"state":"closed"},{"kind":"issue","number":63737,"title":"Credential pool: manually-added Anthropic OAuth setup-tokens (sk-ant-oat) stored as api_key → sent via x-api-key → 429","url":null,"state":"closed"},{"kind":"pr","number":15175,"title":"fix(anthropic): consolidated OAuth — atomic writes, cc- prefix, stale detection + Keychain, aux 401 refresh","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","agent/auxiliary_client.py","hermes_cli/main.py","scripts/release.py","tests/agent/test_anthropic_keychain.py","tests/agent/test_auxiliary_client.py","tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -14,6 +14,8 @@\n import json\n import logging\n import os\n+import platform\n+import subprocess\n from pathlib import Path\n \n from hermes_constants import get_hermes_home","@@ -277,8 +279,9 @@ def _is_oauth_token(key: str) -> bool:\n     Positively identifies Anthropic OAuth tokens by their key format:\n     - ``sk-ant-`` prefix (but NOT ``sk-ant-api``) → setup tokens, managed keys\n     - ``eyJ`` prefix → JWTs from the Anthropic OAuth flow\n+    - ``cc-`` prefix → Claude Code OAuth access tokens (from CLAUDE_CODE_OAUTH_TOKEN)\n \n-    Non-Anthropic keys (MiniMax, Alibaba, etc.) don't match either pattern\n+    Non-Anthropic keys (MiniMax, Alibaba, etc.) don't match any pattern\n     and correctly return False.\n     \"\"\"\n     if not key:","@@ -292,6 +295,9 @@ def _is_oauth_token(key: str) -> bool:\n     # JWTs from Anthropic OAuth flow\n     if key.startswith(\"eyJ\"):\n         return True\n+    # Claude Code OAuth access tokens (opaque, from CLAUDE_CODE_OAUTH_TOKEN)\n+    if key.startswith(\"cc-\"):\n+        return True\n     return False\n \n ","@@ -461,8 +467,72 @@ def build_anthropic_bedrock_client(region: str):\n     )\n \n \n+def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:\n+    \"\"\"Read Claude Code OAuth credentials from the macOS Keychain.\n+\n+    Claude Code >=2.1.114 stores credentials in the macOS Keychain under the\n+    service name \"Claude Code-credentials\" rather than (or in addition to)\n+    the JSON file at ~/.claude/.credentials.json.\n+\n+    The password field contains a JSON string with the same claudeAiOauth\n+    structure as the JSON file.\n+\n+    Returns dict with {accessToken, refreshToken?, expiresAt?} or None.\n+    \"\"\"\n+    import platform\n+    import subprocess\n+\n+    if platform.system() != \"Darwin\":\n+        return None\n+\n+    try:\n+        # Read the \"Claude Code-credentials\" generic password entry\n+        result = subprocess.run(\n+            [\"security\", \"find-generic-password\",\n+             \"-s\", \"Claude Code-credentials\",\n+             \"-w\"],\n+            capture_output=True,\n+            text=True,\n+            timeout=5,\n+        )\n+    except (OSError, subprocess.TimeoutExpired):\n+        logger.debug(\"Keychain: security command not available or timed out\")\n+        return None\n+\n+    if result.returncode != 0:\n+        logger.debug(\"Keychain: no entry found for 'Claude Code-credentials'\")\n+        return None\n+\n+    raw = result.stdout.strip()\n+    if not raw:\n+        return None\n+\n+    try:\n+        data = json.loads(raw)\n+    except json.JSONDecodeError:\n+        logger.debug(\"Keychain: credentials payload is not valid JSON\")\n+        return None\n+\n+    oauth_data = data.get(\"claudeAiOauth\")\n+    if oauth_data and isinstance(oauth_data, dict):\n+        access_token = [redacted:secret-assignment]\"accessToken\", \"\")\n+        if access_token:\n+            return {\n+                \"accessToken\": access_token,\n+                \"refreshToken\": oauth_data.get(\"refreshToken\", \"\"),\n+                \"expiresAt\": oauth_data.get(\"expiresAt\", 0),\n+                \"source\": \"macos_keychain\",\n+            }\n+\n+    return None\n+\n+\n def read_claude_code_credentials() -> Optional[Dict[str, Any]]:\n-    \"\"\"Read refreshable Claude Code OAuth credentials from ~/.claude/.credentials.json.\n+    \"\"\"Read refreshable Claude Code OAuth credentials.\n+\n+    Checks two sources in order:\n+      1. macOS Keychain (Darwin only) — \"Claude Code-credentials\" entry\n+      2. ~/.claude/.credentials.json file\n \n     This intentionally excludes ~/.claude.json primaryApiKey. Opencode's\n     subscription flow is OAuth/setup-token based with refreshable credentials,","@@ -471,6 +541,12 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]:\n \n     Returns dict with {accessToken, refreshToken?, expiresAt?} or None.\n     \"\"\"\n+    # Try macOS Keychain first (covers Claude Code >=2.1.114)\n+    kc_creds = _read_claude_code_credentials_from_keychain()\n+    if kc_creds:\n+        return kc_creds\n+\n+    # Fall back to JSON file\n     cred_path = Path.home() / \".claude\" / \".credentials.json\"\n     if cred_path.exists():\n         try:","@@ -641,7 +717,9 @@ def _write_claude_code_credentials(\n         existing[\"claudeAiOauth\"] = oauth_data\n \n         cred_path.parent.mkdir(parents=True, exist_ok=True)\n-        cred_path.write_text(json.dumps(existing, indent=2), encoding=\"utf-8\")\n+        _tmp_cred = cred_path.with_suffix(\".tmp\")\n+        _tmp_cred.write_text(json.dumps(existing, indent=2), encoding=\"utf-8\")\n+        _tmp_cred.replace(cred_path)\n         # Restrict permissions (credentials file)\n         cred_path.chmod(0o600)\n     except (OSError, IOError) as e:","@@ -1598,4 +1676,3 @@ def build_anthropic_kwargs(\n     return kwargs\n \n \n-"]},{"file":"agent/auxiliary_client.py","hunks":["@@ -1349,6 +1349,68 @@ def _is_auth_error(exc: Exception) -> bool:\n     return \"error code: 401\" in err_lower or \"authenticationerror\" in type(exc).__name__.lower()\n \n \n+def _evict_cached_clients(provider: str) -> None:\n+    \"\"\"Drop cached auxiliary clients for a provider so fresh creds are used.\"\"\"\n+    normalized = _normalize_aux_provider(provider)\n+    with _client_cache_lock:\n+        stale_keys = [\n+            key for key in _client_cache\n+            if _normalize_aux_provider(str(key[0])) == normalized\n+        ]\n+        for key in stale_keys:\n+            client = _client_cache.get(key, (None, None, None))[0]\n+            if client is not None:\n+                _force_close_async_httpx(client)\n+                try:\n+                    close_fn = getattr(client, \"close\", None)\n+                    if callable(close_fn):\n+                        close_fn()\n+                except Exception:\n+                    pass\n+            _client_cache.pop(key, None)\n+\n+\n+def _refresh_provider_credentials(provider: str) -> bool:\n+    \"\"\"Refresh short-lived credentials for OAuth-backed auxiliary providers.\"\"\"\n+    normalized = _normalize_aux_provider(provider)\n+    try:\n+        if normalized == \"openai-codex\":\n+            from hermes_cli.auth import resolve_codex_runtime_credentials\n+\n+            creds = resolve_codex_runtime_credentials(force_refresh=True)\n+            if not str(creds.get(\"api_key\", \"\") or \"\").strip():\n+                return False\n+            _evict_cached_clients(normalized)\n+            return True\n+        if normalized == \"nous\":\n+            from hermes_cli.auth import resolve_nous_runtime_credentials\n+\n+            creds = resolve_nous_runtime_credentials(\n+                min_key_ttl_seconds=max(60, int(os.getenv(\"HERMES_NOUS_MIN_KEY_TTL_SECONDS\", \"1800\"))),\n+                timeout_seconds=float(os.getenv(\"HERMES_NOUS_TIMEOUT_SECONDS\", \"15\")),\n+                force_mint=True,\n+            )\n+            if not str(creds.get(\"api_key\", \"\") or \"\").strip():\n+                return False\n+            _evict_cached_clients(normalized)\n+            return True\n+        if normalized == \"anthropic\":\n+            from agent.anthropic_adapter import read_claude_code_credentials, _refresh_oauth_token, resolve_anthropic_token\n+\n+            creds = read_claude_code_credentials()\n+            token = [redacted:secret-assignment] if isinstance(creds, dict) and creds.get(\"refreshToken\") else None\n+            if not str(token or \"\").strip():\n+                token = [redacted:secret-assignment]\n+            if not str(token or \"\").strip():\n+                return False\n+            _evict_cached_clients(normalized)\n+            return True\n+    except Exception as exc:\n+        logger.debug(\"Auxiliary provider credential refresh failed for %s: %s\", normalized, exc)\n+        return False\n+    return False\n+\n+\n def _try_payment_fallback(\n     failed_provider: str,\n     task: str = None,","@@ -2899,6 +2961,49 @@ def call_llm(\n                 return _validate_llm_response(\n                     refreshed_client.chat.completions.create(**kwargs), task)\n \n+        # ── Auth refresh retry ───────────────────────────────────────\n+        if (_is_auth_error(first_err)\n+                and resolved_provider not in (\"auto\", \"\", None)\n+                and not client_is_nous):\n+            if _refresh_provider_credentials(resolved_provider):\n+                logger.info(\n+                    \"Auxiliary %s: refreshed %s credentials after auth error, retrying\",\n+                    task or \"call\", resolved_provider,\n+                )\n+                retry_client, retry_model = (\n+                    resolve_vision_provider_client(\n+                        provider=resolved_provider,\n+                        model=final_model,\n+                        async_mode=False,\n+                    )[1:]\n+                    if task == \"vision\"\n+                    else _get_cached_client(\n+                        resolved_provider,\n+                        resolved_model,\n+                        base_url=resolved_base_url,\n+                        api_key=[redacted:secret-assignment]\n+                        api_mode=resolved_api_mode,\n+                        main_runtime=main_runtime,\n+                    )\n+                )\n+                if retry_client is not None:\n+                    retry_kwargs = _build_call_kwargs(\n+                        resolved_provider,\n+                        retry_model or final_model,\n+                        messages,\n+                        temperature=temperature,\n+                        max_tokens=[redacted:secret-assignment]\n+                        tools=tools,\n+                        timeout=effective_timeout,\n+                        extra_body=effective_extra_body,\n+                        base_url=resolved_base_url,\n+                    )\n+                    _retry_base = str(getattr(retry_client, \"base_url\", \"\") or \"\")\n+                    if _is_anthropic_compat_endpoint(resolved_provider, _retry_base):\n+                        retry_kwargs[\"messages\"] = _convert_openai_images_to_anthropic(retry_kwargs[\"messages\"])\n+                    return _validate_llm_response(\n+                        retry_client.chat.completions.create(**retry_kwargs), task)\n+\n         # ── Payment / credit exhaustion fallback ──────────────────────\n         # When the resolved provider returns 402 or a credit-related error,\n         # try alternative providers instead of giving up.  This handles the","@@ -3119,6 +3224,48 @@ async def async_call_llm(\n                 return _validate_llm_response(\n                     await refreshed_client.chat.completions.create(**kwargs), task)\n \n+        # ── Auth refresh retry (mirrors sync call_llm) ───────────────\n+        if (_is_auth_error(first_err)\n+                and resolved_provider not in (\"auto\", \"\", None)\n+                and not client_is_nous):\n+            if _refresh_provider_credentials(resolved_provider):\n+                logger.info(\n+                    \"Auxiliary %s (async): refreshed %s credentials after auth error, retrying\",\n+                    task or \"call\", resolved_provider,\n+                )\n+                if task == \"vision\":\n+                    _, retry_client, retry_model = resolve_vision_provider_client(\n+                        provider=resolved_provider,\n+                        model=final_model,\n+                        async_mode=True,\n+                    )\n+                else:\n+                    retry_client, retry_model = _get_cached_client(\n+                        resolved_provider,\n+                        resolved_model,\n+                        async_mode=True,\n+                        base_url=resolved_base_url,\n+                        api_key=[redacted:secret-assignment]\n+                        api_mode=resolved_api_mode,\n+                    )\n+                if retry_client is not None:\n+                    retry_kwargs = _build_call_kwargs(\n+                        resolved_provider,\n+                        retry_model or final_model,\n+                        messages,\n+                        temperature=temperature,\n+                        max_tokens=[redacted:secret-assignment]\n+                        tools=tools,\n+                        timeout=effective_timeout,\n+                        extra_body=effective_extra_body,\n+                        base_url=resolved_base_url,\n+                    )\n+                    _retry_base = str(getattr(retry_client, \"base_url\", \"\") or \"\")\n+                    if _is_anthropic_compat_endpoint(resolved_provider, _retry_base):\n+                        retry_kwargs[\"messages\"] = _convert_openai_images_to_anthropic(retry_kwargs[\"messages\"])\n+                    return _validate_llm_response(\n+                        await retry_client.chat.completions.create(**retry_kwargs), task)\n+\n         # ── Payment / connection fallback (mirrors sync call_llm) ─────\n         should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)\n         is_auto = resolved_provider in (\"auto\", \"\", None)"]},{"file":"hermes_cli/main.py","hunks":["@@ -4316,6 +4316,8 @@ def _model_flow_anthropic(config, current_model=\"\"):\n         from agent.anthropic_adapter import (\n             read_claude_code_credentials,\n             is_claude_code_token_valid,\n+            _is_oauth_token,\n+            _resolve_claude_code_token_from_credentials,\n         )\n \n         cc_creds = read_claude_code_credentials()","@@ -4324,7 +4326,14 @@ def _model_flow_anthropic(config, current_model=\"\"):\n     except Exception:\n         pass\n \n-    has_creds = bool(existing_key) or cc_available\n+    # Stale-OAuth guard: if the only existing cred is an expired OAuth token\n+    # (no valid cc_creds to fall back on), treat it as missing so the re-auth\n+    # path is offered instead of silently accepting a broken token.\n+    existing_is_stale_oauth = False\n+    if existing_key and _is_oauth_token(existing_key) and not cc_available:\n+        existing_is_stale_oauth = True\n+\n+    has_creds = (bool(existing_key) and not existing_is_stale_oauth) or cc_available\n     needs_auth = not has_creds\n \n     if has_creds:"]},{"file":"scripts/release.py","hunks":["@@ -195,6 +195,11 @@\n     \"[redacted:email]\": \"Wangshengyang2004\",\n     \"[redacted:email]\": \"H-Ali13381\",\n     \"[redacted:email]\": \"XieNBi\",\n+    \"[redacted:email]\": \"maymuneth\",\n+    \"[redacted:email]\": \"nightq\",\n+    \"[redacted:email]\": \"5park1e\",\n+    \"[redacted:email]\": \"5park1e\",\n+    \"[redacted:email]\": \"innocarpe\",\n     \"[redacted:email]\": \"nummanali\",\n     \"[redacted:email]\": \"whitehatjr1001\",\n     \"[redacted:email]\": \"0xNyk\","]},{"file":"tests/agent/test_anthropic_keychain.py","hunks":["@@ -0,0 +1,165 @@\n+\"\"\"Tests for Bug #12905 fixes in agent/anthropic_adapter.py — macOS Keychain support.\"\"\"\n+\n+import json\n+import platform\n+from unittest.mock import patch, MagicMock\n+\n+import pytest\n+\n+from agent.anthropic_adapter import (\n+    _read_claude_code_credentials_from_keychain,\n+    read_claude_code_credentials,\n+)\n+\n+\n+class TestReadClaudeCodeCredentialsFromKeychain:\n+    \"\"\"Bug 4: macOS Keychain support for Claude Code >=2.1.114.\"\"\"\n+\n+    def test_returns_none_on_linux(self):\n+        \"\"\"Keychain reading is Darwin-only; must return None on other platforms.\"\"\"\n+        with patch(\"agent.anthropic_adapter.platform.system\", return_value=\"Linux\"):\n+            assert _read_claude_code_credentials_from_keychain() is None\n+\n+    def test_returns_none_on_windows(self):\n+        with patch(\"agent.anthropic_adapter.platform.system\", return_value=\"Windows\"):\n+            assert _read_claude_code_credentials_from_keychain() is None\n+\n+    def test_returns_none_when_security_command_not_found(self):\n+        \"\"\"OSError from missing security binary must be handled gracefully.\"\"\"\n+        with patch(\"agent.anthropic_adapter.platform.system\", return_value=\"Darwin\"), \\\n+             patch(\"agent.anthropic_adapter.subprocess.run\",\n+                   side_effect=OSError(\"security not found\")):\n+            assert _read_claude_code_credentials_from_keychain() is None\n+\n+    def test_returns_none_on_nonzero_exit_code(self):\n+        \"\"\"security returns non-zero when the Keychain entry doesn't exist.\"\"\"\n+        with patch(\"agent.anthropic_adapter.platform.system\", return_value=\"Darwin\"), \\\n+             patch(\"agent.anthropic_adapter.subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(returncode=1, stdout=\"\", stderr=\"\")\n+            assert _read_claude_code_credentials_from_keychain() is None\n+\n+    def test_returns_none_for_empty_stdout(self):\n+        with patch(\"agent.anthropic_adapter.platform.system\", return_value=\"Darwin\"), \\\n+             patch(\"agent.anthropic_adapter.subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(returncode=0, stdout=\"\", stderr=\"\")\n+            assert _read_claude_code_credentials_from_keychain() is None\n+\n+    def test_returns_none_for_non_json_payload(self):\n+        with patch(\"agent.anthropic_adapter.platform.system\", return_value=\"Darwin\"), \\\n+             patch(\"agent.anthropic_adapter.subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(returncode=0, stdout=\"not valid json\", stderr=\"\")\n+            assert _read_claude_code_credentials_from_keychain() is None\n+\n+    def test_returns_none_when_password_field_is_missing_claude_ai_oauth(self):\n+        with patch(\"agent.anthropic_adapter.platform.system\", return_value=\"Darwin\"), \\\n+             patch(\"agent.anthropic_adapter.subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(\n+                returncode=0,\n+                stdout=json.dumps({\"someOtherService\": {\"accessToken\": \"tok\"}}),\n+                stderr=\"\",\n+            )\n+            assert _read_claude_code_credentials_from_keychain() is None\n+\n+    def test_returns_none_when_access_token_is_empty(self):\n+        with patch(\"agent.anthropic_adapter.platform.system\", return_value=\"Darwin\"), \\\n+             patch(\"agent.anthropic_adapter.subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(\n+                returncode=0,\n+                stdout=json.dumps({\"claudeAiOauth\": {\"accessToken\": \"\", \"refreshToken\": \"x\"}}),\n+                stderr=\"\",\n+            )\n+            assert _read_claude_code_credentials_from_keychain() is None\n+\n+    def test_parses_valid_keychain_entry(self):\n+        with patch(\"agent.anthropic_adapter.platform.system\", return_value=\"Darwin\"), \\\n+             patch(\"agent.anthropic_adapter.subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(\n+                returncode=0,\n+                stdout=json.dumps({\n+                    \"claudeAiOauth\": {\n+                        \"accessToken\": \"kc-access-token-abc\",\n+                        \"refreshToken\": \"kc-refresh-token-xyz\",\n+                        \"expiresAt\": 9999999999999,\n+                    }\n+                }),\n+                stderr=\"\",\n+            )\n+            creds = _read_claude_code_credentials_from_keychain()\n+            assert creds is not None\n+            assert creds[\"accessToken\"] == \"kc-access-token-abc\"\n+            assert creds[\"refreshToken\"] == \"kc-refresh-token-xyz\"\n+            assert creds[\"expiresAt\"] == 9999999999999\n+            assert creds[\"source\"] == \"macos_keychain\"\n+\n+\n+class TestReadClaudeCodeCredentialsPriority:\n+    \"\"\"Bug 4: Keychain must be checked before the JSON file.\"\"\"\n+\n+    def test_keychain_takes_priority_over_json_file(self, tmp_path, monkeypatch):\n+        \"\"\"When both Keychain and JSON file have credentials, Keychain wins.\"\"\"\n+        # Set up JSON file with \"older\" token\n+        json_cred_file = tmp_path / \".claude\" / \".credentials.json\"\n+        json_cred_file.parent.mkdir(parents=True)\n+        json_cred_file.write_text(json.dumps({\n+            \"claudeAiOauth\": {\n+                \"accessToken\": \"json-token\",\n+                \"refreshToken\": \"json-refresh\",\n+                \"expiresAt\": 9999999999999,\n+            }\n+        }))\n+        monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+\n+        # Mock Keychain to return a \"newer\" token\n+        with patch(\"agent.anthropic_adapter.platform.system\", return_value=\"Darwin\"), \\\n+             patch(\"agent.anthropic_adapter.subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(\n+                returncode=0,\n+                stdout=json.dumps({\n+                    \"claudeAiOauth\": {\n+                        \"accessToken\": \"keychain-token\",\n+                        \"refreshToken\": \"keychain-refresh\",\n+                        \"expiresAt\": 9999999999999,\n+                    }\n+                }),\n+                stderr=\"\",\n+            )\n+            creds = read_claude_code_credentials()\n+\n+        # Keychain token should be returned, not JSON file token\n+        assert creds is not None\n+        assert creds[\"accessToken\"] == \"keychain-token\"\n+        assert creds[\"source\"] == \"macos_keychain\"\n+\n+    def test_falls_back_to_json_when_keychain_returns_none(self, tmp_path, monkeypatch):\n+        \"\"\"When Keychain has no entry, JSON file is used as fallback.\"\"\"\n+        json_cred_file = tmp_path / \".claude\" / \".credentials.json\"\n+        json_cred_file.parent.mkdir(parents=True)\n+        json_cred_file.write_text(json.dumps({\n+            \"claudeAiOauth\": {\n+                \"accessToken\": \"json-fallback-token\",\n+                \"refreshToken\": \"json-refresh\",\n+                \"expiresAt\": 9999999999999,\n+            }\n+        }))\n+        monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+\n+        with patch(\"agent.anthropic_adapter.platform.system\", return_value=\"Darwin\"), \\\n+             patch(\"agent.anthropic_adapter.subprocess.run\") as mock_run:\n+            # Simulate Keychain entry not found\n+            mock_run.return_value = MagicMock(returncode=1, stdout=\"\", stderr=\"\")\n+            creds = read_claude_code_credentials()\n+\n+        assert creds is not None\n+        assert creds[\"accessToken\"] == \"json-fallback-token\"\n+        assert creds[\"source\"] == \"claude_code_credentials_file\"\n+\n+    def test_returns_none_when_neither_keychain_nor_json_has_creds(self, tmp_path, monkeypatch):\n+        \"\"\"No credentials anywhere — must return None cleanly.\"\"\"\n+        monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+\n+        with patch(\"agent.anthropic_adapter.platform.system\", return_value=\"Darwin\"), \\\n+             patch(\"agent.anthropic_adapter.subprocess.run\") as mock_run:\n+            mock_run.return_value = MagicMock(returncode=1, stdout=\"\", stderr=\"\")\n+            creds = read_claude_code_credentials()\n+\n+        assert creds is None"]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -1215,3 +1215,201 @@ def test_jpeg_media_type_parsed(self):\n         }]\n         result = _convert_openai_images_to_anthropic(messages)\n         assert result[0][\"content\"][0][\"source\"][\"media_type\"] == \"image/jpeg\"\n+\n+\n+class _AuxAuth401(Exception):\n+    status_code = 401\n+\n+    def __init__(self, message=\"Provided authentication token is expired\"):\n+        super().__init__(message)\n+\n+\n+class _DummyResponse:\n+    def __init__(self, text=\"ok\"):\n+        self.choices = [MagicMock(message=MagicMock(content=text))]\n+\n+\n+class _FailingThenSuccessCompletions:\n+    def __init__(self):\n+        self.calls = 0\n+\n+    def create(self, **kwargs):\n+        self.calls += 1\n+        if self.calls == 1:\n+            raise _AuxAuth401()\n+        return _DummyResponse(\"sync-ok\")\n+\n+\n+class _AsyncFailingThenSuccessCompletions:\n+    def __init__(self):\n+        self.calls = 0\n+\n+    async def create(self, **kwargs):\n+        self.calls += 1\n+        if self.calls == 1:\n+            raise _AuxAuth401()\n+        return _DummyResponse(\"async-ok\")\n+\n+\n+class TestAuxiliaryAuthRefreshRetry:\n+    def test_call_llm_refreshes_codex_on_401_for_vision(self):\n+        failing_client = MagicMock()\n+        failing_client.base_url = \"https://chatgpt.com/backend-api/codex\"\n+        failing_client.chat.completions = _FailingThenSuccessCompletions()\n+\n+        fresh_client = MagicMock()\n+        fresh_client.base_url = \"https://chatgpt.com/backend-api/codex\"\n+        fresh_client.chat.completions.create.return_value = _DummyResponse(\"fresh-sync\")\n+\n+        with (\n+            patch(\n+                \"agent.auxiliary_client.resolve_vision_provider_client\",\n+                side_effect=[(\"openai-codex\", failing_client, \"gpt-5.2-codex\"), (\"openai-codex\", fresh_client, \"gpt-5.2-codex\")],\n+            ),\n+            patch(\"agent.auxiliary_client._refresh_provider_credentials\", return_value=True) as mock_refresh,\n+        ):\n+            resp = call_llm(\n+                task=\"vision\",\n+                provider=\"openai-codex\",\n+                model=\"gpt-5.2-codex\",\n+                messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            )\n+\n+        assert resp.choices[0].message.content == \"fresh-sync\"\n+        mock_refresh.assert_called_once_with(\"openai-codex\")\n+\n+    def test_call_llm_refreshes_codex_on_401_for_non_vision(self):\n+        stale_client = MagicMock()\n+        stale_client.base_url = \"https://chatgpt.com/backend-api/codex\"\n+        stale_client.chat.completions.create.side_effect = _AuxAuth401(\"stale codex token\")\n+\n+        fresh_client = MagicMock()\n+        fresh_client.base_url = \"https://chatgpt.com/backend-api/codex\"\n+        fresh_client.chat.completions.create.return_value = _DummyResponse(\"fresh-non-vision\")\n+\n+        with (\n+            patch(\"agent.auxiliary_client._resolve_task_provider_model\", return_value=(\"openai-codex\", \"gpt-5.2-codex\", None, None, None)),\n+            patch(\"agent.auxiliary_client._get_cached_client\", side_effect=[(stale_client, \"gpt-5.2-codex\"), (fresh_client, \"gpt-5.2-codex\")]),\n+            patch(\"agent.auxiliary_client._refresh_provider_credentials\", return_value=True) as mock_refresh,\n+        ):\n+            resp = call_llm(\n+                task=\"compression\",\n+                provider=\"openai-codex\",\n+                model=\"gpt-5.2-codex\",\n+                messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            )\n+\n+        assert resp.choices[0].message.content == \"fresh-non-vision\"\n+        mock_refresh.assert_called_once_with(\"openai-codex\")\n+        assert stale_client.chat.completions.create.call_count == 1\n+        assert fresh_client.chat.completions.create.call_count == 1\n+\n+    def test_call_llm_refreshes_anthropic_on_401_for_non_vision(self):\n+        stale_client = MagicMock()\n+        stale_client.base_url = \"https://api.anthropic.com\"\n+        stale_client.chat.completions.create.side_effect = _AuxAuth401(\"anthropic token expired\")\n+\n+        fresh_client = MagicMock()\n+        fresh_client.base_url = \"https://api.anthropic.com\"\n+        fresh_client.chat.completions.create.return_value = _DummyResponse(\"fresh-anthropic\")\n+\n+        with (\n+            patch(\"agent.auxiliary_client._resolve_task_provider_model\", return_value=(\"anthropic\", \"claude-haiku-4-5-20251001\", None, None, None)),\n+            patch(\"agent.auxiliary_client._get_cached_client\", side_effect=[(stale_client, \"claude-haiku-4-5-20251001\"), (fresh_client, \"claude-haiku-4-5-20251001\")]),\n+            patch(\"agent.auxiliary_client._refresh_provider_credentials\", return_value=True) as mock_refresh,\n+        ):\n+            resp = call_llm(\n+                task=\"compression\",\n+                provider=\"anthropic\",\n+                model=\"claude-haiku-4-5-20251001\",\n+                messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            )\n+\n+        assert resp.choices[0].message.content == \"fresh-anthropic\"\n+        mock_refresh.assert_called_once_with(\"anthropic\")\n+        assert stale_client.chat.completions.create.call_count == 1\n+        assert fresh_client.chat.completions.create.call_count == 1\n+\n+    @pytest.mark.asyncio\n+    async def test_async_call_llm_refreshes_codex_on_401_for_vision(self):\n+        failing_client = MagicMock()\n+        failing_client.base_url = \"https://chatgpt.com/backend-api/codex\"\n+        failing_client.chat.completions = _AsyncFailingThenSuccessCompletions()\n+\n+        fresh_client = MagicMock()\n+        fresh_client.base_url = \"https://chatgpt.com/backend-api/codex\"\n+        fresh_client.chat.completions.create = AsyncMock(return_value=_DummyResponse(\"fresh-async\"))\n+\n+        with (\n+            patch(\n+                \"agent.auxiliary_client.resolve_vision_provider_client\",\n+                side_effect=[(\"openai-codex\", failing_client, \"gpt-5.2-codex\"), (\"openai-codex\", fresh_client, \"gpt-5.2-codex\")],\n+            ),\n+            patch(\"agent.auxiliary_client._refresh_provider_credentials\", return_value=True) as mock_refresh,\n+        ):\n+            resp = await async_call_llm(\n+                task=\"vision\",\n+                provider=\"openai-codex\",\n+                model=\"gpt-5.2-codex\",\n+                messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            )\n+\n+        assert resp.choices[0].message.content == \"fresh-async\"\n+        mock_refresh.assert_called_once_with(\"openai-codex\")\n+\n+    def test_refresh_provider_credentials_force_refreshes_anthropic_oauth_and_evicts_cache(self, monkeypatch):\n+        stale_client = MagicMock()\n+        cache_key = (\"anthropic\", False, None, None, None)\n+\n+        monkeypatch.setenv(\"ANTHROPIC_TOKEN\", \"\")\n+        monkeypatch.setenv(\"CLAUDE_CODE_OAUTH_TOKEN\", \"\")\n+        monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"\")\n+\n+        with (\n+            patch(\"agent.auxiliary_client._client_cache\", {cache_key: (stale_client, \"claude-haiku-4-5-20251001\", None)}),\n+            patch(\"agent.anthropic_adapter.read_claude_code_credentials\", return_value={\n+                \"accessToken\": \"expired-token\",\n+                \"refreshToken\": \"refresh-token\",\n+                \"expiresAt\": 0,\n+            }),\n+            patch(\"agent.anthropic_adapter.refresh_anthropic_oauth_pure\", return_value={\n+                \"access_token\": \"fresh-token\",\n+                \"refresh_token\": \"refresh-token-2\",\n+                \"expires_at_ms\": 9999999999999,\n+            }) as mock_refresh_oauth,\n+            patch(\"agent.anthropic_adapter._write_claude_code_credentials\") as mock_write,\n+        ):\n+            from agent.auxiliary_client import _refresh_provider_credentials\n+\n+            assert _refresh_provider_credentials(\"anthropic\") is True\n+\n+        mock_refresh_oauth.assert_called_once_with(\"refresh-token\", use_json=False)\n+        mock_write.assert_called_once_with(\"fresh-token\", \"refresh-token-2\", 9999999999999)\n+        stale_client.close.assert_called_once()\n+\n+    @pytest.mark.asyncio\n+    async def test_async_call_llm_refreshes_anthropic_on_401_for_non_vision(self):\n+        stale_client = MagicMock()\n+        stale_client.base_url = \"https://api.anthropic.com\"\n+        stale_client.chat.completions.create = AsyncMock(side_effect=_AuxAuth401(\"anthropic token expired\"))\n+\n+        fresh_client = MagicMock()\n+        fresh_client.base_url = \"https://api.anthropic.com\"\n+        fresh_client.chat.completions.create = AsyncMock(return_value=_DummyResponse(\"fresh-async-anthropic\"))\n+\n+        with (\n+            patch(\"agent.auxiliary_client._resolve_task_provider_model\", return_value=(\"anthropic\", \"claude-haiku-4-5-20251001\", None, None, None)),\n+            patch(\"agent.auxiliary_client._get_cached_client\", side_effect=[(stale_client, \"claude-haiku-4-5-20251001\"), (fresh_client, \"claude-haiku-4-5-20251001\")]),\n+            patch(\"agent.auxiliary_client._refresh_provider_credentials\", return_value=True) as mock_refresh,\n+        ):\n+            resp = await async_call_llm(\n+                task=\"compression\",\n+                provider=\"anthropic\",\n+                model=\"claude-haiku-4-5-20251001\",\n+                messages=[{\"role\": \"user\", \"content\": \"hi\"}],\n+            )\n+\n+        assert resp.choices[0].message.content == \"fresh-async-anthropic\"\n+        mock_refresh.assert_called_once_with(\"anthropic\")\n+        assert stale_client.chat.completions.create.await_count == 1\n+        assert fresh_client.chat.completions.create.await_count == 1"]},{"file":"tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py","hunks":["@@ -0,0 +1,210 @@\n+\"\"\"Tests for Bug #12905 fix — stale OAuth token detection in hermes model flow.\n+\n+Bug 3: `hermes model` with `provider=anthropic` skips OAuth re-authentication\n+when a stale ANTHROPIC_TOKEN exists in ~/.hermes/.env but no valid\n+Claude Code credentials are available. The fast-path silently proceeds to\n+model selection with a broken token instead of offering re-auth.\n+\"\"\"\n+\n+import json\n+import pytest\n+from unittest.mock import patch, MagicMock\n+\n+from hermes_cli.config import load_env, save_env_value\n+\n+\n+class TestStaleOAuthTokenDetection:\n+    \"\"\"Bug 3: stale OAuth token must trigger needs_auth=True in _model_flow_anthropic.\"\"\"\n+\n+    def test_stale_oauth_token_triggers_reauth(self, tmp_path, monkeypatch, capsys):\n+        \"\"\"\n+        Scenario: ANTHROPIC_TOKEN is an expired OAuth token and there are no\n+        valid Claude Code credentials anywhere. The flow MUST offer re-auth\n+        instead of silently skipping to model selection.\n+        \"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        # Pre-load .env with an expired OAuth token (sk-ant- prefix = OAuth)\n+        save_env_value(\"ANTHROPIC_TOKEN\", \"[redacted:openai-key]\")\n+        save_env_value(\"ANTHROPIC_API_KEY\", \"\")\n+\n+        # No valid Claude Code credentials available (expired, no refresh token)\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter.read_claude_code_credentials\",\n+            lambda: {\n+                \"accessToken\": \"expired-cc-token\",\n+                \"refreshToken\": \"\",          # No refresh — can't recover\n+                \"expiresAt\": 0,               # Already expired\n+                \"source\": \"claude_code_credentials_file\",\n+            },\n+        )\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter.is_claude_code_token_valid\",\n+            lambda creds: False,             # Explicitly expired\n+        )\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter._is_oauth_token\",\n+            lambda key: key.startswith(\"sk-ant-\"),\n+        )\n+        # _resolve_claude_code_token_from_credentials has no valid path\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter._resolve_claude_code_token_from_credentials\",\n+            lambda creds=None: None,\n+        )\n+\n+        # Simulate user types \"3\" (Cancel) when prompted for re-auth\n+        monkeypatch.setattr(\"builtins.input\", lambda _: \"3\")\n+        monkeypatch.setattr(\"getpass.getpass\", lambda _: \"\")\n+\n+        from hermes_cli.main import _model_flow_anthropic\n+        cfg = {}\n+\n+        _model_flow_anthropic(cfg)\n+\n+        output = capsys.readouterr().out\n+        # Must show auth method choice since token is stale\n+        assert \"subscription\" in output or \"API key\" in output, (\n+            f\"Expected auth method menu but got: {output!r}\"\n+        )\n+\n+    def test_valid_api_key_skips_stale_check(self, tmp_path, monkeypatch, capsys):\n+        \"\"\"\n+        A non-OAuth ANTHROPIC_API_KEY (regular pay-per-token key) must NOT be\n+        flagged as stale even when cc_creds are invalid. Regular API keys don't\n+        expire the same way OAuth tokens do.\n+        \"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        # Regular API key — NOT an OAuth token\n+        save_env_value(\"ANTHROPIC_API_KEY\", \"[redacted:openai-key]\")\n+        save_env_value(\"ANTHROPIC_TOKEN\", \"\")\n+\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter.read_claude_code_credentials\",\n+            lambda: None,   # No CC creds\n+        )\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter.is_claude_code_token_valid\",\n+            lambda creds: False,\n+        )\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter._is_oauth_token\",\n+            lambda key: key.startswith(\"sk-ant-\") and \"oat\" in key,\n+        )\n+\n+        # Simulate user picks \"1\" (use existing)\n+        monkeypatch.setattr(\"builtins.input\", lambda _: \"1\")\n+\n+        from hermes_cli.main import _model_flow_anthropic\n+        cfg = {}\n+\n+        _model_flow_anthropic(cfg)\n+\n+        output = capsys.readouterr().out\n+        # Should show \"Use existing credentials\" menu, NOT auth method choice\n+        assert \"Use existing\" in output or \"credentials\" in output.lower()\n+\n+    def test_valid_oauth_token_with_refresh_available_skips_reauth(self, tmp_path, monkeypatch, capsys):\n+        \"\"\"\n+        When ANTHROPIC_TOKEN is OAuth and valid cc_creds with refresh exist,\n+        the flow should use existing credentials (no forced re-auth).\n+        \"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+        save_env_value(\"ANTHROPIC_TOKEN\", \"[redacted:openai-key]\")\n+        save_env_value(\"ANTHROPIC_API_KEY\", \"\")\n+\n+        # Valid Claude Code credentials with refresh token\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter.read_claude_code_credentials\",\n+            lambda: {\n+                \"accessToken\": \"valid-cc-token\",\n+                \"refreshToken\": \"valid-refresh\",\n+                \"expiresAt\": 9999999999999,\n+            },\n+        )\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter.is_claude_code_token_valid\",\n+            lambda creds: True,\n+        )\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter._is_oauth_token\",\n+            lambda key: key.startswith(\"sk-ant-\"),\n+        )\n+        monkeypatch.setattr(\n+            \"agent.anthropic_adapter._resolve_claude_code_token_from_credentials\",\n+            lambda creds=None: \"valid-cc-token\",\n+        )\n+\n+        # Simulate user picks \"1\" (use existing)\n+        monkeypatch.setattr(\"builtins.input\", lambda _: \"1\")\n+\n+        from hermes_cli.main import _model_flow_anthropic\n+        cfg = {}\n+\n+        _model_flow_anthropic(cfg)\n+\n+        output = capsys.readouterr().out\n+        # Should show \"Use existing\" without forcing re-auth\n+        assert \"Use existing\" in output or \"credentials\" in output.lower()\n+\n+\n+class TestStaleOAuthGuardLogic:\n+    \"\"\"Unit-level test of the stale-OAuth detection guard logic.\"\"\"\n+\n+    def test_stale_oauth_flag_logic_no_cc_creds(self):\n+        \"\"\"\n+        When existing_key is OAuth and cc_available is False,\n+        existing_is_stale_oauth should be True → has_creds = False.\n+        \"\"\"\n+        existing_key = \"[redacted:openai-key]\"\n+        _is_oauth_token = lambda k: k.startswith(\"sk-ant-\")\n+        cc_available = False\n+\n+        existing_is_stale_oauth = (\n+            bool(existing_key) and\n+            _is_oauth_token(existing_key) and\n+            not cc_available\n+        )\n+        has_creds = (bool(existing_key) and not existing_is_stale_oauth) or cc_available\n+\n+        assert existing_is_stale_oauth is True\n+        assert has_creds is False\n+\n+    def test_stale_oauth_flag_logic_with_valid_cc_creds(self):\n+        \"\"\"\n+        When existing_key is OAuth but cc_available is True (valid creds exist),\n+        has_creds should be True — the cc_creds will be used instead.\n+        \"\"\"\n+        existing_key = \"sk-ant-oat-sometoken\"\n+        _is_oauth_token = lambda k: k.startswith(\"sk-ant-\")\n+        cc_available = True\n+\n+        existing_is_stale_oauth = (\n+            bool(existing_key) and\n+            _is_oauth_token(existing_key) and\n+            not cc_available\n+        )\n+        has_creds = (bool(existing_key) and not existing_is_stale_oauth) or cc_available\n+\n+        assert existing_is_stale_oauth is False\n+        assert has_creds is True\n+\n+    def test_non_oauth_key_not_flagged_as_stale(self):\n+        \"\"\"\n+        Regular ANTHROPIC_API_KEY (non-OAuth) must not be flagged as stale\n+        even when cc_available is False.\n+        \"\"\"\n+        existing_key = \"[redacted:openai-key]\"\n+        _is_oauth_token = lambda k: k.startswith(\"sk-ant-\") and \"oat\" in k\n+        cc_available = False\n+\n+        existing_is_stale_oauth = (\n+            bool(existing_key) and\n+            _is_oauth_token(existing_key) and\n+            not cc_available\n+        )\n+        has_creds = (bool(existing_key) and not existing_is_stale_oauth) or cc_available\n+\n+        assert existing_is_stale_oauth is False\n+        assert has_creds is True\n"]}]},{"kind":"pr","number":47990,"title":"fix(auth): support OAuth tokens in Anthropic fetch_models (#47965)","url":null,"state":"closed","close_reason":{"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":63738,"title":"fix(auth): normalize Anthropic sk-ant-oat pool credentials to OAuth","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":63883,"title":"fix(auth): normalize Anthropic setup-token pool metadata","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","scripts/release.py","tests/agent/test_credential_pool_oat_authtype.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}