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

181 statements  

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

1"""AWS Bedrock LLM client for the Lexigram LLM routing system. 

2 

3Implements the :class:`~lexigram.contracts.ai.protocols.LLMClientProtocol` 

4protocol against the AWS Bedrock ``Converse`` API. The ``Converse`` API 

5provides a unified interface for all Bedrock models and natively supports 

6multi-turn conversations and tool (function) calling. 

7 

8Authentication and request signing use the ``boto3``/``botocore`` AWS SDK, 

9which honours the standard AWS credential chain (environment variables, shared 

10credentials file, EC2 instance profile, ECS task role, etc.). 

11 

12Configuration is sourced from ``ClientConfig.extra``: 

13 

14* ``aws_region`` — AWS region, e.g. ``us-east-1`` (required) 

15* ``aws_access_key_id`` — AWS access key ID (optional; from credential chain) 

16* ``aws_secret_access_key`` — AWS secret access key (optional; from credential chain) 

17* ``aws_session_token`` — AWS session token for temporary credentials (optional) 

18* ``aws_profile`` — Named profile from ``~/.aws/credentials`` (optional) 

19 

20Notes: 

21 ``boto3`` and ``botocore`` are optional dependencies. An 

22 :class:`ImportError` is raised at construction time if they are absent. 

23 

24 The Bedrock ``Converse`` API does not natively expose a server-sent event 

25 (SSE) streaming response over HTTP like OpenAI. Streaming uses 

26 ``ConverseStream``, which returns an event-stream that ``botocore`` 

27 processes asynchronously via ``aiobotocore`` if available, or 

28 synchronously via a thread-pool executor otherwise. 

29""" 

30 

31from __future__ import annotations 

32 

33import asyncio 

34import atexit 

35from concurrent.futures import ThreadPoolExecutor 

36from concurrent.futures.thread import _threads_queues, _worker 

37import threading 

38from typing import TYPE_CHECKING, Any 

39import weakref 

40 

41from lexigram.ai.llm.clients._bedrock_mappers import ( 

42 bedrock_stream_chunks, 

43 extract_system, 

44 parse_bedrock_response, 

45 tool_to_bedrock, 

46) 

47from lexigram.ai.llm.clients._tools_utils import ( 

48 _tool_schema_fields, 

49 parse_json_arguments, 

50) 

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

52from lexigram.ai.llm.exceptions import ( 

53 LLMAuthenticationError, 

54 LLMContentFilterError, 

55 LLMError, 

56 LLMModelNotFoundError, 

57 LLMRateLimitError, 

58) 

59from lexigram.ai.llm.multimodal.fetcher import fetch_image_as_base64 

60from lexigram.ai.llm.types import AIError, Completion, StreamChunk 

61from lexigram.contracts.ai.multimodal import ( 

62 ImageBase64Part, 

63 ImageUrlPart, 

64 MessageContent, 

65 TextPart, 

66) 

67from lexigram.contracts.core import HealthCheckResult, HealthStatus 

68from lexigram.logging import ( 

69 get_logger, 

70) 

71from lexigram.result import Err, Ok, Result 

72 

73if TYPE_CHECKING: 

74 from collections.abc import AsyncIterator 

75 

76 from lexigram.ai.llm.config import ClientConfig 

77 

78logger = get_logger(__name__) 

79 

80__all__ = ["BedrockClient"] 

81 

82_thread_pool: ThreadPoolExecutor | None = None 

83 

84 

85class _DaemonThreadPoolExecutor(ThreadPoolExecutor): 

86 """ThreadPoolExecutor whose workers are daemon threads. 

87 

88 Keeps the pool off the non-daemon thread list so it never blocks 

89 interpreter shutdown or trips test teardown assertions. Mirrors the 

90 CPython 3.13 worker-spawn logic with ``daemon=True``. 

91 """ 

92 

93 def _adjust_thread_count(self) -> None: 

94 if self._idle_semaphore.acquire(timeout=0): 

95 return 

96 

97 def weakref_cb(_: Any, q: Any = self._work_queue) -> None: 

98 q.put(None) 

99 

100 num_threads = len(self._threads) 

101 if num_threads < self._max_workers: 

102 thread_name = f"{self._thread_name_prefix or self}_{num_threads}" 

