- harness/es.py: append-only event store + проекции (образец — кооп-портал) - harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу - harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы - harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ create_harness) - cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом) - 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
1065 lines
45 KiB
Python
1065 lines
45 KiB
Python
"""Anthropic Claude model provider.
|
|
|
|
- Docs: https://docs.anthropic.com/claude/reference/getting-started-with-the-api
|
|
- Server tools: https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
from collections.abc import AsyncGenerator
|
|
from typing import Any, TypeVar, cast
|
|
from urllib.parse import urlparse
|
|
|
|
import anthropic
|
|
from pydantic import BaseModel
|
|
from typing_extensions import Required, Unpack, override
|
|
|
|
from ..event_loop.streaming import process_stream
|
|
from ..tools.structured_output.structured_output_utils import convert_pydantic_to_tool_spec
|
|
from ..types.citations import (
|
|
DocumentCharLocationDict,
|
|
DocumentChunkLocationDict,
|
|
DocumentPageLocationDict,
|
|
SearchResultLocationDict,
|
|
WebLocation,
|
|
WebLocationDict,
|
|
)
|
|
from ..types.content import ContentBlock, Message, Messages, SystemContentBlock
|
|
from ..types.event_loop import Usage
|
|
from ..types.exceptions import ContextWindowOverflowException, ModelThrottledException
|
|
from ..types.streaming import CitationsDelta, StreamEvent
|
|
from ..types.tools import ToolChoice, ToolChoiceToolDict, ToolSpec
|
|
from ._defaults import resolve_config_metadata
|
|
from ._validation import _has_location_source, _warn_on_deprecated_cache_tools, validate_config_keys
|
|
from .model import BaseModelConfig, CacheConfig, CacheToolsConfig, Model
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
T = TypeVar("T", bound=BaseModel)
|
|
|
|
# Content blocks for tools Anthropic executes server side. They have no toolUse/toolResult equivalent
|
|
# (the agent never runs them), so they are not streamed.
|
|
_SERVER_TOOL_BLOCK_TYPES = frozenset(
|
|
{
|
|
"bash_code_execution_tool_result",
|
|
"code_execution_tool_result",
|
|
"container_upload",
|
|
"mcp_tool_result",
|
|
"mcp_tool_use",
|
|
"server_tool_use",
|
|
"text_editor_code_execution_tool_result",
|
|
"tool_search_tool_result",
|
|
"web_fetch_tool_result",
|
|
"web_search_tool_result",
|
|
}
|
|
)
|
|
|
|
# Anthropic pauses a long server-side tool turn with stop_reason=pause_turn and expects the paused
|
|
# assistant message to be sent back as-is to resume it. Bounds how many times stream() does so.
|
|
_MAX_PAUSE_TURN_CONTINUATIONS = 10
|
|
|
|
_IMAGE_MEDIA_TYPES = {
|
|
"gif": "image/gif",
|
|
"jpeg": "image/jpeg",
|
|
"jpg": "image/jpeg",
|
|
"png": "image/png",
|
|
"webp": "image/webp",
|
|
}
|
|
|
|
# Anthropic document sources accept only pdf (base64) and plain text; these formats are delivered as text.
|
|
_TEXT_FILE_FORMATS = frozenset({"csv", "html", "md", "txt"})
|
|
|
|
# Anthropic accepts ``cache_control`` on these block types only; any other block is rejected.
|
|
# https://docs.claude.com/en/docs/build-with-claude/prompt-caching
|
|
_CACHEABLE_BLOCK_TYPES = frozenset({"document", "image", "text", "tool_result", "tool_use"})
|
|
|
|
# ``ephemeral`` is the only cache type the Anthropic API supports
|
|
_ANTHROPIC_CACHE_TYPE = "ephemeral"
|
|
|
|
|
|
class AnthropicModel(Model):
|
|
"""Anthropic model provider implementation."""
|
|
|
|
EVENT_TYPES = {
|
|
"message_start",
|
|
"content_block_start",
|
|
"content_block_delta",
|
|
"content_block_stop",
|
|
"message_stop",
|
|
}
|
|
|
|
OVERFLOW_MESSAGES = {
|
|
"prompt is too long:",
|
|
"input is too long",
|
|
"input length exceeds context window",
|
|
"input and output tokens exceed your context limit",
|
|
}
|
|
|
|
class AnthropicConfig(BaseModelConfig, total=False):
|
|
"""Configuration options for Anthropic models.
|
|
|
|
Attributes:
|
|
cache_config: Configuration for prompt caching. Adds a cache point to the last user message,
|
|
caching everything before it. Caching is off when unset.
|
|
cache_tools: Caches the tool definitions (deprecated, use CacheConfig(tools_ttl=...)). Superseded
|
|
by an explicitly set cache_config.tools_ttl.
|
|
max_tokens: Maximum number of tokens to generate.
|
|
model_id: Calude model ID (e.g., "claude-3-7-sonnet-latest").
|
|
For a complete list of supported models, see
|
|
https://docs.anthropic.com/en/docs/about-claude/models/all-models.
|
|
params: Additional model parameters (e.g., temperature).
|
|
For a complete list of supported parameters, see https://docs.anthropic.com/en/api/messages.
|
|
anthropic_tools: Anthropic-specific server-side tools that are not function tools
|
|
(e.g., web_search, web_fetch, code_execution). Appended alongside the agent's function tools.
|
|
Use the standard tools interface for function calling tools.
|
|
For a complete list of supported tools, see
|
|
https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview
|
|
use_native_token_count: Whether to use the native Anthropic count_tokens API.
|
|
When True, count_tokens() calls the Anthropic API for accurate counts.
|
|
When False (default), skips the API call and uses the local estimator.
|
|
"""
|
|
|
|
cache_config: CacheConfig | None
|
|
cache_tools: str | CacheToolsConfig | None
|
|
max_tokens: Required[int]
|
|
model_id: Required[str]
|
|
params: dict[str, Any] | None
|
|
anthropic_tools: list[dict[str, Any]]
|
|
use_native_token_count: bool
|
|
|
|
def __init__(self, *, client_args: dict[str, Any] | None = None, **model_config: Unpack[AnthropicConfig]):
|
|
"""Initialize provider instance.
|
|
|
|
Args:
|
|
client_args: Arguments for the underlying Anthropic client (e.g., api_key).
|
|
For a complete list of supported arguments, see https://docs.anthropic.com/en/api/client-sdks.
|
|
**model_config: Configuration options for the Anthropic model.
|
|
"""
|
|
validate_config_keys(model_config, self.AnthropicConfig)
|
|
_warn_on_deprecated_cache_tools(model_config, stacklevel=3)
|
|
self.config = AnthropicModel.AnthropicConfig(**model_config)
|
|
|
|
if "anthropic_tools" in self.config:
|
|
self._validate_anthropic_tools(self.config["anthropic_tools"])
|
|
|
|
logger.debug("config=<%s> | initializing", self.config)
|
|
|
|
client_args = client_args or {}
|
|
self.client = anthropic.AsyncAnthropic(**client_args)
|
|
|
|
@override
|
|
def update_config(self, **model_config: Unpack[AnthropicConfig]) -> None: # type: ignore[override]
|
|
"""Update the Anthropic model configuration with the provided arguments.
|
|
|
|
Args:
|
|
**model_config: Configuration overrides.
|
|
"""
|
|
validate_config_keys(model_config, self.AnthropicConfig)
|
|
|
|
if "anthropic_tools" in model_config:
|
|
self._validate_anthropic_tools(model_config["anthropic_tools"])
|
|
|
|
_warn_on_deprecated_cache_tools(model_config, stacklevel=3)
|
|
self.config.update(model_config)
|
|
|
|
@override
|
|
def get_config(self) -> AnthropicConfig:
|
|
"""Get the Anthropic model configuration.
|
|
|
|
Returns:
|
|
The Anthropic model configuration.
|
|
"""
|
|
return resolve_config_metadata(self.config, self.config["model_id"])
|
|
|
|
def _format_request_message_content(self, content: ContentBlock) -> dict[str, Any]:
|
|
"""Format an Anthropic content block.
|
|
|
|
Args:
|
|
content: Message content.
|
|
|
|
Returns:
|
|
Anthropic formatted content block.
|
|
|
|
Raises:
|
|
TypeError: If the content block type or document format cannot be converted to an
|
|
Anthropic-compatible format.
|
|
"""
|
|
if "document" in content:
|
|
document_format = content["document"]["format"]
|
|
if document_format == "pdf":
|
|
source: dict[str, Any] = {
|
|
"data": base64.b64encode(content["document"]["source"]["bytes"]).decode("utf-8"),
|
|
"media_type": "application/pdf",
|
|
"type": "base64",
|
|
}
|
|
elif document_format in _TEXT_FILE_FORMATS:
|
|
try:
|
|
text_data = content["document"]["source"]["bytes"].decode("utf-8")
|
|
except UnicodeDecodeError as decode_error:
|
|
raise TypeError(
|
|
f"content_type=<document>, format=<{document_format}> | document is not valid utf-8 text"
|
|
) from decode_error
|
|
source = {
|
|
"data": text_data,
|
|
"media_type": "text/plain",
|
|
"type": "text",
|
|
}
|
|
else:
|
|
raise TypeError(f"content_type=<document>, format=<{document_format}> | unsupported format")
|
|
|
|
return {
|
|
"source": source,
|
|
"title": content["document"]["name"],
|
|
"type": "document",
|
|
}
|
|
|
|
if "image" in content:
|
|
image_format = content["image"]["format"]
|
|
if image_format not in _IMAGE_MEDIA_TYPES:
|
|
raise TypeError(f"content_type=<image>, format=<{image_format}> | unsupported format")
|
|
|
|
return {
|
|
"source": {
|
|
"data": base64.b64encode(content["image"]["source"]["bytes"]).decode("utf-8"),
|
|
"media_type": _IMAGE_MEDIA_TYPES[image_format],
|
|
"type": "base64",
|
|
},
|
|
"type": "image",
|
|
}
|
|
|
|
if "reasoningContent" in content:
|
|
return {
|
|
"signature": content["reasoningContent"]["reasoningText"]["signature"],
|
|
"thinking": content["reasoningContent"]["reasoningText"]["text"],
|
|
"type": "thinking",
|
|
}
|
|
|
|
if "text" in content:
|
|
return {"text": content["text"], "type": "text"}
|
|
|
|
if "citationsContent" in content:
|
|
text = "".join(c["text"] for c in content["citationsContent"].get("content", []) if "text" in c)
|
|
return {"text": text, "type": "text"}
|
|
|
|
if "toolUse" in content:
|
|
return {
|
|
"id": content["toolUse"]["toolUseId"],
|
|
"input": content["toolUse"]["input"],
|
|
"name": content["toolUse"]["name"],
|
|
"type": "tool_use",
|
|
}
|
|
|
|
if "toolResult" in content:
|
|
return {
|
|
"content": [
|
|
self._format_request_message_content(
|
|
{"text": json.dumps(tool_result_content["json"], ensure_ascii=False)}
|
|
if "json" in tool_result_content
|
|
else cast(ContentBlock, tool_result_content)
|
|
)
|
|
for tool_result_content in content["toolResult"]["content"]
|
|
],
|
|
"is_error": content["toolResult"]["status"] == "error",
|
|
"tool_use_id": content["toolResult"]["toolUseId"],
|
|
"type": "tool_result",
|
|
}
|
|
|
|
raise TypeError(f"content_type=<{next(iter(content))}> | unsupported type")
|
|
|
|
def _format_request_messages(
|
|
self, messages: Messages, cache_target_idx: int | None = None, dynamic_trailing_blocks: int = 0
|
|
) -> list[dict[str, Any]]:
|
|
"""Format an Anthropic messages array.
|
|
|
|
Args:
|
|
messages: List of message objects to be processed by the model.
|
|
cache_target_idx: Index of the message that owns the managed cache point while
|
|
``cache_config`` is set. Automatic placement applies to that message only when nothing in
|
|
it already carries the cache point.
|
|
dynamic_trailing_blocks: How many trailing blocks of the cache-target message are rebuilt on
|
|
every call, so the cache point stays ahead of them.
|
|
|
|
Returns:
|
|
An Anthropic messages array.
|
|
"""
|
|
cache_config = self.config.get("cache_config")
|
|
configured_ttl = cache_config.ttl if cache_config else None
|
|
formatted_messages = []
|
|
|
|
for message_idx, message in enumerate(messages):
|
|
formatted_contents: list[dict[str, Any]] = []
|
|
marked = False
|
|
|
|
for content in message["content"]:
|
|
if "cachePoint" in content:
|
|
ttl = content["cachePoint"].get("ttl")
|
|
if not ttl and message_idx == cache_target_idx:
|
|
ttl = configured_ttl
|
|
|
|
if self._attach_cache_control(formatted_contents, ttl):
|
|
marked = True
|
|
elif message_idx == cache_target_idx:
|
|
logger.warning(
|
|
"msg_idx=<%d> | nothing ahead of the placed cache point can carry one, "
|
|
"falling back to automatic placement",
|
|
message_idx,
|
|
)
|
|
else:
|
|
logger.warning("no preceding block accepts a cache point | skipped cache point")
|
|
continue
|
|
|
|
# Check for location sources in image, document, or video content
|
|
if _has_location_source(content):
|
|
logger.warning("Location sources are not supported by Anthropic | skipping content block")
|
|
continue
|
|
|
|
formatted_contents.append(self._format_request_message_content(content))
|
|
|
|
# Automatic placement runs once the whole message is formatted, so the cache point lands on a
|
|
# block that survived translation. It is skipped when a caller-placed point already marked one.
|
|
# Per-call trailing blocks apply only to the cache-target message, which is where a producer
|
|
# appends content rebuilt every call.
|
|
if message_idx == cache_target_idx and not marked:
|
|
if self._attach_cache_control(formatted_contents, configured_ttl, dynamic_trailing_blocks):
|
|
logger.debug("msg_idx=<%d> | added cache point to last user message", message_idx)
|
|
else:
|
|
logger.debug("msg_idx=<%d> | no cacheable content block, skipped cache point", message_idx)
|
|
|
|
if formatted_contents:
|
|
formatted_messages.append({"content": formatted_contents, "role": message["role"]})
|
|
|
|
return formatted_messages
|
|
|
|
@classmethod
|
|
def _attach_cache_control(
|
|
cls, formatted_contents: list[dict[str, Any]], ttl: str | None, skip_trailing: int = 0
|
|
) -> bool:
|
|
"""Mark the last already-formatted block that the API accepts ``cache_control`` on.
|
|
|
|
Scans backwards because the nearest block may be a type the API rejects (a ``thinking`` block,
|
|
for example) or may have been dropped in translation.
|
|
|
|
Args:
|
|
formatted_contents: Blocks formatted so far for the current message. Mutated in place.
|
|
ttl: Optional TTL duration carried by the cache point.
|
|
skip_trailing: Trailing blocks rebuilt every call; the cache point stays ahead of them, since a
|
|
prefix that changes every call is written every call and never read.
|
|
|
|
Returns:
|
|
True when a block was marked, False when none of the blocks can carry a cache point.
|
|
"""
|
|
durable = formatted_contents[: len(formatted_contents) - skip_trailing]
|
|
for block in reversed(durable):
|
|
if block.get("type") in _CACHEABLE_BLOCK_TYPES:
|
|
block["cache_control"] = cls._format_cache_control(ttl)
|
|
return True
|
|
|
|
return False
|
|
|
|
def _resolve_tools_cache(self) -> dict[str, Any] | None:
|
|
"""Return the Anthropic ``cache_control`` payload for tool definitions, if enabled.
|
|
|
|
An explicitly set ``cache_config.tools_ttl`` takes precedence; when it is left unset (None) the
|
|
deprecated model-level ``cache_tools`` applies instead so existing configs keep working.
|
|
"""
|
|
cache_config = self.config.get("cache_config")
|
|
if cache_config is None or cache_config.tools_ttl is None:
|
|
return self._resolve_deprecated_cache_tools()
|
|
|
|
if cache_config.tools_ttl is False or cache_config.strategy not in ("auto", "anthropic"):
|
|
return None
|
|
|
|
tools_ttl = cache_config.tools_ttl
|
|
ttl = tools_ttl if isinstance(tools_ttl, str) else cache_config.ttl
|
|
return self._format_cache_control(ttl)
|
|
|
|
def _resolve_deprecated_cache_tools(self) -> dict[str, Any] | None:
|
|
"""Resolve tool caching from the deprecated model-level ``cache_tools`` option.
|
|
|
|
Reached only when ``cache_config.tools_ttl`` is unset; an explicit ``tools_ttl`` supersedes this path.
|
|
"""
|
|
cache_tools = self.config.get("cache_tools")
|
|
if not cache_tools:
|
|
return None
|
|
|
|
ttl = cache_tools.ttl if isinstance(cache_tools, CacheToolsConfig) else None
|
|
if not ttl:
|
|
cache_config = self.config.get("cache_config")
|
|
if cache_config and cache_config.ttl and cache_config.strategy in ("auto", "anthropic"):
|
|
ttl = cache_config.ttl
|
|
return self._format_cache_control(ttl)
|
|
|
|
@staticmethod
|
|
def _format_cache_control(ttl: str | None) -> dict[str, Any]:
|
|
"""Build an Anthropic ``cache_control`` value.
|
|
|
|
Args:
|
|
ttl: TTL duration (e.g. "5m", "1h"). A falsy value is omitted, leaving the API default.
|
|
|
|
Returns:
|
|
An Anthropic cache_control dict.
|
|
"""
|
|
cache_control: dict[str, Any] = {"type": _ANTHROPIC_CACHE_TYPE}
|
|
if ttl:
|
|
cache_control["ttl"] = ttl
|
|
return cache_control
|
|
|
|
def _manage_cache_points(self, messages: Messages) -> tuple[Messages, int | None]:
|
|
"""Return a copy of messages carrying at most one cache point, and the message that owns it.
|
|
|
|
A cache point in the last user message is kept where it sits; extras there, and points in earlier
|
|
messages, are stripped so they cannot accumulate one per turn against the API's shared budget.
|
|
|
|
Args:
|
|
messages: List of message objects to manage cache points for.
|
|
|
|
Returns:
|
|
A new list of messages and the index of the message that owns the cache point, or None when no
|
|
user message can carry one. The input is never modified.
|
|
"""
|
|
cache_config = self.config.get("cache_config")
|
|
if not cache_config:
|
|
return messages, None
|
|
|
|
if cache_config.strategy not in ("auto", "anthropic"):
|
|
logger.warning("strategy=<%s> | unknown cache strategy, prompt caching disabled", cache_config.strategy)
|
|
return messages, None
|
|
|
|
target_idx = next(
|
|
(
|
|
idx
|
|
for idx in reversed(range(len(messages)))
|
|
if messages[idx]["role"] == "user"
|
|
and any("cachePoint" not in block for block in messages[idx]["content"])
|
|
),
|
|
None,
|
|
)
|
|
if target_idx is None:
|
|
logger.debug("no user message with content | skipped cache point")
|
|
|
|
copied: list[Message] = []
|
|
stripped = 0
|
|
for msg_idx, message in enumerate(messages):
|
|
content: list[ContentBlock] = []
|
|
honored = False
|
|
for block in message["content"]:
|
|
if "cachePoint" not in block:
|
|
content.append(block)
|
|
elif msg_idx == target_idx and not honored:
|
|
honored = True
|
|
content.append(block)
|
|
else:
|
|
stripped += 1
|
|
copied.append({"role": message["role"], "content": content})
|
|
|
|
if stripped:
|
|
logger.warning(
|
|
"count=<%d> | stripped extra cache points, cache_config keeps the first cache point in the "
|
|
"last user message; unset cache_config to keep every cache point",
|
|
stripped,
|
|
)
|
|
|
|
return copied, target_idx
|
|
|
|
def format_request(
|
|
self,
|
|
messages: Messages,
|
|
tool_specs: list[ToolSpec] | None = None,
|
|
system_prompt: str | None = None,
|
|
tool_choice: ToolChoice | None = None,
|
|
dynamic_trailing_blocks: int = 0,
|
|
*,
|
|
system_prompt_content: list[SystemContentBlock] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Format an Anthropic streaming request.
|
|
|
|
Args:
|
|
messages: List of message objects to be processed by the model.
|
|
tool_specs: List of tool specifications to make available to the model.
|
|
system_prompt: Plain string system prompt. Ignored when system_prompt_content is provided.
|
|
tool_choice: Selection strategy for tool invocation.
|
|
dynamic_trailing_blocks: How many trailing blocks of the last user message are rebuilt on
|
|
every call, so the cache point stays ahead of them.
|
|
system_prompt_content: Structured system prompt content blocks, which can carry a cache point.
|
|
|
|
Returns:
|
|
An Anthropic streaming request.
|
|
|
|
Raises:
|
|
TypeError: If a message contains a content block type that cannot be converted to an Anthropic-compatible
|
|
format.
|
|
"""
|
|
messages, cache_target_idx = self._manage_cache_points(messages)
|
|
|
|
tools: list[dict[str, Any]] = [
|
|
{
|
|
"name": tool_spec["name"],
|
|
"description": tool_spec["description"],
|
|
"input_schema": tool_spec["inputSchema"]["json"],
|
|
}
|
|
for tool_spec in tool_specs or []
|
|
]
|
|
|
|
params = self.config.get("params") or {}
|
|
server_tools = [*(self.config.get("anthropic_tools") or []), *(params.get("tools") or [])]
|
|
# Forcing a tool means this turn must call a function tool, so server tools are left out.
|
|
if tool_choice is None or "auto" in tool_choice:
|
|
# Copied so the cache_control below never lands on the caller's config.
|
|
tools.extend(dict(tool) for tool in server_tools)
|
|
elif server_tools:
|
|
logger.warning(
|
|
"tool_choice=<%s>, server_tools=<%s> | forced tool call, omitting server tools",
|
|
next(iter(tool_choice), None),
|
|
[tool.get("name") for tool in server_tools],
|
|
)
|
|
|
|
# A cache_control on the final tool caches all of them, so one cache point suffices.
|
|
if tools and (cache_control := self._resolve_tools_cache()):
|
|
tools[-1]["cache_control"] = cache_control
|
|
|
|
system = self._format_system_prompt(system_prompt, system_prompt_content)
|
|
|
|
request = {
|
|
"max_tokens": self.config["max_tokens"],
|
|
"messages": self._format_request_messages(messages, cache_target_idx, dynamic_trailing_blocks),
|
|
"model": self.config["model_id"],
|
|
**(self._format_tool_choice(tool_choice)),
|
|
**({"system": system} if system else {}),
|
|
**params,
|
|
"tools": tools,
|
|
}
|
|
|
|
return request
|
|
|
|
def _format_system_prompt(
|
|
self, system_prompt: str | None, system_prompt_content: list[SystemContentBlock] | None
|
|
) -> str | list[dict[str, Any]] | None:
|
|
"""Format the system prompt for the Anthropic API, auto-injecting a cache point at its end.
|
|
|
|
Args:
|
|
system_prompt: Plain string system prompt. Ignored when system_prompt_content is provided.
|
|
system_prompt_content: Structured system prompt content blocks.
|
|
|
|
Returns:
|
|
The API system value (string or text blocks), or None when no system prompt is given.
|
|
"""
|
|
cache_config = self.config.get("cache_config")
|
|
if cache_config is None or cache_config.strategy not in ("auto", "anthropic"):
|
|
managed_ttl: str | None = None
|
|
auto_inject = False
|
|
else:
|
|
system_prompt_ttl = cache_config.system_prompt_ttl
|
|
managed_ttl = system_prompt_ttl if isinstance(system_prompt_ttl, str) else cache_config.ttl
|
|
auto_inject = system_prompt_ttl is not False
|
|
|
|
if system_prompt_content is None:
|
|
if not system_prompt:
|
|
return None
|
|
if not auto_inject:
|
|
return system_prompt
|
|
return [{"type": "text", "text": system_prompt, "cache_control": self._format_cache_control(managed_ttl)}]
|
|
|
|
formatted: list[dict[str, Any]] = []
|
|
placed = False
|
|
for block in system_prompt_content:
|
|
if "cachePoint" in block:
|
|
if formatted and "cache_control" in formatted[-1]:
|
|
logger.warning("stripped an extra system cache point | keeping the earlier point on the block")
|
|
elif self._attach_cache_control(formatted, block["cachePoint"].get("ttl") or managed_ttl):
|
|
placed = True
|
|
continue
|
|
if "text" in block:
|
|
formatted.append({"type": "text", "text": block["text"]})
|
|
|
|
if not formatted:
|
|
return None
|
|
if auto_inject and not placed:
|
|
formatted[-1]["cache_control"] = self._format_cache_control(managed_ttl)
|
|
return formatted
|
|
|
|
@staticmethod
|
|
def _validate_anthropic_tools(anthropic_tools: list[dict[str, Any]]) -> None:
|
|
"""Validate that anthropic_tools does not contain function tool definitions.
|
|
|
|
Args:
|
|
anthropic_tools: List of Anthropic tools to validate.
|
|
|
|
Raises:
|
|
ValueError: If any tool carries an input_schema.
|
|
"""
|
|
for tool in anthropic_tools:
|
|
if "input_schema" in tool:
|
|
raise ValueError(
|
|
"anthropic_tools should not contain function tool definitions. "
|
|
"Use the standard tools interface for function calling tools. "
|
|
"anthropic_tools is reserved for Anthropic server-side tools like "
|
|
"web_search, web_fetch, and code_execution."
|
|
)
|
|
|
|
@staticmethod
|
|
def _format_citation(citation: dict[str, Any]) -> CitationsDelta:
|
|
"""Format an Anthropic citation into a Strands citation delta.
|
|
|
|
Args:
|
|
citation: An Anthropic citation object from a `citations_delta` event.
|
|
|
|
Returns:
|
|
The formatted citation delta.
|
|
"""
|
|
formatted: CitationsDelta = {}
|
|
|
|
if title := citation.get("title") or citation.get("document_title"):
|
|
formatted["title"] = title
|
|
|
|
if cited_text := citation.get("cited_text"):
|
|
formatted["sourceContent"] = [{"text": cited_text}]
|
|
|
|
match citation.get("type"):
|
|
case "web_search_result_location":
|
|
url = citation.get("url") or ""
|
|
web: WebLocation = {"url": url}
|
|
if domain := urlparse(url).hostname:
|
|
web["domain"] = domain
|
|
web_location: WebLocationDict = {"web": web}
|
|
formatted["location"] = web_location
|
|
|
|
case "search_result_location":
|
|
search_location: SearchResultLocationDict = {
|
|
"searchResultLocation": {
|
|
"searchResultIndex": citation.get("search_result_index", 0),
|
|
"start": citation.get("start_block_index", 0),
|
|
"end": citation.get("end_block_index", 0),
|
|
}
|
|
}
|
|
formatted["location"] = search_location
|
|
|
|
case "char_location":
|
|
char_location: DocumentCharLocationDict = {
|
|
"documentChar": {
|
|
"documentIndex": citation.get("document_index", 0),
|
|
"start": citation.get("start_char_index", 0),
|
|
"end": citation.get("end_char_index", 0),
|
|
}
|
|
}
|
|
formatted["location"] = char_location
|
|
|
|
case "page_location":
|
|
page_location: DocumentPageLocationDict = {
|
|
"documentPage": {
|
|
"documentIndex": citation.get("document_index", 0),
|
|
"start": citation.get("start_page_number", 0),
|
|
"end": citation.get("end_page_number", 0),
|
|
}
|
|
}
|
|
formatted["location"] = page_location
|
|
|
|
case "content_block_location":
|
|
chunk_location: DocumentChunkLocationDict = {
|
|
"documentChunk": {
|
|
"documentIndex": citation.get("document_index", 0),
|
|
"start": citation.get("start_block_index", 0),
|
|
"end": citation.get("end_block_index", 0),
|
|
}
|
|
}
|
|
formatted["location"] = chunk_location
|
|
|
|
case unknown_type:
|
|
logger.warning("citation_type=<%s> | unsupported citation location | skipping", unknown_type)
|
|
|
|
return formatted
|
|
|
|
@staticmethod
|
|
def _log_server_tool_block(content_block: Any) -> None:
|
|
"""Log a skipped server-side tool block, at warning level when the tool returned an error.
|
|
|
|
Args:
|
|
content_block: The `content_block` of a `content_block_start` event.
|
|
"""
|
|
error_code = getattr(getattr(content_block, "content", None), "error_code", None)
|
|
if error_code is not None:
|
|
logger.warning("block_type=<%s>, error_code=<%s> | server-side tool failed", content_block.type, error_code)
|
|
else:
|
|
logger.debug("block_type=<%s> | skipping server-side tool block", content_block.type)
|
|
|
|
@staticmethod
|
|
def _format_tool_choice(tool_choice: ToolChoice | None) -> dict:
|
|
if tool_choice is None:
|
|
return {}
|
|
|
|
if "any" in tool_choice:
|
|
return {"tool_choice": {"type": "any"}}
|
|
elif "auto" in tool_choice:
|
|
return {"tool_choice": {"type": "auto"}}
|
|
elif "tool" in tool_choice:
|
|
return {"tool_choice": {"type": "tool", "name": cast(ToolChoiceToolDict, tool_choice)["tool"]["name"]}}
|
|
else:
|
|
return {}
|
|
|
|
def format_chunk(self, event: dict[str, Any]) -> StreamEvent:
|
|
"""Format the Anthropic response events into standardized message chunks.
|
|
|
|
Args:
|
|
event: A response event from the Anthropic model.
|
|
|
|
Returns:
|
|
The formatted chunk.
|
|
|
|
Raises:
|
|
RuntimeError: If chunk_type is not recognized.
|
|
This error should never be encountered as we control chunk_type in the stream method.
|
|
"""
|
|
match event["type"]:
|
|
case "message_start":
|
|
return {"messageStart": {"role": "assistant"}}
|
|
|
|
case "content_block_start":
|
|
content = event["content_block"]
|
|
|
|
if content["type"] == "tool_use":
|
|
return {
|
|
"contentBlockStart": {
|
|
"contentBlockIndex": event["index"],
|
|
"start": {
|
|
"toolUse": {
|
|
"name": content["name"],
|
|
"toolUseId": content["id"],
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
return {"contentBlockStart": {"contentBlockIndex": event["index"], "start": {}}}
|
|
|
|
case "content_block_delta":
|
|
delta = event["delta"]
|
|
|
|
match delta["type"]:
|
|
case "signature_delta":
|
|
return {
|
|
"contentBlockDelta": {
|
|
"contentBlockIndex": event["index"],
|
|
"delta": {
|
|
"reasoningContent": {
|
|
"signature": delta["signature"],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
case "thinking_delta":
|
|
return {
|
|
"contentBlockDelta": {
|
|
"contentBlockIndex": event["index"],
|
|
"delta": {
|
|
"reasoningContent": {
|
|
"text": delta["thinking"],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
case "input_json_delta":
|
|
return {
|
|
"contentBlockDelta": {
|
|
"contentBlockIndex": event["index"],
|
|
"delta": {
|
|
"toolUse": {
|
|
"input": delta["partial_json"],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
case "text_delta":
|
|
return {
|
|
"contentBlockDelta": {
|
|
"contentBlockIndex": event["index"],
|
|
"delta": {
|
|
"text": delta["text"],
|
|
},
|
|
},
|
|
}
|
|
|
|
case "citations_delta":
|
|
return {
|
|
"contentBlockDelta": {
|
|
"contentBlockIndex": event["index"],
|
|
"delta": {
|
|
"citation": self._format_citation(delta["citation"]),
|
|
},
|
|
},
|
|
}
|
|
|
|
case _:
|
|
raise RuntimeError(
|
|
f"event_type=<content_block_delta>, delta_type=<{delta['type']}> | unknown type"
|
|
)
|
|
|
|
case "content_block_stop":
|
|
return {"contentBlockStop": {"contentBlockIndex": event["index"]}}
|
|
|
|
case "message_stop":
|
|
message = event["message"]
|
|
|
|
return {"messageStop": {"stopReason": message["stop_reason"]}}
|
|
|
|
case "metadata":
|
|
usage = event["usage"]
|
|
input_tokens = usage["input_tokens"]
|
|
output_tokens = usage["output_tokens"]
|
|
cache_read = usage.get("cache_read_input_tokens") or 0
|
|
cache_write = usage.get("cache_creation_input_tokens") or 0
|
|
usage_chunk: Usage = {
|
|
"inputTokens": input_tokens,
|
|
"outputTokens": output_tokens,
|
|
"totalTokens": input_tokens + output_tokens,
|
|
}
|
|
if cache_read:
|
|
usage_chunk["cacheReadInputTokens"] = cache_read
|
|
if cache_write:
|
|
usage_chunk["cacheWriteInputTokens"] = cache_write
|
|
|
|
return {
|
|
"metadata": {
|
|
"usage": usage_chunk,
|
|
"metrics": {
|
|
"latencyMs": 0, # TODO
|
|
},
|
|
}
|
|
}
|
|
|
|
case _:
|
|
raise RuntimeError(f"event_type=<{event['type']} | unknown type")
|
|
|
|
@override
|
|
async def count_tokens(
|
|
self,
|
|
messages: Messages,
|
|
tool_specs: list[ToolSpec] | None = None,
|
|
system_prompt: str | None = None,
|
|
system_prompt_content: list[SystemContentBlock] | None = None,
|
|
) -> int:
|
|
"""Count tokens using Anthropic's native count_tokens API.
|
|
|
|
Uses the same message format as the Messages API to get accurate token counts
|
|
directly from the Anthropic service.
|
|
|
|
Args:
|
|
messages: List of message objects to count tokens for.
|
|
tool_specs: List of tool specifications to include in the count.
|
|
system_prompt: Plain string system prompt. Ignored if system_prompt_content is provided.
|
|
system_prompt_content: Structured system prompt content blocks.
|
|
|
|
Returns:
|
|
Total input token count.
|
|
"""
|
|
if self.config.get("use_native_token_count") is not True:
|
|
return await super().count_tokens(messages, tool_specs, system_prompt, system_prompt_content)
|
|
|
|
try:
|
|
request = self.format_request(
|
|
messages, tool_specs, system_prompt, system_prompt_content=system_prompt_content
|
|
)
|
|
# Keep only fields accepted by count_tokens; strip inference params (max_tokens, temperature, etc.)
|
|
count_tokens_fields = {"model", "messages", "tools", "tool_choice", "system"}
|
|
request = {k: request[k] for k in request.keys() & count_tokens_fields}
|
|
|
|
response = await self.client.messages.count_tokens(**request)
|
|
total_tokens: int = response.input_tokens
|
|
|
|
logger.debug(
|
|
"model_id=<%s>, total_tokens=<%d> | native token count",
|
|
self.config["model_id"],
|
|
total_tokens,
|
|
)
|
|
return total_tokens
|
|
except Exception as e:
|
|
logger.debug(
|
|
"model_id=<%s>, error=<%s> | native token counting failed, falling back to estimation",
|
|
self.config["model_id"],
|
|
e,
|
|
)
|
|
return await super().count_tokens(messages, tool_specs, system_prompt, system_prompt_content)
|
|
|
|
@override
|
|
async def stream(
|
|
self,
|
|
messages: Messages,
|
|
tool_specs: list[ToolSpec] | None = None,
|
|
system_prompt: str | None = None,
|
|
*,
|
|
tool_choice: ToolChoice | None = None,
|
|
system_prompt_content: list[SystemContentBlock] | None = None,
|
|
**kwargs: Any,
|
|
) -> AsyncGenerator[StreamEvent, None]:
|
|
"""Stream conversation with the Anthropic model.
|
|
|
|
Args:
|
|
messages: List of message objects to be processed by the model.
|
|
tool_specs: List of tool specifications to make available to the model.
|
|
system_prompt: Plain string system prompt. Ignored when system_prompt_content is provided.
|
|
tool_choice: Selection strategy for tool invocation.
|
|
system_prompt_content: Structured system prompt content blocks, which can carry a cache point.
|
|
**kwargs: Additional keyword arguments for future extensibility.
|
|
|
|
Yields:
|
|
Formatted message chunks from the model.
|
|
|
|
A server-side tool turn that Anthropic pauses is resumed with follow-up requests inside this call, so
|
|
an error raised by one of those requests can surface after earlier chunks were already yielded.
|
|
|
|
Raises:
|
|
ContextWindowOverflowException: If the input exceeds the model's context window.
|
|
ModelThrottledException: If the request is throttled by Anthropic.
|
|
RuntimeError: If a paused server-side tool turn is still paused after the continuation limit.
|
|
"""
|
|
logger.debug("formatting request")
|
|
request = self.format_request(
|
|
messages,
|
|
tool_specs,
|
|
system_prompt,
|
|
system_prompt_content=system_prompt_content,
|
|
tool_choice=tool_choice,
|
|
dynamic_trailing_blocks=kwargs.get("dynamic_trailing_blocks", 0),
|
|
)
|
|
logger.debug("request=<%s>", request)
|
|
|
|
logger.debug("invoking model")
|
|
usage: dict[str, int] = {}
|
|
block_index_offset = 0
|
|
continuations = 0
|
|
try:
|
|
while True:
|
|
async with self.client.messages.stream(**request) as stream:
|
|
logger.debug("got response from model")
|
|
server_tool_block_indexes: set[int] = set()
|
|
next_block_index = block_index_offset
|
|
stop_reason: str | None = None
|
|
async for event in stream:
|
|
if event.type not in AnthropicModel.EVENT_TYPES:
|
|
continue
|
|
if event.type == "message_start":
|
|
if continuations == 0:
|
|
yield self.format_chunk(event.model_dump())
|
|
continue
|
|
if event.type == "message_stop":
|
|
stop_reason = event.message.stop_reason
|
|
continue
|
|
if event.type == "content_block_start" and event.content_block.type in _SERVER_TOOL_BLOCK_TYPES:
|
|
server_tool_block_indexes.add(event.index)
|
|
self._log_server_tool_block(event.content_block)
|
|
continue
|
|
if event.type == "content_block_delta" and event.index in server_tool_block_indexes:
|
|
continue
|
|
if event.type == "content_block_stop":
|
|
if event.index in server_tool_block_indexes:
|
|
server_tool_block_indexes.discard(event.index)
|
|
continue
|
|
payload: dict[str, Any] = {"type": "content_block_stop", "index": event.index}
|
|
else:
|
|
payload = event.model_dump()
|
|
|
|
payload["index"] += block_index_offset
|
|
next_block_index = max(next_block_index, payload["index"] + 1)
|
|
yield self.format_chunk(payload)
|
|
|
|
message_snapshot = None
|
|
try:
|
|
message_snapshot = await stream.get_final_message()
|
|
except AssertionError as e:
|
|
logger.warning(
|
|
"error=<%s> | failed to retrieve message snapshot, usage metadata unavailable", e
|
|
)
|
|
else:
|
|
for key, value in message_snapshot.usage.model_dump().items():
|
|
if isinstance(value, int):
|
|
usage[key] = usage.get(key, 0) + value
|
|
|
|
if stop_reason == "pause_turn":
|
|
if continuations >= _MAX_PAUSE_TURN_CONTINUATIONS:
|
|
raise RuntimeError(
|
|
f"server-side tool turn did not complete after {continuations} continuations"
|
|
)
|
|
if message_snapshot is not None:
|
|
continuations += 1
|
|
block_index_offset = next_block_index
|
|
request = {
|
|
**request,
|
|
"messages": [
|
|
*request["messages"],
|
|
{"role": "assistant", "content": message_snapshot.content},
|
|
],
|
|
}
|
|
logger.debug("continuation=<%d> | resuming paused server-side tool turn", continuations)
|
|
continue
|
|
logger.warning(
|
|
"continuations=<%d> | paused server-side tool turn not resumed, ending turn", continuations
|
|
)
|
|
stop_reason = "end_turn"
|
|
|
|
if stop_reason is not None:
|
|
# Build dict directly to avoid Pydantic serialization warnings
|
|
# when the message contains ParsedTextBlock objects (issue #1746)
|
|
yield self.format_chunk({"type": "message_stop", "message": {"stop_reason": stop_reason}})
|
|
if usage:
|
|
yield self.format_chunk({"type": "metadata", "usage": usage})
|
|
break
|
|
|
|
except anthropic.RateLimitError as error:
|
|
raise ModelThrottledException(str(error)) from error
|
|
|
|
except anthropic.BadRequestError as error:
|
|
if any(overflow_message in str(error).lower() for overflow_message in AnthropicModel.OVERFLOW_MESSAGES):
|
|
raise ContextWindowOverflowException(str(error)) from error
|
|
|
|
raise error
|
|
|
|
logger.debug("finished streaming response from model")
|
|
|
|
@override
|
|
async def structured_output(
|
|
self, output_model: type[T], prompt: Messages, system_prompt: str | None = None, **kwargs: Any
|
|
) -> AsyncGenerator[dict[str, T | Any], None]:
|
|
"""Get structured output from the model.
|
|
|
|
Args:
|
|
output_model: The output model to use for the agent.
|
|
prompt: The prompt messages to use for the agent.
|
|
system_prompt: System prompt to provide context to the model.
|
|
**kwargs: Additional keyword arguments for future extensibility.
|
|
|
|
Yields:
|
|
Model events with the last being the structured output.
|
|
"""
|
|
tool_spec = convert_pydantic_to_tool_spec(output_model)
|
|
|
|
response = self.stream(
|
|
messages=prompt,
|
|
tool_specs=[tool_spec],
|
|
system_prompt=system_prompt,
|
|
tool_choice=cast(ToolChoice, {"any": {}}),
|
|
**kwargs,
|
|
)
|
|
async for event in process_stream(response):
|
|
yield event
|
|
|
|
stop_reason, messages, _, _ = event["stop"]
|
|
|
|
if stop_reason != "tool_use":
|
|
raise ValueError(f'Model returned stop_reason: {stop_reason} instead of "tool_use".')
|
|
|
|
content = messages["content"]
|
|
output_response: dict[str, Any] | None = None
|
|
for block in content:
|
|
# if the tool use name doesn't match the tool spec name, skip, and if the block is not a tool use, skip.
|
|
# if the tool use name never matches, raise an error.
|
|
if block.get("toolUse") and block["toolUse"]["name"] == tool_spec["name"]:
|
|
output_response = block["toolUse"]["input"]
|
|
else:
|
|
continue
|
|
|
|
if output_response is None:
|
|
raise ValueError("No valid tool use or tool use input was found in the Anthropic response.")
|
|
|
|
yield {"output": output_model(**output_response)}
|