From bb3f5ed32a5e0a5bb7cf3fb0940ab7b120006b0c Mon Sep 17 00:00:00 2001 From: kshitijk4poor Date: Fri, 13 Mar 2026 02:09:52 -0700 Subject: [PATCH] fix: separate Anthropic OAuth tokens from API keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist OAuth/setup tokens in ANTHROPIC_TOKEN instead of ANTHROPIC_API_KEY. Reserve ANTHROPIC_API_KEY for regular Console API keys. Changes: - anthropic_adapter: reorder resolve_anthropic_token() priority — ANTHROPIC_TOKEN first, ANTHROPIC_API_KEY as legacy fallback - config: add save_anthropic_oauth_token() / save_anthropic_api_key() helpers that clear the opposing slot to prevent priority conflicts - config: show_config() prefers ANTHROPIC_TOKEN for display - setup: OAuth login and pasted setup-tokens write to ANTHROPIC_TOKEN - setup: API key entry writes to ANTHROPIC_API_KEY and clears ANTHROPIC_TOKEN - main: same fixes in _run_anthropic_oauth_flow() and _model_flow_anthropic() - main: _has_any_provider_configured() checks ANTHROPIC_TOKEN - doctor: use _is_oauth_token() for correct auth method validation - runtime_provider: updated error message - run_agent: simplified client init to use resolve_anthropic_token() - run_agent: updated 401 troubleshooting messages - status: prefer ANTHROPIC_TOKEN in status display - tests: updated priority test, added persistence helper tests Cherry-picked from PR #1141 by kshitijk4poor, rebased onto current main with unrelated changes (web_policy config, blocklist CLI) removed. Co-authored-by: kshitijk4poor --- agent/anthropic_adapter.py | 25 ++++++++-------- hermes_cli/config.py | 17 ++++++++++- hermes_cli/doctor.py | 16 ++++++---- hermes_cli/main.py | 22 ++++++++------ hermes_cli/runtime_provider.py | 2 +- hermes_cli/setup.py | 13 ++++---- hermes_cli/status.py | 9 +++++- run_agent.py | 13 ++++---- tests/test_anthropic_adapter.py | 9 +++++- tests/test_anthropic_provider_persistence.py | 31 ++++++++++++++++++++ 10 files changed, 114 insertions(+), 43 deletions(-) create mode 100644 tests/test_anthropic_provider_persistence.py diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 1e6a2a24..4cdb4f5d 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -240,30 +240,25 @@ def resolve_anthropic_token() -> Optional[str]: """Resolve an Anthropic token from all available sources. Priority: - 1. ANTHROPIC_API_KEY env var (regular API key) - 2. ANTHROPIC_TOKEN env var (OAuth/setup token) - 3. CLAUDE_CODE_OAUTH_TOKEN env var - 4. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json) + 1. ANTHROPIC_TOKEN env var (OAuth/setup token saved by Hermes) + 2. CLAUDE_CODE_OAUTH_TOKEN env var + 3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json) — with automatic refresh if expired and a refresh token is available + 4. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback) Returns the token string or None. """ - # 1. Regular API key - api_key = os.getenv("ANTHROPIC_API_KEY", "").strip() - if api_key: - return api_key - - # 2. OAuth/setup token env var + # 1. Hermes-managed OAuth/setup token env var token = os.getenv("ANTHROPIC_TOKEN", "").strip() if token: return token - # 3. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens) + # 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens) cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip() if cc_token: return cc_token - # 4. Claude Code credential file + # 3. Claude Code credential file creds = read_claude_code_credentials() if creds and is_claude_code_token_valid(creds): logger.debug("Using Claude Code credentials (auto-detected)") @@ -276,6 +271,12 @@ def resolve_anthropic_token() -> Optional[str]: return refreshed logger.debug("Token refresh failed — re-run 'claude setup-token' to reauthenticate") + # 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY. + # This remains as a compatibility fallback for pre-migration Hermes configs. + api_key = os.getenv("ANTHROPIC_API_KEY", "").strip() + if api_key: + return api_key + return None diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4648914f..bc7768fd 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1034,6 +1034,20 @@ def save_env_value(key: str, value: str): pass +def save_anthropic_oauth_token(value: str, save_fn=None): + """Persist an Anthropic OAuth/setup token and clear the API-key slot.""" + writer = save_fn or save_env_value + writer("ANTHROPIC_TOKEN", value) + writer("ANTHROPIC_API_KEY", "") + + +def save_anthropic_api_key(value: str, save_fn=None): + """Persist an Anthropic API key and clear the OAuth/setup-token slot.""" + writer = save_fn or save_env_value + writer("ANTHROPIC_API_KEY", value) + writer("ANTHROPIC_TOKEN", "") + + def get_env_value(key: str) -> Optional[str]: """Get a value from ~/.hermes/.env or environment.""" # Check environment first @@ -1081,7 +1095,6 @@ def show_config(): keys = [ ("OPENROUTER_API_KEY", "OpenRouter"), - ("ANTHROPIC_API_KEY", "Anthropic"), ("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"), ("FIRECRAWL_API_KEY", "Firecrawl"), ("BROWSERBASE_API_KEY", "Browserbase"), @@ -1091,6 +1104,8 @@ def show_config(): for env_key, name in keys: value = get_env_value(env_key) print(f" {name:<14} {redact_key(value)}") + anthropic_value = get_env_value("ANTHROPIC_TOKEN") or get_env_value("ANTHROPIC_API_KEY") + print(f" {'Anthropic':<14} {redact_key(anthropic_value)}") # Model settings print() diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 6c7b58c8..88c767c7 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -38,6 +38,7 @@ _PROVIDER_ENV_HINTS = ( "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", "OPENAI_BASE_URL", "GLM_API_KEY", "ZAI_API_KEY", @@ -493,17 +494,22 @@ def run_doctor(args): else: check_warn("OpenRouter API", "(not configured)") - anthropic_key = os.getenv("ANTHROPIC_API_KEY") + anthropic_key = os.getenv("ANTHROPIC_TOKEN") or os.getenv("ANTHROPIC_API_KEY") if anthropic_key: print(" Checking Anthropic API...", end="", flush=True) try: import httpx + from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS + + headers = {"anthropic-version": "2023-06-01"} + if _is_oauth_token(anthropic_key): + headers["Authorization"] = f"Bearer {anthropic_key}" + headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS) + else: + headers["x-api-key"] = anthropic_key response = httpx.get( "https://api.anthropic.com/v1/models", - headers={ - "x-api-key": anthropic_key, - "anthropic-version": "2023-06-01" - }, + headers=headers, timeout=10 ) if response.status_code == 200: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index cad11531..14706f23 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -86,7 +86,7 @@ def _has_any_provider_configured() -> bool: from hermes_cli.auth import PROVIDER_REGISTRY # Collect all provider env vars - provider_env_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_BASE_URL"} + provider_env_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"} for pconfig in PROVIDER_REGISTRY.values(): if pconfig.auth_type == "api_key": provider_env_vars.update(pconfig.api_key_env_vars) @@ -1593,6 +1593,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): def _run_anthropic_oauth_flow(save_env_value): """Run the Claude OAuth setup-token flow. Returns True if credentials were saved.""" from agent.anthropic_adapter import run_oauth_setup_token + from hermes_cli.config import save_anthropic_oauth_token try: print() @@ -1601,7 +1602,7 @@ def _run_anthropic_oauth_flow(save_env_value): print() token = run_oauth_setup_token() if token: - save_env_value("ANTHROPIC_API_KEY", token) + save_anthropic_oauth_token(token, save_fn=save_env_value) print(" ✓ OAuth credentials saved.") return True @@ -1615,7 +1616,7 @@ def _run_anthropic_oauth_flow(save_env_value): print() return False if manual_token: - save_env_value("ANTHROPIC_API_KEY", manual_token) + save_anthropic_oauth_token(manual_token, save_fn=save_env_value) print(" ✓ Setup-token saved.") return True @@ -1642,7 +1643,7 @@ def _run_anthropic_oauth_flow(save_env_value): print() return False if token: - save_env_value("ANTHROPIC_API_KEY", token) + save_anthropic_oauth_token(token, save_fn=save_env_value) print(" ✓ Setup-token saved.") return True print(" Cancelled — install Claude Code and try again.") @@ -1656,17 +1657,20 @@ def _model_flow_anthropic(config, current_model=""): PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice, _update_config_for_provider, deactivate_provider, ) - from hermes_cli.config import get_env_value, save_env_value, load_config, save_config + from hermes_cli.config import ( + get_env_value, save_env_value, load_config, save_config, + save_anthropic_api_key, + ) from hermes_cli.models import _PROVIDER_MODELS pconfig = PROVIDER_REGISTRY["anthropic"] # Check ALL credential sources existing_key = ( - get_env_value("ANTHROPIC_API_KEY") - or os.getenv("ANTHROPIC_API_KEY", "") - or get_env_value("ANTHROPIC_TOKEN") + get_env_value("ANTHROPIC_TOKEN") or os.getenv("ANTHROPIC_TOKEN", "") + or get_env_value("ANTHROPIC_API_KEY") + or os.getenv("ANTHROPIC_API_KEY", "") or os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "") ) cc_available = False @@ -1734,7 +1738,7 @@ def _model_flow_anthropic(config, current_model=""): if not api_key: print(" Cancelled.") return - save_env_value("ANTHROPIC_API_KEY", api_key) + save_anthropic_api_key(api_key, save_fn=save_env_value) print(" ✓ API key saved.") else: diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 062558ca..5a39c79c 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -159,7 +159,7 @@ def resolve_runtime_provider( token = resolve_anthropic_token() if not token: raise AuthError( - "No Anthropic credentials found. Set ANTHROPIC_API_KEY, " + "No Anthropic credentials found. Set ANTHROPIC_TOKEN or ANTHROPIC_API_KEY, " "run 'claude setup-token', or authenticate with 'claude /login'." ) return { diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index f217540b..0b5a165c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1074,6 +1074,7 @@ def setup_model_provider(config: dict): print() print_header("Anthropic Authentication") from hermes_cli.auth import PROVIDER_REGISTRY + from hermes_cli.config import save_anthropic_api_key, save_anthropic_oauth_token pconfig = PROVIDER_REGISTRY["anthropic"] # Check ALL credential sources @@ -1086,8 +1087,8 @@ def setup_model_provider(config: dict): cc_valid = bool(cc_creds and is_claude_code_token_valid(cc_creds)) existing_key = ( - get_env_value("ANTHROPIC_API_KEY") - or get_env_value("ANTHROPIC_TOKEN") + get_env_value("ANTHROPIC_TOKEN") + or get_env_value("ANTHROPIC_API_KEY") or _os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "") ) @@ -1127,14 +1128,14 @@ def setup_model_provider(config: dict): print() token = run_oauth_setup_token() if token: - save_env_value("ANTHROPIC_API_KEY", token) + save_anthropic_oauth_token(token, save_fn=save_env_value) print_success("OAuth credentials saved") else: # Subprocess completed but no token auto-detected print() token = prompt("Paste setup-token here (if displayed above)", password=True) if token: - save_env_value("ANTHROPIC_API_KEY", token) + save_anthropic_oauth_token(token, save_fn=save_env_value) print_success("Setup-token saved") else: print_warning("Skipped — agent won't work without credentials") @@ -1148,7 +1149,7 @@ def setup_model_provider(config: dict): print() token = prompt("Setup-token (sk-ant-oat-...)", password=True) if token: - save_env_value("ANTHROPIC_API_KEY", token) + save_anthropic_oauth_token(token, save_fn=save_env_value) print_success("Setup-token saved") else: print_warning("Skipped — install Claude Code and re-run setup") @@ -1158,7 +1159,7 @@ def setup_model_provider(config: dict): print() api_key = prompt("API key (sk-ant-...)", password=True) if api_key: - save_env_value("ANTHROPIC_API_KEY", api_key) + save_anthropic_api_key(api_key, save_fn=save_env_value) print_success("API key saved") else: print_warning("Skipped — agent won't work without credentials") diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 971dad47..53491a5b 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -77,7 +77,6 @@ def show_status(args): keys = { "OpenRouter": "OPENROUTER_API_KEY", - "Anthropic": "ANTHROPIC_API_KEY", "OpenAI": "OPENAI_API_KEY", "Z.AI/GLM": "GLM_API_KEY", "Kimi": "KIMI_API_KEY", @@ -98,6 +97,14 @@ def show_status(args): display = redact_key(value) if not show_all else value print(f" {name:<12} {check_mark(has_key)} {display}") + anthropic_value = ( + get_env_value("ANTHROPIC_TOKEN") + or get_env_value("ANTHROPIC_API_KEY") + or "" + ) + anthropic_display = redact_key(anthropic_value) if not show_all else anthropic_value + print(f" {'Anthropic':<12} {check_mark(bool(anthropic_value))} {anthropic_display}") + # ========================================================================= # Auth Providers (OAuth) # ========================================================================= diff --git a/run_agent.py b/run_agent.py index e8fb7bc0..6d5fe4d3 100644 --- a/run_agent.py +++ b/run_agent.py @@ -445,11 +445,8 @@ class AIAgent: self._anthropic_client = None if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client - effective_key = api_key or os.getenv("ANTHROPIC_API_KEY", "") or os.getenv("ANTHROPIC_TOKEN", "") - if not effective_key: - from agent.anthropic_adapter import resolve_anthropic_token - effective_key = resolve_anthropic_token() or "" + from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token + effective_key = api_key or resolve_anthropic_token() or "" self._anthropic_api_key = effective_key self._anthropic_client = build_anthropic_client(effective_key, base_url) # No OpenAI client needed for Anthropic mode @@ -4266,10 +4263,12 @@ class AIAgent: print(f"{self.log_prefix} Auth method: {auth_method}") print(f"{self.log_prefix} Token prefix: {key[:12]}..." if key and len(key) > 12 else f"{self.log_prefix} Token: (empty or short)") print(f"{self.log_prefix} Troubleshooting:") - print(f"{self.log_prefix} • Check ANTHROPIC_API_KEY in ~/.hermes/.env (stale key overrides Claude Code auto-detect)") + print(f"{self.log_prefix} • Check ANTHROPIC_TOKEN in ~/.hermes/.env for Hermes-managed OAuth/setup tokens") + print(f"{self.log_prefix} • Check ANTHROPIC_API_KEY in ~/.hermes/.env for API keys or legacy token values") print(f"{self.log_prefix} • For API keys: verify at https://console.anthropic.com/settings/keys") print(f"{self.log_prefix} • For Claude Code: run 'claude /login' to refresh, then retry") - print(f"{self.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_API_KEY \"\"") + print(f"{self.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_TOKEN \"\"") + print(f"{self.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_API_KEY \"\"") retry_count += 1 elapsed_time = time.time() - api_start_time diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 1f4006d5..c2b20168 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -133,9 +133,16 @@ class TestIsClaudeCodeTokenValid: class TestResolveAnthropicToken: - def test_prefers_api_key(self, monkeypatch): + def test_prefers_oauth_token_over_api_key(self, monkeypatch): monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey") monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken") + assert resolve_anthropic_token() == "sk-ant-oat01-mytoken" + + def test_falls_back_to_api_key_when_no_oauth_sources_exist(self, monkeypatch, tmp_path): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey") + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() == "sk-ant-api03-mykey" def test_falls_back_to_token(self, monkeypatch): diff --git a/tests/test_anthropic_provider_persistence.py b/tests/test_anthropic_provider_persistence.py new file mode 100644 index 00000000..fd55d21b --- /dev/null +++ b/tests/test_anthropic_provider_persistence.py @@ -0,0 +1,31 @@ +"""Tests for Anthropic credential persistence helpers.""" + +from hermes_cli.config import load_env + + +def test_save_anthropic_oauth_token_uses_token_slot_and_clears_api_key(tmp_path, monkeypatch): + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + from hermes_cli.config import save_anthropic_oauth_token + + save_anthropic_oauth_token("sk-ant-oat01-test-token") + + env_vars = load_env() + assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant-oat01-test-token" + assert env_vars["ANTHROPIC_API_KEY"] == "" + + +def test_save_anthropic_api_key_uses_api_key_slot_and_clears_token(tmp_path, monkeypatch): + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + from hermes_cli.config import save_anthropic_api_key + + save_anthropic_api_key("sk-ant-api03-test-key") + + env_vars = load_env() + assert env_vars["ANTHROPIC_API_KEY"] == "sk-ant-api03-test-key" + assert env_vars["ANTHROPIC_TOKEN"] == ""