fix: separate Anthropic OAuth tokens from API keys

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 <kshitijk4poor@users.noreply.github.com>
This commit is contained in:
kshitijk4poor 2026-03-13 02:09:52 -07:00 committed by teknium1
parent f562d97f13
commit bb3f5ed32a
10 changed files with 114 additions and 43 deletions

View file

@ -240,30 +240,25 @@ def resolve_anthropic_token() -> Optional[str]:
"""Resolve an Anthropic token from all available sources. """Resolve an Anthropic token from all available sources.
Priority: Priority:
1. ANTHROPIC_API_KEY env var (regular API key) 1. ANTHROPIC_TOKEN env var (OAuth/setup token saved by Hermes)
2. ANTHROPIC_TOKEN env var (OAuth/setup token) 2. CLAUDE_CODE_OAUTH_TOKEN env var
3. CLAUDE_CODE_OAUTH_TOKEN env var 3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
4. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
with automatic refresh if expired and a refresh token is available 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. Returns the token string or None.
""" """
# 1. Regular API key # 1. Hermes-managed OAuth/setup token env var
api_key = os.getenv("ANTHROPIC_API_KEY", "").strip()
if api_key:
return api_key
# 2. OAuth/setup token env var
token = os.getenv("ANTHROPIC_TOKEN", "").strip() token = os.getenv("ANTHROPIC_TOKEN", "").strip()
if token: if token:
return 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() cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
if cc_token: if cc_token:
return cc_token return cc_token
# 4. Claude Code credential file # 3. Claude Code credential file
creds = read_claude_code_credentials() creds = read_claude_code_credentials()
if creds and is_claude_code_token_valid(creds): if creds and is_claude_code_token_valid(creds):
logger.debug("Using Claude Code credentials (auto-detected)") logger.debug("Using Claude Code credentials (auto-detected)")
@ -276,6 +271,12 @@ def resolve_anthropic_token() -> Optional[str]:
return refreshed return refreshed
logger.debug("Token refresh failed — re-run 'claude setup-token' to reauthenticate") 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 return None

View file

