Coverage for session_buddy / llm / providers / anthropic_provider.py: 27.40%
59 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-04 00:43 -0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-04 00:43 -0800
1"""Anthropic API provider implementation (Claude models).
3Uses anthropic.AsyncAnthropic client. Kept optional; if the package or
4API key is unavailable, the provider reports as unavailable.
5"""
7from __future__ import annotations
9from datetime import datetime
10from typing import TYPE_CHECKING, Any
12from session_buddy.llm.base import LLMProvider
13from session_buddy.llm.models import LLMMessage, LLMResponse
15if TYPE_CHECKING:
16 from collections.abc import AsyncGenerator
19class AnthropicProvider(LLMProvider):
20 """Anthropic Claude API provider."""
22 def __init__(self, config: dict[str, Any]) -> None:
23 super().__init__(config)
24 self.api_key = config.get("api_key")
25 self.default_model = config.get("default_model", "claude-3-5-haiku-20241022")
26 self._client: Any = None
28 async def _get_client(self) -> Any:
29 if self._client is None:
30 try:
31 import anthropic
33 self._client = anthropic.AsyncAnthropic(api_key=self.api_key)
34 except ImportError: # pragma: no cover - optional dependency
35 msg = "Anthropic package not installed. Install with: pip install anthropic"
36 raise ImportError(msg)
37 return self._client
39 def _strip_thinking_blocks(self, content: str) -> str:
40 """Remove thinking blocks from content before sending to API.
42 Anthropic API does not accept thinking blocks in request messages.
43 They can only appear in responses from the API.
44 """
45 import re
47 # Remove all <thinking>...</thinking> blocks (with any attributes)
48 pattern = r"<thinking[^>]*>.*?</thinking>"
49 cleaned = re.sub(pattern, "", content, flags=re.DOTALL | re.IGNORECASE)
50 return cleaned.strip()
52 def _convert_messages(self, messages: list[LLMMessage]) -> list[dict[str, Any]]:
53 """Convert to Anthropic messages format.
55 - Maps 'system' into top-level system field (handled in generate)
56 - Converts user/assistant into human/assistant messages
57 - Strips thinking blocks from assistant messages (not allowed in API requests)
58 """
59 converted: list[dict[str, Any]] = []
60 for msg in messages:
61 if msg.role == "user":
62 converted.append({"role": "user", "content": msg.content})
63 elif msg.role == "assistant":
64 # Remove thinking blocks - they cannot be in API requests
65 cleaned_content = self._strip_thinking_blocks(msg.content)
66 if cleaned_content: # Only add if there's content left after stripping
67 converted.append({"role": "assistant", "content": cleaned_content})
68 # 'system' is handled separately
69 return converted
71 async def generate(
72 self,
73 messages: list[LLMMessage],
74 model: str | None = None,
75 temperature: float = 0.7,
76 max_tokens: int | None = None,
77 **kwargs: Any,
78 ) -> LLMResponse:
79 if not await self.is_available():
80 msg = "Anthropic provider not available"
81 raise RuntimeError(msg)
83 client = await self._get_client()
84 model_name = model or self.default_model
86 # Extract a system prompt if present
87 system_parts = [m.content for m in messages if m.role == "system"]
88 system_prompt = "\n\n".join(system_parts) if system_parts else None
89 converted = self._convert_messages(messages)
91 try:
92 resp = await client.messages.create(
93 model=model_name,
94 system=system_prompt,
95 messages=converted,
96 temperature=temperature,
97 max_tokens=max_tokens or 1024,
98 )
100 text = "".join(
101 [
102 block.text
103 for block in resp.content
104 if hasattr(block, "text") and isinstance(block.text, str)
105 ]
106 )
107 usage = getattr(resp, "usage", None)
108 return LLMResponse(
109 content=text,
110 model=model_name,
111 provider="anthropic",
112 usage={
113 "prompt_tokens": getattr(usage, "input_tokens", 0) if usage else 0,
114 "completion_tokens": getattr(usage, "output_tokens", 0)
115 if usage
116 else 0,
117 "total_tokens": (
118 getattr(usage, "input_tokens", 0)
119 + getattr(usage, "output_tokens", 0)
120 if usage
121 else 0
122 ),
123 },
124 finish_reason="stop",
125 timestamp=datetime.now().isoformat(),
126 )
127 except Exception as e:
128 self.logger.exception(f"Anthropic generation failed: {e}")
129 raise
131 async def stream_generate( # type: ignore[override]
132 self,
133 messages: list[LLMMessage],
134 model: str | None = None,
135 temperature: float = 0.7,
136 max_tokens: int | None = None,
137 **kwargs: Any,
138 ) -> AsyncGenerator[str]:
139 # Streaming not essential for extraction; implement later as needed
140 raise NotImplementedError
142 async def is_available(self) -> bool:
143 if not self.api_key: 143 ↛ 145line 143 didn't jump to line 145 because the condition on line 143 was always true
144 return False
145 try:
146 await self._get_client()
147 return True
148 except Exception:
149 return False
151 def get_models(self) -> list[str]:
152 return [
153 "claude-3-5-haiku-20241022",
154 "claude-3-5-sonnet-20241022",
155 "claude-3-opus-20240229",
156 ]