Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-llm/src/lexigram/ai/llm/clients/anthropic.py: 15%

169 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 07:19 +0800

1"""Anthropic Claude LLM client implementation. 

2 

3Production-ready implementation with Anthropic API integration. 

4""" 

5 

6from __future__ import annotations 

7 

8from collections.abc import AsyncGenerator, AsyncIterator 

9from datetime import UTC, datetime 

10from typing import Any 

11 

12from lexigram.ai.llm.clients._message_utils import serialize_content_for_anthropic 

13from lexigram.ai.llm.clients._tools_utils import parse_json_arguments 

14from lexigram.ai.llm.clients.base import AbstractLLMClient 

15from lexigram.ai.llm.config import ClientConfig 

16from lexigram.ai.llm.exceptions import ( 

17 LLMAuthenticationError, 

18 LLMContentFilterError, 

19 LLMError, 

20 LLMModelNotFoundError, 

21 LLMQuotaExceededError, 

22 LLMRateLimitError, 

23) 

24from lexigram.ai.llm.types import ( 

25 AIError, 

26 ChatMessage, 

27 Completion, 

28 FunctionCall, 

29 Role, 

30 StreamChunk, 

31 ThinkingResult, 

32 TokenUsage, 

33 ToolCall, 

34) 

35from lexigram.contracts.core import HealthCheckResult, HealthStatus 

36from lexigram.result import Err, Ok, Result 

37from lexigram.serialization import dumps_str 

38 

39 

40class AnthropicClient(AbstractLLMClient): 

41 """Anthropic Claude LLM client implementation. 

42 

43 Conforms to: :class:`~lexigram.contracts.ai.LLMClientProtocol` protocol via structural typing. 

44 

45 Supports Claude 3 (Opus, Sonnet, Haiku) models with: 

46 - Streaming responses 

47 - Tool calling 

48 - Vision capabilities 

49 - Automatic retry and error handling 

50 

51 Example: 

52 >>> from lexigram.ai import ClientConfig 

53 >>> config = ClientConfig(provider="anthropic", model="claude-3-sonnet-20240229") 

54 >>> client = AnthropicClient(config) 

55 >>> completion = await client.complete([ 

56 ... ChatMessage(role="user", content="Hello!") 

57 ... ]) 

58 """ 

59 

60 def __init__(self, config: ClientConfig): 

61 """Initialize Anthropic client. 

62 

63 Args: 

64 config: LLM configuration 

65 

66 Raises: 

67 ImportError: If anthropic package is not installed 

68 """ 

69 super().__init__(config=config) 

70 

71 try: 

72 from anthropic import AsyncAnthropic 

73 except ImportError as e: 

74 raise ImportError( 

75 "Anthropic client requires 'anthropic' package. " 

76 "Install with: pip install lexigram-ai-llm[anthropic]", 

77 ) from e 

78 

79 api_key = config.api_key.get_secret_value() if config.api_key else None 

80 self.client = AsyncAnthropic( 

81 api_key=api_key, 

82 base_url=config.api_base, 

83 timeout=config.timeout, 

84 ) 

85 

86 async def _do_complete( 

87 self, 

88 messages: list[ChatMessage], 

89 **kwargs: Any, 

90 ) -> Result[Completion, LLMError]: 

91 """Generate completion from messages. 

92 

93 Args: 

94 messages: Chat messages 

95 **kwargs: Additional Anthropic API parameters 

96 

97 Returns: 

98 ``Ok(Completion)`` on success. ``Err(LLMError)`` for recoverable 

99 failures (rate limit, quota, content filter, model not found). 

100 

101 Raises: 

102 LLMAuthenticationError: If API key is invalid. 

103 AIError: For unexpected infrastructure failures. 

104 """ 

105 try: 

106 # Extract system message if present 

107 system_msg = None 

108 conv_messages = [] 

109 for msg in messages: 

110 if msg.role == Role.SYSTEM: 

111 system_msg = msg.content 

112 else: 

113 conv_messages.append(self._convert_message(msg)) 

114 

115 # Build thinking param; suppress temperature (incompatible with thinking) 

116 params = { 

117 "model": kwargs.pop("model", self.config.model), 

118 "messages": conv_messages, 

119 "max_tokens": kwargs.pop("max_tokens", self.config.max_tokens or 1024), 

120 **kwargs, 

121 } 

122 self._apply_thinking(params) 

123 if "thinking" not in params: 

124 params["temperature"] = kwargs.pop( 

125 "temperature", self.config.temperature 

126 ) 

127 

128 tools = params.pop("tools", None) 

129 if tools: 

130 converted_tools = [_tool_to_anthropic(t) for t in tools] 

