feat: native Anthropic provider with Claude Code credential auto-discovery

Add Anthropic as a first-class inference provider, bypassing OpenRouter
for direct API access. Uses the native Anthropic SDK with a full format
adapter (same pattern as the codex_responses api_mode).

## Auth (three methods, priority order)
1. ANTHROPIC_API_KEY env var (regular API key, sk-ant-api-*)
2. ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN env var (setup-token, sk-ant-oat-*)
3. Auto-discovery from ~/.claude/.credentials.json (Claude Code subscription)
   - Reads Claude Code's OAuth credentials
   - Checks token expiry with 60s buffer
   - Setup tokens use Bearer auth + anthropic-beta: oauth-2025-04-20 header
   - Regular API keys use standard x-api-key header

## Changes by file

### New files
- agent/anthropic_adapter.py — Client builder, message/tool/response
  format conversion, Claude Code credential reader, token resolver.
  Handles system prompt extraction, tool_use/tool_result blocks,
  thinking/reasoning, orphaned tool_use cleanup, cache_control.
- tests/test_anthropic_adapter.py — 36 tests covering all adapter logic

### Modified files
- pyproject.toml — Add anthropic>=0.39.0 dependency
- hermes_cli/auth.py — Add 'anthropic' to PROVIDER_REGISTRY with
  three env vars, plus 'claude'/'claude-code' aliases
- hermes_cli/models.py — Add model catalog, labels, aliases, provider order
- hermes_cli/main.py — Add 'anthropic' to --provider CLI choices
- hermes_cli/runtime_provider.py — Add Anthropic branch returning
  api_mode='anthropic_messages' (before generic api_key fallthrough)
- hermes_cli/setup.py — Add Anthropic setup wizard with Claude Code
  credential auto-discovery, model selection, OpenRouter tools prompt
- agent/auxiliary_client.py — Add claude-haiku-4-5 as aux model
- agent/model_metadata.py — Add bare Claude model context lengths
- run_agent.py — Add anthropic_messages api_mode:
  * Client init (Anthropic SDK instead of OpenAI)
  * API call dispatch (_anthropic_client.messages.create)
  * Response validation (content blocks)
  * finish_reason mapping (stop_reason -> finish_reason)
  * Token usage (input_tokens/output_tokens)
  * Response normalization (normalize_anthropic_response)
  * Client interrupt/rebuild
  * Prompt caching auto-enabled for native Anthropic
- tests/test_run_agent.py — Update test_anthropic_base_url_accepted to
  expect native routing, add test_prompt_caching_native_anthropic
This commit is contained in:
teknium1 2026-03-12 15:47:45 -07:00
parent 6b211bf008
commit 5e12442b4b
12 changed files with 1002 additions and 65 deletions

View file

