diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py new file mode 100644 index 000000000..d19d50da6 --- /dev/null +++ b/agent/anthropic_adapter.py @@ -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, + ) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 1c6ac271f..a2175bed7 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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 diff --git a/agent/model_metadata.py b/agent/model_metadata.py index e8d1e51b4..6ad741998 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -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, diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 1ffa85bdc..c1b083484 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 8e25bc2df..7a8653272 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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)" ) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 92dcbf975..f67c5aa28 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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]] = {} diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 4e6910dad..474295ea6 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -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": diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 02fb3c4b4..5b29b9d24 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 876c47f73..fef457e83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ license = { text = "MIT" } dependencies = [ # Core "openai", + "anthropic>=0.39.0", "python-dotenv", "fire", "httpx", diff --git a/run_agent.py b/run_agent.py index 7808435d0..20a05d88b 100644 --- a/run_agent.py +++ b/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,66 +424,84 @@ 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. - if api_key and base_url: - # Explicit credentials from CLI/gateway — construct directly. - # The runtime provider resolver already handled auth for us. - client_kwargs = {"api_key": api_key, "base_url": base_url} - effective_base = base_url - if "openrouter" in effective_base.lower(): - client_kwargs["default_headers"] = { - "HTTP-Referer": "https://github.com/NousResearch/hermes-agent", - "X-OpenRouter-Title": "Hermes Agent", - "X-OpenRouter-Categories": "productivity,cli-agent", - } - elif "api.kimi.com" in effective_base.lower(): - client_kwargs["default_headers"] = { - "User-Agent": "KimiCLI/1.3", - } + 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: - # No explicit creds — use the centralized provider router - from agent.auxiliary_client import resolve_provider_client - _routed_client, _ = resolve_provider_client( - self.provider or "auto", model=self.model, raw_codex=True) - if _routed_client is not None: - client_kwargs = { - "api_key": _routed_client.api_key, - "base_url": str(_routed_client.base_url), - } - # Preserve any default_headers the router set - if hasattr(_routed_client, '_default_headers') and _routed_client._default_headers: - client_kwargs["default_headers"] = dict(_routed_client._default_headers) - else: - # Final fallback: try raw OpenRouter key - client_kwargs = { - "api_key": os.getenv("OPENROUTER_API_KEY", ""), - "base_url": OPENROUTER_BASE_URL, - "default_headers": { + if api_key and base_url: + # Explicit credentials from CLI/gateway — construct directly. + # The runtime provider resolver already handled auth for us. + client_kwargs = {"api_key": api_key, "base_url": base_url} + effective_base = base_url + if "openrouter" in effective_base.lower(): + client_kwargs["default_headers"] = { "HTTP-Referer": "https://github.com/NousResearch/hermes-agent", "X-OpenRouter-Title": "Hermes Agent", "X-OpenRouter-Categories": "productivity,cli-agent", - }, - } - - self._client_kwargs = client_kwargs # stored for rebuilding after interrupt - try: - self.client = OpenAI(**client_kwargs) - if not self.quiet_mode: - print(f"🤖 AI Agent initialized with model: {self.model}") - if base_url: - print(f"🔗 Using custom base URL: {base_url}") - # Always show API key info (masked) for debugging auth issues - key_used = client_kwargs.get("api_key", "none") - if key_used and key_used != "dummy-key" and len(key_used) > 12: - print(f"🔑 Using API key: {key_used[:8]}...{key_used[-4:]}") + } + elif "api.kimi.com" in effective_base.lower(): + client_kwargs["default_headers"] = { + "User-Agent": "KimiCLI/1.3", + } + else: + # No explicit creds — use the centralized provider router + from agent.auxiliary_client import resolve_provider_client + _routed_client, _ = resolve_provider_client( + self.provider or "auto", model=self.model, raw_codex=True) + if _routed_client is not None: + client_kwargs = { + "api_key": _routed_client.api_key, + "base_url": str(_routed_client.base_url), + } + # Preserve any default_headers the router set + if hasattr(_routed_client, '_default_headers') and _routed_client._default_headers: + client_kwargs["default_headers"] = dict(_routed_client._default_headers) else: - print(f"⚠️ Warning: API key appears invalid or missing (got: '{key_used[:20] if key_used else 'none'}...')") - except Exception as e: - raise RuntimeError(f"Failed to initialize OpenAI client: {e}") + # Final fallback: try raw OpenRouter key + client_kwargs = { + "api_key": os.getenv("OPENROUTER_API_KEY", ""), + "base_url": OPENROUTER_BASE_URL, + "default_headers": { + "HTTP-Referer": "https://github.com/NousResearch/hermes-agent", + "X-OpenRouter-Title": "Hermes Agent", + "X-OpenRouter-Categories": "productivity,cli-agent", + }, + } + + self._client_kwargs = client_kwargs # stored for rebuilding after interrupt + try: + self.client = OpenAI(**client_kwargs) + if not self.quiet_mode: + print(f"🤖 AI Agent initialized with model: {self.model}") + if base_url: + print(f"🔗 Using custom base URL: {base_url}") + # Always show API key info (masked) for debugging auth issues + key_used = client_kwargs.get("api_key", "none") + if key_used and key_used != "dummy-key" and len(key_used) > 12: + print(f"🔑 Using API key: {key_used[:8]}...{key_used[-4:]}") + else: + print(f"⚠️ Warning: API key appears invalid or missing (got: '{key_used[:20] if key_used else 'none'}...')") + except Exception as e: + raise RuntimeError(f"Failed to initialize OpenAI client: {e}") # Provider fallback — a single backup model/provider tried when the # primary is exhausted (rate-limit, overload, connection failure). @@ -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,12 +2270,19 @@ class AIAgent: if self._interrupt_requested: # Force-close the HTTP connection to stop token generation try: - self.client.close() + 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: - self.client = OpenAI(**self._client_kwargs) + 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 raise InterruptedError("Agent interrupted during API call") @@ -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 diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py new file mode 100644 index 000000000..54c722f93 --- /dev/null +++ b/tests/test_anthropic_adapter.py @@ -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 diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index c789d7352..24172a94c 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -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")