131 params["tools"] = [t for t in converted_tools if t.get("name")] 

132 

133 if system_msg: 

134 params["system"] = system_msg 

135 

136 # Make API call 

137 response = await self.client.messages.create(**params) 

138 

139 # Parse content blocks: separate thinking blocks from text blocks 

140 content = "" 

141 thinking: ThinkingResult | None = None 

142 thinking_parts: list[str] = [] 

143 thinking_signature: str | None = None 

144 tool_calls: list[ToolCall] = [] 

145 for block in response.content: 

146 block_type = getattr(block, "type", None) 

147 if block_type == "thinking": 

148 thinking_parts.append(getattr(block, "thinking", "") or "") 

149 thinking_signature = getattr(block, "signature", None) 

150 elif block_type == "text" or ( 

151 block_type is None and hasattr(block, "text") 

152 ): 

153 content = getattr(block, "text", "") 

154 elif block_type == "tool_use": 

155 tool_calls.append( 

156 ToolCall( 

157 id=getattr(block, "id", ""), 

158 type="function", 

159 function=FunctionCall( 

160 name=getattr(block, "name", ""), 

161 arguments=dumps_str(getattr(block, "input", {})), 

162 ), 

163 ) 

164 ) 

165 if thinking_parts: 

166 thinking = ThinkingResult( 

167 content="".join(thinking_parts), 

168 signature=thinking_signature, 

169 ) 

170 

171 return Ok( 

172 Completion( 

173 content=content, 

174 model=response.model, 

175 finish_reason=response.stop_reason, 

176 thinking=thinking, 

177 tool_calls=tool_calls or None, 

178 usage=TokenUsage( 

179 prompt_tokens=response.usage.input_tokens, 

180 completion_tokens=response.usage.output_tokens, 

181 total_tokens=response.usage.input_tokens 

182 + response.usage.output_tokens, 

183 ), 

184 metadata={ 

185 "id": response.id, 

186 "type": response.type, 

187 }, 

188 timestamp=datetime.now(UTC), 

189 ) 

190 ) 

191 

192 except (ValueError, RuntimeError, OSError, ConnectionError) as e: 

193 return self._handle_error_as_result(e) 

194 

195 async def _do_stream_chat( 

196 self, 

197 messages: list[ChatMessage], 

198 **kwargs: Any, 

199 ) -> Result[AsyncIterator[StreamChunk], LLMError]: 

200 """Start a streaming completion. 

201 

202 Args: 

203 messages: Chat messages 

204 **kwargs: Additional Anthropic API parameters 

205 

206 Returns: 

207 ``Ok(AsyncIterator[StreamChunk])`` on successful setup. 

208 ``Err(LLMError)`` for recoverable connection failures. 

209 """ 

210 try: 

211 # Extract system message if present 

212 system_msg = None 

213 conv_messages = [] 

214 for msg in messages: 

215 if msg.role == Role.SYSTEM: 

216 system_msg = msg.content 

217 else: 

218 conv_messages.append(self._convert_message(msg)) 

219 

220 # Build thinking param; suppress temperature (incompatible with thinking) 

221 params = { 

222 "model": kwargs.pop("model", self.config.model), 

223 "messages": conv_messages, 

224 "max_tokens": kwargs.pop("max_tokens", self.config.max_tokens or 1024), 

225 **kwargs, 

226 } 

227 self._apply_thinking(params) 

228 if "thinking" not in params: 

229 params["temperature"] = kwargs.pop( 

230 "temperature", self.config.temperature 

231 ) 

232 

233 if system_msg: 

234 params["system"] = system_msg 

235 

236 import asyncio 

237 

238 stream_ctx = self.client.messages.stream(**params) 

239 if asyncio.iscoroutine(stream_ctx): 

240 stream_ctx = await stream_ctx 

241 

242 return Ok(self._stream_impl(stream_ctx)) 

243 

244 except (ValueError, RuntimeError, OSError, ConnectionError) as e: 

245 return self._handle_error_as_result(e) 

246 

247 async def _stream_impl(self, stream_ctx: Any) -> AsyncGenerator[StreamChunk, None]: 

248 """Yield StreamChunk objects from an established Anthropic stream context. 

249 

250 When ``thinking`` config is set on the config, uses the raw event 

251 stream to capture ``thinking_delta`` events alongside ``text_delta`` 

252 events. Otherwise falls back to the simpler ``text_stream`` path. 

253 """ 

254 index = 0 

255 use_thinking = self.config.thinking is not None 

256 try: 

257 async with stream_ctx as stream: 

258 if use_thinking: 

259 # Raw event iteration to capture thinking and text deltas 

260 async for event in stream: 