@ -1034,6 +1034,20 @@ def save_env_value(key: str, value: str):
pass 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]: def get_env_value(key: str) -> Optional[str]:
"""Get a value from ~/.hermes/.env or environment.""" """Get a value from ~/.hermes/.env or environment."""
# Check environment first # Check environment first
@ -1081,7 +1095,6 @@ def show_config():
keys = [ keys = [
("OPENROUTER_API_KEY", "OpenRouter"), ("OPENROUTER_API_KEY", "OpenRouter"),
("ANTHROPIC_API_KEY", "Anthropic"),
("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"), ("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"),
("FIRECRAWL_API_KEY", "Firecrawl"), ("FIRECRAWL_API_KEY", "Firecrawl"),
("BROWSERBASE_API_KEY", "Browserbase"), ("BROWSERBASE_API_KEY", "Browserbase"),
@ -1091,6 +1104,8 @@ def show_config():
for env_key, name in keys: for env_key, name in keys:
value = get_env_value(env_key) value = get_env_value(env_key)
print(f" {name:<14} {redact_key(value)}") 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 # Model settings
print() print()

View file

@ -38,6 +38,7 @@ _PROVIDER_ENV_HINTS = (
"OPENROUTER_API_KEY", "OPENROUTER_API_KEY",
"OPENAI_API_KEY", "OPENAI_API_KEY",
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY",
"ANTHROPIC_TOKEN",
"OPENAI_BASE_URL", "OPENAI_BASE_URL",
"GLM_API_KEY", "GLM_API_KEY",
"ZAI_API_KEY", "ZAI_API_KEY",
@ -493,17 +494,22 @@ def run_doctor(args):
else: else:
check_warn("OpenRouter API", "(not configured)") 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: if anthropic_key:
print(" Checking Anthropic API...", end="", flush=True) print(" Checking Anthropic API...", end="", flush=True)
try: try:
import httpx 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( response = httpx.get(
"https://api.anthropic.com/v1/models", "https://api.anthropic.com/v1/models",
headers={ headers=headers,
"x-api-key": anthropic_key,
"anthropic-version": "2023-06-01"
},
timeout=10 timeout=10
) )
if response.status_code == 200: if response.status_code == 200:

View file

@ -86,7 +86,7 @@ def _has_any_provider_configured() -> bool:
from hermes_cli.auth import PROVIDER_REGISTRY from hermes_cli.auth import PROVIDER_REGISTRY
# Collect all provider env vars # 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(): for pconfig in PROVIDER_REGISTRY.values():
if pconfig.auth_type == "api_key": if pconfig.auth_type == "api_key":
provider_env_vars.update(pconfig.api_key_env_vars) 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): def _run_anthropic_oauth_flow(save_env_value):
"""Run the Claude OAuth setup-token flow. Returns True if credentials were saved.""" """Run the Claude OAuth setup-token flow. Returns True if credentials were saved."""
from agent.anthropic_adapter import run_oauth_setup_token from agent.anthropic_adapter import run_oauth_setup_token
from hermes_cli.config import save_anthropic_oauth_token
try: try:
print() print()
@ -1601,7 +1602,7 @@ def _run_anthropic_oauth_flow(save_env_value):
print() print()
token = run_oauth_setup_token() token = run_oauth_setup_token()
if token: if token:
save_env_value("ANTHROPIC_API_KEY", token) save_anthropic_oauth_token(token, save_fn=save_env_value)
print(" ✓ OAuth credentials saved.") print(" ✓ OAuth credentials saved.")
return True return True
@ -1615,7 +1616,7 @@ def _run_anthropic_oauth_flow(save_env_value):
print() print()
return False return False
if manual_token: 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.") print(" ✓ Setup-token saved.")
return True return True
@ -1642,7 +1643,7 @@ def _run_anthropic_oauth_flow(save_env_value):
print() print()
return False return False
if token: if token:
save_env_value("ANTHROPIC_API_KEY", token) save_anthropic_oauth_token(token, save_fn=save_env_value)
print(" ✓ Setup-token saved.") print(" ✓ Setup-token saved.")
return True return True
print(" Cancelled — install Claude Code and try again.") 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, PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,
_update_config_for_provider, deactivate_provider, _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 from hermes_cli.models import _PROVIDER_MODELS
pconfig = PROVIDER_REGISTRY["anthropic"] pconfig = PROVIDER_REGISTRY["anthropic"]
# Check ALL credential sources # Check ALL credential sources
existing_key = ( existing_key = (
get_env_value("ANTHROPIC_API_KEY") get_env_value("ANTHROPIC_TOKEN")
or os.getenv("ANTHROPIC_API_KEY", "")
or get_env_value("ANTHROPIC_TOKEN")
or os.getenv("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", "") or os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "")
) )
cc_available = False cc_available = False
@ -1734,7 +1738,7 @@ def _model_flow_anthropic(config, current_model=""):
if not api_key: if not api_key:
print(" Cancelled.") print(" Cancelled.")
return return
save_env_value("ANTHROPIC_API_KEY", api_key) save_anthropic_api_key(api_key, save_fn=save_env_value)
print(" ✓ API key saved.") print(" ✓ API key saved.")
else: else:

View file

@ -159,7 +159,7 @@ def resolve_runtime_provider(
token = resolve_anthropic_token() token = resolve_anthropic_token()
if not token: if not token:
raise AuthError( 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'." "run 'claude setup-token', or authenticate with 'claude /login'."
) )
return { return {

View file

@ -1074,6 +1074,7 @@ def setup_model_provider(config: dict):
print() print()
print_header("Anthropic Authentication") print_header("Anthropic Authentication")
from hermes_cli.auth import PROVIDER_REGISTRY from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.config import save_anthropic_api_key, save_anthropic_oauth_token
pconfig = PROVIDER_REGISTRY["anthropic"] pconfig = PROVIDER_REGISTRY["anthropic"]
# Check ALL credential sources # 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)) cc_valid = bool(cc_creds and is_claude_code_token_valid(cc_creds))
existing_key = ( existing_key = (
get_env_value("ANTHROPIC_API_KEY") get_env_value("ANTHROPIC_TOKEN")
or get_env_value("ANTHROPIC_TOKEN") or get_env_value("ANTHROPIC_API_KEY")
or _os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "") or _os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "")
) )
@ -1127,14 +1128,14 @@ def setup_model_provider(config: dict):
print() print()
token = run_oauth_setup_token() token = run_oauth_setup_token()
if 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") print_success("OAuth credentials saved")
else: else:
# Subprocess completed but no token auto-detected # Subprocess completed but no token auto-detected
print() print()
token = prompt("Paste setup-token here (if displayed above)", password=True) token = prompt("Paste setup-token here (if displayed above)", password=True)
if token: if token:
save_env_value("ANTHROPIC_API_KEY", token) save_anthropic_oauth_token(token, save_fn=save_env_value)
print_success("Setup-token saved") print_success("Setup-token saved")
else: else:
print_warning("Skipped — agent won't work without credentials") print_warning("Skipped — agent won't work without credentials")
@ -1148,7 +1149,7 @@ def setup_model_provider(config: dict):
print() print()
token = prompt("Setup-token (sk-ant-oat-...)", password=True) token = prompt("Setup-token (sk-ant-oat-...)", password=True)
if token: if token:
save_env_value("ANTHROPIC_API_KEY", token) save_anthropic_oauth_token(token, save_fn=save_env_value)
print_success("Setup-token saved") print_success("Setup-token saved")
else: else:
print_warning("Skipped — install Claude Code and re-run setup") print_warning("Skipped — install Claude Code and re-run setup")
@ -1158,7 +1159,7 @@ def setup_model_provider(config: dict):
print() print()
api_key = prompt("API key (sk-ant-...)", password=True) api_key = prompt("API key (sk-ant-...)", password=True)
if api_key: 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") print_success("API key saved")
else: else:
print_warning("Skipped — agent won't work without credentials") print_warning("Skipped — agent won't work without credentials")

View file

@ -77,7 +77,6 @@ def show_status(args):
keys = { keys = {
"OpenRouter": "OPENROUTER_API_KEY", "OpenRouter": "OPENROUTER_API_KEY",
"Anthropic": "ANTHROPIC_API_KEY",
"OpenAI": "OPENAI_API_KEY", "OpenAI": "OPENAI_API_KEY",
"Z.AI/GLM": "GLM_API_KEY", "Z.AI/GLM": "GLM_API_KEY",
"Kimi": "KIMI_API_KEY", "Kimi": "KIMI_API_KEY",
@ -98,6 +97,14 @@ def show_status(args):
display = redact_key(value) if not show_all else value display = redact_key(value) if not show_all else value
print(f" {name:<12} {check_mark(has_key)} {display}") 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) # Auth Providers (OAuth)
# ========================================================================= # =========================================================================

View file

@ -445,11 +445,8 @@ class AIAgent:
self._anthropic_client = None self._anthropic_client = None
if self.api_mode == "anthropic_messages": if self.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
effective_key = api_key or os.getenv("ANTHROPIC_API_KEY", "") or os.getenv("ANTHROPIC_TOKEN", "") effective_key = api_key or resolve_anthropic_token() or ""
if not effective_key:
from agent.anthropic_adapter import resolve_anthropic_token
effective_key = resolve_anthropic_token() or ""
self._anthropic_api_key = effective_key self._anthropic_api_key = effective_key
self._anthropic_client = build_anthropic_client(effective_key, base_url) self._anthropic_client = build_anthropic_client(effective_key, base_url)
# No OpenAI client needed for Anthropic mode # 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} 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} 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} 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 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} • 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 retry_count += 1
elapsed_time = time.time() - api_start_time elapsed_time = time.time() - api_start_time

View file

@ -133,9 +133,16 @@ class TestIsClaudeCodeTokenValid:
class TestResolveAnthropicToken: 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_API_KEY", "sk-ant-api03-mykey")
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken") 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" assert resolve_anthropic_token() == "sk-ant-api03-mykey"
def test_falls_back_to_token(self, monkeypatch): def test_falls_back_to_token(self, monkeypatch):

View file

@ -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"] == ""