mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Merge pull request #1097 from NousResearch/hermes/hermes-c877bdeb
feat: native Anthropic provider with Claude Code credential auto-discovery
This commit is contained in:
commit
0219abfeed
15 changed files with 1578 additions and 75 deletions
466
agent/anthropic_adapter.py
Normal file
466
agent/anthropic_adapter.py
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
"""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.json or ~/.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 for enhanced features (sent with ALL auth types)
|
||||
_COMMON_BETAS = [
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
]
|
||||
|
||||
# Additional beta headers required for OAuth/subscription auth
|
||||
_OAUTH_ONLY_BETAS = [
|
||||
"oauth-2025-04-20",
|
||||
]
|
||||
|
||||
|
||||
def _is_oauth_token(key: str) -> bool:
|
||||
"""Check if the key is an OAuth/setup token (not a regular Console API key).
|
||||
|
||||
Regular API keys start with 'sk-ant-api'. Everything else (setup-tokens
|
||||
starting with 'sk-ant-oat', managed keys, JWTs, etc.) needs Bearer auth.
|
||||
"""
|
||||
if not key:
|
||||
return False
|
||||
# Regular Console API keys use x-api-key header
|
||||
if key.startswith("sk-ant-api"):
|
||||
return False
|
||||
# Everything else (setup-tokens, managed keys, JWTs) uses Bearer auth
|
||||
return True
|
||||
|
||||
|
||||
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 headers
|
||||
all_betas = _COMMON_BETAS + _OAUTH_ONLY_BETAS
|
||||
kwargs["auth_token"] = api_key
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(all_betas)}
|
||||
else:
|
||||
# Regular API key → x-api-key header + common betas
|
||||
kwargs["api_key"] = api_key
|
||||
if _COMMON_BETAS:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
|
||||
|
||||
return _anthropic_sdk.Anthropic(**kwargs)
|
||||
|
||||
|
||||
def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read credentials from Claude Code's config files.
|
||||
|
||||
Checks two locations (in order):
|
||||
1. ~/.claude.json — top-level primaryApiKey (native binary, v2.x)
|
||||
2. ~/.claude/.credentials.json — claudeAiOauth block (npm/legacy installs)
|
||||
|
||||
Returns dict with {accessToken, refreshToken?, expiresAt?} or None.
|
||||
"""
|
||||
# 1. Native binary (v2.x): ~/.claude.json with top-level primaryApiKey
|
||||
claude_json = Path.home() / ".claude.json"
|
||||
if claude_json.exists():
|
||||
try:
|
||||
data = json.loads(claude_json.read_text(encoding="utf-8"))
|
||||
primary_key = data.get("primaryApiKey", "")
|
||||
if primary_key:
|
||||
return {
|
||||
"accessToken": primary_key,
|
||||
"refreshToken": "",
|
||||
"expiresAt": 0, # Managed keys don't have a user-visible expiry
|
||||
}
|
||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||
logger.debug("Failed to read ~/.claude.json: %s", e)
|
||||
|
||||
# 2. Legacy/npm installs: ~/.claude/.credentials.json
|
||||
cred_path = Path.home() / ".claude" / ".credentials.json"
|
||||
if cred_path.exists():
|
||||
try:
|
||||
data = json.loads(cred_path.read_text(encoding="utf-8"))
|
||||
oauth_data = data.get("claudeAiOauth")
|
||||
if oauth_data and isinstance(oauth_data, dict):
|
||||
access_token = oauth_data.get("accessToken", "")
|
||||
if access_token:
|
||||
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/.credentials.json: %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:
|
||||
# No expiry set (managed keys) — valid if token is present
|
||||
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.json or ~/.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 (auto-detected)")
|
||||
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, case-insensitive)
|
||||
"""
|
||||
lower = model.lower()
|
||||
if lower.startswith("anthropic/"):
|
||||
model = model[len("anthropic/"):]
|
||||
return model
|
||||
|
||||
|
||||
def _sanitize_tool_id(tool_id: str) -> str:
|
||||
"""Sanitize a tool call ID for the Anthropic API.
|
||||
|
||||
Anthropic requires IDs matching [a-zA-Z0-9_-]. Replace invalid
|
||||
characters with underscores and ensure non-empty.
|
||||
"""
|
||||
import re
|
||||
if not tool_id:
|
||||
return "tool_0"
|
||||
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id)
|
||||
return sanitized or "tool_0"
|
||||
|
||||
|
||||
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", "{}")
|
||||
try:
|
||||
parsed_args = json.loads(args) if isinstance(args, str) else args
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed_args = {}
|
||||
blocks.append({
|
||||
"type": "tool_use",
|
||||
"id": _sanitize_tool_id(tc.get("id", "")),
|
||||
"name": fn.get("name", ""),
|
||||
"input": parsed_args,
|
||||
})
|
||||
# Anthropic rejects empty assistant content
|
||||
effective = blocks or content
|
||||
if not effective or effective == "":
|
||||
effective = [{"type": "text", "text": "(empty)"}]
|
||||
result.append({"role": "assistant", "content": effective})
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
# Sanitize tool_use_id and ensure non-empty content
|
||||
result_content = content if isinstance(content, str) else json.dumps(content)
|
||||
if not result_content:
|
||||
result_content = "(no output)"
|
||||
tool_result = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")),
|
||||
"content": result_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)"}]
|
||||
|
||||
# Enforce strict role alternation (Anthropic rejects consecutive same-role messages)
|
||||
fixed = []
|
||||
for m in result:
|
||||
if fixed and fixed[-1]["role"] == m["role"]:
|
||||
if m["role"] == "user":
|
||||
# Merge consecutive user messages
|
||||
prev_content = fixed[-1]["content"]
|
||||
curr_content = m["content"]
|
||||
if isinstance(prev_content, str) and isinstance(curr_content, str):
|
||||
fixed[-1]["content"] = prev_content + "\n" + curr_content
|
||||
elif isinstance(prev_content, list) and isinstance(curr_content, list):
|
||||
fixed[-1]["content"] = prev_content + curr_content
|
||||
else:
|
||||
# Mixed types — wrap string in list
|
||||
if isinstance(prev_content, str):
|
||||
prev_content = [{"type": "text", "text": prev_content}]
|
||||
if isinstance(curr_content, str):
|
||||
curr_content = [{"type": "text", "text": curr_content}]
|
||||
fixed[-1]["content"] = prev_content + curr_content
|
||||
else:
|
||||
# Consecutive assistant messages — merge text content
|
||||
prev_blocks = fixed[-1]["content"]
|
||||
curr_blocks = m["content"]
|
||||
if isinstance(prev_blocks, list) and isinstance(curr_blocks, list):
|
||||
fixed[-1]["content"] = prev_blocks + curr_blocks
|
||||
elif isinstance(prev_blocks, str) and isinstance(curr_blocks, str):
|
||||
fixed[-1]["content"] = prev_blocks + "\n" + curr_blocks
|
||||
else:
|
||||
# Keep the later message
|
||||
fixed[-1] = m
|
||||
else:
|
||||
fixed.append(m)
|
||||
result = fixed
|
||||
|
||||
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]],
|
||||
tool_choice: Optional[str] = None,
|
||||
) -> 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 OpenAI tool_choice to Anthropic format
|
||||
if tool_choice == "auto" or tool_choice is None:
|
||||
kwargs["tool_choice"] = {"type": "auto"}
|
||||
elif tool_choice == "required":
|
||||
kwargs["tool_choice"] = {"type": "any"}
|
||||
elif tool_choice == "none":
|
||||
pass # Don't send tool_choice — Anthropic will use tools if needed
|
||||
elif isinstance(tool_choice, str):
|
||||
# Specific tool name
|
||||
kwargs["tool_choice"] = {"type": "tool", "name": tool_choice}
|
||||
|
||||
# Map reasoning_config to Anthropic's thinking parameter
|
||||
# Newer models (4.6+) prefer "adaptive" thinking; older models use "enabled"
|
||||
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)
|
||||
# Use adaptive thinking for 4.5+ models (they deprecate type=enabled)
|
||||
if any(v in model for v in ("4-6", "4-5", "4.6", "4.5")):
|
||||
kwargs["thinking"] = {"type": "adaptive", "budget_tokens": budget}
|
||||
else:
|
||||
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
|
||||
# Anthropic requires temperature=1 when thinking is enabled on older models
|
||||
kwargs["temperature"] = 1
|
||||
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,15 @@ 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-6": 200000,
|
||||
"claude-sonnet-4-6": 200000,
|
||||
"claude-opus-4-5-20251101": 200000,
|
||||
"claude-sonnet-4-5-20250929": 200000,
|
||||
"claude-opus-4-1-20250805": 200000,
|
||||
"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)
|
||||
|
||||
|
|
|
|||
|
|
@ -748,6 +748,7 @@ def cmd_model(args):
|
|||
"openrouter": "OpenRouter",
|
||||
"nous": "Nous Portal",
|
||||
"openai-codex": "OpenAI Codex",
|
||||
"anthropic": "Anthropic",
|
||||
"zai": "Z.AI / GLM",
|
||||
"kimi-coding": "Kimi / Moonshot",
|
||||
"minimax": "MiniMax",
|
||||
|
|
@ -766,6 +767,7 @@ def cmd_model(args):
|
|||
("openrouter", "OpenRouter (100+ models, pay-per-use)"),
|
||||
("nous", "Nous Portal (Nous Research subscription)"),
|
||||
("openai-codex", "OpenAI Codex"),
|
||||
("anthropic", "Anthropic (Claude models — API key or Claude Code)"),
|
||||
("zai", "Z.AI / GLM (Zhipu AI direct API)"),
|
||||
("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"),
|
||||
("minimax", "MiniMax (global direct API)"),
|
||||
|
|
@ -834,6 +836,8 @@ def cmd_model(args):
|
|||
_model_flow_named_custom(config, _custom_provider_map[selected_provider])
|
||||
elif selected_provider == "remove-custom":
|
||||
_remove_custom_provider(config)
|
||||
elif selected_provider == "anthropic":
|
||||
_model_flow_anthropic(config, current_model)
|
||||
elif selected_provider == "kimi-coding":
|
||||
_model_flow_kimi(config, current_model)
|
||||
elif selected_provider in ("zai", "minimax", "minimax-cn"):
|
||||
|
|
@ -1570,6 +1574,140 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
|||
print("No change.")
|
||||
|
||||
|
||||
def _model_flow_anthropic(config, current_model=""):
|
||||
"""Flow for Anthropic provider — setup-token, API key, or Claude Code creds."""
|
||||
import os
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,
|
||||
_update_config_for_provider, deactivate_provider,
|
||||
)
|
||||
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
|
||||
pconfig = PROVIDER_REGISTRY["anthropic"]
|
||||
|
||||
# Check for existing credentials
|
||||
existing_key = (
|
||||
get_env_value("ANTHROPIC_API_KEY")
|
||||
or os.getenv("ANTHROPIC_API_KEY", "")
|
||||
or get_env_value("ANTHROPIC_TOKEN")
|
||||
or os.getenv("ANTHROPIC_TOKEN", "")
|
||||
)
|
||||
cc_available = False
|
||||
try:
|
||||
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):
|
||||
cc_available = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if existing_key:
|
||||
print(f" Anthropic credentials: {existing_key[:12]}... ✓")
|
||||
print()
|
||||
try:
|
||||
update = input("Update credentials? [y/N]: ").strip().lower()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
update = ""
|
||||
if update != "y":
|
||||
pass # skip to model selection
|
||||
else:
|
||||
existing_key = "" # fall through to auth choice below
|
||||
elif cc_available:
|
||||
print(" Claude Code credentials: ✓ (auto-detected)")
|
||||
print()
|
||||
|
||||
if not existing_key and not cc_available:
|
||||
# No credentials — show auth method choice
|
||||
print()
|
||||
print(" Choose authentication method:")
|
||||
print()
|
||||
print(" 1. Claude Pro/Max subscription (setup-token)")
|
||||
print(" 2. Anthropic API key (pay-per-token)")
|
||||
print(" 3. Cancel")
|
||||
print()
|
||||
try:
|
||||
choice = input(" Choice [1/2/3]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
|
||||
if choice == "1":
|
||||
print()
|
||||
print(" To get a setup-token from your Claude subscription:")
|
||||
print()
|
||||
print(" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code")
|
||||
print(" 2. Run: claude setup-token")
|
||||
print(" 3. Open the URL it prints in your browser")
|
||||
print(" 4. Log in and click \"Authorize\"")
|
||||
print(" 5. Paste the auth code back into Claude Code")
|
||||
print(" 6. Copy the resulting sk-ant-oat01-... token")
|
||||
print()
|
||||
try:
|
||||
token = input(" Paste setup-token here: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
if not token:
|
||||
print(" Cancelled.")
|
||||
return
|
||||
save_env_value("ANTHROPIC_API_KEY", token)
|
||||
print(" ✓ Setup-token saved.")
|
||||
|
||||
elif choice == "2":
|
||||
print()
|
||||
print(" Get an API key at: https://console.anthropic.com/settings/keys")
|
||||
print()
|
||||
try:
|
||||
api_key = input(" API key (sk-ant-api03-...): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return
|
||||
if not api_key:
|
||||
print(" Cancelled.")
|
||||
return
|
||||
save_env_value("ANTHROPIC_API_KEY", api_key)
|
||||
print(" ✓ API key saved.")
|
||||
|
||||
else:
|
||||
print(" No change.")
|
||||
return
|
||||
print()
|
||||
|
||||
# Model selection
|
||||
model_list = _PROVIDER_MODELS.get("anthropic", [])
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
else:
|
||||
try:
|
||||
selected = input("Model name (e.g., claude-sonnet-4-20250514): ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
selected = None
|
||||
|
||||
if selected:
|
||||
# Clear custom endpoint if set
|
||||
if get_env_value("OPENAI_BASE_URL"):
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
|
||||
_save_model_choice(selected)
|
||||
|
||||
# Update config with provider
|
||||
cfg = load_config()
|
||||
model = cfg.get("model")
|
||||
if not isinstance(model, dict):
|
||||
model = {"default": model} if model else {}
|
||||
cfg["model"] = model
|
||||
model["provider"] = "anthropic"
|
||||
model["base_url"] = pconfig.inference_base_url
|
||||
save_config(cfg)
|
||||
deactivate_provider()
|
||||
|
||||
print(f"Default model set to: {selected} (via Anthropic)")
|
||||
else:
|
||||
print("No change.")
|
||||
|
||||
|
||||
def cmd_login(args):
|
||||
"""Authenticate Hermes CLI with a provider."""
|
||||
from hermes_cli.auth import login_command
|
||||
|
|
@ -2050,7 +2188,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,15 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
|||
"MiniMax-M2.5-highspeed",
|
||||
"MiniMax-M2.1",
|
||||
],
|
||||
"anthropic": [
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-haiku-4-5-20251001",
|
||||
],
|
||||
}
|
||||
|
||||
_PROVIDER_LABELS = {
|
||||
|
|
@ -78,6 +87,7 @@ _PROVIDER_LABELS = {
|
|||
"kimi-coding": "Kimi / Moonshot",
|
||||
"minimax": "MiniMax",
|
||||
"minimax-cn": "MiniMax (China)",
|
||||
"anthropic": "Anthropic",
|
||||
"custom": "Custom endpoint",
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +100,8 @@ _PROVIDER_ALIASES = {
|
|||
"moonshot": "kimi-coding",
|
||||
"minimax-china": "minimax-cn",
|
||||
"minimax_cn": "minimax-cn",
|
||||
"claude": "anthropic",
|
||||
"claude-code": "anthropic",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -123,7 +135,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]] = {}
|
||||
|
|
@ -234,9 +246,56 @@ def provider_model_ids(provider: Optional[str]) -> list[str]:
|
|||
return live
|
||||
except Exception:
|
||||
pass
|
||||
if normalized == "anthropic":
|
||||
live = _fetch_anthropic_models()
|
||||
if live:
|
||||
return live
|
||||
return list(_PROVIDER_MODELS.get(normalized, []))
|
||||
|
||||
|
||||
def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
"""Fetch available models from the Anthropic /v1/models endpoint.
|
||||
|
||||
Uses resolve_anthropic_token() to find credentials (env vars or
|
||||
Claude Code auto-discovery). Returns sorted model IDs or None.
|
||||
"""
|
||||
try:
|
||||
from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
token = resolve_anthropic_token()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
headers: dict[str, str] = {"anthropic-version": "2023-06-01"}
|
||||
if _is_oauth_token(token):
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
headers["anthropic-beta"] = "oauth-2025-04-20"
|
||||
else:
|
||||
headers["x-api-key"] = token
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
models = [m["id"] for m in data.get("data", []) if m.get("id")]
|
||||
# Sort: latest/largest first (opus > sonnet > haiku, higher version first)
|
||||
return sorted(models, key=lambda m: (
|
||||
"opus" not in m, # opus first
|
||||
"sonnet" not in m, # then sonnet
|
||||
"haiku" not in m, # then haiku
|
||||
m, # alphabetical within tier
|
||||
))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).debug("Failed to fetch Anthropic models: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_api_models(
|
||||
api_key: Optional[str],
|
||||
base_url: Optional[str],
|
||||
|
|
|
|||
|
|
@ -153,6 +153,24 @@ 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()
|
||||
if not token:
|
||||
raise AuthError(
|
||||
"No Anthropic credentials found. Set ANTHROPIC_API_KEY, "
|
||||
"run 'claude setup-token', or authenticate with 'claude /login'."
|
||||
)
|
||||
return {
|
||||
"provider": "anthropic",
|
||||
"api_mode": "anthropic_messages",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
"api_key": token,
|
||||
"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":
|
||||
|
|
|
|||
|
|
@ -689,6 +689,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)
|
||||
|
|
@ -1068,7 +1069,74 @@ 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 Authentication")
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY["anthropic"]
|
||||
|
||||
# Check for Claude Code credential auto-discovery
|
||||
from agent.anthropic_adapter import read_claude_code_credentials, is_claude_code_token_valid
|
||||
cc_creds = read_claude_code_credentials()
|
||||
if cc_creds and is_claude_code_token_valid(cc_creds):
|
||||
print_success("Found valid Claude Code credentials (~/.claude/.credentials.json)")
|
||||
if prompt_yes_no("Use these credentials?", True):
|
||||
print_success("Using Claude Code subscription credentials")
|
||||
else:
|
||||
cc_creds = None
|
||||
|
||||
existing_key = get_env_value("ANTHROPIC_API_KEY") or get_env_value("ANTHROPIC_TOKEN")
|
||||
|
||||
if not (cc_creds and is_claude_code_token_valid(cc_creds)):
|
||||
if existing_key:
|
||||
print_info(f"Current credentials: {existing_key[:12]}...")
|
||||
if not prompt_yes_no("Update credentials?", False):
|
||||
# User wants to keep existing — skip auth prompt entirely
|
||||
existing_key = "KEEP" # truthy sentinel to skip auth choice
|
||||
|
||||
if not existing_key and not (cc_creds and is_claude_code_token_valid(cc_creds)):
|
||||
auth_choices = [
|
||||
"Claude Pro/Max subscription (setup-token)",
|
||||
"Anthropic API key (pay-per-token)",
|
||||
]
|
||||
auth_idx = prompt_choice("Choose authentication method:", auth_choices, 0)
|
||||
|
||||
if auth_idx == 0:
|
||||
print()
|
||||
print_info("To get a setup-token from your Claude subscription:")
|
||||
print_info(" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code")
|
||||
print_info(" 2. Run: claude setup-token")
|
||||
print_info(" 3. Open the URL it prints in your browser")
|
||||
print_info(" 4. Log in and click \"Authorize\"")
|
||||
print_info(" 5. Paste the auth code back into Claude Code")
|
||||
print_info(" 6. Copy the resulting sk-ant-oat01-... token")
|
||||
print()
|
||||
token = prompt("Paste setup-token here", password=True)
|
||||
if token:
|
||||
save_env_value("ANTHROPIC_API_KEY", token)
|
||||
print_success("Setup-token saved")
|
||||
else:
|
||||
print_warning("Skipped — agent won't work without credentials")
|
||||
else:
|
||||
print()
|
||||
print_info("Get an API key at: https://console.anthropic.com/settings/keys")
|
||||
print()
|
||||
api_key = prompt("API key (sk-ant-api03-...)", password=True)
|
||||
if api_key:
|
||||
save_env_value("ANTHROPIC_API_KEY", api_key)
|
||||
print_success("API key saved")
|
||||
else:
|
||||
print_warning("Skipped — agent won't work without credentials")
|
||||
|
||||
# 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.
|
||||
|
|
@ -1081,6 +1149,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)")
|
||||
|
|
@ -1174,6 +1243,79 @@ def setup_model_provider(config: dict):
|
|||
config, selected_provider, current_model,
|
||||
prompt_choice, prompt,
|
||||
)
|
||||
if is_coding_plan:
|
||||
zai_models = ["glm-4.7", "glm-4.5", "glm-4.5-flash"]
|
||||
else:
|
||||
zai_models = ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"]
|
||||
model_choices = list(zai_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(zai_models):
|
||||
_set_default_model(config, zai_models[model_idx])
|
||||
elif model_idx == len(zai_models):
|
||||
custom = prompt("Enter model name")
|
||||
if custom:
|
||||
_set_default_model(config, custom)
|
||||
# else: keep current
|
||||
elif selected_provider == "kimi-coding":
|
||||
kimi_models = ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"]
|
||||
model_choices = list(kimi_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(kimi_models):
|
||||
_set_default_model(config, kimi_models[model_idx])
|
||||
elif model_idx == len(kimi_models):
|
||||
custom = prompt("Enter model name")
|
||||
if custom:
|
||||
_set_default_model(config, custom)
|
||||
# else: keep current
|
||||
elif selected_provider in ("minimax", "minimax-cn"):
|
||||
minimax_models = ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"]
|
||||
model_choices = list(minimax_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(minimax_models):
|
||||
_set_default_model(config, minimax_models[model_idx])
|
||||
elif model_idx == len(minimax_models):
|
||||
custom = prompt("Enter model name")
|
||||
if custom:
|
||||
_set_default_model(config, custom)
|
||||
# else: keep current
|
||||
elif selected_provider == "anthropic":
|
||||
# Try live model list first, fall back to static
|
||||
from hermes_cli.models import provider_model_ids
|
||||
live_models = provider_model_ids("anthropic")
|
||||
anthropic_models = live_models if live_models else [
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
"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",
|
||||
|
|
|
|||
244
run_agent.py
244
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://hermes-agent.nousresearch.com",
|
||||
"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://hermes-agent.nousresearch.com",
|
||||
"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://hermes-agent.nousresearch.com",
|
||||
"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
|
||||
|
|
@ -2659,6 +2701,15 @@ class AIAgent:
|
|||
if "max_output_tokens" in codex_kwargs:
|
||||
codex_kwargs["max_output_tokens"] = 5120
|
||||
response = self._run_codex_stream(codex_kwargs)
|
||||
elif not _aux_available and self.api_mode == "anthropic_messages":
|
||||
# Native Anthropic — use the Anthropic client directly
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs as _build_ant_kwargs
|
||||
ant_kwargs = _build_ant_kwargs(
|
||||
model=self.model, messages=api_messages,
|
||||
tools=[memory_tool_def], max_tokens=5120,
|
||||
reasoning_config=None,
|
||||
)
|
||||
response = self._anthropic_client.messages.create(**ant_kwargs)
|
||||
elif not _aux_available:
|
||||
api_kwargs = {
|
||||
"model": self.model,
|
||||
|
|
@ -2669,12 +2720,17 @@ class AIAgent:
|
|||
}
|
||||
response = self.client.chat.completions.create(**api_kwargs, timeout=30.0)
|
||||
|
||||
# Extract tool calls from the response, handling both API formats
|
||||
# Extract tool calls from the response, handling all API formats
|
||||
tool_calls = []
|
||||
if self.api_mode == "codex_responses" and not _aux_available:
|
||||
assistant_msg, _ = self._normalize_codex_response(response)
|
||||
if assistant_msg and assistant_msg.tool_calls:
|
||||
tool_calls = assistant_msg.tool_calls
|
||||
elif self.api_mode == "anthropic_messages" and not _aux_available:
|
||||
from agent.anthropic_adapter import normalize_anthropic_response as _nar_flush
|
||||
_flush_msg, _ = _nar_flush(response)
|
||||
if _flush_msg and _flush_msg.tool_calls:
|
||||
tool_calls = _flush_msg.tool_calls
|
||||
elif hasattr(response, "choices") and response.choices:
|
||||
assistant_message = response.choices[0].message
|
||||
if assistant_message.tool_calls:
|
||||
|
|
@ -3147,12 +3203,20 @@ class AIAgent:
|
|||
if summary_extra_body:
|
||||
summary_kwargs["extra_body"] = summary_extra_body
|
||||
|
||||
summary_response = self.client.chat.completions.create(**summary_kwargs)
|
||||
|
||||
if summary_response.choices and summary_response.choices[0].message.content:
|
||||
final_response = summary_response.choices[0].message.content
|
||||
if self.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs as _bak, normalize_anthropic_response as _nar
|
||||
_ant_kw = _bak(model=self.model, messages=api_messages, tools=None,
|
||||
max_tokens=self.max_tokens, reasoning_config=self.reasoning_config)
|
||||
summary_response = self._anthropic_client.messages.create(**_ant_kw)
|
||||
_msg, _ = _nar(summary_response)
|
||||
final_response = (_msg.content or "").strip()
|
||||
else:
|
||||
final_response = ""
|
||||
summary_response = self.client.chat.completions.create(**summary_kwargs)
|
||||
|
||||
if summary_response.choices and summary_response.choices[0].message.content:
|
||||
final_response = summary_response.choices[0].message.content
|
||||
else:
|
||||
final_response = ""
|
||||
|
||||
if final_response:
|
||||
if "<think>" in final_response:
|
||||
|
|
@ -3169,6 +3233,13 @@ class AIAgent:
|
|||
retry_response = self._run_codex_stream(codex_kwargs)
|
||||
retry_msg, _ = self._normalize_codex_response(retry_response)
|
||||
final_response = (retry_msg.content or "").strip() if retry_msg else ""
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs as _bak2, normalize_anthropic_response as _nar2
|
||||
_ant_kw2 = _bak2(model=self.model, messages=api_messages, tools=None,
|
||||
max_tokens=self.max_tokens, reasoning_config=self.reasoning_config)
|
||||
retry_response = self._anthropic_client.messages.create(**_ant_kw2)
|
||||
_retry_msg, _ = _nar2(retry_response)
|
||||
final_response = (_retry_msg.content or "").strip()
|
||||
else:
|
||||
summary_kwargs = {
|
||||
"model": self.model,
|
||||
|
|
@ -3548,6 +3619,7 @@ class AIAgent:
|
|||
compression_attempts = 0
|
||||
max_compression_attempts = 3
|
||||
codex_auth_retry_attempted = False
|
||||
anthropic_auth_retry_attempted = False
|
||||
nous_auth_retry_attempted = False
|
||||
restart_with_compressed_messages = False
|
||||
restart_with_length_continuation = False
|
||||
|
|
@ -3598,6 +3670,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
|
||||
|
|
@ -3699,6 +3782,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
|
||||
|
||||
|
|
@ -3776,7 +3862,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 = (
|
||||
|
|
@ -3811,9 +3897,15 @@ class AIAgent:
|
|||
|
||||
# Log cache hit stats when prompt caching is active
|
||||
if self._use_prompt_caching:
|
||||
details = getattr(response.usage, 'prompt_tokens_details', None)
|
||||
cached = getattr(details, 'cached_tokens', 0) or 0 if details else 0
|
||||
written = getattr(details, 'cache_write_tokens', 0) or 0 if details else 0
|
||||
if self.api_mode == "anthropic_messages":
|
||||
# Anthropic uses cache_read_input_tokens / cache_creation_input_tokens
|
||||
cached = getattr(response.usage, 'cache_read_input_tokens', 0) or 0
|
||||
written = getattr(response.usage, 'cache_creation_input_tokens', 0) or 0
|
||||
else:
|
||||
# OpenRouter uses prompt_tokens_details.cached_tokens
|
||||
details = getattr(response.usage, 'prompt_tokens_details', None)
|
||||
cached = getattr(details, 'cached_tokens', 0) or 0 if details else 0
|
||||
written = getattr(details, 'cache_write_tokens', 0) or 0 if details else 0
|
||||
prompt = usage_dict["prompt_tokens"]
|
||||
hit_pct = (cached / prompt * 100) if prompt > 0 else 0
|
||||
if not self.quiet_mode:
|
||||
|
|
@ -3863,6 +3955,21 @@ class AIAgent:
|
|||
if self._try_refresh_nous_client_credentials(force=True):
|
||||
print(f"{self.log_prefix}🔐 Nous agent key refreshed after 401. Retrying request...")
|
||||
continue
|
||||
if (
|
||||
self.api_mode == "anthropic_messages"
|
||||
and status_code == 401
|
||||
and hasattr(self, '_anthropic_api_key')
|
||||
and not anthropic_auth_retry_attempted
|
||||
):
|
||||
anthropic_auth_retry_attempted = True
|
||||
# Try re-reading Claude Code credentials (they may have been refreshed)
|
||||
from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client
|
||||
new_token = resolve_anthropic_token()
|
||||
if new_token and new_token != self._anthropic_api_key:
|
||||
self._anthropic_api_key = new_token
|
||||
self._anthropic_client = build_anthropic_client(new_token)
|
||||
print(f"{self.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...")
|
||||
continue
|
||||
|
||||
retry_count += 1
|
||||
elapsed_time = time.time() - api_start_time
|
||||
|
|
@ -4105,6 +4212,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
|
||||
|
||||
|
|
|
|||
500
tests/test_anthropic_adapter.py
Normal file
500
tests/test_anthropic_adapter.py
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
"""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_managed_key(self):
|
||||
# Managed keys from ~/.claude.json are NOT regular API keys
|
||||
assert _is_oauth_token("ou1R1z-ft0A-bDeZ9wAA") is True
|
||||
|
||||
def test_jwt_token(self):
|
||||
# JWTs from OAuth flow
|
||||
assert _is_oauth_token("eyJhbGciOiJSUzI1NiJ9.test") is True
|
||||
|
||||
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
|
||||
betas = kwargs["default_headers"]["anthropic-beta"]
|
||||
assert "oauth-2025-04-20" in betas
|
||||
assert "interleaved-thinking-2025-05-14" in betas
|
||||
assert "fine-grained-tool-streaming-2025-05-14" in betas
|
||||
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
|
||||
# API key auth should still get common betas
|
||||
betas = kwargs["default_headers"]["anthropic-beta"]
|
||||
assert "interleaved-thinking-2025-05-14" in betas
|
||||
assert "oauth-2025-04-20" not in betas # OAuth-only beta NOT present
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role alternation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRoleAlternation:
|
||||
def test_merges_consecutive_user_messages(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "user", "content": "World"},
|
||||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["role"] == "user"
|
||||
assert "Hello" in result[0]["content"]
|
||||
assert "World" in result[0]["content"]
|
||||
|
||||
def test_preserves_proper_alternation(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "Hi"},
|
||||
{"role": "assistant", "content": "Hello!"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
]
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
assert len(result) == 3
|
||||
assert [m["role"] for m in result] == ["user", "assistant", "user"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool choice
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolChoice:
|
||||
_DUMMY_TOOL = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "test",
|
||||
"description": "x",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def test_auto_tool_choice(self):
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-sonnet-4-20250514",
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
tools=self._DUMMY_TOOL,
|
||||
max_tokens=4096,
|
||||
reasoning_config=None,
|
||||
tool_choice="auto",
|
||||
)
|
||||
assert kwargs["tool_choice"] == {"type": "auto"}
|
||||
|
||||
def test_required_tool_choice(self):
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-sonnet-4-20250514",
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
tools=self._DUMMY_TOOL,
|
||||
max_tokens=4096,
|
||||
reasoning_config=None,
|
||||
tool_choice="required",
|
||||
)
|
||||
assert kwargs["tool_choice"] == {"type": "any"}
|
||||
|
||||
def test_specific_tool_choice(self):
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-sonnet-4-20250514",
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
tools=self._DUMMY_TOOL,
|
||||
max_tokens=4096,
|
||||
reasoning_config=None,
|
||||
tool_choice="search",
|
||||
)
|
||||
assert kwargs["tool_choice"] == {"type": "tool", "name": "search"}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ hermes setup # Or configure everything at once
|
|||
|----------|-----------|---------------|
|
||||
| **Nous Portal** | Subscription-based, zero-config | OAuth login via `hermes model` |
|
||||
| **OpenAI Codex** | ChatGPT OAuth, uses Codex models | Device code auth via `hermes model` |
|
||||
| **Anthropic** | Claude models directly (Pro/Max or API key) | API key or Claude Code setup-token |
|
||||
| **OpenRouter** | 200+ models, pay-per-use | Enter your API key |
|
||||
| **Custom Endpoint** | VLLM, SGLang, any OpenAI-compatible API | Set base URL + API key |
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
|
|||
| `MINIMAX_BASE_URL` | Override MiniMax base URL (default: `https://api.minimax.io/v1`) |
|
||||
| `MINIMAX_CN_API_KEY` | MiniMax API key — China endpoint ([minimaxi.com](https://www.minimaxi.com)) |
|
||||
| `MINIMAX_CN_BASE_URL` | Override MiniMax China base URL (default: `https://api.minimaxi.com/v1`) |
|
||||
| `ANTHROPIC_API_KEY` | Anthropic API key or setup-token ([console.anthropic.com](https://console.anthropic.com/)) |
|
||||
| `ANTHROPIC_TOKEN` | Anthropic OAuth/setup token (alternative to `ANTHROPIC_API_KEY`) |
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code setup-token (same as `ANTHROPIC_TOKEN`) |
|
||||
| `HERMES_MODEL` | Preferred model name (checked before `LLM_MODEL`, used by gateway) |
|
||||
| `LLM_MODEL` | Default model name (fallback when not set in config.yaml) |
|
||||
| `VOICE_TOOLS_OPENAI_KEY` | OpenAI key for TTS and voice transcription (separate from custom endpoint) |
|
||||
|
|
@ -32,7 +35,7 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
|
|||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `zai`, `kimi-coding`, `minimax`, `minimax-cn` (default: `auto`) |
|
||||
| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn` (default: `auto`) |
|
||||
| `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) |
|
||||
| `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL |
|
||||
| `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) |
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
|
|||
|----------|-------|
|
||||
| **Nous Portal** | `hermes model` (OAuth, subscription-based) |
|
||||
| **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) |
|
||||
| **Anthropic** | `hermes model` (API key, setup-token, or Claude Code auto-detect) |
|
||||
| **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` |
|
||||
| **z.ai / GLM** | `GLM_API_KEY` in `~/.hermes/.env` (provider: `zai`) |
|
||||
| **Kimi / Moonshot** | `KIMI_API_KEY` in `~/.hermes/.env` (provider: `kimi-coding`) |
|
||||
|
|
@ -78,6 +79,34 @@ The OpenAI Codex provider authenticates via device code (open a URL, enter a cod
|
|||
Even when using Nous Portal, Codex, or a custom endpoint, some tools (vision, web summarization, MoA) use a separate "auxiliary" model — by default Gemini Flash via OpenRouter. An `OPENROUTER_API_KEY` enables these tools automatically. You can also configure which model and provider these tools use — see [Auxiliary Models](#auxiliary-models) below.
|
||||
:::
|
||||
|
||||
### Anthropic (Native)
|
||||
|
||||
Use Claude models directly through the Anthropic API — no OpenRouter proxy needed. Supports three auth methods:
|
||||
|
||||
```bash
|
||||
# With an API key (pay-per-token)
|
||||
export ANTHROPIC_API_KEY=sk-ant-api03-...
|
||||
hermes chat --provider anthropic --model claude-sonnet-4-6
|
||||
|
||||
# With a Claude Code setup-token (Pro/Max subscription)
|
||||
export ANTHROPIC_API_KEY=sk-ant-oat01-... # from 'claude setup-token'
|
||||
hermes chat --provider anthropic
|
||||
|
||||
# Auto-detect Claude Code credentials (if you have Claude Code installed)
|
||||
hermes chat --provider anthropic # reads ~/.claude.json automatically
|
||||
```
|
||||
|
||||
Or set it permanently:
|
||||
```yaml
|
||||
model:
|
||||
provider: "anthropic"
|
||||
default: "claude-sonnet-4-6"
|
||||
```
|
||||
|
||||
:::tip Aliases
|
||||
`--provider claude` and `--provider claude-code` also work as shorthand for `--provider anthropic`.
|
||||
:::
|
||||
|
||||
### First-Class Chinese AI Providers
|
||||
|
||||
These providers have built-in support with dedicated provider IDs. Set the API key and use `--provider` to select:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue