Feedback fixes:
1. Revert _convert_vision_content — vision is handled by the vision_analyze
tool, not by converting image blocks inline in conversation messages.
Removed the function and its tests.
2. Add Anthropic to 'hermes model' (cmd_model in main.py):
- Added to provider_labels dict
- Added to providers selection list
- Added _model_flow_anthropic() with Claude Code credential auto-detection,
API key prompting, and model selection from catalog.
3. Wire up Anthropic as a vision-capable auxiliary provider:
- Added _try_anthropic() to auxiliary_client.py using claude-sonnet-4
as the vision model (Claude natively supports multimodal)
- Added to the get_vision_auxiliary_client() auto-detection chain
(after OpenRouter/Nous, before Codex/custom)
Cache tracking note: the Anthropic cache metrics branch in run_agent.py
(cache_read_input_tokens / cache_creation_input_tokens) is in the correct
place — it's response-level parsing, same location as the existing
OpenRouter cache tracking. auxiliary_client.py has no cache tracking.
493 lines
18 KiB
Python
493 lines
18 KiB
Python
"""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
|
|
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"}
|