hermes-agent/tests/agent/test_anthropic_adapter.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

1672 lines
67 KiB
Python

"""Tests for agent/anthropic_adapter.py — Anthropic Messages API adapter."""
import json
import sys
import time
from types import SimpleNamespace
from unittest.mock import patch, MagicMock
import pytest
from agent.prompt_caching import apply_anthropic_cache_control
from agent.anthropic_adapter import (
_is_azure_anthropic_endpoint,
_is_oauth_token,
_refresh_oauth_token,
_to_plain_data,
_write_claude_code_credentials,
build_anthropic_client,
build_anthropic_bedrock_client,
build_anthropic_kwargs,
convert_messages_to_anthropic,
convert_tools_to_anthropic,
is_claude_code_token_valid,
normalize_model_name,
read_claude_code_credentials,
resolve_anthropic_token,
run_oauth_setup_token,
)
from agent.transports import get_transport
# ---------------------------------------------------------------------------
# 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
class TestBuildAnthropicClient:
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 "context-1m-2025-08-07" not in betas
assert "oauth-2025-04-20" not in betas # OAuth-only beta NOT present
assert "claude-code-20250219" not in betas # OAuth-only beta NOT present
def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"minimax-secret-123",
base_url="https://api.minimax.io/anthropic",
)
kwargs = mock_sdk.Anthropic.call_args[1]
assert kwargs["auth_token"] == "minimax-secret-123"
assert "api_key" not in kwargs
assert kwargs["default_headers"] == {
"anthropic-beta": "interleaved-thinking-2025-05-14"
}
def test_azure_foundry_anthropic_endpoint_uses_bearer_auth(self):
"""Azure AI Foundry's /anthropic endpoint requires Authorization: Bearer.
Regression test for #26970: without this, builds set api_key (x-api-key)
and the endpoint returns HTTP 401. Also verifies that Azure retains the
1M-context beta even though it now matches `_requires_bearer_auth`.
"""
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"azure-foundry-secret-123",
base_url="https://my-resource.openai.azure.com/anthropic",
)
kwargs = mock_sdk.Anthropic.call_args[1]
assert kwargs["auth_token"] == "azure-foundry-secret-123"
assert "api_key" not in kwargs
# Azure endpoints still get the api-version query param plumbing.
assert kwargs.get("default_query") == {"api-version": "2025-04-15"}
# Azure keeps the 1M-context beta (it's not MiniMax).
betas = kwargs["default_headers"]["anthropic-beta"]
assert "context-1m-2025-08-07" in betas
def test_palantir_foundry_anthropic_endpoint_uses_bearer_auth(self):
"""Palantir Foundry's LLM proxy requires Authorization: Bearer.
Regression test for PR #36043: Palantir's
``<org>.palantirfoundry.com/api/v2/llm/proxy/anthropic`` endpoint
rejects x-api-key with 401 — the SDK must be built with auth_token.
"""
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client(
"foundry-secret-123",
base_url="https://acme.palantirfoundry.com/api/v2/llm/proxy/anthropic",
)
kwargs = mock_sdk.Anthropic.call_args[1]
assert kwargs["auth_token"] == "foundry-secret-123"
assert "api_key" not in kwargs
def test_disables_sdk_retries_for_api_key(self):
"""#26293: the SDK's default max_retries=2 ignores Retry-After and
double-retries inside hermes's outer loop. We delegate retry entirely
to the outer loop, so the client must be built with max_retries=0."""
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["max_retries"] == 0
class TestReadClaudeCodeCredentials:
@pytest.fixture(autouse=True)
def no_keychain(self, monkeypatch):
monkeypatch.setattr(
"agent.anthropic_adapter._read_claude_code_credentials_from_keychain",
lambda: None,
)
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-token",
"refreshToken": "sk-ant-oat01-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-token"
assert creds["refreshToken"] == "sk-ant-oat01-refresh"
assert creds["source"] == "claude_code_credentials_file"
def test_ignores_primary_api_key_for_native_anthropic_resolution(self, tmp_path, monkeypatch):
claude_json = tmp_path / ".claude.json"
claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
creds = read_claude_code_credentials()
assert creds 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_oauth_token_over_api_key(self, monkeypatch, tmp_path):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey")
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
def test_does_not_resolve_primary_api_key_as_native_anthropic_token(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)
(tmp_path / ".claude.json").write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() is None
def test_falls_back_to_api_key_when_no_oauth_sources_exist(self, monkeypatch, tmp_path):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey")
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() == "sk-ant-api03-mykey"
def test_falls_back_to_claude_code_credentials(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)
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": "cc-auto-token",
"refreshToken": "refresh",
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "cc-auto-token"
def test_falls_back_to_anthropic_credential_pool_oauth(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)
# Isolate source #4 (credential_pool): ensure source #3 (Claude Code
# creds, incl. the macOS keychain read which Path.home does not cover)
# returns nothing, mirroring a Hermes-PKCE-only setup.
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
pool_entry = SimpleNamespace(
auth_type="oauth",
access_token="pool-oauth-token",
)
pool = SimpleNamespace(
_available_entries=lambda **_kwargs: [pool_entry],
)
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: pool)
assert resolve_anthropic_token() == "pool-oauth-token"
def test_prefers_anthropic_credential_pool_oauth_over_api_key(self, monkeypatch, tmp_path):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant...ykey")
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
# Pool (source #4) must win over ANTHROPIC_API_KEY (source #5); also
# isolate source #3 so a machine-local Claude Code creds / keychain
# entry can't short-circuit before the pool.
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
pool_entry = SimpleNamespace(
auth_type="oauth",
access_token="pool-oauth-token",
)
pool = SimpleNamespace(
_available_entries=lambda **_kwargs: [pool_entry],
)
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: pool)
assert resolve_anthropic_token() == "pool-oauth-token"
def test_pool_entry_with_null_access_token_does_not_crash(self, monkeypatch, tmp_path):
"""A persisted OAuth entry with access_token=None must not crash the
resolver (None.strip() would escape the helper's try/excepts and take
down the whole resolver incl. the ANTHROPIC_API_KEY fallback). It should
be skipped and the api-key fallback (source #5) should win."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant...ykey")
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
broken_entry = SimpleNamespace(auth_type="oauth", access_token=None)
pool = SimpleNamespace(
_available_entries=lambda **_kwargs: [broken_entry],
)
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: pool)
# Must fall through to source #5 (ANTHROPIC_API_KEY), not raise.
assert resolve_anthropic_token() == "sk-ant...ykey"
def test_pool_api_key_only_entry_is_not_returned_as_token(self, monkeypatch, tmp_path):
"""resolve_anthropic_token() returns an OAuth bearer token; a pool entry
whose auth_type is api_key (not oauth) must NOT be returned from the pool
path — those are consumed via the aux client's _pool_runtime_api_key
lane, a different resolution concern."""
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)
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
api_key_entry = SimpleNamespace(auth_type="api_key", access_token="sk-pool-apikey")
pool = SimpleNamespace(
_available_entries=lambda **_kwargs: [api_key_entry],
)
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: pool)
# No OAuth entry and no other source → None (the api_key entry is ignored here).
assert resolve_anthropic_token() is None
def test_pool_resolution_is_read_only(self, monkeypatch, tmp_path):
"""The resolver must enumerate the pool read-only — clear_expired and
refresh must both be False so a bare resolve never writes auth.json or
triggers a network refresh from diagnostic call sites (#50108 MED)."""
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)
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
captured = {}
pool_entry = SimpleNamespace(auth_type="oauth", access_token="pool-oauth-token")
def _available_entries(**kwargs):
captured.update(kwargs)
return [pool_entry]
pool = SimpleNamespace(_available_entries=_available_entries)
monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: pool)
assert resolve_anthropic_token() == "pool-oauth-token"
assert captured == {"clear_expired": False, "refresh": False}
def test_prefers_refreshable_claude_code_credentials_over_static_anthropic_token(self, monkeypatch, tmp_path):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-static-token")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": "cc-auto-token",
"refreshToken": "refresh-token",
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "cc-auto-token"
class TestRefreshOauthToken:
def test_returns_none_without_refresh_token(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
# Neutralize live Claude Code sources (macOS Keychain + ~/.claude file)
# so the adopt-already-refreshed branch can't short-circuit with a real
# credential on a dev/CI machine that happens to have Claude Code creds.
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials", lambda: None
)
creds = {"accessToken": "expired", "refreshToken": "", "expiresAt": 0}
assert _refresh_oauth_token(creds) is None
def test_successful_refresh(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials", lambda: None
)
creds = {
"accessToken": "old-token",
"refreshToken": "refresh-123",
"expiresAt": int(time.time() * 1000) - 3600_000,
}
mock_response = json.dumps({
"access_token": "new-token-abc",
"refresh_token": "new-refresh-456",
"expires_in": 7200,
}).encode()
with patch("urllib.request.urlopen") as mock_urlopen:
mock_ctx = MagicMock()
mock_ctx.__enter__ = MagicMock(return_value=MagicMock(
read=MagicMock(return_value=mock_response)
))
mock_ctx.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_ctx
result = _refresh_oauth_token(creds)
assert result == "new-token-abc"
# Verify credentials were written back
cred_file = tmp_path / ".claude" / ".credentials.json"
assert cred_file.exists()
written = json.loads(cred_file.read_text())
assert written["claudeAiOauth"]["accessToken"] == "new-token-abc"
assert written["claudeAiOauth"]["refreshToken"] == "new-refresh-456"
def test_failed_refresh_returns_none(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials", lambda: None
)
creds = {
"accessToken": "old",
"refreshToken": "refresh-123",
"expiresAt": 0,
}
with patch("urllib.request.urlopen", side_effect=Exception("network error")):
assert _refresh_oauth_token(creds) is None
class TestWriteClaudeCodeCredentials:
def test_writes_new_file(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
_write_claude_code_credentials("tok", "ref", 12345)
cred_file = tmp_path / ".claude" / ".credentials.json"
assert cred_file.exists()
data = json.loads(cred_file.read_text())
assert data["claudeAiOauth"]["accessToken"] == "tok"
assert data["claudeAiOauth"]["refreshToken"] == "ref"
assert data["claudeAiOauth"]["expiresAt"] == 12345
def test_preserves_existing_fields(self, tmp_path, monkeypatch):
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
cred_dir = tmp_path / ".claude"
cred_dir.mkdir()
cred_file = cred_dir / ".credentials.json"
cred_file.write_text(json.dumps({"otherField": "keep-me"}))
_write_claude_code_credentials("new-tok", "new-ref", 99999)
data = json.loads(cred_file.read_text())
assert data["otherField"] == "keep-me"
assert data["claudeAiOauth"]["accessToken"] == "new-tok"
@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX mode bits not enforced on Windows")
def test_credentials_file_created_with_0o600(self, tmp_path, monkeypatch):
"""Refreshed Claude Code credentials must land on disk at 0o600.
Regression for the TOCTOU race where ``write_text`` + ``replace``
+ post-write ``chmod`` left both the temp file and the destination
briefly readable at the process umask (commonly 0o644). Mirrors
the fix shipped in #19673 (google_oauth) and #21148 (mcp_oauth).
"""
import stat as _stat
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
_write_claude_code_credentials("tok", "ref", 12345)
cred_file = tmp_path / ".claude" / ".credentials.json"
assert cred_file.exists()
mode = _stat.S_IMODE(cred_file.stat().st_mode)
assert mode == 0o600, f"creds file mode {oct(mode)} != 0o600 — TOCTOU race regressed"
class TestResolveWithRefresh:
def test_auto_refresh_on_expired_creds(self, monkeypatch, tmp_path):
"""When cred file has expired token + refresh token, auto-refresh is attempted."""
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
# Set up expired creds with a refresh token
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": "expired-tok",
"refreshToken": "valid-refresh",
"expiresAt": int(time.time() * 1000) - 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
# Mock refresh to succeed
with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"):
result = resolve_anthropic_token()
assert result == "refreshed-token"
def test_static_env_oauth_token_does_not_block_refreshable_claude_creds(self, monkeypatch, tmp_path):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-expired-env-token")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": "expired-claude-creds-token",
"refreshToken": "valid-refresh",
"expiresAt": int(time.time() * 1000) - 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"):
result = resolve_anthropic_token()
assert result == "refreshed-token"
class TestRunOauthSetupToken:
def test_returns_token_from_credential_files(self, monkeypatch, tmp_path):
"""After subprocess completes, reads credentials from Claude Code files."""
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
# Pre-create credential files that will be found after subprocess
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
cred_file.write_text(json.dumps({
"claudeAiOauth": {
"accessToken": "from-cred-file",
"refreshToken": "refresh",
"expiresAt": int(time.time() * 1000) + 3600_000,
}
}))
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
token = run_oauth_setup_token()
assert token == "from-cred-file"
# Don't assert exact call count — the contract is "credentials flow
# through", not "exactly one subprocess call". xdist cross-test
# pollution (other tests shimming subprocess via plugins) has flaked
# assert_called_once() in CI.
assert mock_run.called
def test_returns_none_when_no_creds_found(self, monkeypatch, tmp_path):
"""Returns None when subprocess completes but no credentials are found."""
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
token = run_oauth_setup_token()
assert 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_preserve_dots_for_alibaba_dashscope(self):
"""Alibaba/DashScope use dots in model names (e.g. qwen3.5-plus). Fixes #1739."""
assert normalize_model_name("qwen3.5-plus", preserve_dots=True) == "qwen3.5-plus"
assert normalize_model_name("anthropic/qwen3.5-plus", preserve_dots=True) == "qwen3.5-plus"
assert normalize_model_name("qwen3.5-flash", preserve_dots=True) == "qwen3.5-flash"
# ---------------------------------------------------------------------------
# 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) == []
def test_strips_nullable_union_from_input_schema(self):
tools = [
{
"type": "function",
"function": {
"name": "run",
"description": "Run command",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string"},
"timeout": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"default": None,
},
},
"required": ["command"],
},
},
}
]
result = convert_tools_to_anthropic(tools)
assert result[0]["input_schema"]["properties"]["timeout"] == {
"type": "integer",
"default": None,
}
assert result[0]["input_schema"]["required"] == ["command"]
# ---------------------------------------------------------------------------
# Message conversion
# ---------------------------------------------------------------------------
class TestConvertMessages:
def test_strips_tool_use_when_result_not_immediately_adjacent(self):
"""A tool_use whose result appears LATER but not in the immediately
following user message must be stripped (adjacency, #52145).
The old logic matched tool_result ids globally across the whole
transcript, so it would wrongly KEEP such a tool_use; Anthropic then
400s because the result does not follow the tool_use turn. The adjacency
rewrite only honors a result in the next user message.
"""
messages = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_late", "function": {"name": "search", "arguments": "{}"}},
],
},
{"role": "user", "content": "actually, something else"},
{"role": "assistant", "content": "sure"},
{"role": "tool", "tool_call_id": "tc_late", "content": "late result"},
]
_, result = convert_messages_to_anthropic(messages)
for m in result:
if m["role"] == "assistant" and isinstance(m["content"], list):
assert all(b.get("type") != "tool_use" for b in m["content"]), (
"non-adjacent tool_use should have been stripped"
)
for m in result:
if m["role"] == "user" and isinstance(m["content"], list):
assert all(b.get("type") != "tool_result" for b in m["content"]), (
"orphaned late tool_result should have been stripped"
)
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"}
def test_assistant_cache_control_blocks_are_preserved(self):
messages = apply_anthropic_cache_control([
{"role": "system", "content": "System prompt"},
{"role": "assistant", "content": "Hello from assistant"},
])
_, result = convert_messages_to_anthropic(messages)
assistant_msg = next(m for m in result if m["role"] == "assistant")
assistant_blocks = assistant_msg["content"]
assert assistant_blocks[0]["type"] == "text"
assert assistant_blocks[0]["text"] == "Hello from assistant"
assert assistant_blocks[0]["cache_control"] == {"type": "ephemeral"}
def test_assistant_tool_use_cache_control_is_preserved(self):
messages = apply_anthropic_cache_control([
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "Run the tool"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_1", "function": {"name": "test_tool", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
], native_anthropic=True)
_, result = convert_messages_to_anthropic(messages)
assistant_msg = [m for m in result if m["role"] == "assistant"][0]
tool_use = assistant_msg["content"][-1]
assert tool_use["type"] == "tool_use"
assert tool_use["id"] == "tc_1"
assert tool_use["cache_control"] == {"type": "ephemeral"}
def test_ordered_replay_keeps_cache_control_from_nonempty_content(self):
"""An assistant turn that interleaves signed thinking with a tool_use
AND has preamble text carries its cache_control INSIDE ``content``
(apply_anthropic_cache_control marks the last content block, not the
top level). The ordered-replay branch rebuilds the message from
``anthropic_content_blocks`` alone, so without harvesting that marker
the breakpoint is dropped -- and it is *burned*, because
_can_carry_marker already spent a budget slot on this message.
#56195 covers the blank-content shape; this is the non-empty one, which
is what a Claude thinking+tools turn normally looks like.
"""
preamble = "I will read a.py now."
messages = apply_anthropic_cache_control([
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "Read a.py"},
{
"role": "assistant",
"content": preamble,
"anthropic_content_blocks": [
{"type": "thinking", "thinking": "Need a tool.", "signature": "sig_1"},
{"type": "text", "text": preamble},
{"type": "tool_use", "id": "tc_1", "name": "test_tool", "input": {}},
],
"tool_calls": [
{
"id": "tc_1",
"type": "function",
"function": {"name": "test_tool", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "contents"},
])
_system, converted = convert_messages_to_anthropic(messages)
assistant = next(m for m in converted if m.get("role") == "assistant")
marked = [
b for b in assistant["content"]
if isinstance(b, dict) and b.get("cache_control")
]
assert marked, (
"the assistant cache breakpoint was dropped by the ordered-replay "
"path and the budget slot is burned"
)
# The signed thinking block must still lead the replayed message.
assert assistant["content"][0]["type"] == "thinking"
def test_ordered_replay_tool_use_cache_control_is_preserved(self):
messages = apply_anthropic_cache_control([
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "Run the tool"},
{
"role": "assistant",
"content": "",
"anthropic_content_blocks": [
{
"type": "thinking",
"thinking": "Need a tool.",
"signature": "sig_1",
},
{
"type": "tool_use",
"id": "tc_1",
"name": "test_tool",
"input": {"query": "raw"},
},
],
"tool_calls": [
{
"id": "tc_1",
"function": {
"name": "test_tool",
"arguments": '{"query":"redacted"}',
},
},
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
], native_anthropic=True)
_, result = convert_messages_to_anthropic(messages)
assistant_msg = [m for m in result if m["role"] == "assistant"][0]
thinking, tool_use = assistant_msg["content"]
assert thinking["type"] == "thinking"
assert "cache_control" not in thinking
assert tool_use["type"] == "tool_use"
assert tool_use["id"] == "tc_1"
assert tool_use["input"] == {"query": "redacted"}
assert tool_use["cache_control"] == {"type": "ephemeral"}
def test_tool_cache_control_is_preserved_on_tool_result_block(self):
messages = apply_anthropic_cache_control([
{"role": "system", "content": "System prompt"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_1", "function": {"name": "test_tool", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
], native_anthropic=True)
_, result = convert_messages_to_anthropic(messages)
user_msg = next(
m for m in result
if m["role"] == "user"
and isinstance(m["content"], list)
and any(b.get("type") == "tool_result" for b in m["content"])
)
tool_block = user_msg["content"][0]
assert tool_block["type"] == "tool_result"
assert tool_block["tool_use_id"] == "tc_1"
assert tool_block["content"] == "result"
assert tool_block["cache_control"] == {"type": "ephemeral"}
def test_empty_user_message_string_gets_placeholder(self):
"""Empty user message strings should get '(empty message)' placeholder.
Anthropic rejects requests with empty user message content.
Regression test for #3143 — Discord @mention-only messages.
"""
messages = [
{"role": "user", "content": ""},
]
_, result = convert_messages_to_anthropic(messages)
assert result[0]["role"] == "user"
assert result[0]["content"] == "(empty message)"
def test_leading_assistant_after_compaction_gets_user_turn_prepended(self):
"""The adapter backstops compactors that emit a leading assistant summary."""
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "assistant", "content": "[Context compaction summary] earlier work…"},
{"role": "user", "content": "continue"},
]
system, result = convert_messages_to_anthropic(messages)
assert system == "You are helpful."
assert result[0]["role"] == "user"
assert result[0]["content"] == [{"type": "text", "text": " "}]
assert result[1]["role"] == "assistant"
assert any(
m["role"] == "assistant" and "Context compaction summary" in str(m["content"])
for m in result
)
# ---------------------------------------------------------------------------
# Build kwargs
# ---------------------------------------------------------------------------
class TestBuildAnthropicKwargs:
def test_reasoning_config_maps_to_manual_thinking_for_pre_4_6_models(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["temperature"] == 1
assert kwargs["max_tokens"] >= 16000 + 4096
assert "output_config" not in kwargs
def test_reasoning_config_maps_to_adaptive_thinking_for_4_6_models(self):
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
messages=[{"role": "user", "content": "think hard"}],
tools=None,
max_tokens=4096,
reasoning_config={"enabled": True, "effort": "high"},
)
# Adaptive thinking + display="summarized" keeps reasoning text
# populated in the response stream (Opus 4.7 default is "omitted").
assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
assert kwargs["output_config"] == {"effort": "high"}
assert "budget_tokens" not in kwargs["thinking"]
assert "temperature" not in kwargs
assert kwargs["max_tokens"] == 4096
def test_supports_fast_mode_predicate(self):
"""Fast mode is Opus 4.6 only — Opus 4.7 and others must be excluded.
For Opus 4.8 the fast variant is a separate model ID
(anthropic/claude-opus-4.8-fast) routed through the normal model
field, NOT via the ``speed: "fast"`` request parameter. So
``_supports_fast_mode`` (which gates the parameter) must stay
False for both opus-4-8 and opus-4-8-fast.
"""
from agent.anthropic_adapter import _supports_fast_mode
assert _supports_fast_mode("claude-opus-4-6") is True
assert _supports_fast_mode("anthropic/claude-opus-4-6") is True
assert _supports_fast_mode("claude-opus-4-7") is False
assert _supports_fast_mode("claude-opus-4-8") is False
assert _supports_fast_mode("claude-opus-4-8-fast") is False
assert _supports_fast_mode("claude-sonnet-4-6") is False
assert _supports_fast_mode("claude-haiku-4-5") is False
assert _supports_fast_mode("") is False
def test_fable_class_models_route_as_adaptive_thinking(self):
"""Invariant: unknown/new Claude models default to the modern (4.7+)
contract — adaptive thinking, xhigh-capable, sampling-params-forbidden —
without any per-model code change. Named models (claude-fable-5) and
hypothetical future ones must all classify modern; only the explicit
legacy list stays on the manual path.
"""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_supports_xhigh_effort,
_forbids_sampling_params,
_get_anthropic_max_output,
)
# New / unknown Claude models → modern contract by default.
for m in (
"claude-fable-5",
"anthropic/claude-fable-5",
"claude-saga-2", # hypothetical future named model
"anthropic/claude-opus-9", # hypothetical future numbered model
):
assert _supports_adaptive_thinking(m) is True, m
assert _supports_xhigh_effort(m) is True, m
assert _forbids_sampling_params(m) is True, m
# 1M-context reasoning model → highest output ceiling.
assert _get_anthropic_max_output("anthropic/claude-fable-5") == 128_000
def test_non_claude_anthropic_models_use_manual_path(self):
"""Non-Claude Anthropic-Messages models (minimax, qwen3, glm) must not
be misclassified as adaptive by the default-to-modern rule. Kimi is
the deliberate exception — see test_kimi_family_uses_adaptive_path."""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_supports_xhigh_effort,
_forbids_sampling_params,
)
for m in ("minimax-m2", "qwen3-max", "glm-4.6"):
assert _supports_adaptive_thinking(m) is False, m
assert _supports_xhigh_effort(m) is False, m
assert _forbids_sampling_params(m) is False, m
def test_bare_k3_coding_plan_slug_is_kimi_family(self):
"""Kimi Coding Plan serves K3 as the bare slug ``k3`` — it must be
classified as Kimi family (adaptive thinking) even on proxied
endpoints where only the model name is available. Lookalike
non-Kimi names must NOT match the exact-slug rule."""
from agent.anthropic_adapter import (
_model_name_is_kimi_family,
_supports_adaptive_thinking,
)
for m in ("k3", "K3", "moonshotai/k3", "k3.1-preview", "k3-turbo"):
assert _model_name_is_kimi_family(m) is True, m
assert _supports_adaptive_thinking("k3") is True
# Prefix-lookalikes without a separator must not be swept in.
for m in ("k30", "k3000-chat", "keras-3"):
assert _model_name_is_kimi_family(m) is False, m
def test_fast_mode_omitted_for_unsupported_model(self):
"""fast_mode=True on Opus 4.7 must NOT inject speed=fast (API 400s)."""
kwargs = build_anthropic_kwargs(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
tools=None,
max_tokens=1024,
reasoning_config=None,
fast_mode=True,
)
# extra_body either absent or doesn't carry "speed"
assert "speed" not in kwargs.get("extra_body", {})
# No fast-mode beta header should be added either
beta_header = (kwargs.get("extra_headers") or {}).get("anthropic-beta", "")
assert "fast-mode-2026-02-01" not in beta_header
# ---------------------------------------------------------------------------
# Model output limit lookup
# ---------------------------------------------------------------------------
class TestGetAnthropicMaxOutput:
def test_opus_4_6(self):
from agent.anthropic_adapter import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-opus-4-6") == 128_000
# ---------------------------------------------------------------------------
# _to_plain_data hardening
# ---------------------------------------------------------------------------
class TestToPlainData:
def test_deep_nesting_is_capped(self):
deep = "leaf"
for _ in range(25):
deep = {"nested": deep}
result = _to_plain_data(deep)
assert isinstance(result, dict)
def test_plain_values_pass_through(self):
assert _to_plain_data("hello") == "hello"
assert _to_plain_data(42) == 42
assert _to_plain_data(None) is None
def test_object_with_dunder_dict(self):
obj = SimpleNamespace(type="thinking", thinking="reason", signature="sig")
result = _to_plain_data(obj)
assert result == {"type": "thinking", "thinking": "reason", "signature": "sig"}
# ---------------------------------------------------------------------------
# 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_tool_use_response(self):
blocks = [
SimpleNamespace(type="text", text="Searching..."),
SimpleNamespace(
type="tool_use",
id="tc_1",
name="search",
input={"query": "test"},
),
]
nr = get_transport("anthropic_messages").normalize_response(
self._make_response(blocks, "tool_use")
)
assert nr.content == "Searching..."
assert nr.finish_reason == "tool_calls"
assert len(nr.tool_calls) == 1
assert nr.tool_calls[0].name == "search"
assert json.loads(nr.tool_calls[0].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."),
]
nr = get_transport("anthropic_messages").normalize_response(self._make_response(blocks))
assert nr.content == "The answer is 42."
assert nr.reasoning == "Let me reason about this..."
assert nr.provider_data["reasoning_details"] == [{"type": "thinking", "thinking": "Let me reason about this..."}]
def test_stop_reason_mapping(self):
block = SimpleNamespace(type="text", text="x")
nr1 = get_transport("anthropic_messages").normalize_response(
self._make_response([block], "end_turn")
)
nr2 = get_transport("anthropic_messages").normalize_response(
self._make_response([block], "tool_use")
)
nr3 = get_transport("anthropic_messages").normalize_response(
self._make_response([block], "max_tokens")
)
assert nr1.finish_reason == "stop"
assert nr2.finish_reason == "tool_calls"
assert nr3.finish_reason == "length"
# ---------------------------------------------------------------------------
# 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"]
# ---------------------------------------------------------------------------
# Thinking block signature management
# ---------------------------------------------------------------------------
class TestThinkingBlockSignatureManagement:
"""Tests for the thinking block handling strategy:
strip from old turns, preserve latest signed, downgrade unsigned."""
def test_redacted_thinking_with_data_preserved(self):
"""Redacted thinking with 'data' field is kept on last turn."""
messages = [
{
"role": "assistant",
"content": "Response.",
"reasoning_details": [
{"type": "redacted_thinking", "data": "opaque_signature_data"},
],
},
]
_, result = convert_messages_to_anthropic(messages)
blocks = next(m for m in result if m["role"] == "assistant")["content"]
redacted = [b for b in blocks if b.get("type") == "redacted_thinking"]
assert len(redacted) == 1
assert redacted[0]["data"] == "opaque_signature_data"
def test_redacted_thinking_without_data_dropped(self):
"""Redacted thinking without 'data' is dropped — can't be validated."""
messages = [
{
"role": "assistant",
"content": "Response.",
"reasoning_details": [
{"type": "redacted_thinking"},
# No 'data' field
],
},
]
_, result = convert_messages_to_anthropic(messages)
blocks = result[0]["content"]
assert not any(b.get("type") == "redacted_thinking" for b in blocks)
def test_cache_control_stripped_from_thinking_blocks(self):
"""cache_control markers are removed from thinking/redacted_thinking blocks."""
messages = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc_1", "function": {"name": "t", "arguments": "{}"}},
],
"reasoning_details": [
{
"type": "thinking",
"thinking": "Reasoning.",
"signature": "sig_1",
"cache_control": {"type": "ephemeral"},
},
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
]
_, result = convert_messages_to_anthropic(messages)
assistant = next(m for m in result if m["role"] == "assistant")
for block in assistant["content"]:
if block.get("type") in {"thinking", "redacted_thinking"}:
assert "cache_control" not in block
def test_multi_turn_conversation_preserves_only_last(self):
"""Full multi-turn conversation: only last assistant keeps thinking."""
messages = [
{"role": "user", "content": "Question 1"},
{
"role": "assistant",
"content": "Answer 1",
"reasoning_details": [
{"type": "thinking", "thinking": "Thought 1", "signature": "sig_1"},
],
},
{"role": "user", "content": "Question 2"},
{
"role": "assistant",
"content": "Answer 2",
"reasoning_details": [
{"type": "thinking", "thinking": "Thought 2", "signature": "sig_2"},
],
},
{"role": "user", "content": "Question 3"},
{
"role": "assistant",
"content": "Answer 3",
"reasoning_details": [
{"type": "thinking", "thinking": "Thought 3", "signature": "sig_3"},
],
},
]
_, result = convert_messages_to_anthropic(messages)
assistants = [m for m in result if m["role"] == "assistant"]
assert len(assistants) == 3
# First two: no thinking blocks
for a in assistants[:2]:
assert not any(
b.get("type") in {"thinking", "redacted_thinking"}
for b in a["content"]
if isinstance(b, dict)
)
# Last one: thinking preserved
last_thinking = [
b for b in assistants[2]["content"]
if isinstance(b, dict) and b.get("type") == "thinking"
]
assert len(last_thinking) == 1
assert last_thinking[0]["signature"] == "sig_3"
# ---------------------------------------------------------------------------
# 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_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"}
# ---------------------------------------------------------------------------
# max_tokens resolver — openclaw/openclaw#66664 port
# ---------------------------------------------------------------------------
from agent.anthropic_adapter import (
_resolve_positive_anthropic_max_tokens,
_resolve_anthropic_messages_max_tokens,
)
class TestResolvePositiveMaxTokens:
"""Unit tests for the positive-int resolver helper."""
def test_zero_returns_none(self):
assert _resolve_positive_anthropic_max_tokens(0) is None
def test_nan_returns_none(self):
assert _resolve_positive_anthropic_max_tokens(float("nan")) is None
def test_bool_true_returns_none(self):
# True is an int subclass but semantically never a real max_tokens value
assert _resolve_positive_anthropic_max_tokens(True) is None
assert _resolve_positive_anthropic_max_tokens(False) is None
class TestResolveMessagesMaxTokens:
"""Integration tests for the full Messages resolver."""
def test_positive_requested_wins(self):
assert _resolve_anthropic_messages_max_tokens(
8192, "claude-opus-4-6"
) == 8192
def test_sub_one_float_falls_back(self):
# 0.5 floors to 0 -> not positive -> falls back to model ceiling
result = _resolve_anthropic_messages_max_tokens(0.5, "claude-opus-4-6")
assert result > 0
assert result != 0
# ---------------------------------------------------------------------------
# convert_tools_to_anthropic — tool dedup at API boundary
# ---------------------------------------------------------------------------
class TestConvertToolsToAnthropicDedup:
"""convert_tools_to_anthropic must deduplicate tool names.
Anthropic rejects requests with duplicate tool names. This guard converts
a hard failure into a warning log. See:
https://github.com/NousResearch/hermes-agent/issues/18478
"""
def _make_openai_tool(self, name: str) -> dict:
return {
"type": "function",
"function": {
"name": name,
"description": f"Tool {name}",
"parameters": {"type": "object", "properties": {}},
},
}
def test_duplicate_tool_names_are_deduplicated(self):
"""RED test — must fail until dedup guard is added."""
tools = [
self._make_openai_tool("lcm_grep"),
self._make_openai_tool("lcm_describe"),
self._make_openai_tool("lcm_grep"), # duplicate
self._make_openai_tool("lcm_expand"),
self._make_openai_tool("lcm_describe"), # duplicate
]
result = convert_tools_to_anthropic(tools)
names = [t["name"] for t in result]
assert len(names) == len(set(names)), (
f"Duplicate tool names found: {names}"
)
assert len(result) == 3 # lcm_grep, lcm_describe, lcm_expand
def test_none_tools_returns_empty(self):
assert convert_tools_to_anthropic(None) == []
class TestBlankTextBlockFiltering:
"""Regression tests for blank text block filtering in _convert_assistant_message.
Bedrock and strict Anthropic-compatible endpoints reject text blocks where
"text" is empty or whitespace-only with HTTP 400. Both the normal list-
content path and the ordered-replay fast path must drop such blocks while
preserving tool_use and other block types, and must relocate (not lose)
any cache_control marker attached to the dropped block.
"""
def _convert(self, message):
from agent.anthropic_adapter import _convert_assistant_message
return _convert_assistant_message(message)
def test_normal_path_filters_none_text_block_without_crashing(self):
"""Regression (review of #63228): text=None must not raise
AttributeError. _convert_content_part_to_anthropic() can preserve
None from an invalid upstream input text block -- a bare .strip()
on blk.get("text", "") crashes because .get() only substitutes the
default when the key is ABSENT, not when it's present with value None."""
msg = {
"role": "assistant",
"content": [
{"type": "text", "text": None},
{"type": "tool_use", "id": "call_none", "name": "web_search",
"input": {"query": "test"}},
],
}
result = self._convert(msg) # must not raise
blocks = result["content"]
text_blocks = [b for b in blocks if b.get("type") == "text"]
tool_blocks = [b for b in blocks if b.get("type") == "tool_use"]
assert len(text_blocks) == 0, f"None text block not filtered: {text_blocks}"
assert len(tool_blocks) == 1
def test_normal_path_relocates_cache_control_from_dropped_block(self):
"""Regression (review of #63228): prompt_caching.py's _apply_cache_marker
sets cache_control directly on content[-1] for list content. If that
last part is blank text, dropping it must relocate the marker to the
surviving last cacheable block (here: the tool_use), not lose it."""
msg = {
"role": "assistant",
"content": [
{"type": "text", "text": "I'll look that up."},
{"type": "tool_use", "id": "call_cache", "name": "web_search",
"input": {"query": "test"}},
{"type": "text", "text": "", "cache_control": {"type": "ephemeral"}},
],
}
result = self._convert(msg)
blocks = result["content"]
assert not any(b.get("type") == "text" and not b.get("text", "").strip() for b in blocks), (
"Blank text block must be dropped"
)
cacheable_with_marker = [b for b in blocks if isinstance(b.get("cache_control"), dict)]
assert len(cacheable_with_marker) == 1, (
f"cache_control marker must survive on exactly one surviving block: {blocks}"
)
assert cacheable_with_marker[0]["type"] == "tool_use", (
f"Marker must relocate to the new last cacheable block: {blocks}"
)
def test_replay_path_relocates_cache_control_from_dropped_block(self):
"""Same cache_control-relocation guarantee on the ordered-replay path:
a blank text block carrying cache_control (e.g. a stored, previously
cache-marked turn where prompt_caching later becomes blank on replay)
must not silently lose the breakpoint when dropped."""
from agent.anthropic_adapter import _convert_assistant_message
msg = {
"role": "assistant",
"content": "",
"anthropic_content_blocks": [
{"type": "tool_use", "id": "call_5", "name": "web_search",
"input": {"query": "test"}},
{"type": "text", "text": " ", "cache_control": {"type": "ephemeral"}},
],
"tool_calls": [
{
"id": "call_5",
"function": {"name": "web_search",
"arguments": '{"query": "test"}'},
}
],
}
result = _convert_assistant_message(msg)
blocks = result["content"]
assert not any(b.get("type") == "text" for b in blocks), "Blank replay text must be dropped"
cacheable_with_marker = [b for b in blocks if isinstance(b.get("cache_control"), dict)]
assert len(cacheable_with_marker) == 1
assert cacheable_with_marker[0]["type"] == "tool_use"
class TestAllBlankFallbackAndNonStringText:
"""Regression tests for the two bugs found in independent review of
#68633 (GPT-5.6-sol-xhigh in Codex, egilewski):
1. `effective = blocks or content` fell back to the RAW, unfiltered
`content` when every block was filtered out as blank -- restoring
exactly the invalid (blank/whitespace) payload the filter exists to
remove, for any message where blank content is the ONLY content
(no surviving tool_use/text/thinking block).
2. The normal-path blank-text check used `(blk.get("text") or "").strip()`,
which is not type-safe for a truthy NON-string, non-None text value
(e.g. an int) -- `or` doesn't substitute for a truthy value, so
`(7 or "").strip()` still raises AttributeError.
"""
def _convert(self, message):
from agent.anthropic_adapter import _convert_assistant_message
return _convert_assistant_message(message)
def test_sole_cache_marked_blank_block_relocates_marker_to_placeholder(self):
"""A message whose ONLY content is a blank text block that also
carries cache_control: the marker must not be silently dropped just
because there's nothing else to relocate it onto -- it must land on
the (empty) placeholder that replaces the dropped block."""
msg = {
"role": "assistant",
"content": [{"type": "text", "text": "", "cache_control": {"type": "ephemeral"}}],
}
result = self._convert(msg)
blocks = result["content"]
assert blocks == [
{"type": "text", "text": "(empty)", "cache_control": {"type": "ephemeral"}}
], f"cache_control must relocate onto the (empty) placeholder: {blocks}"
def test_non_string_truthy_text_treated_as_invalid_not_crash(self):
"""Regression: text=7 (a truthy int, not None) must not reach
.strip() and raise AttributeError -- it must be treated the same as
blank/invalid text and dropped."""
msg = {
"role": "assistant",
"content": [
{"type": "text", "text": 7},
{"type": "tool_use", "id": "call_int", "name": "web_search",
"input": {"query": "test"}},
],
}
result = self._convert(msg) # must not raise
blocks = result["content"]
text_blocks = [b for b in blocks if b.get("type") == "text"]
tool_blocks = [b for b in blocks if b.get("type") == "tool_use"]
assert len(text_blocks) == 0, f"Non-string text value must be dropped, not kept: {text_blocks}"
assert len(tool_blocks) == 1
def test_dict_valued_text_treated_as_invalid_not_crash(self):
"""Another truthy non-string shape (dict) must also be safely dropped."""
msg = {
"role": "assistant",
"content": [{"type": "text", "text": {"nested": "garbage"}}],
"tool_calls": [
{"id": "call_d", "function": {"name": "web_search",
"arguments": '{"query": "test"}'}},
],
}
result = self._convert(msg) # must not raise
blocks = result["content"]
assert not any(b.get("type") == "text" for b in blocks)
class TestReplayAllBlankFallback:
"""Regression for the final open review point on #68633 (egilewski):
``_relocated_replay_cache_control`` was applied only inside ``if
replayed:``. For ``anthropic_content_blocks`` containing only a blank
cache-marked text block, ``replayed`` became empty, the function fell
through to the main path's ``(empty)`` fallback, and the marker was
lost. A signed-thinking block plus the blank marked text also returned
without any relocated marker (thinking is not a cacheable carrier).
The replay branch now resolves a cacheable ``(empty)`` placeholder when
no cacheable block survives the blank filter.
"""
def _convert(self, message):
from agent.anthropic_adapter import _convert_assistant_message
return _convert_assistant_message(message)
def test_sole_blank_marked_replay_block_keeps_marker_on_placeholder(self):
msg = {
"role": "assistant",
"content": "",
"anthropic_content_blocks": [
{"type": "text", "text": " ", "cache_control": {"type": "ephemeral"}},
],
}
result = self._convert(msg)
assert result["content"] == [
{"type": "text", "text": "(empty)", "cache_control": {"type": "ephemeral"}}
], result["content"]
def test_thinking_plus_blank_marked_text_keeps_thinking_and_marker(self):
msg = {
"role": "assistant",
"content": "",
"anthropic_content_blocks": [
{"type": "thinking", "thinking": "reasoning", "signature": "sig-A"},
{"type": "text", "text": " ", "cache_control": {"type": "ephemeral"}},
],
}
result = self._convert(msg)
blocks = result["content"]
assert blocks[0] == {"type": "thinking", "thinking": "reasoning", "signature": "sig-A"}
marked = [b for b in blocks if isinstance(b.get("cache_control"), dict)]
assert len(marked) == 1 and marked[0]["type"] == "text"
assert marked[0]["text"].strip(), "placeholder must be non-whitespace"
def test_thinking_plus_blank_unmarked_text_gets_schema_valid_placeholder(self):
"""Even without a cache marker, dropping the only text block from a
thinking-only replay must leave schema-valid content."""
msg = {
"role": "assistant",
"content": "",
"anthropic_content_blocks": [
{"type": "thinking", "thinking": "reasoning", "signature": "sig-B"},
{"type": "text", "text": "\n"},
],
}
result = self._convert(msg)
texts = [b for b in result["content"] if b.get("type") == "text"]
assert texts == [{"type": "text", "text": "(empty)"}]