261 event_type = getattr(event, "type", None) 

262 if event_type != "content_block_delta": 

263 continue 

264 delta = event.delta 

265 delta_type = getattr(delta, "type", None) 

266 if delta_type == "thinking_delta": 

267 yield StreamChunk( 

268 thinking_delta=getattr(delta, "thinking", None), 

269 is_thinking=True, 

270 model=self.config.model, 

271 finish_reason=None, 

272 index=index, 

273 ) 

274 index += 1 

275 elif delta_type == "text_delta": 

276 yield StreamChunk( 

277 delta=getattr(delta, "text", None), 

278 is_thinking=False, 

279 model=self.config.model, 

280 finish_reason=None, 

281 index=index, 

282 ) 

283 index += 1 

284 else: 

285 iter_source = getattr(stream, "text_stream", stream) 

286 async for raw_text in iter_source: 

287 text = ( 

288 raw_text.decode() 

289 if isinstance(raw_text, bytes) 

290 else raw_text 

291 ) 

292 yield StreamChunk( 

293 delta=text, 

294 model=self.config.model, 

295 finish_reason=None, 

296 index=index, 

297 ) 

298 index += 1 

299 except (ValueError, RuntimeError, OSError, ConnectionError) as e: 

300 err = self._handle_error_as_result(e) 

301 raise err if isinstance(err, BaseException) else LLMError(str(err)) from e 

302 

303 async def _do_chat( 

304 self, 

305 messages: list[ChatMessage], 

306 tools: list[ToolCall] | None = None, 

307 **kwargs: Any, 

308 ) -> Result[Completion, LLMError]: 

309 """Generate completion with Anthropic tool/function calling. 

310 

311 Tool calling is handled by :meth:`_do_complete` via 

312 ``complete(..., tools=...)``; this method forwards the tool 

313 descriptors to keep the ``chat`` code path consistent. 

314 

315 Args: 

316 messages: Chat messages. 

317 tools: Optional tool/function descriptors. 

318 **kwargs: Additional Anthropic API parameters. 

319 

320 Returns: 

321 ``Ok(Completion)`` on success. ``Err(LLMError)`` for recoverable 

322 failures. 

323 """ 

324 return await self._do_complete(messages, tools=tools, **kwargs) 

325 

326 def _apply_thinking(self, params: dict[str, Any]) -> None: 

327 """Inject Anthropic extended-thinking parameters into the API payload. 

328 

329 When ``suppress`` is set, returns immediately — Anthropic's default is 

330 no thinking, so suppression simply means not injecting the parameter. 

331 

332 Args: 

333 params: Mutable API payload dict. 

334 """ 

335 if self.config.thinking is None: 

336 return 

337 if self.config.thinking.suppress: 

338 return 

339 params["thinking"] = { 

340 "type": "enabled", 

341 "budget_tokens": self.config.thinking.budget_tokens, 

342 } 

343 params.pop("temperature", None) 

344 

345 def _convert_message(self, msg: ChatMessage) -> dict[str, Any]: 

346 """Convert ChatMessage to Anthropic format. 

347 

348 Uses ``serialize_content_for_anthropic`` to convert multimodal message 

349 content to Anthropic-compatible block format. When ``thinking_blocks`` 

350 is populated (multi-turn with extended thinking), prepends thinking blocks 

351 to the serialized content. 

352 

353 Tool messages (``Role.TOOL``) become user turns with a ``tool_result`` 

354 block; assistant turns that requested tools gain ``tool_use`` blocks so 

355 tool conversations round-trip correctly. 

356 

357 Args: 

358 msg: ChatMessage to convert 

359 

360 Returns: 

361 Anthropic message dict with ``role`` and ``content`` keys 

362 """ 

363 if msg.role == Role.TOOL: 

364 return { 

365 "role": "user", 

366 "content": [ 

367 { 

368 "type": "tool_result", 

369 "tool_use_id": msg.tool_call_id or "", 

370 "content": _tool_result_text(msg.content), 

371 } 

372 ], 

373 } 

374 

375 role = "user" if msg.role == Role.USER else "assistant" 

376 tool_use_blocks: list[dict[str, Any]] = [] 

377 for call in msg.tool_calls or []: 

378 if call.function is None: 

379 continue 

380 tool_use_blocks.append( 

381 { 

382 "type": "tool_use", 

383 "id": call.id, 

384 "name": call.function.name, 

385 "input": parse_json_arguments(call.function.arguments), 

386 } 

387 ) 

388 

389 if msg.thinking_blocks: 

390 # Only include serialized_content if it's non-trivially empty 

391 # (empty string content produces [{"type": "text", "text": ""}] which Anthropic rejects) 

