fix: Anthropic OAuth — beta header, token refresh, config contamination, reauthentication (#1132)

Fixes Anthropic OAuth/subscription authentication end-to-end:

Auth failures (401 errors):
- Add missing 'claude-code-20250219' beta header for OAuth tokens. Both
  clawdbot and OpenCode include this alongside 'oauth-2025-04-20' — without
  it, Anthropic's API rejects OAuth tokens with 401 authentication errors.
- Fix _fetch_anthropic_models() to use canonical beta headers from
  _COMMON_BETAS + _OAUTH_ONLY_BETAS instead of hardcoding.

Token refresh:
- Add _refresh_oauth_token() — when Claude Code credentials from
  ~/.claude/.credentials.json are expired but have a refresh token,
  automatically POST to console.anthropic.com/v1/oauth/token to get
  a new access token. Uses the same client_id as Claude Code / OpenCode.
- Add _write_claude_code_credentials() — writes refreshed tokens back
  to ~/.claude/.credentials.json, preserving other fields.
- resolve_anthropic_token() now auto-refreshes expired tokens before
  returning None.

Config contamination:
- Anthropic's _model_flow_anthropic() no longer saves base_url to config.
  Since resolve_runtime_provider() always hardcodes Anthropic's URL, the
  stale base_url was contaminating other providers when users switched
  without re-running 'hermes model' (e.g., Codex hitting api.anthropic.com).
- _update_config_for_provider() now pops base_url when passed empty string.
- Same fix in setup.py.

Flow/UX (hermes model command):
- CLAUDE_CODE_OAUTH_TOKEN env var now checked in credential detection
- Reauthentication option when existing credentials found
- run_oauth_setup_token() runs 'claude setup-token' as interactive
  subprocess, then auto-detects saved credentials
- Clean has_creds/needs_auth flow in both main.py and setup.py

Tests (14 new):
- Beta header assertions for claude-code-20250219
- Token refresh: successful refresh with credential writeback, failed
  refresh returns None, no refresh token returns None
- Credential writeback: new file creation, preserving existing fields
- Auto-refresh integration in resolve_anthropic_token()
- CLAUDE_CODE_OAUTH_TOKEN fallback, credential file auto-discovery
- run_oauth_setup_token() (5 scenarios)
This commit is contained in:
Teknium 2026-03-12 20:45:50 -07:00 committed by GitHub
parent 6ceae61a56
commit d24bcad90b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 500 additions and 85 deletions

View file

@ -1571,7 +1571,11 @@ def _update_config_for_provider(provider_id: str, inference_base_url: str) -> Pa
model_cfg = {}
model_cfg["provider"] = provider_id
model_cfg["base_url"] = inference_base_url.rstrip("/")
if inference_base_url and inference_base_url.strip():
model_cfg["base_url"] = inference_base_url.rstrip("/")
else:
# Clear stale base_url to prevent contamination when switching providers
model_cfg.pop("base_url", None)
config["model"] = model_cfg
config_path.write_text(yaml.safe_dump(config, sort_keys=False))

View file

@ -1590,8 +1590,67 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
print("No change.")
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
try:
print()
print(" Running 'claude setup-token' — follow the prompts below.")
print(" A browser window will open for you to authorize access.")
print()
token = run_oauth_setup_token()
if token:
save_env_value("ANTHROPIC_API_KEY", token)
print(" ✓ OAuth credentials saved.")
return True
# Subprocess completed but no token auto-detected — ask user to paste
print()
print(" If the setup-token was displayed above, paste it here:")
print()
try:
manual_token = input(" Paste setup-token (or Enter to cancel): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return False
if manual_token:
save_env_value("ANTHROPIC_API_KEY", manual_token)
print(" ✓ Setup-token saved.")
return True
print(" ⚠ Could not detect saved credentials.")
return False
except FileNotFoundError:
# Claude CLI not installed — guide user through manual setup
print()
print(" The 'claude' CLI is required for OAuth login.")
print()
print(" To install and authenticate:")
print()
print(" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code")
print(" 2. Run: claude setup-token")
print(" 3. Follow the browser prompts to authorize")
print(" 4. Re-run: hermes model")
print()
print(" Or paste an existing setup-token now (sk-ant-oat-...):")
print()
try:
token = input(" Setup-token (or Enter to cancel): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return False
if token:
save_env_value("ANTHROPIC_API_KEY", token)
print(" ✓ Setup-token saved.")
return True
print(" Cancelled — install Claude Code and try again.")
return False
def _model_flow_anthropic(config, current_model=""):
"""Flow for Anthropic provider — setup-token, API key, or Claude Code creds."""
"""Flow for Anthropic provider — OAuth subscription, API key, or Claude Code creds."""
import os
from hermes_cli.auth import (
PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,
@ -1602,12 +1661,13 @@ def _model_flow_anthropic(config, current_model=""):
pconfig = PROVIDER_REGISTRY["anthropic"]
# Check for existing credentials
# Check ALL credential sources
existing_key = (
get_env_value("ANTHROPIC_API_KEY")
or os.getenv("ANTHROPIC_API_KEY", "")
or get_env_value("ANTHROPIC_TOKEN")
or os.getenv("ANTHROPIC_TOKEN", "")
or os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "")
)
cc_available = False
try:
@ -1618,27 +1678,37 @@ def _model_flow_anthropic(config, current_model=""):
except Exception:
pass
if existing_key:
print(f" Anthropic credentials: {existing_key[:12]}... ✓")
has_creds = bool(existing_key) or cc_available
needs_auth = not has_creds
if has_creds:
# Show what we found
if existing_key:
print(f" Anthropic credentials: {existing_key[:12]}... ✓")
elif cc_available:
print(" Claude Code credentials: ✓ (auto-detected)")
print()
print(" 1. Use existing credentials")
print(" 2. Reauthenticate (new OAuth login)")
print(" 3. Cancel")
print()
try:
update = input("Update credentials? [y/N]: ").strip().lower()
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
update = ""
if update != "y":
pass # skip to model selection
else:
existing_key = "" # fall through to auth choice below
elif cc_available:
print(" Claude Code credentials: ✓ (auto-detected)")
print()
if not existing_key and not cc_available:
# No credentials — show auth method choice
choice = "1"
if choice == "2":
needs_auth = True
elif choice == "3":
return
# choice == "1" or default: use existing, proceed to model selection
if needs_auth:
# Show auth method choice
print()
print(" Choose authentication method:")
print()
print(" 1. Claude Pro/Max subscription (setup-token)")
print(" 1. Claude Pro/Max subscription (OAuth login)")
print(" 2. Anthropic API key (pay-per-token)")
print(" 3. Cancel")
print()
@ -1649,33 +1719,15 @@ def _model_flow_anthropic(config, current_model=""):
return
if choice == "1":
print()
print(" To get a setup-token from your Claude subscription:")
print()
print(" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code")
print(" 2. Run: claude setup-token")
print(" 3. Open the URL it prints in your browser")
print(" 4. Log in and click \"Authorize\"")
print(" 5. Paste the auth code back into Claude Code")
print(" 6. Copy the resulting sk-ant-oat01-... token")
print()
try:
token = input(" Paste setup-token here: ").strip()
except (KeyboardInterrupt, EOFError):
print()
if not _run_anthropic_oauth_flow(save_env_value):
return
if not token:
print(" Cancelled.")
return
save_env_value("ANTHROPIC_API_KEY", token)
print(" ✓ Setup-token saved.")
elif choice == "2":
print()
print(" Get an API key at: https://console.anthropic.com/settings/keys")
print()
try:
api_key = input(" API key (sk-ant-api03-...): ").strip()
api_key = input(" API key (sk-ant-...): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return
@ -1708,14 +1760,17 @@ def _model_flow_anthropic(config, current_model=""):
_save_model_choice(selected)
# Update config with provider
# Update config with provider — clear base_url since
# resolve_runtime_provider() always hardcodes Anthropic's URL.
# Leaving a stale base_url in config can contaminate other
# providers if the user switches without running 'hermes model'.
cfg = load_config()
model = cfg.get("model")
if not isinstance(model, dict):
model = {"default": model} if model else {}
cfg["model"] = model
model["provider"] = "anthropic"
model["base_url"] = pconfig.inference_base_url
model.pop("base_url", None)
save_config(cfg)
deactivate_provider()

View file

@ -271,7 +271,8 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
headers: dict[str, str] = {"anthropic-version": "2023-06-01"}
if _is_oauth_token(token):
headers["Authorization"] = f"Bearer {token}"
headers["anthropic-beta"] = "oauth-2025-04-20"
from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
else:
headers["x-api-key"] = token

View file

@ -1076,65 +1076,101 @@ def setup_model_provider(config: dict):
from hermes_cli.auth import PROVIDER_REGISTRY
pconfig = PROVIDER_REGISTRY["anthropic"]
# Check for Claude Code credential auto-discovery
from agent.anthropic_adapter import read_claude_code_credentials, is_claude_code_token_valid
# Check ALL credential sources
import os as _os
from agent.anthropic_adapter import (
read_claude_code_credentials, is_claude_code_token_valid,
run_oauth_setup_token,
)
cc_creds = read_claude_code_credentials()
if cc_creds and is_claude_code_token_valid(cc_creds):
print_success("Found valid Claude Code credentials (~/.claude/.credentials.json)")
if prompt_yes_no("Use these credentials?", True):
print_success("Using Claude Code subscription credentials")
else:
cc_creds = None
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")
existing_key = (
get_env_value("ANTHROPIC_API_KEY")
or get_env_value("ANTHROPIC_TOKEN")
or _os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "")
)
if not (cc_creds and is_claude_code_token_valid(cc_creds)):
has_creds = bool(existing_key) or cc_valid
needs_auth = not has_creds
if has_creds:
if existing_key:
print_info(f"Current credentials: {existing_key[:12]}...")
if not prompt_yes_no("Update credentials?", False):
# User wants to keep existing — skip auth prompt entirely
existing_key = "KEEP" # truthy sentinel to skip auth choice
elif cc_valid:
print_success("Found valid Claude Code credentials (auto-detected)")
if not existing_key and not (cc_creds and is_claude_code_token_valid(cc_creds)):
auth_choices = [
"Claude Pro/Max subscription (setup-token)",
"Anthropic API key (pay-per-token)",
]
auth_idx = prompt_choice("Choose authentication method:", auth_choices, 0)
auth_choices = [
"Use existing credentials",
"Reauthenticate (new OAuth login)",
"Cancel",
]
choice_idx = prompt_choice("What would you like to do?", auth_choices, 0)
if choice_idx == 1:
needs_auth = True
elif choice_idx == 2:
pass # fall through to provider config
if auth_idx == 0:
if needs_auth:
auth_choices = [
"Claude Pro/Max subscription (OAuth login)",
"Anthropic API key (pay-per-token)",
]
auth_idx = prompt_choice("Choose authentication method:", auth_choices, 0)
if auth_idx == 0:
# OAuth setup-token flow
try:
print()
print_info("To get a setup-token from your Claude subscription:")
print_info(" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code")
print_info(" 2. Run: claude setup-token")
print_info(" 3. Open the URL it prints in your browser")
print_info(" 4. Log in and click \"Authorize\"")
print_info(" 5. Paste the auth code back into Claude Code")
print_info(" 6. Copy the resulting sk-ant-oat01-... token")
print_info("Running 'claude setup-token' — follow the prompts below.")
print_info("A browser window will open for you to authorize access.")
print()
token = prompt("Paste setup-token here", password=True)
token = run_oauth_setup_token()
if token:
save_env_value("ANTHROPIC_API_KEY", token)
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)
print_success("Setup-token saved")
else:
print_warning("Skipped — agent won't work without credentials")
except FileNotFoundError:
print()
print_info("The 'claude' CLI is required for OAuth login.")
print()
print_info("To install: npm install -g @anthropic-ai/claude-code")
print_info("Then run: claude setup-token")
print_info("Or paste an existing setup-token below:")
print()
token = prompt("Setup-token (sk-ant-oat-...)", password=True)
if token:
save_env_value("ANTHROPIC_API_KEY", token)
print_success("Setup-token saved")
else:
print_warning("Skipped — agent won't work without credentials")
print_warning("Skipped — install Claude Code and re-run setup")
else:
print()
print_info("Get an API key at: https://console.anthropic.com/settings/keys")
print()
api_key = prompt("API key (sk-ant-...)", password=True)
if api_key:
save_env_value("ANTHROPIC_API_KEY", api_key)
print_success("API key saved")
else:
print()
print_info("Get an API key at: https://console.anthropic.com/settings/keys")
print()
api_key = prompt("API key (sk-ant-api03-...)", password=True)
if api_key:
save_env_value("ANTHROPIC_API_KEY", api_key)
print_success("API key saved")
else:
print_warning("Skipped — agent won't work without credentials")
print_warning("Skipped — agent won't work without credentials")
# Clear custom endpoint vars if switching
if existing_custom:
save_env_value("OPENAI_BASE_URL", "")
save_env_value("OPENAI_API_KEY", "")
_update_config_for_provider("anthropic", pconfig.inference_base_url)
_set_model_provider(config, "anthropic", pconfig.inference_base_url)
# Don't save base_url for Anthropic — resolve_runtime_provider()
# always hardcodes it. Stale base_urls contaminate other providers.
_update_config_for_provider("anthropic", "")
_set_model_provider(config, "anthropic")
# else: provider_idx == 9 (Keep current) — only shown when a provider already exists