zett_agent.providers.anthropic 源代码

from __future__ import annotations

import base64
import json
import ssl
from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import replace
from typing import TYPE_CHECKING, Any, cast

from ..messages import (
    AgentMessage,
    AnyMessage,
    AssistantMessage,
    ImageBytesSource,
    ImageContent,
    ImageUrlSource,
    SystemMessage,
    TextContent,
    ToolMessage,
    UserMessage,
)
from ..model import (
    DEFAULT_RETRY_OPTIONS,
    DEFAULT_TIMEOUT_OPTIONS,
    ModelEvent,
    ModelRequest,
    ModelResponse,
    ModelUsage,
    ReasoningEffort,
    RetryOptions,
    ServerToolCall,
    ServerToolDefinition,
    ServerToolInputDelta,
    ServerToolResult,
    TimeoutOptions,
    ToolCallDelta,
    ToolDefinition,
    validate_response,
    validate_retry,
    validate_timeout,
)
from ..stop import StopInfo, StopReason, classify_stop
from .base import (
    ProviderAuthError,
    ProviderResponseError,
    RetryingProvider,
    _empty_stream_message,
    _parse_tool_calls,
    _reasoning_effort_to_budget,
    _to_model_data,
    _ToolCallAccumulator,
    describe_status_error,
    guard_idle,
    http_error,
    httpx_timeout,
    require_event_stream,
    retry_model_stream,
    validate_extra_body,
    validate_extra_headers,
    validate_extra_query,
)
from .responses import local_tool_search_name
from .stop_reasons import withhold_replay_blocks
from .tool_arguments import ToolArgumentsError, decode_tool_arguments
from .tool_images import expand_tool_images

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable

    import httpx
    from anthropic import AsyncStream

#: Output budget per response. The original 4096 truncated any tool call that wrote a
#: sizeable file, leaving its JSON arguments incomplete.
DEFAULT_MAX_TOKENS = 16_384


def _tools_to_anthropic_payload(
    tools: Sequence[ToolDefinition], server_tools: Sequence[ServerToolDefinition] = ()
) -> list[dict[str, Any]]:
    """Render local tools, deferring the ones tool search is meant to load.

    A tool marked ``deferred`` is declared with ``defer_loading``: Anthropic
    keeps it out of the model's context until a ``tool_reference`` names it.
    That only works when something can produce the reference, so the flag is
    honored only when the request carries a search: the local search tool, or
    Anthropic's hosted ``tool_search_tool_*``. Without one the tool is declared
    normally, because a deferred tool nothing can load would be unusable.

    The local search tool is an ordinary function here, since Anthropic has no
    client-side search endpoint; the model calls it and its result carries the
    references (see :func:`_search_result_blocks`).
    """
    if sum(tool.local_tool_search for tool in tools) > 1:
        raise ValueError("Only one local tool search tool can be registered")
    can_load = any(tool.local_tool_search for tool in tools) or any(
        tool.type.startswith("tool_search_tool_") for tool in server_tools
    )
    rendered: list[dict[str, Any]] = []
    for tool in tools:
        payload: dict[str, Any] = {
            "name": tool.name,
            "description": tool.description,
            "input_schema": dict(tool.parameters),
        }
        if tool.deferred and can_load:
            payload["defer_loading"] = True
        rendered.append(payload)
    return rendered


def _search_result_blocks(message: Any) -> list[dict[str, Any]]:
    """Turn a local tool search result into ``tool_reference`` blocks.

    The search tool returns the definitions it found; Anthropic needs only their
    names, because each deferred tool is already declared in the request. A
    search that finds nothing is reported as text, since an empty reference list
    is not a valid result.
    """
    try:
        found = json.loads(message.content)
    except (TypeError, json.JSONDecodeError) as error:
        raise ProviderResponseError("A local tool search tool must return JSON tool definitions") from error
    if not isinstance(found, list):
        raise ProviderResponseError("A local tool search tool must return a list of definitions")
    names = [item["name"] for item in found if isinstance(item, Mapping) and isinstance(item.get("name"), str)]
    if len(names) != len(found):
        raise ProviderResponseError("Every definition a local tool search tool returns needs a name")
    if not names:
        return [{"type": "text", "text": "No matching tools found."}]
    return [{"type": "tool_reference", "tool_name": name} for name in names]