103 t = threading.Thread( 

104 name=thread_name, 

105 target=_worker, 

106 args=( 

107 weakref.ref(self, weakref_cb), 

108 self._work_queue, 

109 self._initializer, 

110 self._initargs, 

111 ), 

112 daemon=True, 

113 ) 

114 t.start() 

115 self._threads.add(t) # type: ignore[attr-defined] 

116 _threads_queues[t] = self._work_queue # type: ignore[index] 

117 

118 

119def _get_thread_pool() -> ThreadPoolExecutor: 

120 """Get the shared executor, creating it lazily on first use.""" 

121 global _thread_pool 

122 if _thread_pool is None: 

123 _thread_pool = _DaemonThreadPoolExecutor( 

124 max_workers=4, thread_name_prefix="bedrock-sync" 

125 ) 

126 atexit.register(_thread_pool.shutdown) 

127 return _thread_pool 

128 

129 

130def _content_to_text(content: Any) -> str: 

131 """Extract plain text from message content for tool-result payloads. 

132 

133 Args: 

134 content: Message content (string or list of content parts). 

135 

136 Returns: 

137 Plain text string. 

138 """ 

139 if isinstance(content, str): 

140 return content 

141 if isinstance(content, list): 

142 return " ".join( 

143 str(p.get("text", "")) 

144 for p in content 

145 if isinstance(p, dict) and p.get("type") == "text" 

146 ) 

147 return str(content) 

148 

149 

150class BedrockClient(AbstractLLMClient): 

151 """AWS Bedrock client using the ``Converse`` API. 

152 

153 Routes requests to the Bedrock ``converse`` (non-streaming) and 

154 ``converse_stream`` (streaming) endpoints. The ``Converse`` API 

155 normalises request/response format across all Bedrock models, so 

156 no per-model message translation is needed. 

157 

158 Args: 

159 config: LLM configuration. ``config.extra`` must contain 

160 ``aws_region``. 

161 """ 

162 

163 def __init__(self, config: ClientConfig) -> None: 

164 """Initialise the Bedrock client. 

165 

166 Args: 

167 config: LLM configuration with AWS-specific ``extra`` keys. 

168 

169 Raises: 

170 ImportError: If ``boto3`` is not installed. 

171 ValueError: If ``aws_region`` is missing from ``config.extra``. 

172 """ 

173 super().__init__(config=config) 

174 

175 try: 

176 import boto3 

177 except ImportError as exc: 

178 raise ImportError( 

179 "BedrockClient requires 'boto3'. Install with: pip install boto3" 

180 ) from exc 

181 

182 extra: dict[str, Any] = config.extra or {} 

183 region = extra.get("aws_region", "") 

184 if not region: 

185 raise ValueError( 

186 "BedrockClient requires 'aws_region' in ClientConfig.extra" 

187 ) 

188 

189 self._region = region 

190 boto_kwargs: dict[str, Any] = {"region_name": region} 

191 if extra.get("aws_access_key_id"): 

192 boto_kwargs["aws_access_key_id"] = extra["aws_access_key_id"] 

193 if extra.get("aws_secret_access_key"): 

194 boto_kwargs["aws_secret_access_key"] = extra["aws_secret_access_key"] 

195 if extra.get("aws_session_token"): 

196 boto_kwargs["aws_session_token"] = extra["aws_session_token"] 

197 if extra.get("aws_profile"): 

198 import boto3 

199 

200 session = boto3.Session(profile_name=extra["aws_profile"], **boto_kwargs) 

201 self._client = session.client("bedrock-runtime") 

202 else: 

203 import boto3 

204 

205 self._client = boto3.client("bedrock-runtime", **boto_kwargs) 

206 

207 # ────────────────────────────────────────────────────────────────── 

208 # LLMClientProtocol implementation 

209 # ────────────────────────────────────────────────────────────────── 

210 

211 async def _do_complete( 

212 self, 

213 messages: list[Any], 

214 *, 

215 model: str | None = None, 

216 temperature: float = 0.2, 

217 max_tokens: int | None = None, 

218 **kwargs: Any, 

219 ) -> Result[Completion, LLMError]: 

