168 lines
7.6 KiB
Python
168 lines
7.6 KiB
Python
"""Anthropic Messages API transport.
|
|
|
|
Delegates format conversion to agent/anthropic_adapter.py; owns normalization,
|
|
not client lifecycle.
|
|
"""
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from agent.transports.base import ProviderTransport
|
|
from agent.transports.types import NormalizedResponse, ToolCall
|
|
|
|
_MCP_PREFIX = "mcp__"
|
|
|
|
|
|
def _unprefix_oauth_tool_name(name: str) -> str:
|
|
"""Reverse the OAuth-wire ``mcp__`` prefix back to the registered tool name.
|
|
|
|
Two originals map onto one wire name (``mcp__read_file`` <- ``read_file``;
|
|
``mcp__linear_get_issue`` <- ``mcp_linear_get_issue``), so resolve by registry
|
|
lookup, never rewriting a name that already resolves natively (GH-25255).
|
|
OAuth wire aliases (e.g. chat_history_lookup -> session_search) are checked
|
|
LAST so a real tool registered under the wire name still wins.
|
|
"""
|
|
from agent.anthropic_adapter import _OAUTH_TOOL_NAME_REVERSE_ALIASES
|
|
from tools.registry import registry as _tool_registry
|
|
|
|
bare = name[len(_MCP_PREFIX):]
|
|
for candidate in (name, "mcp_" + bare, bare):
|
|
if _tool_registry.get_entry(candidate):
|
|
return candidate
|
|
return _OAUTH_TOOL_NAME_REVERSE_ALIASES.get(bare, name)
|
|
|
|
|
|
class AnthropicTransport(ProviderTransport):
|
|
"""Transport for api_mode='anthropic_messages'."""
|
|
|
|
_STOP_REASON_MAP = {
|
|
"end_turn": "stop",
|
|
"tool_use": "tool_calls",
|
|
"max_tokens": "length",
|
|
"stop_sequence": "stop",
|
|
"refusal": "content_filter",
|
|
"model_context_window_exceeded": "length",
|
|
}
|
|
|
|
@property
|
|
def api_mode(self) -> str:
|
|
return "anthropic_messages"
|
|
|
|
def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any:
|
|
"""Convert OpenAI messages to an Anthropic (system, messages) tuple; ``base_url`` affects thinking-signature handling."""
|
|
from agent.anthropic_adapter import convert_messages_to_anthropic
|
|
|
|
return convert_messages_to_anthropic(messages, base_url=kwargs.get("base_url"))
|
|
|
|
def convert_tools(self, tools: List[Dict[str, Any]]) -> Any:
|
|
"""Convert OpenAI tool schemas to Anthropic input_schema format."""
|
|
from agent.anthropic_adapter import convert_tools_to_anthropic
|
|
|
|
return convert_tools_to_anthropic(tools)
|
|
|
|
def build_kwargs(
|
|
self,
|
|
model: str,
|
|
messages: List[Dict[str, Any]],
|
|
tools: Optional[List[Dict[str, Any]]] = None,
|
|
**params,
|
|
) -> Dict[str, Any]:
|
|
"""Build Anthropic messages.create() kwargs (converts messages and tools internally)."""
|
|
from agent.anthropic_adapter import build_anthropic_kwargs
|
|
|
|
return build_anthropic_kwargs(
|
|
model=model,
|
|
messages=messages,
|
|
tools=tools,
|
|
max_tokens=params.get("max_tokens", 16384),
|
|
reasoning_config=params.get("reasoning_config"),
|
|
tool_choice=params.get("tool_choice"),
|
|
is_oauth=params.get("is_oauth", False),
|
|
preserve_dots=params.get("preserve_dots", False),
|
|
context_length=params.get("context_length"),
|
|
base_url=params.get("base_url"),
|
|
fast_mode=params.get("fast_mode", False),
|
|
drop_context_1m_beta=params.get("drop_context_1m_beta", False),
|
|
)
|
|
|
|
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
|
|
"""Parse content blocks (text/thinking/tool_use), map stop_reason, collect reasoning_details."""
|
|
import json
|
|
from agent.anthropic_adapter import _sanitize_replay_block, _to_plain_data
|
|
|
|
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
|
text_parts, reasoning_parts, reasoning_details, tool_calls = [], [], [], []
|
|
# Anthropic signs each thinking block against the blocks that PRECEDE it.
|
|
# When thinking interleaves with tool_use, the parallel reasoning_details +
|
|
# tool_calls lists lose that ordering and replay -> HTTP 400 "thinking ...
|
|
# blocks cannot be modified". Keep the exact sequence for the adapter.
|
|
ordered_blocks = []
|
|
|
|
for block in response.content:
|
|
block_dict = _to_plain_data(block)
|
|
clean_block = None
|
|
if isinstance(block_dict, dict):
|
|
# Sanitize at capture so output-only SDK fields never persist to
|
|
# state.db and leak back as request input on replay (HTTP 400).
|
|
clean_block = _sanitize_replay_block(block_dict)
|
|
if clean_block is not None:
|
|
ordered_blocks.append(clean_block)
|
|
if block.type == "text":
|
|
text_parts.append(block.text)
|
|
elif block.type in ("thinking", "redacted_thinking"):
|
|
if block.type == "thinking":
|
|
reasoning_parts.append(block.thinking)
|
|
# Prefer the sanitized block (replayed on the non-ordered path); raw only if sanitize dropped it.
|
|
if isinstance(clean_block, dict):
|
|
reasoning_details.append(clean_block)
|
|
elif isinstance(block_dict, dict):
|
|
reasoning_details.append(block_dict)
|
|
elif block.type == "tool_use":
|
|
name = block.name
|
|
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
|
|
name = _unprefix_oauth_tool_name(name)
|
|
tool_calls.append(ToolCall(id=block.id, name=name, arguments=json.dumps(block.input)))
|
|
|
|
provider_data = {}
|
|
if reasoning_details:
|
|
provider_data["reasoning_details"] = reasoning_details
|
|
# Carry the ordered channel only for the one shape the parallel lists
|
|
# reconstruct wrongly: signed thinking interleaved with tool_use.
|
|
_has_signed_thinking = any(
|
|
isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking") and (b.get("signature") or b.get("data"))
|
|
for b in ordered_blocks
|
|
)
|
|
if _has_signed_thinking and any(isinstance(b, dict) and b.get("type") == "tool_use" for b in ordered_blocks):
|
|
provider_data["anthropic_content_blocks"] = ordered_blocks
|
|
|
|
return NormalizedResponse(
|
|
content="\n".join(text_parts) if text_parts else None,
|
|
tool_calls=tool_calls or None,
|
|
finish_reason=self.map_finish_reason(response.stop_reason),
|
|
reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None,
|
|
usage=None,
|
|
provider_data=provider_data or None,
|
|
)
|
|
|
|
def validate_response(self, response: Any) -> bool:
|
|
"""Structural check. An empty content list is legitimate for ``end_turn`` (nothing to add
|
|
after a tool turn) and ``refusal`` (Claude 4.5+ declines with empty content); treating
|
|
either as invalid would retry a completed/deterministic response forever."""
|
|
content_blocks = getattr(response, "content", None) if response is not None else None
|
|
if not isinstance(content_blocks, list):
|
|
return False
|
|
return bool(content_blocks) or getattr(response, "stop_reason", None) in {"end_turn", "refusal"}
|
|
|
|
def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]:
|
|
"""Anthropic cache_read / cache_creation token counts."""
|
|
usage = getattr(response, "usage", None)
|
|
if usage is None:
|
|
return None
|
|
cached = getattr(usage, "cache_read_input_tokens", 0) or 0
|
|
written = getattr(usage, "cache_creation_input_tokens", 0) or 0
|
|
return {"cached_tokens": cached, "creation_tokens": written} if cached or written else None
|
|
|
|
|
|
from agent.transports import register_transport # noqa: E402
|
|
|
|
register_transport("anthropic_messages", AnthropicTransport)
|