def _server_tools_to_anthropic_payload(tools: Sequence[ServerToolDefinition]) -> list[dict[str, Any]]:
    """Render Anthropic server tools using their versioned native schemas."""
    return [{"type": tool.type, **dict(tool.configuration)} for tool in tools]


def _apply_prompt_cache(body: dict[str, Any], client_tools: Sequence[dict[str, Any]]) -> None:
    """Mark Anthropic cache breakpoints on the largest stable request prefixes.

    Anthropic caches only what a ``cache_control`` marker asks for, then reads
    each cached prefix at a fraction of the input price. The three markers below
    cover the tool definitions, the whole system prompt, and the conversation so
    far, so a repeated prefix is read instead of re-billed as fresh input. A
    prefix shorter than the model's minimum cacheable length is ignored
    silently.
    """
    breakpoint = {"type": "ephemeral"}
    # Server tools are provider-owned definitions and must not carry the marker.
    # A deferred tool is not part of the cached prefix, so the marker goes on the
    # last tool that is loaded up front.
    loaded = [tool for tool in client_tools if not tool.get("defer_loading")]
    if loaded:
        loaded[-1]["cache_control"] = breakpoint
    system = body.get("system")
    if isinstance(system, str) and system:
        body["system"] = [{"type": "text", "text": system, "cache_control": breakpoint}]
    messages = body.get("messages")
    if messages:
        content = messages[-1].get("content")
        if isinstance(content, list) and content and isinstance(content[-1], dict):
            content[-1]["cache_control"] = breakpoint