392 if msg.content in ("", []): 

393 content: list[dict[str, Any]] = list(msg.thinking_blocks) 

394 else: 

395 content = list(msg.thinking_blocks) + serialize_content_for_anthropic( 

396 msg.content 

397 ) 

398 if tool_use_blocks: 

399 content.extend(tool_use_blocks) 

400 return {"role": role, "content": content} 

401 

402 if msg.content in ("", []): 

403 return { 

404 "role": role, 

405 "content": tool_use_blocks, 

406 } 

407 

408 return { 

409 "role": role, 

410 "content": serialize_content_for_anthropic(msg.content) + tool_use_blocks, 

411 } 

412 

413 def _handle_error_as_result(self, error: Exception) -> Result[Any, LLMError]: 

414 """Map a caught exception to ``Err`` (recoverable) or re-raise (infra).""" 

415 err_str = str(error).lower() 

416 if "authentication" in err_str or "api key" in err_str: 

417 raise LLMAuthenticationError( 

418 f"Anthropic authentication failed: {error}" 

419 ) from error 

420 if "rate limit" in err_str: 

421 return Err(LLMRateLimitError(f"Anthropic rate limit exceeded: {error}")) 

422 if "quota" in err_str or "billing" in err_str or "credit" in err_str: 

423 return Err(LLMQuotaExceededError(f"Anthropic quota exceeded: {error}")) 

424 if "content" in err_str and ("filter" in err_str or "policy" in err_str): 

425 return Err(LLMContentFilterError(f"Anthropic content filter: {error}")) 

426 if "model" in err_str and ( 

427 "not found" in err_str or "does not exist" in err_str 

428 ): 

429 return Err(LLMModelNotFoundError(f"Anthropic model not found: {error}")) 

430 raise AIError(f"Anthropic infrastructure error: {error}") from error 

431 

432 async def close(self) -> None: 

433 """Close the Anthropic client.""" 

434 if getattr(self, "client", None) and not self._closed: 

435 await self.client.close() 

436 await super().close() 

437 

438 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult: 

439 """Perform health check. 

440 

441 Returns: 

442 Structured health check result. 

443 """ 

444 if self._closed: 

445 return HealthCheckResult( 

446 component="llm.anthropic", 

447 status=HealthStatus.UNHEALTHY, 

448 error="Client is closed", 

449 ) 

450 

451 return HealthCheckResult( 

452 component="llm.anthropic", 

453 status=HealthStatus.HEALTHY, 

454 details={ 

455 "provider": "anthropic", 

456 "model": self.config.model, 

457 }, 

458 ) 

459 

460 

461# ────────────────────────────────────────────────────────────────────── 

462# Private helpers 

463# ────────────────────────────────────────────────────────────────────── 

464 

465 

466def _tool_to_anthropic(tool: Any) -> dict[str, Any]: 

467 """Convert a tool descriptor to Anthropic ``tool_use`` format. 

468 

469 Supports objects that expose a ``__tool_schema__`` class attribute 

470 (Lexigram tool convention) as well as plain dicts in OpenAI tool format. 

471 

472 Args: 

473 tool: A class with ``__tool_schema__``, or a dict with a ``function`` 

474 key in OpenAI tool format. 

475 

476 Returns: 

477 Anthropic tool dict with ``name``, ``description``, and 

478 ``input_schema`` keys. 

479 """ 

480 if hasattr(tool, "__tool_schema__"): 

481 schema: dict[str, Any] = tool.__tool_schema__ 

482 return { 

483 "name": schema["name"], 

484 "description": schema.get("description", ""), 

485 "input_schema": schema.get( 

486 "parameters", {"type": "object", "properties": {}} 

487 ), 

488 } 

489 if isinstance(tool, dict): 

490 func = tool.get("function", tool) 

491 return { 

492 "name": func.get("name", ""), 

493 "description": func.get("description", ""), 

494 "input_schema": func.get( 

495 "parameters", {"type": "object", "properties": {}} 

496 ), 

497 } 

498 return { 

499 "name": getattr(tool, "name", str(tool)), 

500 "description": getattr(tool, "description", ""), 

501 "input_schema": getattr(tool, "parameters", None) 

502 or {"type": "object", "properties": {}}, 

503 } 

504 

505 

506def _tool_result_text(content: Any) -> str: 

507 """Extract plain text from tool-result content. 

508 

509 Args: 

510 content: Message content (str or list of content parts). 

511 

512 Returns: 

513 Joined text string. 

514 """ 

515 if isinstance(content, str): 

516 return content 

517 blocks = serialize_content_for_anthropic(content) 

518 return " ".join(str(b.get("text", "")) for b in blocks if b.get("type") == "text")