/ 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          )