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:
351
agent/anthropic_adapter.py
Normal file
351
agent/anthropic_adapter.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""Anthropic Messages API adapter for Hermes Agent.
|
||||
|
||||
Translates between Hermes's internal OpenAI-style message format and
|
||||
Anthropic's Messages API. Follows the same pattern as the codex_responses
|
||||
adapter — all provider-specific logic is isolated here.
|
||||
|
||||
Auth supports:
|
||||
- Regular API keys (sk-ant-api*) → x-api-key header
|
||||
- OAuth setup-tokens (sk-ant-oat*) → Bearer auth + beta header
|
||||
- Claude Code credentials (~/.claude/.credentials.json) → Bearer auth
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
import anthropic as _anthropic_sdk
|
||||
except ImportError:
|
||||
_anthropic_sdk = None # type: ignore[assignment]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
THINKING_BUDGET = {"xhigh": 32000, "high": 16000, "medium": 8000, "low": 4000}
|
||||
|
||||
# Beta headers required for OAuth/subscription auth
|
||||
_OAUTH_BETAS = ["oauth-2025-04-20"]
|
||||
|
||||
|
||||
def _is_oauth_token(key: str) -> bool:
|
||||
"""Check if the key is an OAuth access/setup token (not a regular API key)."""
|
||||
return key.startswith("sk-ant-oat")
|
||||
|
||||
|
||||
def build_anthropic_client(api_key: str, base_url: str = None):
|
||||
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys.
|
||||
|
||||
Returns an anthropic.Anthropic instance.
|
||||
"""
|
||||
if _anthropic_sdk is None:
|
||||
raise ImportError(
|
||||
"The 'anthropic' package is required for the Anthropic provider. "
|
||||
"Install it with: pip install 'anthropic>=0.39.0'"
|
||||
)
|
||||
from httpx import Timeout
|
||||
|
||||
kwargs = {
|
||||
"timeout": Timeout(timeout=900.0, connect=10.0),
|
||||
}
|
||||
if base_url:
|
||||
kwargs["base_url"] = base_url
|
||||
|
||||
if _is_oauth_token(api_key):
|
||||
# OAuth access token / setup-token → Bearer auth + beta header
|
||||
kwargs["auth_token"] = api_key
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(_OAUTH_BETAS)}
|
||||
else:
|
||||
# Regular API key → x-api-key header
|
||||
kwargs["api_key"] = api_key
|
||||
|
||||
return _anthropic_sdk.Anthropic(**kwargs)
|
||||
|
||||
|
||||
def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read credentials from Claude Code's credential file.
|
||||
|
||||
Returns dict with {accessToken, refreshToken, expiresAt} or None.
|
||||
"""
|
||||
cred_path = Path.home() / ".claude" / ".credentials.json"
|
||||
if not cred_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(cred_path.read_text(encoding="utf-8"))
|
||||
oauth_data = data.get("claudeAiOauth")
|
||||
if not oauth_data or not isinstance(oauth_data, dict):
|
||||
return None
|
||||
|
||||
access_token = oauth_data.get("accessToken", "")
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
return {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": oauth_data.get("refreshToken", ""),
|
||||
"expiresAt": oauth_data.get("expiresAt", 0),
|
||||
}
|
||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||
logger.debug("Failed to read Claude Code credentials: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
|
||||
"""Check if Claude Code credentials have a non-expired access token."""
|
||||
import time
|
||||
|
||||
expires_at = creds.get("expiresAt", 0)
|
||||
if not expires_at:
|
||||
return bool(creds.get("accessToken"))
|
||||
|
||||
# expiresAt is in milliseconds since epoch
|
||||
now_ms = int(time.time() * 1000)
|
||||
# Allow 60 seconds of buffer
|
||||
return now_ms < (expires_at - 60_000)
|
||||
|
||||
|
||||
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 credentials (~/.claude/.credentials.json)
|
||||
|
||||
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
|
||||
token = os.getenv("ANTHROPIC_TOKEN", "").strip()
|
||||
if token:
|
||||
return token
|
||||
|
||||
# Also check 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
|
||||
|
||||
# 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 from ~/.claude/.credentials.json")
|
||||
return creds["accessToken"]
|
||||
elif creds:
|
||||
logger.debug("Claude Code credentials expired — run 'claude' to refresh")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message / tool / response format conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_model_name(model: str) -> str:
|
||||
"""Normalize a model name for the Anthropic API.
|
||||
|
||||
- Strips 'anthropic/' prefix (OpenRouter format)
|
||||
"""
|
||||
if model.startswith("anthropic/"):
|
||||
model = model[len("anthropic/"):]
|
||||
return model
|
||||
|
||||
|
||||
def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
|
||||
"""Convert OpenAI tool definitions to Anthropic format."""
|
||||
if not tools:
|
||||
return []
|
||||
result = []
|
||||
for t in tools:
|
||||
fn = t.get("function", {})
|
||||
result.append({
|
||||
"name": fn.get("name", ""),
|
||||
"description": fn.get("description", ""),
|
||||
"input_schema": fn.get("parameters", {"type": "object", "properties": {}}),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def convert_messages_to_anthropic(
|
||||
messages: List[Dict],
|
||||
) -> Tuple[Optional[Any], List[Dict]]:
|
||||
"""Convert OpenAI-format messages to Anthropic format.
|
||||
|
||||
Returns (system_prompt, anthropic_messages).
|
||||
System messages are extracted since Anthropic takes them as a separate param.
|
||||
system_prompt is a string or list of content blocks (when cache_control present).
|
||||
"""
|
||||
system = None
|
||||
result = []
|
||||
|
||||
for m in messages:
|
||||
role = m.get("role", "user")
|
||||
content = m.get("content", "")
|
||||
|
||||
if role == "system":
|
||||
if isinstance(content, list):
|
||||
# Preserve cache_control markers on content blocks
|
||||
has_cache = any(
|
||||
p.get("cache_control") for p in content if isinstance(p, dict)
|
||||
)
|
||||
if has_cache:
|
||||
system = [p for p in content if isinstance(p, dict)]
|
||||
else:
|
||||
system = "\n".join(
|
||||
p["text"] for p in content if p.get("type") == "text"
|
||||
)
|
||||
else:
|
||||
system = content
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
blocks = []
|
||||
if content:
|
||||
text = content if isinstance(content, str) else json.dumps(content)
|
||||
blocks.append({"type": "text", "text": text})
|
||||
for tc in m.get("tool_calls", []):
|
||||
fn = tc.get("function", {})
|
||||
args = fn.get("arguments", "{}")
|
||||
blocks.append({
|
||||
"type": "tool_use",
|
||||
"id": tc.get("id", ""),
|
||||
"name": fn.get("name", ""),
|
||||
"input": json.loads(args) if isinstance(args, str) else args,
|
||||
})
|
||||
result.append({"role": "assistant", "content": blocks or content})
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
tool_result = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": m.get("tool_call_id", ""),
|
||||
"content": content if isinstance(content, str) else json.dumps(content),
|
||||
}
|
||||
# Merge consecutive tool results into one user message
|
||||
if (
|
||||
result
|
||||
and result[-1]["role"] == "user"
|
||||
and isinstance(result[-1]["content"], list)
|
||||
and result[-1]["content"]
|
||||
and result[-1]["content"][0].get("type") == "tool_result"
|
||||
):
|
||||
result[-1]["content"].append(tool_result)
|
||||
else:
|
||||
result.append({"role": "user", "content": [tool_result]})
|
||||
continue
|
||||
|
||||
# Regular user message
|
||||
result.append({"role": "user", "content": content})
|
||||
|
||||
# Strip orphaned tool_use blocks (no matching tool_result follows)
|
||||
tool_result_ids = set()
|
||||
for m in result:
|
||||
if m["role"] == "user" and isinstance(m["content"], list):
|
||||
for block in m["content"]:
|
||||
if block.get("type") == "tool_result":
|
||||
tool_result_ids.add(block.get("tool_use_id"))
|
||||
for m in result:
|
||||
if m["role"] == "assistant" and isinstance(m["content"], list):
|
||||
m["content"] = [
|
||||
b
|
||||
for b in m["content"]
|
||||
if b.get("type") != "tool_use" or b.get("id") in tool_result_ids
|
||||
]
|
||||
if not m["content"]:
|
||||
m["content"] = [{"type": "text", "text": "(tool call removed)"}]
|
||||
|
||||
return system, result
|
||||
|
||||
|
||||
def build_anthropic_kwargs(
|
||||
model: str,
|
||||
messages: List[Dict],
|
||||
tools: Optional[List[Dict]],
|
||||
max_tokens: Optional[int],
|
||||
reasoning_config: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build kwargs for anthropic.messages.create()."""
|
||||
system, anthropic_messages = convert_messages_to_anthropic(messages)
|
||||
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
|
||||
|
||||
model = normalize_model_name(model)
|
||||
effective_max_tokens = max_tokens or 16384
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": anthropic_messages,
|
||||
"max_tokens": effective_max_tokens,
|
||||
}
|
||||
|
||||
if system:
|
||||
kwargs["system"] = system
|
||||
|
||||
if anthropic_tools:
|
||||
kwargs["tools"] = anthropic_tools
|
||||
|
||||
# Map reasoning_config to Anthropic's thinking parameter
|
||||
if reasoning_config and isinstance(reasoning_config, dict):
|
||||
if reasoning_config.get("enabled") is not False:
|
||||
effort = reasoning_config.get("effort", "medium")
|
||||
budget = THINKING_BUDGET.get(effort, 8000)
|
||||
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
||||
kwargs["max_tokens"] = max(effective_max_tokens, budget + 4096)
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
def normalize_anthropic_response(
|
||||
response,
|
||||
) -> Tuple[SimpleNamespace, str]:
|
||||
"""Normalize Anthropic response to match the shape expected by AIAgent.
|
||||
|
||||
Returns (assistant_message, finish_reason) where assistant_message has
|
||||
.content, .tool_calls, and .reasoning attributes.
|
||||
"""
|
||||
text_parts = []
|
||||
reasoning_parts = []
|
||||
tool_calls = []
|
||||
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
text_parts.append(block.text)
|
||||
elif block.type == "thinking":
|
||||
reasoning_parts.append(block.thinking)
|
||||
elif block.type == "tool_use":
|
||||
tool_calls.append(
|
||||
SimpleNamespace(
|
||||
id=block.id,
|
||||
type="function",
|
||||
function=SimpleNamespace(
|
||||
name=block.name,
|
||||
arguments=json.dumps(block.input),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Map Anthropic stop_reason to OpenAI finish_reason
|
||||
stop_reason_map = {
|
||||
"end_turn": "stop",
|
||||
"tool_use": "tool_calls",
|
||||
"max_tokens": "length",
|
||||
"stop_sequence": "stop",
|
||||
}
|
||||
finish_reason = stop_reason_map.get(response.stop_reason, "stop")
|
||||
|
||||
return (
|
||||
SimpleNamespace(
|
||||
content="\n".join(text_parts) if text_parts else None,
|
||||
tool_calls=tool_calls or None,
|
||||
reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None,
|
||||
reasoning_content=None,
|
||||
reasoning_details=None,
|
||||
),
|
||||
finish_reason,
|
||||
)
|
||||
@@ -51,6 +51,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
|
||||
"kimi-coding": "kimi-k2-turbo-preview",
|
||||
"minimax": "MiniMax-M2.5-highspeed",
|
||||
"minimax-cn": "MiniMax-M2.5-highspeed",
|
||||
"anthropic": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
|
||||
# OpenRouter app attribution headers
|
||||
|
||||
@@ -41,6 +41,10 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"anthropic/claude-sonnet-4": 200000,
|
||||
"anthropic/claude-sonnet-4-20250514": 200000,
|
||||
"anthropic/claude-haiku-4.5": 200000,
|
||||
# Bare Anthropic model IDs (for native API provider)
|
||||
"claude-opus-4-20250514": 200000,
|
||||
"claude-sonnet-4-20250514": 200000,
|
||||
"claude-haiku-4-5-20251001": 200000,
|
||||
"openai/gpt-4o": 128000,
|
||||
"openai/gpt-4-turbo": 128000,
|
||||
"openai/gpt-4o-mini": 128000,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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]] = {}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ license = { text = "MIT" }
|
||||
dependencies = [
|
||||
# Core
|
||||
"openai",
|
||||
"anthropic>=0.39.0",
|
||||
"python-dotenv",
|
||||
"fire",
|
||||
"httpx",
|
||||
|
||||
71
run_agent.py
71
run_agent.py
@@ -296,13 +296,16 @@ class AIAgent:
|
||||
self.base_url = base_url or OPENROUTER_BASE_URL
|
||||
provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
|
||||
self.provider = provider_name or "openrouter"
|
||||
if api_mode in {"chat_completions", "codex_responses"}:
|
||||
if api_mode in {"chat_completions", "codex_responses", "anthropic_messages"}:
|
||||
self.api_mode = api_mode
|
||||
elif self.provider == "openai-codex":
|
||||
self.api_mode = "codex_responses"
|
||||
elif (provider_name is None) and "chatgpt.com/backend-api/codex" in self.base_url.lower():
|
||||
self.api_mode = "codex_responses"
|
||||
self.provider = "openai-codex"
|
||||
elif self.provider == "anthropic" or (provider_name is None and "api.anthropic.com" in self.base_url.lower()):
|
||||
self.api_mode = "anthropic_messages"
|
||||
self.provider = "anthropic"
|
||||
else:
|
||||
self.api_mode = "chat_completions"
|
||||
|
||||
@@ -343,7 +346,8 @@ class AIAgent:
|
||||
# conversation prefix. Uses system_and_3 strategy (4 breakpoints).
|
||||
is_openrouter = "openrouter" in self.base_url.lower()
|
||||
is_claude = "claude" in self.model.lower()
|
||||
self._use_prompt_caching = is_openrouter and is_claude
|
||||
is_native_anthropic = self.api_mode == "anthropic_messages"
|
||||
self._use_prompt_caching = (is_openrouter and is_claude) or is_native_anthropic
|
||||
self._cache_ttl = "5m" # Default 5-minute TTL (1.25x write cost)
|
||||
|
||||
# Iteration budget pressure: warn the LLM as it approaches max_iterations.
|
||||
@@ -420,11 +424,29 @@ class AIAgent:
|
||||
]:
|
||||
logging.getLogger(quiet_logger).setLevel(logging.ERROR)
|
||||
|
||||
# Initialize OpenAI client via centralized provider router.
|
||||
# Initialize LLM client via centralized provider router.
|
||||
# The router handles auth resolution, base URL, headers, and
|
||||
# Codex wrapping for all known providers.
|
||||
# Codex/Anthropic wrapping for all known providers.
|
||||
# raw_codex=True because the main agent needs direct responses.stream()
|
||||
# access for Codex Responses API streaming.
|
||||
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 ""
|
||||
self._anthropic_api_key = effective_key
|
||||
self._anthropic_client = build_anthropic_client(effective_key, base_url if base_url and "anthropic" in base_url else None)
|
||||
# No OpenAI client needed for Anthropic mode
|
||||
self.client = None
|
||||
self._client_kwargs = {}
|
||||
if not self.quiet_mode:
|
||||
print(f"🤖 AI Agent initialized with model: {self.model} (Anthropic native)")
|
||||
if effective_key and len(effective_key) > 12:
|
||||
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
|
||||
else:
|
||||
if api_key and base_url:
|
||||
# Explicit credentials from CLI/gateway — construct directly.
|
||||
# The runtime provider resolver already handled auth for us.
|
||||
@@ -533,7 +555,8 @@ class AIAgent:
|
||||
|
||||
# Show prompt caching status
|
||||
if self._use_prompt_caching and not self.quiet_mode:
|
||||
print(f"💾 Prompt caching: ENABLED (Claude via OpenRouter, {self._cache_ttl} TTL)")
|
||||
source = "native Anthropic" if is_native_anthropic else "Claude via OpenRouter"
|
||||
print(f"💾 Prompt caching: ENABLED ({source}, {self._cache_ttl} TTL)")
|
||||
|
||||
# Session logging setup - auto-save conversation trajectories for debugging
|
||||
self.session_start = datetime.now()
|
||||
@@ -2233,6 +2256,8 @@ class AIAgent:
|
||||
try:
|
||||
if self.api_mode == "codex_responses":
|
||||
result["response"] = self._run_codex_stream(api_kwargs)
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
result["response"] = self._anthropic_client.messages.create(**api_kwargs)
|
||||
else:
|
||||
result["response"] = self.client.chat.completions.create(**api_kwargs)
|
||||
except Exception as e:
|
||||
@@ -2245,11 +2270,18 @@ class AIAgent:
|
||||
if self._interrupt_requested:
|
||||
# Force-close the HTTP connection to stop token generation
|
||||
try:
|
||||
if self.api_mode == "anthropic_messages":
|
||||
self._anthropic_client.close()
|
||||
else:
|
||||
self.client.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Rebuild the client for future calls (cheap, no network)
|
||||
try:
|
||||
if self.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
self._anthropic_client = build_anthropic_client(self._anthropic_api_key)
|
||||
else:
|
||||
self.client = OpenAI(**self._client_kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -2336,6 +2368,16 @@ class AIAgent:
|
||||
|
||||
def _build_api_kwargs(self, api_messages: list) -> dict:
|
||||
"""Build the keyword arguments dict for the active API mode."""
|
||||
if self.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
return build_anthropic_kwargs(
|
||||
model=self.model,
|
||||
messages=api_messages,
|
||||
tools=self.tools,
|
||||
max_tokens=None,
|
||||
reasoning_config=self.reasoning_config,
|
||||
)
|
||||
|
||||
if self.api_mode == "codex_responses":
|
||||
instructions = ""
|
||||
payload_messages = api_messages
|
||||
@@ -3561,6 +3603,17 @@ class AIAgent:
|
||||
elif len(output_items) == 0:
|
||||
response_invalid = True
|
||||
error_details.append("response.output is empty")
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
content_blocks = getattr(response, "content", None) if response is not None else None
|
||||
if response is None:
|
||||
response_invalid = True
|
||||
error_details.append("response is None")
|
||||
elif not isinstance(content_blocks, list):
|
||||
response_invalid = True
|
||||
error_details.append("response.content is not a list")
|
||||
elif len(content_blocks) == 0:
|
||||
response_invalid = True
|
||||
error_details.append("response.content is empty")
|
||||
else:
|
||||
if response is None or not hasattr(response, 'choices') or response.choices is None or len(response.choices) == 0:
|
||||
response_invalid = True
|
||||
@@ -3662,6 +3715,9 @@ class AIAgent:
|
||||
finish_reason = "length"
|
||||
else:
|
||||
finish_reason = "stop"
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
stop_reason_map = {"end_turn": "stop", "tool_use": "tool_calls", "max_tokens": "length", "stop_sequence": "stop"}
|
||||
finish_reason = stop_reason_map.get(response.stop_reason, "stop")
|
||||
else:
|
||||
finish_reason = response.choices[0].finish_reason
|
||||
|
||||
@@ -3739,7 +3795,7 @@ class AIAgent:
|
||||
|
||||
# Track actual token usage from response for context management
|
||||
if hasattr(response, 'usage') and response.usage:
|
||||
if self.api_mode == "codex_responses":
|
||||
if self.api_mode in ("codex_responses", "anthropic_messages"):
|
||||
prompt_tokens = getattr(response.usage, 'input_tokens', 0) or 0
|
||||
completion_tokens = getattr(response.usage, 'output_tokens', 0) or 0
|
||||
total_tokens = (
|
||||
@@ -4068,6 +4124,9 @@ class AIAgent:
|
||||
try:
|
||||
if self.api_mode == "codex_responses":
|
||||
assistant_message, finish_reason = self._normalize_codex_response(response)
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import normalize_anthropic_response
|
||||
assistant_message, finish_reason = normalize_anthropic_response(response)
|
||||
else:
|
||||
assistant_message = response.choices[0].message
|
||||
|
||||
|
||||
406
tests/test_anthropic_adapter.py
Normal file
406
tests/test_anthropic_adapter.py
Normal file
@@ -0,0 +1,406 @@
|
||||
"""Tests for agent/anthropic_adapter.py — Anthropic Messages API adapter."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.anthropic_adapter import (
|
||||
_is_oauth_token,
|
||||
build_anthropic_client,
|
||||
build_anthropic_kwargs,
|
||||
convert_messages_to_anthropic,
|
||||
convert_tools_to_anthropic,
|
||||
is_claude_code_token_valid,
|
||||
normalize_anthropic_response,
|
||||
normalize_model_name,
|
||||
read_claude_code_credentials,
|
||||
resolve_anthropic_token,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsOAuthToken:
|
||||
def test_setup_token(self):
|
||||
assert _is_oauth_token("sk-ant-oat01-abcdef1234567890") is True
|
||||
|
||||
def test_api_key(self):
|
||||
assert _is_oauth_token("sk-ant-api03-abcdef1234567890") is False
|
||||
|
||||
def test_empty(self):
|
||||
assert _is_oauth_token("") is False
|
||||
|
||||
|
||||
class TestBuildAnthropicClient:
|
||||
def test_setup_token_uses_auth_token(self):
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
build_anthropic_client("sk-ant-oat01-" + "x" * 60)
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
assert "auth_token" in kwargs
|
||||
assert "oauth-2025-04-20" in kwargs["default_headers"]["anthropic-beta"]
|
||||
assert "api_key" not in kwargs
|
||||
|
||||
def test_api_key_uses_api_key(self):
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
build_anthropic_client("sk-ant-api03-something")
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
assert kwargs["api_key"] == "sk-ant-api03-something"
|
||||
assert "auth_token" not in kwargs
|
||||
|
||||
def test_custom_base_url(self):
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
build_anthropic_client("sk-ant-api03-x", base_url="https://custom.api.com")
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
assert kwargs["base_url"] == "https://custom.api.com"
|
||||
|
||||
|
||||
class TestReadClaudeCodeCredentials:
|
||||
def test_reads_valid_credentials(self, tmp_path, monkeypatch):
|
||||
cred_file = tmp_path / ".claude" / ".credentials.json"
|
||||
cred_file.parent.mkdir(parents=True)
|
||||
cred_file.write_text(json.dumps({
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-oat01-test-token",
|
||||
"refreshToken": "sk-ant-ort01-refresh",
|
||||
"expiresAt": int(time.time() * 1000) + 3600_000,
|
||||
}
|
||||
}))
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
creds = read_claude_code_credentials()
|
||||
assert creds is not None
|
||||
assert creds["accessToken"] == "sk-ant-oat01-test-token"
|
||||
assert creds["refreshToken"] == "sk-ant-ort01-refresh"
|
||||
|
||||
def test_returns_none_for_missing_file(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
assert read_claude_code_credentials() is None
|
||||
|
||||
def test_returns_none_for_missing_oauth_key(self, tmp_path, monkeypatch):
|
||||
cred_file = tmp_path / ".claude" / ".credentials.json"
|
||||
cred_file.parent.mkdir(parents=True)
|
||||
cred_file.write_text(json.dumps({"someOtherKey": {}}))
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
assert read_claude_code_credentials() is None
|
||||
|
||||
def test_returns_none_for_empty_access_token(self, tmp_path, monkeypatch):
|
||||
cred_file = tmp_path / ".claude" / ".credentials.json"
|
||||
cred_file.parent.mkdir(parents=True)
|
||||
cred_file.write_text(json.dumps({
|
||||
"claudeAiOauth": {"accessToken": "", "refreshToken": "x"}
|
||||
}))
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
assert read_claude_code_credentials() is None
|
||||
|
||||
|
||||
class TestIsClaudeCodeTokenValid:
|
||||
def test_valid_token(self):
|
||||
creds = {"accessToken": "tok", "expiresAt": int(time.time() * 1000) + 3600_000}
|
||||
assert is_claude_code_token_valid(creds) is True
|
||||
|
||||
def test_expired_token(self):
|
||||
creds = {"accessToken": "tok", "expiresAt": int(time.time() * 1000) - 3600_000}
|
||||
assert is_claude_code_token_valid(creds) is False
|
||||
|
||||
def test_no_expiry_but_has_token(self):
|
||||
creds = {"accessToken": "tok", "expiresAt": 0}
|
||||
assert is_claude_code_token_valid(creds) is True
|
||||
|
||||
|
||||
class TestResolveAnthropicToken:
|
||||
def test_prefers_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-api03-mykey"
|
||||
|
||||
def test_falls_back_to_token(self, monkeypatch):
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
|
||||
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
|
||||
|
||||
def test_returns_none_with_no_creds(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)
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
assert resolve_anthropic_token() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model name normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalizeModelName:
|
||||
def test_strips_anthropic_prefix(self):
|
||||
assert normalize_model_name("anthropic/claude-sonnet-4-20250514") == "claude-sonnet-4-20250514"
|
||||
|
||||
def test_leaves_bare_name(self):
|
||||
assert normalize_model_name("claude-sonnet-4-20250514") == "claude-sonnet-4-20250514"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConvertTools:
|
||||
def test_converts_openai_to_anthropic_format(self):
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"description": "Search the web",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
result = convert_tools_to_anthropic(tools)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "search"
|
||||
assert result[0]["description"] == "Search the web"
|
||||
assert result[0]["input_schema"]["properties"]["query"]["type"] == "string"
|
||||
|
||||
def test_empty_tools(self):
|
||||
assert convert_tools_to_anthropic([]) == []
|
||||
assert convert_tools_to_anthropic(None) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConvertMessages:
|
||||
def test_extracts_system_prompt(self):
|
||||
messages = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
system, result = convert_messages_to_anthropic(messages)
|
||||
assert system == "You are helpful."
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
|
||||
def test_converts_tool_calls(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Let me search.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc_1",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"arguments": '{"query": "test"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": "search results"},
|
||||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
blocks = result[0]["content"]
|
||||
assert blocks[0] == {"type": "text", "text": "Let me search."}
|
||||
assert blocks[1]["type"] == "tool_use"
|
||||
assert blocks[1]["id"] == "tc_1"
|
||||
assert blocks[1]["input"] == {"query": "test"}
|
||||
|
||||
def test_converts_tool_results(self):
|
||||
messages = [
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": "result data"},
|
||||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
assert result[0]["role"] == "user"
|
||||
assert result[0]["content"][0]["type"] == "tool_result"
|
||||
assert result[0]["content"][0]["tool_use_id"] == "tc_1"
|
||||
|
||||
def test_merges_consecutive_tool_results(self):
|
||||
messages = [
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": "result 1"},
|
||||
{"role": "tool", "tool_call_id": "tc_2", "content": "result 2"},
|
||||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
assert len(result) == 1
|
||||
assert len(result[0]["content"]) == 2
|
||||
|
||||
def test_strips_orphaned_tool_use(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "tc_orphan", "function": {"name": "x", "arguments": "{}"}}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "never mind"},
|
||||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
# tc_orphan has no matching tool_result, should be stripped
|
||||
assistant_blocks = result[0]["content"]
|
||||
assert all(b.get("type") != "tool_use" for b in assistant_blocks)
|
||||
|
||||
def test_system_with_cache_control(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{"type": "text", "text": "System prompt", "cache_control": {"type": "ephemeral"}},
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "Hi"},
|
||||
]
|
||||
system, result = convert_messages_to_anthropic(messages)
|
||||
# When cache_control is present, system should be a list of blocks
|
||||
assert isinstance(system, list)
|
||||
assert system[0]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build kwargs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildAnthropicKwargs:
|
||||
def test_basic_kwargs(self):
|
||||
messages = [
|
||||
{"role": "system", "content": "Be helpful."},
|
||||
{"role": "user", "content": "Hi"},
|
||||
]
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-sonnet-4-20250514",
|
||||
messages=messages,
|
||||
tools=None,
|
||||
max_tokens=4096,
|
||||
reasoning_config=None,
|
||||
)
|
||||
assert kwargs["model"] == "claude-sonnet-4-20250514"
|
||||
assert kwargs["system"] == "Be helpful."
|
||||
assert kwargs["max_tokens"] == 4096
|
||||
assert "tools" not in kwargs
|
||||
|
||||
def test_strips_anthropic_prefix(self):
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="anthropic/claude-sonnet-4-20250514",
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
tools=None,
|
||||
max_tokens=4096,
|
||||
reasoning_config=None,
|
||||
)
|
||||
assert kwargs["model"] == "claude-sonnet-4-20250514"
|
||||
|
||||
def test_reasoning_config_maps_to_thinking(self):
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-sonnet-4-20250514",
|
||||
messages=[{"role": "user", "content": "think hard"}],
|
||||
tools=None,
|
||||
max_tokens=4096,
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
)
|
||||
assert kwargs["thinking"]["type"] == "enabled"
|
||||
assert kwargs["thinking"]["budget_tokens"] == 16000
|
||||
assert kwargs["max_tokens"] >= 16000 + 4096
|
||||
|
||||
def test_reasoning_disabled(self):
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-sonnet-4-20250514",
|
||||
messages=[{"role": "user", "content": "quick"}],
|
||||
tools=None,
|
||||
max_tokens=4096,
|
||||
reasoning_config={"enabled": False},
|
||||
)
|
||||
assert "thinking" not in kwargs
|
||||
|
||||
def test_default_max_tokens(self):
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-sonnet-4-20250514",
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
reasoning_config=None,
|
||||
)
|
||||
assert kwargs["max_tokens"] == 16384
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalizeResponse:
|
||||
def _make_response(self, content_blocks, stop_reason="end_turn"):
|
||||
resp = SimpleNamespace()
|
||||
resp.content = content_blocks
|
||||
resp.stop_reason = stop_reason
|
||||
resp.usage = SimpleNamespace(input_tokens=100, output_tokens=50)
|
||||
return resp
|
||||
|
||||
def test_text_response(self):
|
||||
block = SimpleNamespace(type="text", text="Hello world")
|
||||
msg, reason = normalize_anthropic_response(self._make_response([block]))
|
||||
assert msg.content == "Hello world"
|
||||
assert reason == "stop"
|
||||
assert msg.tool_calls is None
|
||||
|
||||
def test_tool_use_response(self):
|
||||
blocks = [
|
||||
SimpleNamespace(type="text", text="Searching..."),
|
||||
SimpleNamespace(
|
||||
type="tool_use",
|
||||
id="tc_1",
|
||||
name="search",
|
||||
input={"query": "test"},
|
||||
),
|
||||
]
|
||||
msg, reason = normalize_anthropic_response(
|
||||
self._make_response(blocks, "tool_use")
|
||||
)
|
||||
assert msg.content == "Searching..."
|
||||
assert reason == "tool_calls"
|
||||
assert len(msg.tool_calls) == 1
|
||||
assert msg.tool_calls[0].function.name == "search"
|
||||
assert json.loads(msg.tool_calls[0].function.arguments) == {"query": "test"}
|
||||
|
||||
def test_thinking_response(self):
|
||||
blocks = [
|
||||
SimpleNamespace(type="thinking", thinking="Let me reason about this..."),
|
||||
SimpleNamespace(type="text", text="The answer is 42."),
|
||||
]
|
||||
msg, reason = normalize_anthropic_response(self._make_response(blocks))
|
||||
assert msg.content == "The answer is 42."
|
||||
assert msg.reasoning == "Let me reason about this..."
|
||||
|
||||
def test_stop_reason_mapping(self):
|
||||
block = SimpleNamespace(type="text", text="x")
|
||||
_, r1 = normalize_anthropic_response(
|
||||
self._make_response([block], "end_turn")
|
||||
)
|
||||
_, r2 = normalize_anthropic_response(
|
||||
self._make_response([block], "tool_use")
|
||||
)
|
||||
_, r3 = normalize_anthropic_response(
|
||||
self._make_response([block], "max_tokens")
|
||||
)
|
||||
assert r1 == "stop"
|
||||
assert r2 == "tool_calls"
|
||||
assert r3 == "length"
|
||||
|
||||
def test_no_text_content(self):
|
||||
block = SimpleNamespace(
|
||||
type="tool_use", id="tc_1", name="search", input={"q": "hi"}
|
||||
)
|
||||
msg, reason = normalize_anthropic_response(
|
||||
self._make_response([block], "tool_use")
|
||||
)
|
||||
assert msg.content is None
|
||||
assert len(msg.tool_calls) == 1
|
||||
@@ -281,20 +281,21 @@ class TestMaskApiKey:
|
||||
|
||||
class TestInit:
|
||||
def test_anthropic_base_url_accepted(self):
|
||||
"""Anthropic base URLs should be accepted (OpenAI-compatible endpoint)."""
|
||||
"""Anthropic base URLs should route to native Anthropic client."""
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=[]),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI") as mock_openai,
|
||||
patch("agent.anthropic_adapter._anthropic_sdk") as mock_anthropic,
|
||||
):
|
||||
AIAgent(
|
||||
agent = AIAgent(
|
||||
api_key="test-key-1234567890",
|
||||
base_url="https://api.anthropic.com/v1/",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
mock_openai.assert_called_once()
|
||||
assert agent.api_mode == "anthropic_messages"
|
||||
mock_anthropic.Anthropic.assert_called_once()
|
||||
|
||||
def test_prompt_caching_claude_openrouter(self):
|
||||
"""Claude model via OpenRouter should enable prompt caching."""
|
||||
@@ -345,6 +346,23 @@ class TestInit:
|
||||
)
|
||||
assert a._use_prompt_caching is False
|
||||
|
||||
def test_prompt_caching_native_anthropic(self):
|
||||
"""Native Anthropic provider should enable prompt caching."""
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=[]),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("agent.anthropic_adapter._anthropic_sdk"),
|
||||
):
|
||||
a = AIAgent(
|
||||
api_key="test-key-1234567890",
|
||||
base_url="https://api.anthropic.com/v1/",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
assert a.api_mode == "anthropic_messages"
|
||||
assert a._use_prompt_caching is True
|
||||
|
||||
def test_valid_tool_names_populated(self):
|
||||
"""valid_tool_names should contain names from loaded tools."""
|
||||
tools = _make_tool_defs("web_search", "terminal")
|
||||
|
||||
Reference in New Issue
Block a user