@ -132,6 +132,13 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
api_key_env_vars=("MINIMAX_API_KEY",),
base_url_env_var="MINIMAX_BASE_URL",
),
"anthropic": ProviderConfig(
id="anthropic",
name="Anthropic",
auth_type="api_key",
inference_base_url="https://api.anthropic.com",
api_key_env_vars=("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
),
"minimax-cn": ProviderConfig(
id="minimax-cn",
name="MiniMax (China)",
@ -516,6 +523,7 @@ def resolve_provider(
"glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai",
"kimi": "kimi-coding", "moonshot": "kimi-coding",
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
"claude": "anthropic", "claude-code": "anthropic",
}
normalized = _PROVIDER_ALIASES.get(normalized, normalized)

View file

@ -2035,7 +2035,7 @@ For more help on a command:
)
chat_parser.add_argument(
"--provider",
choices=["auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn"],
choices=["auto", "openrouter", "nous", "openai-codex", "anthropic", "zai", "kimi-coding", "minimax", "minimax-cn"],
default=None,
help="Inference provider (default: auto)"
)

View file

@ -68,6 +68,11 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"MiniMax-M2.5-highspeed",
"MiniMax-M2.1",
],
"anthropic": [
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"claude-haiku-4-5-20251001",
],
}
_PROVIDER_LABELS = {
@ -78,6 +83,7 @@ _PROVIDER_LABELS = {
"kimi-coding": "Kimi / Moonshot",
"minimax": "MiniMax",
"minimax-cn": "MiniMax (China)",
"anthropic": "Anthropic",
"custom": "Custom endpoint",
}
@ -90,6 +96,8 @@ _PROVIDER_ALIASES = {
"moonshot": "kimi-coding",
"minimax-china": "minimax-cn",
"minimax_cn": "minimax-cn",
"claude": "anthropic",
"claude-code": "anthropic",
}
@ -123,7 +131,7 @@ def list_available_providers() -> list[dict[str, str]]:
# Canonical providers in display order
_PROVIDER_ORDER = [
"openrouter", "nous", "openai-codex",
"zai", "kimi-coding", "minimax", "minimax-cn",
"zai", "kimi-coding", "minimax", "minimax-cn", "anthropic",
]
# Build reverse alias map
aliases_for: dict[str, list[str]] = {}

View file

@ -153,6 +153,19 @@ def resolve_runtime_provider(
"requested_provider": requested_provider,
}
# Anthropic (native Messages API)
if provider == "anthropic":
from agent.anthropic_adapter import resolve_anthropic_token
token = resolve_anthropic_token()
return {
"provider": "anthropic",
"api_mode": "anthropic_messages",
"base_url": "https://api.anthropic.com",
"api_key": token or "",
"source": "env",
"requested_provider": requested_provider,
}
# API-key providers (z.ai/GLM, Kimi, MiniMax, MiniMax-CN)
pconfig = PROVIDER_REGISTRY.get(provider)
if pconfig and pconfig.auth_type == "api_key":

View file

@ -626,6 +626,7 @@ def setup_model_provider(config: dict):
"Kimi / Moonshot (Kimi coding models)",
"MiniMax (global endpoint)",
"MiniMax China (mainland China endpoint)",
"Anthropic (Claude models — API key or Claude Code subscription)",
]
if keep_label:
provider_choices.append(keep_label)
@ -1004,7 +1005,53 @@ def setup_model_provider(config: dict):
_update_config_for_provider("minimax-cn", pconfig.inference_base_url)
_set_model_provider(config, "minimax-cn", pconfig.inference_base_url)
# else: provider_idx == 8 (Keep current) — only shown when a provider already exists
elif provider_idx == 8: # Anthropic
selected_provider = "anthropic"
print()
print_header("Anthropic API Key or Claude Code Credentials")
from hermes_cli.auth import PROVIDER_REGISTRY
pconfig = PROVIDER_REGISTRY["anthropic"]
print_info(f"Provider: {pconfig.name}")
print_info("Accepts API keys (sk-ant-api-*) or setup-tokens (sk-ant-oat-*)")
print_info("Get an API key at: https://console.anthropic.com/")
print_info("Or run 'claude setup-token' to get a setup-token from Claude Code")
print()
# Check for Claude Code credential auto-discovery
from agent.anthropic_adapter import read_claude_code_credentials, is_claude_code_token_valid
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 not prompt_yes_no("Use Claude Code credentials? (You can also enter an API key)", True):
cc_creds = None
existing_key = get_env_value("ANTHROPIC_API_KEY") or get_env_value("ANTHROPIC_TOKEN")
if cc_creds and is_claude_code_token_valid(cc_creds):
# Use Claude Code creds — no need to prompt for a key
print_success("Using Claude Code subscription credentials")
elif existing_key:
print_info(f"Current: {existing_key[:12]}... (configured)")
if prompt_yes_no("Update key?", False):
api_key = prompt("Enter Anthropic API key or setup-token", password=True)
if api_key:
save_env_value("ANTHROPIC_API_KEY", api_key)
print_success("Anthropic key saved")
else:
api_key = prompt("Enter Anthropic API key or setup-token", password=True)
if api_key:
save_env_value("ANTHROPIC_API_KEY", api_key)
print_success("Anthropic key saved")
else:
print_warning("Skipped - agent won't work without an API key")
# 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)
# else: provider_idx == 9 (Keep current) — only shown when a provider already exists
# ── OpenRouter API Key for tools (if not already set) ──
# Tools (vision, web, MoA) use OpenRouter independently of the main provider.
@ -1017,6 +1064,7 @@ def setup_model_provider(config: dict):
"kimi-coding",
"minimax",
"minimax-cn",
"anthropic",
) and not get_env_value("OPENROUTER_API_KEY"):
print()
print_header("OpenRouter API Key (for tools)")
@ -1160,6 +1208,26 @@ def setup_model_provider(config: dict):
if custom:
_set_default_model(config, custom)
# else: keep current
elif selected_provider == "anthropic":
anthropic_models = [
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"claude-haiku-4-5-20251001",
]
model_choices = list(anthropic_models)
model_choices.append("Custom model")
model_choices.append(f"Keep current ({current_model})")
keep_idx = len(model_choices) - 1
model_idx = prompt_choice("Select default model:", model_choices, keep_idx)
if model_idx < len(anthropic_models):
_set_default_model(config, anthropic_models[model_idx])
elif model_idx == len(anthropic_models):
custom = prompt("Enter model name (e.g., claude-sonnet-4-20250514)")
if custom:
_set_default_model(config, custom)
# else: keep current
else:
# Static list for OpenRouter / fallback (from canonical list)
from hermes_cli.models import model_ids, menu_labels