[文档] def _to_anthropic_content_blocks( message: AnyMessage, *, model: str | None = None, search_tool: str | None = None ) -> list[dict[str, Any]]: """Convert a domain message into Anthropic content blocks.""" blocks: list[dict[str, Any]] = [] match message: case UserMessage() | AgentMessage(): content = message.content if isinstance(content, str): return [{"type": "text", "text": content}] if content else [] for part in content: match part: case TextContent(text=text): blocks.append({"type": "text", "text": text}) case ImageContent(source=source): blocks.append(_to_anthropic_image_block(source)) return blocks case AssistantMessage(): if message.provider == "anthropic" and message.model == model and message.replay_blocks: return [dict(block) for block in message.replay_blocks] if message.content: blocks.append({"type": "text", "text": message.content}) for call in message.tool_calls: blocks.append( { "type": "tool_use", "id": call.id, "name": call.name, "input": dict(call.arguments), } ) return blocks case ToolMessage(): result = ( _search_result_blocks(message) if search_tool is not None and message.name == search_tool and message.success else message.content ) return [ { "type": "tool_result", "tool_use_id": message.tool_call_id, "content": result, "is_error": not message.success, } ] case _: return []
def _to_anthropic_image_block(source: Any) -> dict[str, Any]: """Convert a typed image source into an Anthropic-compatible content block.""" match source: case ImageUrlSource(url=url): if url.startswith("data:"): header, separator, data = url.partition(",") if not separator or not header.endswith(";base64"): raise ProviderResponseError("Anthropic image data URLs must contain base64 data") return {"type": "image", "source": {"type": "base64", "media_type": header[5:-7], "data": data}} return {"type": "image", "source": {"type": "url", "url": url}} case ImageBytesSource(data=data, media_type=media_type): encoded = base64.b64encode(data).decode("ascii") return { "type": "image", "source": {"type": "base64", "media_type": media_type, "data": encoded}, } case _: raise ProviderResponseError(f"Unsupported image content source: {source!r}")
[文档] class AnthropicProvider(RetryingProvider): """Map Anthropic SDK streams, including signed thinking and tool blocks. Args: model: Model identifier accepted by the Anthropic-compatible endpoint. api_key: API credential supplied by the application. base_url: API root, defaulting to the official Anthropic endpoint. transport: Optional HTTPX transport for isolated tests or custom routing. response: Must remain false; this adapter uses Anthropic Messages API. retry: Exponential backoff for transient failures before output starts. timeout: How long the provider may stay silent before the request is given up on: connecting, waiting for the response headers, and between chunks of a response that has started. See :class:`~zett_agent.model.TimeoutOptions`; a timeout raises :class:`~zett_agent.providers.base.ProviderTimeoutError` and is not retried. prompt_cache: Mark cache breakpoints on the tool definitions, the system prompt, and the conversation so far. Anthropic caches only what a marker asks for, so disabling this trades prompt-cache reads for a payload without ``cache_control``. max_tokens: Output budget for one response, excluding thinking. A tool call that writes a large file must fit in it: a response cut off by this limit leaves the call's JSON incomplete, and the model is then told to retry with a smaller call. Lower it for models with a smaller output limit. extra_headers: HTTP headers sent on every request, for gateways that route or bill on a header or for ``anthropic-beta`` flags. A header given here replaces the SDK default of the same name. extra_query: URL query parameters sent on every request. extra_body: JSON fields merged into every request body last, so they add an option the adapter does not model and may override one it does. Note: Client-side tool search works here too: ``ToolSearchExtension`` ranks the deferred tools locally, and this adapter declares them with ``defer_loading`` and answers the search with ``tool_reference`` blocks. Note: Signed replay blocks belong to the originating provider and model. Persist them unchanged for tool round trips; do not render them as user-facing reasoning. Await aclose() to release client resources. Examples: Stream an Anthropic model through the provider-neutral client:: model = AnthropicProvider( model=os.environ["ANTHROPIC_MODEL"], api_key=os.environ["ANTHROPIC_API_KEY"], ) try: client = await create_agent(model) reply = await client.run("Review the proposed API") finally: await model.aclose() .. warning:: Signed thinking and tool replay blocks must be returned unchanged only to the same provider and model. They are not interchangeable with plain reasoning text. .. seealso:: :class:`~zett_agent.messages.AssistantMessage` documents ``replay_blocks``; :doc:`/concepts/context` explains replay during tool round trips. """ #: See :attr:`zett_agent.model.NativeDeferredToolsModel.native_deferred_tools`. The Messages #: API accepts ``defer_loading`` tools and loads one when a ``tool_reference`` #: names it, so every deferred definition travels with the request. native_deferred_tools: bool = True def __init__( self, model: str, api_key: str, base_url: str = "https://api.anthropic.com", transport: httpx.AsyncBaseTransport | None = None, *, response: bool = False, retry: RetryOptions = DEFAULT_RETRY_OPTIONS, prompt_cache: bool = True, max_tokens: int = DEFAULT_MAX_TOKENS, extra_headers: Mapping[str, str] | None = None, extra_query: Mapping[str, str] | None = None, extra_body: Mapping[str, Any] | None = None, timeout: TimeoutOptions = DEFAULT_TIMEOUT_OPTIONS, ) -> None: import httpx import truststore validate_response(response) if response: raise ValueError("AnthropicProvider does not support the Responses API") if not api_key: raise ValueError("api_key is required") validate_retry(retry) validate_timeout(timeout) if not isinstance(prompt_cache, bool): raise ValueError("prompt_cache must be a boolean") if isinstance(max_tokens, bool) or not isinstance(max_tokens, int) or max_tokens < 1: raise ValueError("max_tokens must be a positive integer") self.max_tokens = max_tokens self.extra_headers = validate_extra_headers(extra_headers) self.extra_query = validate_extra_query(extra_query) self.extra_body = validate_extra_body(extra_body) self.retry = retry self.timeout = timeout self.response = response self.prompt_cache = prompt_cache self.model = model self._http_client = httpx.AsyncClient( transport=transport, verify=truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT), trust_env=True, ) from anthropic import AsyncAnthropic self._client = AsyncAnthropic( api_key=api_key, base_url=base_url, http_client=self._http_client, max_retries=0, timeout=httpx_timeout(timeout), default_headers=self.extra_headers or None, default_query=self.extra_query or None, )
[文档] async def aclose(self) -> None: """Close the owned SDK connection pool.""" await self._client.close()
@retry_model_stream async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]: messages: list[dict[str, Any]] = [] system: list[str] = [] search_tool = local_tool_search_name(request.tools) for message in expand_tool_images(request.messages): match message: case SystemMessage(): system.append(message.content) case UserMessage() | AgentMessage(): content = _to_anthropic_content_blocks(message, model=self.model) if content: messages.append({"role": "user", "content": content}) case AssistantMessage(): content = _to_anthropic_content_blocks(message, model=self.model) if content: messages.append({"role": "assistant", "content": content}) case ToolMessage(): content = _to_anthropic_content_blocks(message, model=self.model, search_tool=search_tool) if content: messages.append({"role": "user", "content": content}) client_tools = _tools_to_anthropic_payload(request.tools, request.server_tools) body: dict[str, Any] = { "model": self.model, "system": "\n\n".join(system), "messages": messages, "tools": [ *client_tools, *_server_tools_to_anthropic_payload(request.server_tools), ], "max_tokens": self.max_tokens, "stream": True, } if not system: body.pop("system") if self.prompt_cache: _apply_prompt_cache(body, client_tools) if request.tool_choice: body["tool_choice"] = { "type": "tool", "name": request.tool_choice, "disable_parallel_tool_use": not request.parallel_tool_call, } elif not request.parallel_tool_call and (request.tools or request.server_tools): body["tool_choice"] = {"type": "auto", "disable_parallel_tool_use": True} if request.reasoning_effort != ReasoningEffort.OFF and not request.tool_choice: body["max_tokens"] = _reasoning_effort_to_budget(request.reasoning_effort) + self.max_tokens body["thinking"] = { "type": "enabled", "budget_tokens": _reasoning_effort_to_budget(request.reasoning_effort), } else: body["thinking"] = {"type": "disabled"} text = "" reasoning = "" finish_reason = None stop_details: Mapping[str, Any] = {} streams: dict[int, _ToolCallAccumulator] = {} server_streams: dict[int, _ToolCallAccumulator] = {} server_initial_inputs: dict[int, dict[str, Any]] = {} usage = ModelUsage() replay: dict[int, dict[str, Any]] = {} from anthropic import AnthropicError try: # The request body is assembled as a dict, which the SDK's many typed overloads cannot match # (they select on literal ``stream=True``), so the call is typed by what it returns here. create = cast("Callable[..., Awaitable[AsyncStream[Any]]]", self._client.messages.create) response = await self._first_byte( create(**body, **({"extra_body": self.extra_body} if self.extra_body else {})) ) except AnthropicError as error: if getattr(error, "status_code", None) is None: # No HTTP response at all: the connection failed or timed out. The SDK's own # text ("Connection error.") is what says why; a timeout is recognized from the # chain by the retry layer, which turns it into a typed error. raise ProviderResponseError(f"Anthropic request failed: {error}") from error status, failure, description = describe_status_error("anthropic", error) if status == 401: raise ProviderAuthError(f"Provider rejected API credential. {description}") from error raise http_error(status, failure, description) from error await require_event_stream(response, "anthropic") events_seen = 0 try: async for event in guard_idle(response, self.timeout): events_seen += 1 data = _to_model_data(event) event_type = data.get("type") match event_type: case "message_start": message_usage = data.get("message", {}).get("usage", {}) usage = ModelUsage( input_tokens=sum( int(message_usage.get(key, 0) or 0) for key in ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens") ), cache_read_tokens=int(message_usage.get("cache_read_input_tokens", 0) or 0), cache_write_tokens=int(message_usage.get("cache_creation_input_tokens", 0) or 0), output_tokens=int(message_usage.get("output_tokens", 0) or 0), ) case "content_block_delta": index = int(data.get("index", 0)) delta = data.get("delta", {}) match delta.get("type"): case "text_delta": chunk = delta.get("text", "") text += chunk replay[index]["text"] = replay[index].get("text", "") + chunk yield ModelEvent.text(chunk) case "thinking_delta": chunk = delta.get("thinking", "") reasoning += chunk replay[index]["thinking"] = replay[index].get("thinking", "") + chunk yield ModelEvent.reasoning(chunk) case "signature_delta": replay[index]["signature"] = replay[index].get("signature", "") + delta.get( "signature", "" ) case "input_json_delta": partial = delta.get("partial_json", "") if not partial: continue stream = server_streams.get(index) if stream is not None: stream.argument_buffer += partial yield ModelEvent.server_tool_input( ServerToolInputDelta(stream.call_id, partial), ) continue stream = streams.setdefault(index, _ToolCallAccumulator(index=index)) stream.append(ToolCallDelta(index=index, arguments_delta=partial)) yield ModelEvent.tool_call(ToolCallDelta(index=index, arguments_delta=partial)) case _: pass case "content_block_start": index = int(data.get("index", 0)) block = data.get("content_block", {}) replay[index] = dict(block) match block.get("type"): case "tool_use": stream = streams.setdefault(index, _ToolCallAccumulator(index=index)) stream.call_id = block.get("id", stream.call_id) stream.name = block.get("name", stream.name) yield ModelEvent.tool_call( ToolCallDelta(index=index, id_delta=stream.call_id, name_delta=stream.name) ) case "server_tool_use": call_id = str(block.get("id", "")) name = str(block.get("name", "")) initial_input = block.get("input") input_data = initial_input if isinstance(initial_input, dict) else {} stream = _ToolCallAccumulator(index=index, call_id=call_id, name=name) server_streams[index] = stream server_initial_inputs[index] = input_data yield ModelEvent.server_tool_started(ServerToolCall(call_id, name, input_data or None)) case str(block_type) if block_type.endswith("_tool_result"): call_id = str(block.get("tool_use_id", "")) name = block_type.removesuffix("_tool_result") output = block.get("content") error_code = _anthropic_server_tool_error(output) result = ServerToolResult(call_id, name, output, error_code) yield ( ModelEvent.server_tool_failed(result) if error_code is not None else ModelEvent.server_tool_completed(result) ) case _: pass case "content_block_stop": index = int(data.get("index", 0)) # A malformed block is not fatal here: the replay input # falls back to an empty object, and the completed # response reports the call as failed to the model. if index in streams: replay[index]["input"] = _lenient_arguments(streams[index].argument_buffer, index) if index in server_streams: stream = server_streams[index] replay[index]["input"] = ( _lenient_arguments(stream.argument_buffer, index) if stream.argument_buffer else server_initial_inputs[index] ) case "message_delta": delta = data.get("delta", {}) finish_reason = delta.get("stop_reason") if isinstance(details := delta.get("stop_details"), Mapping): stop_details = details token_info = data.get("usage", {}) usage = replace( usage, output_tokens=int(token_info.get("output_tokens", 0) or 0), ) case _: pass finally: await response.close() if not events_seen: raise ProviderResponseError(_empty_stream_message("anthropic", response)) tool_calls = _parse_tool_calls(streams, finish_reason) for index, stream in streams.items(): replay[index]["input"] = _lenient_arguments(stream.argument_buffer, index) stop = _anthropic_stop(finish_reason, stop_details, has_tool_calls=bool(tool_calls)) blocks: tuple[Mapping[str, Any], ...] = tuple(replay[index] for index in sorted(replay)) if stop.reason in (StopReason.REFUSAL, StopReason.INTERRUPTED): # A refusal can arrive after a complete ``tool_use`` was streamed. The call is # withheld from the message, and a ``tool_use`` block replayed without a result # would be rejected, so the block goes as well. blocks = withhold_replay_blocks(blocks, lambda block: block.get("type") == "tool_use") yield ModelEvent.completed( ModelResponse( message=AssistantMessage( content=text, reasoning=reasoning or None, tool_calls=tool_calls, provider="anthropic", model=self.model, replay_blocks=blocks, ), finish_reason=finish_reason, usage=usage, stop=stop, ) )
def _anthropic_stop(finish_reason: str | None, details: Mapping[str, Any], *, has_tool_calls: bool) -> StopInfo: """Build the stop of a Messages response from ``stop_reason`` and ``stop_details``. ``stop_details`` is null for every reason but ``refusal``. On a refusal its ``category`` and ``explanation`` can both be null, which is a normal value and is not treated as missing data; the category then falls back to the reason itself. """ kept = { key: details[key] for key in ("fallback_credit_token", "fallback_has_prefill_claim") if details.get(key) is not None } category, explanation, recommended = (details.get(key) for key in ("category", "explanation", "recommended_model")) return classify_stop( finish_reason, has_tool_calls=has_tool_calls, category=str(category) if category else None, message=str(explanation) if explanation else None, recommended_model=str(recommended) if recommended else None, detail=kept, ) def _lenient_arguments(payload: str, index: int) -> dict[str, Any]: """Decode a replay block's input, using an empty object when it is unusable.""" try: return decode_tool_arguments(payload, index=index) except ToolArgumentsError: return {} def _anthropic_server_tool_error(output: Any) -> str | None: """Return Anthropic's structured hosted-tool error code, when present.""" if not isinstance(output, dict): return None output_type = output.get("type") if not isinstance(output_type, str) or not output_type.endswith("_error"): return None code = output.get("error_code") return str(code) if code else output_type