/ integrations / anthropic.py
anthropic.py
1 """Anthropic provider implementation of the LLMProvider protocol.""" 2 3 __all__ = ["AnthropicClient"] 4 5 import json 6 import logging 7 import threading 8 from base64 import b64encode 9 from collections.abc import Sequence 10 11 import anthropic 12 from pydantic import BaseModel 13 from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential 14 15 from exceptions import APIError 16 from models.llm import Attachment 17 18 logger = logging.getLogger(__name__) 19 20 21 def _is_transient(exc: BaseException) -> bool: 22 if isinstance(exc, anthropic.APIStatusError): 23 return exc.status_code == 429 or exc.status_code >= 500 24 return isinstance(exc, (anthropic.APITimeoutError, anthropic.APIConnectionError)) 25 26 27 class AnthropicClient: 28 """LLMProvider backed by the Anthropic SDK.""" 29 30 def __init__(self, api_key: str, model: str, max_tokens: int = 1024) -> None: 31 self._client = anthropic.Anthropic(api_key=api_key) 32 self._model = model 33 self._max_tokens = max_tokens 34 self._tls = threading.local() 35 36 @retry( 37 retry=retry_if_exception(_is_transient), 38 wait=wait_exponential(multiplier=1, min=2, max=60), 39 stop=stop_after_attempt(5), 40 reraise=True, 41 ) 42 def complete( 43 self, 44 system: str, 45 user: str, 46 *, 47 temperature: float | None = None, 48 seed: int | None = None, 49 response_schema: type[BaseModel] | None = None, 50 attachments: Sequence[Attachment] | None = None, 51 ) -> str: 52 """Send a prompt to Anthropic and return the response. 53 54 Args: 55 system: System prompt text. 56 user: User message text. 57 temperature: Sampling temperature, or None to use the model's default. 58 seed: Ignored — Anthropic does not support seed. 59 response_schema: If provided, uses tool-use structured output. 60 attachments: Binary files sent as base64 document content blocks. 61 62 Returns: 63 Response text or JSON string. 64 65 Raises: 66 APIError: On non-retriable HTTP errors (4xx). 67 """ 68 if seed is not None: 69 logger.debug("AnthropicClient: seed parameter ignored (not supported)") 70 71 logger.debug("Calling anthropic model=%s", self._model) 72 73 content: str | list[dict[str, object]] 74 if attachments: 75 blocks: list[dict[str, object]] = [ 76 { 77 "type": "document", 78 "source": { 79 "type": "base64", 80 "media_type": a.media_type, 81 "data": b64encode(a.data).decode(), 82 }, 83 } 84 for a in attachments 85 ] 86 blocks.append({"type": "text", "text": user}) 87 content = blocks 88 else: 89 content = user 90 91 kwargs: dict[str, object] = { 92 "model": self._model, 93 "max_tokens": self._max_tokens, 94 "system": system, 95 "messages": [{"role": "user", "content": content}], 96 } 97 if temperature is not None: 98 kwargs["temperature"] = temperature 99 100 if response_schema is not None: 101 kwargs["tools"] = [ 102 { 103 "name": "output", 104 "description": "Structured output", 105 "input_schema": response_schema.model_json_schema(), 106 } 107 ] 108 kwargs["tool_choice"] = {"type": "tool", "name": "output"} 109 110 try: 111 response = self._client.messages.create(**kwargs) # type: ignore[call-overload] 112 except anthropic.APIStatusError as exc: 113 if exc.status_code == 429 or exc.status_code >= 500: 114 raise 115 raise APIError("anthropic", exc.status_code, str(exc)) from exc 116 except (anthropic.APITimeoutError, anthropic.APIConnectionError): 117 raise 118 119 if response_schema is not None: 120 tool_block = next(b for b in response.content if b.type == "tool_use") 121 result = json.dumps(tool_block.input) 122 else: 123 result = response.content[0].text 124 125 self._tls.input_tokens = response.usage.input_tokens 126 self._tls.output_tokens = response.usage.output_tokens 127 128 logger.info("Response received (%d chars)", len(result)) 129 return result 130 131 @property 132 def last_usage(self) -> tuple[int, int]: 133 """(input_tokens, output_tokens) from the most recent successful call.""" 134 return ( 135 getattr(self._tls, "input_tokens", 0), 136 getattr(self._tls, "output_tokens", 0), 137 ) 138 139 def ping(self, temperature: float | None = None) -> None: 140 """Send a minimal request to verify the provider and model are reachable.""" 141 self.complete( 142 system="You are a helpful assistant.", user="Say: OK", temperature=temperature 143 )