220 """Generate completion via Bedrock ``Converse``. 

221 

222 Args: 

223 messages: OpenAI-compatible message list. 

224 model: Model ID override (Bedrock model ARN or ID). 

225 temperature: Sampling temperature. 

226 max_tokens: Maximum output tokens. 

227 **kwargs: Ignored for protocol compatibility. 

228 

229 Returns: 

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

231 failures. 

232 

233 Raises: 

234 LLMAuthenticationError: On credential or authorisation failure. 

235 AIError: For unexpected infrastructure failures. 

236 """ 

237 active_model = model or self.config.model 

238 bedrock_messages = await self._to_bedrock_messages_async(messages) 

239 request: dict[str, Any] = { 

240 "modelId": active_model, 

241 "messages": bedrock_messages, 

242 "inferenceConfig": {}, 

243 } 

244 # Extended thinking is incompatible with temperature on Bedrock Claude 

245 self._apply_thinking(request) 

246 if "additionalModelRequestFields" not in request: 

247 request["inferenceConfig"]["temperature"] = temperature 

248 

249 if max_tokens is not None: 

250 request["inferenceConfig"]["maxTokens"] = max_tokens 

251 

252 system = extract_system(messages) 

253 if system: 

254 request["system"] = [{"text": system}] 

255 

256 tools = kwargs.pop("tools", None) 

257 if tools: 

258 converted_tools = [ 

259 tool_to_bedrock(t) for t in tools if _tool_schema_fields(t)[0] 

260 ] 

261 if converted_tools: 

262 request["toolConfig"] = { 

263 "tools": converted_tools, 

264 "toolChoice": {"auto": {}}, 

265 } 

266 

267 try: 

268 raw = await asyncio.get_event_loop().run_in_executor( 

269 _get_thread_pool(), 

270 lambda: self._client.converse(**request), 

271 ) 

272 except Exception as exc: # noqa: BLE001 - botocore raises dynamic provider exceptions 

273 return self._handle_error_as_result(exc) 

274 

275 return Ok(parse_bedrock_response(raw, active_model)) 

276 

