"""Anthropic provider: structured output via forced tool-use (the response schema becomes the tool's input_schema, so the model can only "call" it with arguments matching the shape we asked for), with a bounded repair loop for the rare malformed response. """ from __future__ import annotations from anthropic import AsyncAnthropic from pydantic import BaseModel, ValidationError from app.analysis.llm.base import LLMResponseError from app.core.config import Settings _TOOL_NAME = "emit_result" class AnthropicLLMProvider: provider_name = "anthropic" def __init__(self, settings: Settings) -> None: self._settings = settings self._client = AsyncAnthropic(api_key=settings.anthropic_api_key) async def generate_structured[T: BaseModel]( self, system_prompt: str, user_prompt: str, response_model: type[T] ) -> T: tools = [ { "name": _TOOL_NAME, "description": f"Emit the result matching the {response_model.__name__} schema.", "input_schema": response_model.model_json_schema(), } ] last_error: Exception | None = None messages: list[dict] = [{"role": "user", "content": user_prompt}] for attempt in range(self._settings.llm_max_retries + 1): if attempt > 0 and last_error is not None: messages = [ *messages, { "role": "user", "content": ( f"Your previous response did not match the required schema: " f"{last_error}. Please try again." ), }, ] response = await self._client.messages.create( model=self._settings.anthropic_model, max_tokens=self._settings.llm_max_tokens_per_request, system=system_prompt, tools=tools, tool_choice={"type": "tool", "name": _TOOL_NAME}, messages=messages, ) tool_use = next((b for b in response.content if b.type == "tool_use"), None) if tool_use is None: last_error = ValueError("No tool_use block in the model's response") continue try: return response_model.model_validate(tool_use.input) except ValidationError as exc: last_error = exc continue raise LLMResponseError( f"Anthropic provider failed to produce a valid {response_model.__name__} after " f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}" ) async def generate_text(self, system_prompt: str, user_prompt: str) -> str: response = await self._client.messages.create( model=self._settings.anthropic_model, max_tokens=self._settings.llm_max_tokens_per_request, system=system_prompt, messages=[{"role": "user", "content": user_prompt}], ) return "\n".join(block.text for block in response.content if block.type == "text")