"""Anthropic(Claude)适配器(ARCH §4.2/§4.4/§4.6)。 - 能力:原生结构化输出(经 instructor)、显式前缀缓存(`cache_control` 断点)、思考。 - 网络客户端注入(`AnthropicClient` Protocol,`AsyncAnthropic` 即满足)——测试注入替身, 绝不联网;真实 SDK 仅在构造客户端时(apps/api)需要,本模块不硬 import `anthropic`。 - 瞬时故障(429/超时/5xx/连接错误)翻译为 `TransientProviderError`,交网关退避/回退。 """ from __future__ import annotations from collections.abc import AsyncIterator from typing import TYPE_CHECKING, Any, Protocol, cast import instructor from pydantic import BaseModel from ..errors import TransientProviderError from ..types import LlmRequest from .base import ( Capabilities, ProviderResult, ProviderUsage, StreamChunk, is_transient_by_name, provider_error_summary, ) if TYPE_CHECKING: from anthropic import AsyncAnthropic # 瞬时错误类名(按名匹配,避免硬依赖 anthropic SDK 类型)。 _TRANSIENT_NAMES = frozenset( { "RateLimitError", "APITimeoutError", "APIConnectionError", "InternalServerError", "APIStatusError", } ) class AnthropicClient(Protocol): """`AsyncAnthropic` 满足此 Protocol(仅用到 `messages`)。""" messages: Any class StructuredAnthropic(Protocol): async def create_with_completion( self, *, response_model: type[BaseModel], **kwargs: Any ) -> tuple[BaseModel, Any]: ... def _is_transient(exc: Exception) -> bool: return is_transient_by_name(exc, _TRANSIENT_NAMES) def _system_blocks(req: LlmRequest) -> list[dict[str, Any]]: """system 块;缓存断点前的稳定块带 `cache_control`(§4.6)。""" out: list[dict[str, Any]] = [] for b in req.system: block: dict[str, Any] = {"type": "text", "text": b.text} if b.cache: block["cache_control"] = {"type": "ephemeral"} out.append(block) return out def _input_text(req: LlmRequest) -> str: if isinstance(req.input, str): return req.input return "\n\n".join(b.text for b in req.input) def _user_messages(req: LlmRequest) -> list[dict[str, Any]]: return [{"role": "user", "content": _input_text(req)}] def _usage_from(usage: Any) -> ProviderUsage: if usage is None: return ProviderUsage(input_tokens=0, output_tokens=0) return ProviderUsage( input_tokens=int(getattr(usage, "input_tokens", 0) or 0), output_tokens=int(getattr(usage, "output_tokens", 0) or 0), cache_read_tokens=int(getattr(usage, "cache_read_input_tokens", 0) or 0), ) def _text_from(resp: Any) -> str: parts: list[str] = [] for block in getattr(resp, "content", []) or []: if getattr(block, "type", None) == "text": parts.append(getattr(block, "text", "") or "") return "".join(parts) _DEFAULT_MAX_TOKENS = 4096 class AnthropicAdapter: def __init__( self, provider: str, client: AnthropicClient, *, structured_client: StructuredAnthropic | None = None, ) -> None: self.provider = provider self._client = client self._structured_client = structured_client def capabilities(self) -> Capabilities: return Capabilities(structured_output=True, prefix_cache=True, thinking=True) def _structured(self) -> StructuredAnthropic: if self._structured_client is None: # 注入客户端是 `AnthropicClient` Protocol(保留测试替身缝), # 但 instructor.from_anthropic 只重载真实 SDK 的具体类型; # 本适配器异步 → 转 `AsyncAnthropic` 选中 AsyncInstructor 重载, # 返回的 AsyncInstructor 鸭子匹配 StructuredAnthropic(含 create_with_completion)。 async_client = cast("AsyncAnthropic", self._client) self._structured_client = cast( "StructuredAnthropic", instructor.from_anthropic(async_client) ) return self._structured_client async def complete(self, req: LlmRequest, model: str) -> ProviderResult: try: if req.output_schema is not None: return await self._complete_structured(req, model) return await self._complete_text(req, model) except Exception as exc: if _is_transient(exc): raise TransientProviderError( provider_error_summary(exc), provider=self.provider ) from exc raise async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult: kwargs: dict[str, Any] = { "model": model, "max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS, "messages": _user_messages(req), } system = _system_blocks(req) if system: kwargs["system"] = system resp = await self._client.messages.create(**kwargs) usage = _usage_from(getattr(resp, "usage", None)) return ProviderResult(text=_text_from(resp), usage=usage) async def _complete_structured(self, req: LlmRequest, model: str) -> ProviderResult: if req.output_schema is None: raise ValueError("_complete_structured called without output_schema") kwargs: dict[str, Any] = { "model": model, "max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS, "messages": _user_messages(req), } system = _system_blocks(req) if system: kwargs["system"] = system parsed, raw = await self._structured().create_with_completion( response_model=req.output_schema, **kwargs ) # 从 raw completion 提取真实 usage(记账真源,§4.8);不再硬编码零(CR-H3)。 usage = _usage_from(getattr(raw, "usage", None)) return ProviderResult(text=parsed.model_dump_json(), usage=usage, parsed=parsed) async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]: kwargs: dict[str, Any] = { "model": model, "max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS, "messages": _user_messages(req), } system = _system_blocks(req) if system: kwargs["system"] = system try: async with self._client.messages.stream(**kwargs) as stream: async for event in stream: if getattr(event, "type", None) == "content_block_delta": delta = getattr(event, "delta", None) text = getattr(delta, "text", "") if delta is not None else "" if text: yield StreamChunk(text=text) usage = getattr(event, "usage", None) if usage is not None: yield StreamChunk(usage=_usage_from(usage)) except Exception as exc: if _is_transient(exc): raise TransientProviderError( provider_error_summary(exc), provider=self.provider ) from exc raise