277 async def _do_stream_chat( 

278 self, 

279 messages: list[Any], 

280 *, 

281 model: str | None = None, 

282 temperature: float = 0.2, 

283 max_tokens: int | None = None, 

284 **kwargs: Any, 

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

286 """Stream completion tokens from Bedrock ``ConverseStream``. 

287 

288 Args: 

289 messages: OpenAI-compatible message list. 

290 model: Model ID override. 

291 temperature: Sampling temperature. 

292 max_tokens: Maximum output tokens. 

293 **kwargs: Ignored for protocol compatibility. 

294 

295 Returns: 

296 ``Ok(AsyncIterator[StreamChunk])`` on success. 

297 ``Err(LLMError)`` for recoverable failures. 

298 

299 Raises: 

300 LLMAuthenticationError: On credential or authorisation failure. 

301 AIError: For unexpected infrastructure failures. 

302 """ 

303 active_model = model or self.config.model 

304 bedrock_messages = await self._to_bedrock_messages_async(messages) 

305 request: dict[str, Any] = { 

306 "modelId": active_model, 

307 "messages": bedrock_messages, 

308 "inferenceConfig": {}, 

309 } 

310 # Extended thinking is incompatible with temperature on Bedrock Claude 

311 self._apply_thinking(request) 

312 if "additionalModelRequestFields" not in request: 

313 request["inferenceConfig"]["temperature"] = temperature 

314 

315 if max_tokens is not None: 

316 request["inferenceConfig"]["maxTokens"] = max_tokens 

317 

318 system = extract_system(messages) 

319 if system: 

320 request["system"] = [{"text": system}] 

321 

322 try: 

323 raw_stream = await asyncio.get_event_loop().run_in_executor( 

324 _get_thread_pool(), 

325 lambda: self._client.converse_stream(**request), 

326 ) 

327 except Exception as exc: # noqa: BLE001 - botocore raises dynamic provider exceptions 

328 return self._handle_error_as_result(exc) 

329 

330 return Ok(bedrock_stream_chunks(raw_stream, active_model, _get_thread_pool())) 

331 

332 async def _do_chat( 

333 self, 

334 messages: list[Any], 

335 tools: list[Any] | None = None, 

336 *, 

337 model: str | None = None, 

338 temperature: float = 0.2, 

339 max_tokens: int | None = None, 

340 **kwargs: Any, 

341 ) -> Result[Completion, LLMError]: 

342 """Generate completion with optional tool calling on Bedrock. 

343 

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

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

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

347 

348 Args: 

349 messages: OpenAI-compatible message list. 

350 tools: Optional tool descriptors. 

351 model: Model ID override. 

352 temperature: Sampling temperature. 

353 max_tokens: Maximum output tokens. 

354 **kwargs: Ignored for protocol compatibility. 

355 

356 Returns: 

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

358 failures. 

359 """ 

360 return await self._do_complete( 

361 messages, 

362 model=model, 

363 temperature=temperature, 

364 max_tokens=max_tokens, 

365 tools=tools, 

366 **kwargs, 

367 ) 

368 

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

370 """Probe Bedrock by listing foundation models. 

371 

372 Args: 

373 timeout: Informational only. 

374 

375 Returns: 

376 Structured :class:`~lexigram.contracts.core.health.HealthCheckResult`. 

377 """ 

378 try: 

379 import boto3 

380 

381 mgmt = boto3.client("bedrock", region_name=self._region) 

382 await asyncio.get_event_loop().run_in_executor( 

383 _get_thread_pool(), 

384 lambda: mgmt.list_foundation_models(maxResults=1), 

385 ) 

386 except Exception as exc: # noqa: BLE001 - boto3 exposes provider-specific exceptions 

387 return HealthCheckResult( 

388 component="llm.bedrock", 

389 status=HealthStatus.UNHEALTHY, 

390 error=str(exc), 

391 details={"region": self._region, "model": self.config.model}, 

392 ) 

393 

394 return HealthCheckResult( 

395 component="llm.bedrock", 

396 status=HealthStatus.HEALTHY, 

397 details={"region": self._region, "model": self.config.model}, 

398 ) 

399 

400 async def close(self) -> None: 

401 """Release the Bedrock boto3 client.""" 

402 if self._client is not None: 

403 self._client.close() 

404 self._client = None 

405 await super().close() 

406 

407 def _apply_thinking(self, request: dict[str, Any]) -> None: 

408 """Inject Bedrock extended-thinking parameters into the request payload. 

409 

410 Sets ``additionalModelRequestFields.thinking`` when ``config.thinking`` 

411 is configured. Also removes ``temperature`` from ``inferenceConfig`` 

412 because Bedrock Claude rejects that combination. 

413 

414 Args: 

415 request: Mutable Bedrock request dict. 

416 """ 

417 if self.config.thinking is None: 

418 return 

419 request["additionalModelRequestFields"] = { 

420 "thinking": { 

421 "type": "enabled", 

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

423 } 

424 } 

425 request.get("inferenceConfig", {}).pop("temperature", None) 

426 

427 async def _build_content_blocks( 

428 self, content: MessageContent 

429 ) -> list[dict[str, Any]]: 

430 """Convert MessageContent to Bedrock ``Converse`` content blocks. 

431 

432 Handles plain strings, TextPart, ImageBase64Part, and ImageUrlPart. 

433 ImageUrlPart entries are fetched and converted to base64 via 

434 ``fetch_image_as_base64``. 

435 

436 Args: 

437 content: Message content (string or list of ContentPart). 

438 

439 Returns: 

440 List of Bedrock content block dicts with wire format: 

441 - ``{"type": "text", "text": "..."}``. 

442 - ``{"type": "image", "source": {"type": "base64", "mediaType": "...", "data": "..."}}`` 

443 (camelCase ``mediaType`` as per Bedrock API). 

444 

445 Raises: 

446 LLMError: If image fetching fails (infrastructure error). 

447 """ 

448 if isinstance(content, str): 

449 return [{"type": "text", "text": content}] 

450 

451 blocks: list[dict[str, Any]] = [] 

452 for part in content: 

453 if isinstance(part, TextPart): 

454 blocks.append({"type": "text", "text": part.text}) 

455 elif isinstance(part, ImageBase64Part): 

456 blocks.append( 

457 { 

458 "type": "image", 

459 "source": { 

460 "type": "base64", 

461 "mediaType": part.media_type, 

462 "data": part.data, 

463 }, 

464 } 

465 ) 

466 elif isinstance(part, ImageUrlPart): 

467 # Fetch and convert to base64 

468 fetched = await fetch_image_as_base64(part.url) 

469 blocks.append( 

470 { 

471 "type": "image", 

472 "source": { 

473 "type": "base64", 

474 "mediaType": fetched.media_type, 

475 "data": fetched.data, 

476 }, 

477 } 

478 ) 

479 return blocks 

480 

481 async def _to_bedrock_messages_async( 

482 self, messages: list[Any] 

483 ) -> list[dict[str, Any]]: 

484 """Async conversion of OpenAI-compatible messages to Bedrock format. 

485 

486 System messages are excluded (handled separately). Multimodal content 

487 is converted using ``_build_content_blocks``. 

488 

489 Args: 

490 messages: OpenAI-compatible message list (dicts or ChatMessage). 

491 

492 Returns: 

493 List of Bedrock ``ConversationTurn`` dicts. 

494 """ 

495 result: list[dict[str, Any]] = [] 

496 for msg in messages: 

497 role = ( 

498 msg.get("role", "user") 

499 if isinstance(msg, dict) 

500 else getattr(msg, "role", "user") 

501 ) 

502 role_str = role.value if hasattr(role, "value") else str(role) 

503 if role_str == "system": 

504 continue 

505 

506 content_raw = ( 

507 msg.get("content", "") 

508 if isinstance(msg, dict) 

509 else getattr(msg, "content", "") 

510 ) 

511 tool_calls = ( 

512 msg.get("tool_calls") 

513 if isinstance(msg, dict) 

514 else getattr(msg, "tool_calls", None) 

515 ) 

516 tool_call_id = ( 

517 msg.get("tool_call_id", "") 

518 if isinstance(msg, dict) 

519 else getattr(msg, "tool_call_id", "") 

520 ) 

521 

522 # Tool results become user turns with a toolResult block 

523 if role_str == "tool": 

524 result.append( 

525 { 

526 "role": "user", 

527 "content": [ 

528 { 

529 "toolResult": { 

530 "toolUseId": tool_call_id, 

531 "content": [ 

532 {"text": _content_to_text(content_raw)} 

533 ], 

534 } 

535 } 

536 ], 

537 } 

538 ) 

539 continue 

540 

541 # Use _build_content_blocks for content serialization 

542 bedrock_content = await self._build_content_blocks(content_raw) 

543 

544 # Assistant turns that requested tools gain toolUse blocks 

545 if role_str == "assistant" and tool_calls: 

546 for call in tool_calls: 

547 fn = getattr(call, "function", None) 

548 if fn is None or not getattr(fn, "name", None): 

549 continue 

550 bedrock_content.append( 

551 { 

552 "toolUse": { 

553 "toolUseId": getattr(call, "id", ""), 

554 "name": fn.name, 

555 "input": parse_json_arguments(fn.arguments), 

556 } 

557 } 

558 ) 

559 

560 # Bedrock uses "user" and "assistant" roles only 

561 bedrock_role = "assistant" if role_str == "assistant" else "user" 

562 result.append({"role": bedrock_role, "content": bedrock_content}) 

563 return result 

564 

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

566 """Map a caught exception to ``Err`` or re-raise for infrastructure failures.""" 

567 err_str = str(error) 

568 err_code = getattr( 

569 getattr(error, "response", {}).get("Error", {}), "Code", None 

570 ) 

571 if err_code is None and hasattr(error, "response"): 

572 resp = error.response 

573 if isinstance(resp, dict): 

574 err_code = resp.get("Error", {}).get("Code", "") 

575 

576 if err_code in ( 

577 "AccessDeniedException", 

578 "AuthorizationException", 

579 "UnauthorizedException", 

580 ): 

581 raise LLMAuthenticationError(f"bedrock: auth failed: {error}") from error 

582 if err_code in ("ThrottlingException", "TooManyRequestsException"): 

583 return Err(LLMRateLimitError(f"bedrock: rate limit: {error}")) 

584 if err_code in ( 

585 "ModelNotReadyException", 

586 "ModelNotFoundException", 

587 "ResourceNotFoundException", 

588 ): 

589 return Err(LLMModelNotFoundError(f"bedrock: model not found: {error}")) 

590 if err_code == "ValidationException" and "content filter" in err_str.lower(): 

591 return Err(LLMContentFilterError(f"bedrock: content filtered: {error}")) 

592 raise AIError(f"bedrock: infrastructure error: {error}") from error