mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 17:27:37 +08:00
Compare commits
1 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ae6fb2330 |
@@ -33,7 +33,10 @@ _COMMON_BETAS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Additional beta headers required for OAuth/subscription auth
|
# Additional beta headers required for OAuth/subscription auth
|
||||||
|
# Both clawdbot and OpenCode include claude-code-20250219 alongside oauth-2025-04-20.
|
||||||
|
# Without claude-code-20250219, Anthropic's API rejects OAuth tokens with 401.
|
||||||
_OAUTH_ONLY_BETAS = [
|
_OAUTH_ONLY_BETAS = [
|
||||||
|
"claude-code-20250219",
|
||||||
"oauth-2025-04-20",
|
"oauth-2025-04-20",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -144,13 +147,91 @@ def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
|
|||||||
return now_ms < (expires_at - 60_000)
|
return now_ms < (expires_at - 60_000)
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""Attempt to refresh an expired Claude Code OAuth token.
|
||||||
|
|
||||||
|
Uses the same token endpoint and client_id as Claude Code / OpenCode.
|
||||||
|
Only works for credentials that have a refresh token (from claude /login
|
||||||
|
or claude setup-token with OAuth flow).
|
||||||
|
|
||||||
|
Returns the new access token, or None if refresh fails.
|
||||||
|
"""
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
refresh_token = creds.get("refreshToken", "")
|
||||||
|
if not refresh_token:
|
||||||
|
logger.debug("No refresh token available — cannot refresh")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Client ID used by Claude Code's OAuth flow
|
||||||
|
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||||
|
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://console.anthropic.com/v1/oauth/token",
|
||||||
|
data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
result = json.loads(resp.read().decode())
|
||||||
|
new_access = result.get("access_token", "")
|
||||||
|
new_refresh = result.get("refresh_token", refresh_token)
|
||||||
|
expires_in = result.get("expires_in", 3600) # seconds
|
||||||
|
|
||||||
|
if new_access:
|
||||||
|
import time
|
||||||
|
new_expires_ms = int(time.time() * 1000) + (expires_in * 1000)
|
||||||
|
# Write refreshed credentials back to ~/.claude/.credentials.json
|
||||||
|
_write_claude_code_credentials(new_access, new_refresh, new_expires_ms)
|
||||||
|
logger.debug("Successfully refreshed Claude Code OAuth token")
|
||||||
|
return new_access
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Failed to refresh Claude Code token: %s", e)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_claude_code_credentials(access_token: str, refresh_token: str, expires_at_ms: int) -> None:
|
||||||
|
"""Write refreshed credentials back to ~/.claude/.credentials.json."""
|
||||||
|
cred_path = Path.home() / ".claude" / ".credentials.json"
|
||||||
|
try:
|
||||||
|
# Read existing file to preserve other fields
|
||||||
|
existing = {}
|
||||||
|
if cred_path.exists():
|
||||||
|
existing = json.loads(cred_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
existing["claudeAiOauth"] = {
|
||||||
|
"accessToken": access_token,
|
||||||
|
"refreshToken": refresh_token,
|
||||||
|
"expiresAt": expires_at_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
cred_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cred_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
||||||
|
# Restrict permissions (credentials file)
|
||||||
|
cred_path.chmod(0o600)
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
logger.debug("Failed to write refreshed credentials: %s", e)
|
||||||
|
|
||||||
|
|
||||||
def resolve_anthropic_token() -> Optional[str]:
|
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_API_KEY env var (regular API key)
|
||||||
2. ANTHROPIC_TOKEN env var (OAuth/setup token)
|
2. ANTHROPIC_TOKEN env var (OAuth/setup token)
|
||||||
3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
|
3. CLAUDE_CODE_OAUTH_TOKEN env var
|
||||||
|
4. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
|
||||||
|
— with automatic refresh if expired and a refresh token is available
|
||||||
|
|
||||||
Returns the token string or None.
|
Returns the token string or None.
|
||||||
"""
|
"""
|
||||||
@@ -164,18 +245,63 @@ def resolve_anthropic_token() -> Optional[str]:
|
|||||||
if token:
|
if token:
|
||||||
return token
|
return token
|
||||||
|
|
||||||
# Also check CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens)
|
# 3. 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
|
||||||
|
|
||||||
# 3. Claude Code credential file
|
# 4. 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)")
|
||||||
return creds["accessToken"]
|
return creds["accessToken"]
|
||||||
elif creds:
|
elif creds:
|
||||||
logger.debug("Claude Code credentials expired — run 'claude' to refresh")
|
# Token expired — attempt to refresh
|
||||||
|
logger.debug("Claude Code credentials expired — attempting refresh")
|
||||||
|
refreshed = _refresh_oauth_token(creds)
|
||||||
|
if refreshed:
|
||||||
|
return refreshed
|
||||||
|
logger.debug("Token refresh failed — re-run 'claude setup-token' to reauthenticate")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_oauth_setup_token() -> Optional[str]:
|
||||||
|
"""Run 'claude setup-token' interactively and return the resulting token.
|
||||||
|
|
||||||
|
Checks multiple sources after the subprocess completes:
|
||||||
|
1. Claude Code credential files (may be written by the subprocess)
|
||||||
|
2. CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_TOKEN env vars
|
||||||
|
|
||||||
|
Returns the token string, or None if no credentials were obtained.
|
||||||
|
Raises FileNotFoundError if the 'claude' CLI is not installed.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
claude_path = shutil.which("claude")
|
||||||
|
if not claude_path:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"The 'claude' CLI is not installed. "
|
||||||
|
"Install it with: npm install -g @anthropic-ai/claude-code"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run interactively — stdin/stdout/stderr inherited so user can interact
|
||||||
|
try:
|
||||||
|
subprocess.run([claude_path, "setup-token"])
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if credentials were saved to Claude Code's config files
|
||||||
|
creds = read_claude_code_credentials()
|
||||||
|
if creds and is_claude_code_token_valid(creds):
|
||||||
|
return creds["accessToken"]
|
||||||
|
|
||||||
|
# Check env vars that may have been set
|
||||||
|
for env_var in ("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_TOKEN"):
|
||||||
|
val = os.getenv(env_var, "").strip()
|
||||||
|
if val:
|
||||||
|
return val
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -1571,7 +1571,11 @@ def _update_config_for_provider(provider_id: str, inference_base_url: str) -> Pa
|
|||||||
model_cfg = {}
|
model_cfg = {}
|
||||||
|
|
||||||
model_cfg["provider"] = provider_id
|
model_cfg["provider"] = provider_id
|
||||||
|
if inference_base_url and inference_base_url.strip():
|
||||||
model_cfg["base_url"] = inference_base_url.rstrip("/")
|
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["model"] = model_cfg
|
||||||
|
|
||||||
config_path.write_text(yaml.safe_dump(config, sort_keys=False))
|
config_path.write_text(yaml.safe_dump(config, sort_keys=False))
|
||||||
|
|||||||
@@ -1574,8 +1574,67 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
|||||||
print("No change.")
|
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=""):
|
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
|
import os
|
||||||
from hermes_cli.auth import (
|
from hermes_cli.auth import (
|
||||||
PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,
|
PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,
|
||||||
@@ -1586,12 +1645,13 @@ def _model_flow_anthropic(config, current_model=""):
|
|||||||
|
|
||||||
pconfig = PROVIDER_REGISTRY["anthropic"]
|
pconfig = PROVIDER_REGISTRY["anthropic"]
|
||||||
|
|
||||||
# Check for existing credentials
|
# Check ALL credential sources
|
||||||
existing_key = (
|
existing_key = (
|
||||||
get_env_value("ANTHROPIC_API_KEY")
|
get_env_value("ANTHROPIC_API_KEY")
|
||||||
or os.getenv("ANTHROPIC_API_KEY", "")
|
or os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
or get_env_value("ANTHROPIC_TOKEN")
|
or get_env_value("ANTHROPIC_TOKEN")
|
||||||
or os.getenv("ANTHROPIC_TOKEN", "")
|
or os.getenv("ANTHROPIC_TOKEN", "")
|
||||||
|
or os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "")
|
||||||
)
|
)
|
||||||
cc_available = False
|
cc_available = False
|
||||||
try:
|
try:
|
||||||
@@ -1602,27 +1662,37 @@ def _model_flow_anthropic(config, current_model=""):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
has_creds = bool(existing_key) or cc_available
|
||||||
|
needs_auth = not has_creds
|
||||||
|
|
||||||
|
if has_creds:
|
||||||
|
# Show what we found
|
||||||
if existing_key:
|
if existing_key:
|
||||||
print(f" Anthropic credentials: {existing_key[:12]}... ✓")
|
print(f" Anthropic credentials: {existing_key[:12]}... ✓")
|
||||||
print()
|
|
||||||
try:
|
|
||||||
update = input("Update credentials? [y/N]: ").strip().lower()
|
|
||||||
except (KeyboardInterrupt, EOFError):
|
|
||||||
update = ""
|
|
||||||
if update != "y":
|
|
||||||
pass # skip to model selection
|
|
||||||
else:
|
|
||||||
existing_key = "" # fall through to auth choice below
|
|
||||||
elif cc_available:
|
elif cc_available:
|
||||||
print(" Claude Code credentials: ✓ (auto-detected)")
|
print(" Claude Code credentials: ✓ (auto-detected)")
|
||||||
print()
|
print()
|
||||||
|
print(" 1. Use existing credentials")
|
||||||
|
print(" 2. Reauthenticate (new OAuth login)")
|
||||||
|
print(" 3. Cancel")
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
choice = input(" Choice [1/2/3]: ").strip()
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
choice = "1"
|
||||||
|
|
||||||
if not existing_key and not cc_available:
|
if choice == "2":
|
||||||
# No credentials — show auth method choice
|
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()
|
||||||
print(" Choose authentication method:")
|
print(" Choose authentication method:")
|
||||||
print()
|
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(" 2. Anthropic API key (pay-per-token)")
|
||||||
print(" 3. Cancel")
|
print(" 3. Cancel")
|
||||||
print()
|
print()
|
||||||
@@ -1633,33 +1703,15 @@ def _model_flow_anthropic(config, current_model=""):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if choice == "1":
|
if choice == "1":
|
||||||
print()
|
if not _run_anthropic_oauth_flow(save_env_value):
|
||||||
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()
|
|
||||||
return
|
return
|
||||||
if not token:
|
|
||||||
print(" Cancelled.")
|
|
||||||
return
|
|
||||||
save_env_value("ANTHROPIC_API_KEY", token)
|
|
||||||
print(" ✓ Setup-token saved.")
|
|
||||||
|
|
||||||
elif choice == "2":
|
elif choice == "2":
|
||||||
print()
|
print()
|
||||||
print(" Get an API key at: https://console.anthropic.com/settings/keys")
|
print(" Get an API key at: https://console.anthropic.com/settings/keys")
|
||||||
print()
|
print()
|
||||||
try:
|
try:
|
||||||
api_key = input(" API key (sk-ant-api03-...): ").strip()
|
api_key = input(" API key (sk-ant-...): ").strip()
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
print()
|
print()
|
||||||
return
|
return
|
||||||
@@ -1692,14 +1744,17 @@ def _model_flow_anthropic(config, current_model=""):
|
|||||||
|
|
||||||
_save_model_choice(selected)
|
_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()
|
cfg = load_config()
|
||||||
model = cfg.get("model")
|
model = cfg.get("model")
|
||||||
if not isinstance(model, dict):
|
if not isinstance(model, dict):
|
||||||
model = {"default": model} if model else {}
|
model = {"default": model} if model else {}
|
||||||
cfg["model"] = model
|
cfg["model"] = model
|
||||||
model["provider"] = "anthropic"
|
model["provider"] = "anthropic"
|
||||||
model["base_url"] = pconfig.inference_base_url
|
model.pop("base_url", None)
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
deactivate_provider()
|
deactivate_provider()
|
||||||
|
|
||||||
|
|||||||
@@ -271,7 +271,8 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
|||||||
headers: dict[str, str] = {"anthropic-version": "2023-06-01"}
|
headers: dict[str, str] = {"anthropic-version": "2023-06-01"}
|
||||||
if _is_oauth_token(token):
|
if _is_oauth_token(token):
|
||||||
headers["Authorization"] = f"Bearer {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:
|
else:
|
||||||
headers["x-api-key"] = token
|
headers["x-api-key"] = token
|
||||||
|
|
||||||
|
|||||||
@@ -1076,53 +1076,87 @@ def setup_model_provider(config: dict):
|
|||||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||||
pconfig = PROVIDER_REGISTRY["anthropic"]
|
pconfig = PROVIDER_REGISTRY["anthropic"]
|
||||||
|
|
||||||
# Check for Claude Code credential auto-discovery
|
# Check ALL credential sources
|
||||||
from agent.anthropic_adapter import read_claude_code_credentials, is_claude_code_token_valid
|
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()
|
cc_creds = read_claude_code_credentials()
|
||||||
if cc_creds and is_claude_code_token_valid(cc_creds):
|
cc_valid = bool(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
|
|
||||||
|
|
||||||
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:
|
if existing_key:
|
||||||
print_info(f"Current credentials: {existing_key[:12]}...")
|
print_info(f"Current credentials: {existing_key[:12]}...")
|
||||||
if not prompt_yes_no("Update credentials?", False):
|
elif cc_valid:
|
||||||
# User wants to keep existing — skip auth prompt entirely
|
print_success("Found valid Claude Code credentials (auto-detected)")
|
||||||
existing_key = "KEEP" # truthy sentinel to skip auth choice
|
|
||||||
|
|
||||||
if not existing_key and not (cc_creds and is_claude_code_token_valid(cc_creds)):
|
|
||||||
auth_choices = [
|
auth_choices = [
|
||||||
"Claude Pro/Max subscription (setup-token)",
|
"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 needs_auth:
|
||||||
|
auth_choices = [
|
||||||
|
"Claude Pro/Max subscription (OAuth login)",
|
||||||
"Anthropic API key (pay-per-token)",
|
"Anthropic API key (pay-per-token)",
|
||||||
]
|
]
|
||||||
auth_idx = prompt_choice("Choose authentication method:", auth_choices, 0)
|
auth_idx = prompt_choice("Choose authentication method:", auth_choices, 0)
|
||||||
|
|
||||||
if auth_idx == 0:
|
if auth_idx == 0:
|
||||||
|
# OAuth setup-token flow
|
||||||
|
try:
|
||||||
print()
|
print()
|
||||||
print_info("To get a setup-token from your Claude subscription:")
|
print_info("Running 'claude setup-token' — follow the prompts below.")
|
||||||
print_info(" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code")
|
print_info("A browser window will open for you to authorize access.")
|
||||||
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()
|
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:
|
if token:
|
||||||
save_env_value("ANTHROPIC_API_KEY", token)
|
save_env_value("ANTHROPIC_API_KEY", token)
|
||||||
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")
|
||||||
|
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 — install Claude Code and re-run setup")
|
||||||
else:
|
else:
|
||||||
print()
|
print()
|
||||||
print_info("Get an API key at: https://console.anthropic.com/settings/keys")
|
print_info("Get an API key at: https://console.anthropic.com/settings/keys")
|
||||||
print()
|
print()
|
||||||
api_key = prompt("API key (sk-ant-api03-...)", 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_env_value("ANTHROPIC_API_KEY", api_key)
|
||||||
print_success("API key saved")
|
print_success("API key saved")
|
||||||
@@ -1133,8 +1167,10 @@ def setup_model_provider(config: dict):
|
|||||||
if existing_custom:
|
if existing_custom:
|
||||||
save_env_value("OPENAI_BASE_URL", "")
|
save_env_value("OPENAI_BASE_URL", "")
|
||||||
save_env_value("OPENAI_API_KEY", "")
|
save_env_value("OPENAI_API_KEY", "")
|
||||||
_update_config_for_provider("anthropic", pconfig.inference_base_url)
|
# Don't save base_url for Anthropic — resolve_runtime_provider()
|
||||||
_set_model_provider(config, "anthropic", pconfig.inference_base_url)
|
# 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
|
# else: provider_idx == 9 (Keep current) — only shown when a provider already exists
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import pytest
|
|||||||
|
|
||||||
from agent.anthropic_adapter import (
|
from agent.anthropic_adapter import (
|
||||||
_is_oauth_token,
|
_is_oauth_token,
|
||||||
|
_refresh_oauth_token,
|
||||||
|
_write_claude_code_credentials,
|
||||||
build_anthropic_client,
|
build_anthropic_client,
|
||||||
build_anthropic_kwargs,
|
build_anthropic_kwargs,
|
||||||
convert_messages_to_anthropic,
|
convert_messages_to_anthropic,
|
||||||
@@ -18,6 +20,7 @@ from agent.anthropic_adapter import (
|
|||||||
normalize_model_name,
|
normalize_model_name,
|
||||||
read_claude_code_credentials,
|
read_claude_code_credentials,
|
||||||
resolve_anthropic_token,
|
resolve_anthropic_token,
|
||||||
|
run_oauth_setup_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +56,7 @@ class TestBuildAnthropicClient:
|
|||||||
assert "auth_token" in kwargs
|
assert "auth_token" in kwargs
|
||||||
betas = kwargs["default_headers"]["anthropic-beta"]
|
betas = kwargs["default_headers"]["anthropic-beta"]
|
||||||
assert "oauth-2025-04-20" in betas
|
assert "oauth-2025-04-20" in betas
|
||||||
|
assert "claude-code-20250219" in betas
|
||||||
assert "interleaved-thinking-2025-05-14" in betas
|
assert "interleaved-thinking-2025-05-14" in betas
|
||||||
assert "fine-grained-tool-streaming-2025-05-14" in betas
|
assert "fine-grained-tool-streaming-2025-05-14" in betas
|
||||||
assert "api_key" not in kwargs
|
assert "api_key" not in kwargs
|
||||||
@@ -67,6 +71,7 @@ class TestBuildAnthropicClient:
|
|||||||
betas = kwargs["default_headers"]["anthropic-beta"]
|
betas = kwargs["default_headers"]["anthropic-beta"]
|
||||||
assert "interleaved-thinking-2025-05-14" in betas
|
assert "interleaved-thinking-2025-05-14" in betas
|
||||||
assert "oauth-2025-04-20" not in betas # OAuth-only beta NOT present
|
assert "oauth-2025-04-20" not in betas # OAuth-only beta NOT present
|
||||||
|
assert "claude-code-20250219" not in betas # OAuth-only beta NOT present
|
||||||
|
|
||||||
def test_custom_base_url(self):
|
def test_custom_base_url(self):
|
||||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||||
@@ -145,6 +150,194 @@ class TestResolveAnthropicToken:
|
|||||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
assert resolve_anthropic_token() is None
|
assert resolve_anthropic_token() is None
|
||||||
|
|
||||||
|
def test_falls_back_to_claude_code_oauth_token(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||||
|
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-test-token")
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
|
assert resolve_anthropic_token() == "sk-ant-oat01-test-token"
|
||||||
|
|
||||||
|
def test_falls_back_to_claude_code_credentials(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||||
|
cred_file = tmp_path / ".claude" / ".credentials.json"
|
||||||
|
cred_file.parent.mkdir(parents=True)
|
||||||
|
cred_file.write_text(json.dumps({
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": "cc-auto-token",
|
||||||
|
"refreshToken": "refresh",
|
||||||
|
"expiresAt": int(time.time() * 1000) + 3600_000,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
|
assert resolve_anthropic_token() == "cc-auto-token"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefreshOauthToken:
|
||||||
|
def test_returns_none_without_refresh_token(self):
|
||||||
|
creds = {"accessToken": "expired", "refreshToken": "", "expiresAt": 0}
|
||||||
|
assert _refresh_oauth_token(creds) is None
|
||||||
|
|
||||||
|
def test_successful_refresh(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
|
|
||||||
|
creds = {
|
||||||
|
"accessToken": "old-token",
|
||||||
|
"refreshToken": "refresh-123",
|
||||||
|
"expiresAt": int(time.time() * 1000) - 3600_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_response = json.dumps({
|
||||||
|
"access_token": "new-token-abc",
|
||||||
|
"refresh_token": "new-refresh-456",
|
||||||
|
"expires_in": 7200,
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
mock_ctx.__enter__ = MagicMock(return_value=MagicMock(
|
||||||
|
read=MagicMock(return_value=mock_response)
|
||||||
|
))
|
||||||
|
mock_ctx.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_urlopen.return_value = mock_ctx
|
||||||
|
|
||||||
|
result = _refresh_oauth_token(creds)
|
||||||
|
|
||||||
|
assert result == "new-token-abc"
|
||||||
|
# Verify credentials were written back
|
||||||
|
cred_file = tmp_path / ".claude" / ".credentials.json"
|
||||||
|
assert cred_file.exists()
|
||||||
|
written = json.loads(cred_file.read_text())
|
||||||
|
assert written["claudeAiOauth"]["accessToken"] == "new-token-abc"
|
||||||
|
assert written["claudeAiOauth"]["refreshToken"] == "new-refresh-456"
|
||||||
|
|
||||||
|
def test_failed_refresh_returns_none(self):
|
||||||
|
creds = {
|
||||||
|
"accessToken": "old",
|
||||||
|
"refreshToken": "refresh-123",
|
||||||
|
"expiresAt": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen", side_effect=Exception("network error")):
|
||||||
|
assert _refresh_oauth_token(creds) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestWriteClaudeCodeCredentials:
|
||||||
|
def test_writes_new_file(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
|
_write_claude_code_credentials("tok", "ref", 12345)
|
||||||
|
cred_file = tmp_path / ".claude" / ".credentials.json"
|
||||||
|
assert cred_file.exists()
|
||||||
|
data = json.loads(cred_file.read_text())
|
||||||
|
assert data["claudeAiOauth"]["accessToken"] == "tok"
|
||||||
|
assert data["claudeAiOauth"]["refreshToken"] == "ref"
|
||||||
|
assert data["claudeAiOauth"]["expiresAt"] == 12345
|
||||||
|
|
||||||
|
def test_preserves_existing_fields(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
|
cred_dir = tmp_path / ".claude"
|
||||||
|
cred_dir.mkdir()
|
||||||
|
cred_file = cred_dir / ".credentials.json"
|
||||||
|
cred_file.write_text(json.dumps({"otherField": "keep-me"}))
|
||||||
|
_write_claude_code_credentials("new-tok", "new-ref", 99999)
|
||||||
|
data = json.loads(cred_file.read_text())
|
||||||
|
assert data["otherField"] == "keep-me"
|
||||||
|
assert data["claudeAiOauth"]["accessToken"] == "new-tok"
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveWithRefresh:
|
||||||
|
def test_auto_refresh_on_expired_creds(self, monkeypatch, tmp_path):
|
||||||
|
"""When cred file has expired token + refresh token, auto-refresh is attempted."""
|
||||||
|
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||||
|
|
||||||
|
# Set up expired creds with a refresh token
|
||||||
|
cred_file = tmp_path / ".claude" / ".credentials.json"
|
||||||
|
cred_file.parent.mkdir(parents=True)
|
||||||
|
cred_file.write_text(json.dumps({
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": "expired-tok",
|
||||||
|
"refreshToken": "valid-refresh",
|
||||||
|
"expiresAt": int(time.time() * 1000) - 3600_000,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
|
|
||||||
|
# Mock refresh to succeed
|
||||||
|
with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"):
|
||||||
|
result = resolve_anthropic_token()
|
||||||
|
|
||||||
|
assert result == "refreshed-token"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunOauthSetupToken:
|
||||||
|
def test_raises_when_claude_not_installed(self, monkeypatch):
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _: None)
|
||||||
|
with pytest.raises(FileNotFoundError, match="claude.*CLI.*not installed"):
|
||||||
|
run_oauth_setup_token()
|
||||||
|
|
||||||
|
def test_returns_token_from_credential_files(self, monkeypatch, tmp_path):
|
||||||
|
"""After subprocess completes, reads credentials from Claude Code files."""
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
|
||||||
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||||
|
|
||||||
|
# Pre-create credential files that will be found after subprocess
|
||||||
|
cred_file = tmp_path / ".claude" / ".credentials.json"
|
||||||
|
cred_file.parent.mkdir(parents=True)
|
||||||
|
cred_file.write_text(json.dumps({
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": "from-cred-file",
|
||||||
|
"refreshToken": "refresh",
|
||||||
|
"expiresAt": int(time.time() * 1000) + 3600_000,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
|
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
token = run_oauth_setup_token()
|
||||||
|
|
||||||
|
assert token == "from-cred-file"
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
|
||||||
|
def test_returns_token_from_env_var(self, monkeypatch, tmp_path):
|
||||||
|
"""Falls back to CLAUDE_CODE_OAUTH_TOKEN env var when no cred files."""
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
|
||||||
|
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "from-env-var")
|
||||||
|
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
|
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
token = run_oauth_setup_token()
|
||||||
|
|
||||||
|
assert token == "from-env-var"
|
||||||
|
|
||||||
|
def test_returns_none_when_no_creds_found(self, monkeypatch, tmp_path):
|
||||||
|
"""Returns None when subprocess completes but no credentials are found."""
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
|
||||||
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
|
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
|
token = run_oauth_setup_token()
|
||||||
|
|
||||||
|
assert token is None
|
||||||
|
|
||||||
|
def test_returns_none_on_keyboard_interrupt(self, monkeypatch):
|
||||||
|
"""Returns None gracefully when user interrupts the flow."""
|
||||||
|
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
|
||||||
|
|
||||||
|
with patch("subprocess.run", side_effect=KeyboardInterrupt):
|
||||||
|
token = run_oauth_setup_token()
|
||||||
|
|
||||||
|
assert token is None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Model name normalization
|
# Model name normalization
|
||||||
|
|||||||
Reference in New Issue
Block a user