app.ai_providers.claude_provider
Anthropic Claude AI provider implementation.
Uses the anthropic Python SDK to communicate with the Anthropic
Messages API. Supports synchronous and streaming generation, CSV file
attachments via content blocks, and automatic token-limit retry.
Attributes:
- logger: Module-level logger for Claude provider operations.
1"""Anthropic Claude AI provider implementation. 2 3Uses the ``anthropic`` Python SDK to communicate with the Anthropic 4Messages API. Supports synchronous and streaming generation, CSV file 5attachments via content blocks, and automatic token-limit retry. 6 7Attributes: 8 logger: Module-level logger for Claude provider operations. 9""" 10 11from __future__ import annotations 12 13import base64 14import logging 15from pathlib import Path 16from typing import Any, Callable, Iterator, Mapping 17 18from .base import ( 19 AIProvider, 20 AIProviderError, 21 DEFAULT_CLOUD_REQUEST_TIMEOUT_SECONDS, 22 DEFAULT_MAX_TOKENS, 23 _is_attachment_unsupported_error, 24 _is_anthropic_streaming_required_error, 25 _is_context_length_error, 26 _normalize_api_key_value, 27 _resolve_completion_token_retry_limit, 28 _resolve_timeout_seconds, 29 _run_with_rate_limit_retries, 30 _T, 31) 32from .utils import ( 33 _extract_anthropic_stream_text, 34 _extract_anthropic_text, 35 _inline_attachment_data_into_prompt, 36) 37 38logger = logging.getLogger(__name__) 39 40DEFAULT_CLAUDE_MODEL = "claude-opus-4-6" 41 42 43class ClaudeProvider(AIProvider): 44 """Anthropic Claude provider implementation. 45 46 Supports both synchronous and streaming generation, CSV file attachments 47 via content blocks (base64-encoded PDFs or inline text), and automatic 48 token-limit retry when ``max_tokens`` exceeds the model's maximum. 49 50 Attributes: 51 api_key (str): The Anthropic API key. 52 model (str): The Claude model identifier. 53 attach_csv_as_file (bool): Whether to upload CSV artifacts as 54 content blocks. 55 request_timeout_seconds (float): HTTP timeout in seconds. 56 client: The ``anthropic.Anthropic`` SDK client instance. 57 """ 58 59 def __init__( 60 self, 61 api_key: str, 62 model: str = DEFAULT_CLAUDE_MODEL, 63 attach_csv_as_file: bool = True, 64 request_timeout_seconds: float = DEFAULT_CLOUD_REQUEST_TIMEOUT_SECONDS, 65 ) -> None: 66 """Initialize the Claude provider. 67 68 Args: 69 api_key: Anthropic API key. Must be non-empty. 70 model: Claude model identifier. 71 attach_csv_as_file: If ``True``, send CSV artifacts as structured 72 content blocks. 73 request_timeout_seconds: HTTP timeout in seconds. 74 75 Raises: 76 AIProviderError: If the ``anthropic`` SDK is not installed or 77 the API key is empty. 78 """ 79 try: 80 import anthropic 81 except ImportError as error: 82 raise AIProviderError( 83 "anthropic SDK is not installed. Install it with `pip install anthropic`." 84 ) from error 85 86 normalized_api_key = _normalize_api_key_value(api_key) 87 if not normalized_api_key: 88 raise AIProviderError( 89 "Claude API key is not configured. " 90 "Set `ai.claude.api_key` in config.yaml or the ANTHROPIC_API_KEY environment variable." 91 ) 92 93 self._anthropic = anthropic 94 self.api_key = normalized_api_key 95 self.model = model 96 self.attach_csv_as_file = bool(attach_csv_as_file) 97 self._csv_attachment_supported: bool | None = None 98 self.request_timeout_seconds = _resolve_timeout_seconds( 99 request_timeout_seconds, 100 DEFAULT_CLOUD_REQUEST_TIMEOUT_SECONDS, 101 ) 102 self.client = anthropic.Anthropic( 103 api_key=normalized_api_key, 104 timeout=self.request_timeout_seconds, 105 ) 106 logger.info("Initialized Claude provider with model %s (timeout %.1fs)", model, self.request_timeout_seconds) 107 108 def analyze( 109 self, 110 system_prompt: str, 111 user_prompt: str, 112 max_tokens: int = DEFAULT_MAX_TOKENS, 113 ) -> str: 114 """Send a prompt to Claude and return the generated text. 115 116 Args: 117 system_prompt: The system-level instruction text. 118 user_prompt: The user-facing prompt with investigation context. 119 max_tokens: Maximum completion tokens. 120 121 Returns: 122 The generated analysis text. 123 124 Raises: 125 AIProviderError: On any API or network failure. 126 """ 127 return self.analyze_with_attachments( 128 system_prompt=system_prompt, 129 user_prompt=user_prompt, 130 attachments=None, 131 max_tokens=max_tokens, 132 ) 133 134 def analyze_stream( 135 self, 136 system_prompt: str, 137 user_prompt: str, 138 max_tokens: int = DEFAULT_MAX_TOKENS, 139 ) -> Iterator[str]: 140 """Stream generated text chunks from Claude. 141 142 Args: 143 system_prompt: The system-level instruction text. 144 user_prompt: The user-facing prompt with investigation context. 145 max_tokens: Maximum completion tokens. 146 147 Yields: 148 Text chunk strings as they are generated. 149 150 Raises: 151 AIProviderError: On empty response or API failure. 152 """ 153 def _stream() -> Iterator[str]: 154 request_kwargs: dict[str, Any] = { 155 "model": self.model, 156 "max_tokens": max_tokens, 157 "system": system_prompt, 158 "messages": [{"role": "user", "content": user_prompt}], 159 "stream": True, 160 } 161 try: 162 stream = _run_with_rate_limit_retries( 163 request_fn=lambda: self._with_token_limit_retry( 164 lambda kw: self.client.messages.create(**kw), 165 request_kwargs, 166 ), 167 rate_limit_error_type=self._anthropic.RateLimitError, 168 provider_name="Claude", 169 ) 170 emitted = False 171 for event in stream: 172 chunk_text = _extract_anthropic_stream_text(event) 173 if not chunk_text: 174 continue 175 emitted = True 176 yield chunk_text 177 if not emitted: 178 raise AIProviderError("Claude returned an empty response.") 179 except AIProviderError: 180 raise 181 except self._anthropic.APIConnectionError as error: 182 raise AIProviderError( 183 "Unable to connect to Claude API. Check network access and endpoint configuration." 184 ) from error 185 except self._anthropic.AuthenticationError as error: 186 raise AIProviderError( 187 "Claude authentication failed. Check `ai.claude.api_key` or ANTHROPIC_API_KEY." 188 ) from error 189 except self._anthropic.BadRequestError as error: 190 if _is_context_length_error(error): 191 raise AIProviderError( 192 "Claude request exceeded the model context length. Reduce prompt size and retry." 193 ) from error 194 raise AIProviderError(f"Claude request was rejected: {error}") from error 195 except self._anthropic.APIError as error: 196 raise AIProviderError(f"Claude API error: {error}") from error 197 except Exception as error: 198 raise AIProviderError(f"Unexpected Claude provider error: {error}") from error 199 200 return _stream() 201 202 def analyze_with_attachments( 203 self, 204 system_prompt: str, 205 user_prompt: str, 206 attachments: list[Mapping[str, str]] | None, 207 max_tokens: int = DEFAULT_MAX_TOKENS, 208 ) -> str: 209 """Analyze with optional CSV file attachments via Claude content blocks. 210 211 Args: 212 system_prompt: The system-level instruction text. 213 user_prompt: The user-facing prompt with investigation context. 214 attachments: Optional list of attachment descriptors. 215 max_tokens: Maximum completion tokens. 216 217 Returns: 218 The generated analysis text. 219 220 Raises: 221 AIProviderError: On any API or network failure. 222 """ 223 def _request() -> str: 224 attachment_response = self._request_with_csv_attachments( 225 system_prompt=system_prompt, 226 user_prompt=user_prompt, 227 max_tokens=max_tokens, 228 attachments=attachments, 229 ) 230 if attachment_response: 231 return attachment_response 232 233 effective_prompt = user_prompt 234 if attachments: 235 effective_prompt, inlined = _inline_attachment_data_into_prompt( 236 user_prompt=user_prompt, 237 attachments=attachments, 238 ) 239 if inlined: 240 logger.info("Claude attachment fallback inlined attachment data into prompt.") 241 242 response = self._create_message_with_stream_fallback( 243 system_prompt=system_prompt, 244 messages=[{"role": "user", "content": effective_prompt}], 245 max_tokens=max_tokens, 246 ) 247 text = _extract_anthropic_text(response) 248 if not text: 249 raise AIProviderError("Claude returned an empty response.") 250 return text 251 252 return self._run_claude_request(_request) 253 254 def _run_claude_request(self, request_fn: Callable[[], _T]) -> _T: 255 """Execute a Claude request with rate-limit retries and error mapping. 256 257 Args: 258 request_fn: A zero-argument callable that performs the API request. 259 260 Returns: 261 The return value of ``request_fn`` on success. 262 263 Raises: 264 AIProviderError: On any Anthropic SDK error. 265 """ 266 try: 267 return _run_with_rate_limit_retries( 268 request_fn=request_fn, 269 rate_limit_error_type=self._anthropic.RateLimitError, 270 provider_name="Claude", 271 ) 272 except AIProviderError: 273 raise 274 except self._anthropic.APIConnectionError as error: 275 raise AIProviderError( 276 "Unable to connect to Claude API. Check network access and endpoint configuration." 277 ) from error 278 except self._anthropic.AuthenticationError as error: 279 raise AIProviderError( 280 "Claude authentication failed. Check `ai.claude.api_key` or ANTHROPIC_API_KEY." 281 ) from error 282 except self._anthropic.BadRequestError as error: 283 if _is_context_length_error(error): 284 raise AIProviderError( 285 "Claude request exceeded the model context length. Reduce prompt size and retry." 286 ) from error 287 raise AIProviderError(f"Claude request was rejected: {error}") from error 288 except self._anthropic.APIError as error: 289 raise AIProviderError(f"Claude API error: {error}") from error 290 except Exception as error: 291 raise AIProviderError(f"Unexpected Claude provider error: {error}") from error 292 293 def _request_with_csv_attachments( 294 self, 295 system_prompt: str, 296 user_prompt: str, 297 max_tokens: int, 298 attachments: list[Mapping[str, str]] | None, 299 ) -> str | None: 300 """Attempt to send a request with CSV files as Claude content blocks. 301 302 Args: 303 system_prompt: The system-level instruction text. 304 user_prompt: The user-facing prompt text. 305 max_tokens: Maximum completion tokens. 306 attachments: Optional list of attachment descriptors. 307 308 Returns: 309 The generated text if attachment mode succeeded, or ``None`` 310 if attachments were skipped or unsupported. 311 """ 312 normalized_attachments = self._prepare_csv_attachments(attachments) 313 if not normalized_attachments: 314 return None 315 316 try: 317 content_blocks: list[dict[str, Any]] = [{"type": "text", "text": user_prompt}] 318 for attachment in normalized_attachments: 319 attachment_path = Path(attachment["path"]) 320 mime_type = attachment["mime_type"].lower() 321 if mime_type == "application/pdf": 322 encoded_data = base64.b64encode(attachment_path.read_bytes()).decode("ascii") 323 content_blocks.append( 324 { 325 "type": "document", 326 "source": { 327 "type": "base64", 328 "media_type": "application/pdf", 329 "data": encoded_data, 330 }, 331 } 332 ) 333 else: 334 attachment_name = attachment.get("name", attachment_path.name) 335 try: 336 attachment_text = attachment_path.read_text( 337 encoding="utf-8-sig", errors="replace" 338 ) 339 except OSError: 340 continue 341 content_blocks.append( 342 { 343 "type": "text", 344 "text": ( 345 f"--- BEGIN ATTACHMENT: {attachment_name} ---\n" 346 f"{attachment_text.rstrip()}\n" 347 f"--- END ATTACHMENT: {attachment_name} ---" 348 ), 349 } 350 ) 351 352 response = self._create_message_with_stream_fallback( 353 system_prompt=system_prompt, 354 messages=[{"role": "user", "content": content_blocks}], 355 max_tokens=max_tokens, 356 ) 357 text = _extract_anthropic_text(response) 358 if not text: 359 raise AIProviderError("Claude returned an empty response for file-attachment mode.") 360 361 self._csv_attachment_supported = True 362 return text 363 except Exception as error: 364 if _is_attachment_unsupported_error(error): 365 self._csv_attachment_supported = False 366 logger.info( 367 "Claude endpoint does not support CSV attachments; " 368 "falling back to standard text mode." 369 ) 370 return None 371 raise 372 373 def _create_message_with_stream_fallback( 374 self, 375 system_prompt: str, 376 messages: list[dict[str, Any]], 377 max_tokens: int, 378 ) -> Any: 379 """Create a Claude message, falling back to streaming for long requests. 380 381 Args: 382 system_prompt: The system-level instruction text. 383 messages: The conversation messages list. 384 max_tokens: Maximum completion tokens. 385 386 Returns: 387 The Anthropic ``Message`` response object. 388 """ 389 request_kwargs: dict[str, Any] = { 390 "model": self.model, 391 "max_tokens": max_tokens, 392 "system": system_prompt, 393 "messages": messages, 394 } 395 try: 396 return self._with_token_limit_retry( 397 lambda kw: self.client.messages.create(**kw), 398 request_kwargs, 399 ) 400 except ValueError as error: 401 if not _is_anthropic_streaming_required_error(error): 402 raise 403 logger.info( 404 "Claude SDK requires streaming for long request; retrying with messages.stream()." 405 ) 406 return self._with_token_limit_retry( 407 lambda kw: self._stream_and_collect(**kw), 408 request_kwargs, 409 ) 410 411 def _stream_and_collect(self, **kwargs: Any) -> Any: 412 """Stream a Claude request and return the final message. 413 414 Args: 415 **kwargs: Keyword arguments for ``client.messages.stream``. 416 417 Returns: 418 The final Anthropic ``Message`` response object. 419 """ 420 with self.client.messages.stream(**kwargs) as stream: 421 return stream.get_final_message() 422 423 def _with_token_limit_retry( 424 self, 425 create_fn: Callable[[dict[str, Any]], Any], 426 request_kwargs: dict[str, Any], 427 ) -> Any: 428 """Execute a Claude API call with automatic token-limit retry. 429 430 If the initial request is rejected because ``max_tokens`` exceeds 431 the model's supported maximum, retries once with the lower limit 432 extracted from the error message. 433 434 This single method replaces the three near-identical retry methods 435 that existed previously. 436 437 Args: 438 create_fn: A callable that takes the request kwargs dict and 439 performs the API call. 440 request_kwargs: Keyword arguments for the API call. 441 442 Returns: 443 The API response object. 444 445 Raises: 446 anthropic.BadRequestError: If the request fails for a reason 447 other than token limits, or if the retry also fails. 448 """ 449 effective_kwargs: dict[str, Any] = dict(request_kwargs) 450 for _ in range(2): 451 try: 452 return create_fn(effective_kwargs) 453 except self._anthropic.BadRequestError as error: 454 requested_tokens = int(effective_kwargs.get("max_tokens", 0)) 455 retry_token_count = _resolve_completion_token_retry_limit( 456 error=error, 457 requested_tokens=requested_tokens, 458 ) 459 if retry_token_count is None: 460 raise 461 logger.warning( 462 "Claude rejected max_tokens=%d; retrying with max_tokens=%d.", 463 requested_tokens, 464 retry_token_count, 465 ) 466 effective_kwargs["max_tokens"] = retry_token_count 467 return create_fn(effective_kwargs) 468 469 def get_model_info(self) -> dict[str, str]: 470 """Return Claude provider and model metadata. 471 472 Returns: 473 A dictionary with ``"provider"`` and ``"model"`` keys. 474 """ 475 return {"provider": "claude", "model": self.model}
44class ClaudeProvider(AIProvider): 45 """Anthropic Claude provider implementation. 46 47 Supports both synchronous and streaming generation, CSV file attachments 48 via content blocks (base64-encoded PDFs or inline text), and automatic 49 token-limit retry when ``max_tokens`` exceeds the model's maximum. 50 51 Attributes: 52 api_key (str): The Anthropic API key. 53 model (str): The Claude model identifier. 54 attach_csv_as_file (bool): Whether to upload CSV artifacts as 55 content blocks. 56 request_timeout_seconds (float): HTTP timeout in seconds. 57 client: The ``anthropic.Anthropic`` SDK client instance. 58 """ 59 60 def __init__( 61 self, 62 api_key: str, 63 model: str = DEFAULT_CLAUDE_MODEL, 64 attach_csv_as_file: bool = True, 65 request_timeout_seconds: float = DEFAULT_CLOUD_REQUEST_TIMEOUT_SECONDS, 66 ) -> None: 67 """Initialize the Claude provider. 68 69 Args: 70 api_key: Anthropic API key. Must be non-empty. 71 model: Claude model identifier. 72 attach_csv_as_file: If ``True``, send CSV artifacts as structured 73 content blocks. 74 request_timeout_seconds: HTTP timeout in seconds. 75 76 Raises: 77 AIProviderError: If the ``anthropic`` SDK is not installed or 78 the API key is empty. 79 """ 80 try: 81 import anthropic 82 except ImportError as error: 83 raise AIProviderError( 84 "anthropic SDK is not installed. Install it with `pip install anthropic`." 85 ) from error 86 87 normalized_api_key = _normalize_api_key_value(api_key) 88 if not normalized_api_key: 89 raise AIProviderError( 90 "Claude API key is not configured. " 91 "Set `ai.claude.api_key` in config.yaml or the ANTHROPIC_API_KEY environment variable." 92 ) 93 94 self._anthropic = anthropic 95 self.api_key = normalized_api_key 96 self.model = model 97 self.attach_csv_as_file = bool(attach_csv_as_file) 98 self._csv_attachment_supported: bool | None = None 99 self.request_timeout_seconds = _resolve_timeout_seconds( 100 request_timeout_seconds, 101 DEFAULT_CLOUD_REQUEST_TIMEOUT_SECONDS, 102 ) 103 self.client = anthropic.Anthropic( 104 api_key=normalized_api_key, 105 timeout=self.request_timeout_seconds, 106 ) 107 logger.info("Initialized Claude provider with model %s (timeout %.1fs)", model, self.request_timeout_seconds) 108 109 def analyze( 110 self, 111 system_prompt: str, 112 user_prompt: str, 113 max_tokens: int = DEFAULT_MAX_TOKENS, 114 ) -> str: 115 """Send a prompt to Claude and return the generated text. 116 117 Args: 118 system_prompt: The system-level instruction text. 119 user_prompt: The user-facing prompt with investigation context. 120 max_tokens: Maximum completion tokens. 121 122 Returns: 123 The generated analysis text. 124 125 Raises: 126 AIProviderError: On any API or network failure. 127 """ 128 return self.analyze_with_attachments( 129 system_prompt=system_prompt, 130 user_prompt=user_prompt, 131 attachments=None, 132 max_tokens=max_tokens, 133 ) 134 135 def analyze_stream( 136 self, 137 system_prompt: str, 138 user_prompt: str, 139 max_tokens: int = DEFAULT_MAX_TOKENS, 140 ) -> Iterator[str]: 141 """Stream generated text chunks from Claude. 142 143 Args: 144 system_prompt: The system-level instruction text. 145 user_prompt: The user-facing prompt with investigation context. 146 max_tokens: Maximum completion tokens. 147 148 Yields: 149 Text chunk strings as they are generated. 150 151 Raises: 152 AIProviderError: On empty response or API failure. 153 """ 154 def _stream() -> Iterator[str]: 155 request_kwargs: dict[str, Any] = { 156 "model": self.model, 157 "max_tokens": max_tokens, 158 "system": system_prompt, 159 "messages": [{"role": "user", "content": user_prompt}], 160 "stream": True, 161 } 162 try: 163 stream = _run_with_rate_limit_retries( 164 request_fn=lambda: self._with_token_limit_retry( 165 lambda kw: self.client.messages.create(**kw), 166 request_kwargs, 167 ), 168 rate_limit_error_type=self._anthropic.RateLimitError, 169 provider_name="Claude", 170 ) 171 emitted = False 172 for event in stream: 173 chunk_text = _extract_anthropic_stream_text(event) 174 if not chunk_text: 175 continue 176 emitted = True 177 yield chunk_text 178 if not emitted: 179 raise AIProviderError("Claude returned an empty response.") 180 except AIProviderError: 181 raise 182 except self._anthropic.APIConnectionError as error: 183 raise AIProviderError( 184 "Unable to connect to Claude API. Check network access and endpoint configuration." 185 ) from error 186 except self._anthropic.AuthenticationError as error: 187 raise AIProviderError( 188 "Claude authentication failed. Check `ai.claude.api_key` or ANTHROPIC_API_KEY." 189 ) from error 190 except self._anthropic.BadRequestError as error: 191 if _is_context_length_error(error): 192 raise AIProviderError( 193 "Claude request exceeded the model context length. Reduce prompt size and retry." 194 ) from error 195 raise AIProviderError(f"Claude request was rejected: {error}") from error 196 except self._anthropic.APIError as error: 197 raise AIProviderError(f"Claude API error: {error}") from error 198 except Exception as error: 199 raise AIProviderError(f"Unexpected Claude provider error: {error}") from error 200 201 return _stream() 202 203 def analyze_with_attachments( 204 self, 205 system_prompt: str, 206 user_prompt: str, 207 attachments: list[Mapping[str, str]] | None, 208 max_tokens: int = DEFAULT_MAX_TOKENS, 209 ) -> str: 210 """Analyze with optional CSV file attachments via Claude content blocks. 211 212 Args: 213 system_prompt: The system-level instruction text. 214 user_prompt: The user-facing prompt with investigation context. 215 attachments: Optional list of attachment descriptors. 216 max_tokens: Maximum completion tokens. 217 218 Returns: 219 The generated analysis text. 220 221 Raises: 222 AIProviderError: On any API or network failure. 223 """ 224 def _request() -> str: 225 attachment_response = self._request_with_csv_attachments( 226 system_prompt=system_prompt, 227 user_prompt=user_prompt, 228 max_tokens=max_tokens, 229 attachments=attachments, 230 ) 231 if attachment_response: 232 return attachment_response 233 234 effective_prompt = user_prompt 235 if attachments: 236 effective_prompt, inlined = _inline_attachment_data_into_prompt( 237 user_prompt=user_prompt, 238 attachments=attachments, 239 ) 240 if inlined: 241 logger.info("Claude attachment fallback inlined attachment data into prompt.") 242 243 response = self._create_message_with_stream_fallback( 244 system_prompt=system_prompt, 245 messages=[{"role": "user", "content": effective_prompt}], 246 max_tokens=max_tokens, 247 ) 248 text = _extract_anthropic_text(response) 249 if not text: 250 raise AIProviderError("Claude returned an empty response.") 251 return text 252 253 return self._run_claude_request(_request) 254 255 def _run_claude_request(self, request_fn: Callable[[], _T]) -> _T: 256 """Execute a Claude request with rate-limit retries and error mapping. 257 258 Args: 259 request_fn: A zero-argument callable that performs the API request. 260 261 Returns: 262 The return value of ``request_fn`` on success. 263 264 Raises: 265 AIProviderError: On any Anthropic SDK error. 266 """ 267 try: 268 return _run_with_rate_limit_retries( 269 request_fn=request_fn, 270 rate_limit_error_type=self._anthropic.RateLimitError, 271 provider_name="Claude", 272 ) 273 except AIProviderError: 274 raise 275 except self._anthropic.APIConnectionError as error: 276 raise AIProviderError( 277 "Unable to connect to Claude API. Check network access and endpoint configuration." 278 ) from error 279 except self._anthropic.AuthenticationError as error: 280 raise AIProviderError( 281 "Claude authentication failed. Check `ai.claude.api_key` or ANTHROPIC_API_KEY." 282 ) from error 283 except self._anthropic.BadRequestError as error: 284 if _is_context_length_error(error): 285 raise AIProviderError( 286 "Claude request exceeded the model context length. Reduce prompt size and retry." 287 ) from error 288 raise AIProviderError(f"Claude request was rejected: {error}") from error 289 except self._anthropic.APIError as error: 290 raise AIProviderError(f"Claude API error: {error}") from error 291 except Exception as error: 292 raise AIProviderError(f"Unexpected Claude provider error: {error}") from error 293 294 def _request_with_csv_attachments( 295 self, 296 system_prompt: str, 297 user_prompt: str, 298 max_tokens: int, 299 attachments: list[Mapping[str, str]] | None, 300 ) -> str | None: 301 """Attempt to send a request with CSV files as Claude content blocks. 302 303 Args: 304 system_prompt: The system-level instruction text. 305 user_prompt: The user-facing prompt text. 306 max_tokens: Maximum completion tokens. 307 attachments: Optional list of attachment descriptors. 308 309 Returns: 310 The generated text if attachment mode succeeded, or ``None`` 311 if attachments were skipped or unsupported. 312 """ 313 normalized_attachments = self._prepare_csv_attachments(attachments) 314 if not normalized_attachments: 315 return None 316 317 try: 318 content_blocks: list[dict[str, Any]] = [{"type": "text", "text": user_prompt}] 319 for attachment in normalized_attachments: 320 attachment_path = Path(attachment["path"]) 321 mime_type = attachment["mime_type"].lower() 322 if mime_type == "application/pdf": 323 encoded_data = base64.b64encode(attachment_path.read_bytes()).decode("ascii") 324 content_blocks.append( 325 { 326 "type": "document", 327 "source": { 328 "type": "base64", 329 "media_type": "application/pdf", 330 "data": encoded_data, 331 }, 332 } 333 ) 334 else: 335 attachment_name = attachment.get("name", attachment_path.name) 336 try: 337 attachment_text = attachment_path.read_text( 338 encoding="utf-8-sig", errors="replace" 339 ) 340 except OSError: 341 continue 342 content_blocks.append( 343 { 344 "type": "text", 345 "text": ( 346 f"--- BEGIN ATTACHMENT: {attachment_name} ---\n" 347 f"{attachment_text.rstrip()}\n" 348 f"--- END ATTACHMENT: {attachment_name} ---" 349 ), 350 } 351 ) 352 353 response = self._create_message_with_stream_fallback( 354 system_prompt=system_prompt, 355 messages=[{"role": "user", "content": content_blocks}], 356 max_tokens=max_tokens, 357 ) 358 text = _extract_anthropic_text(response) 359 if not text: 360 raise AIProviderError("Claude returned an empty response for file-attachment mode.") 361 362 self._csv_attachment_supported = True 363 return text 364 except Exception as error: 365 if _is_attachment_unsupported_error(error): 366 self._csv_attachment_supported = False 367 logger.info( 368 "Claude endpoint does not support CSV attachments; " 369 "falling back to standard text mode." 370 ) 371 return None 372 raise 373 374 def _create_message_with_stream_fallback( 375 self, 376 system_prompt: str, 377 messages: list[dict[str, Any]], 378 max_tokens: int, 379 ) -> Any: 380 """Create a Claude message, falling back to streaming for long requests. 381 382 Args: 383 system_prompt: The system-level instruction text. 384 messages: The conversation messages list. 385 max_tokens: Maximum completion tokens. 386 387 Returns: 388 The Anthropic ``Message`` response object. 389 """ 390 request_kwargs: dict[str, Any] = { 391 "model": self.model, 392 "max_tokens": max_tokens, 393 "system": system_prompt, 394 "messages": messages, 395 } 396 try: 397 return self._with_token_limit_retry( 398 lambda kw: self.client.messages.create(**kw), 399 request_kwargs, 400 ) 401 except ValueError as error: 402 if not _is_anthropic_streaming_required_error(error): 403 raise 404 logger.info( 405 "Claude SDK requires streaming for long request; retrying with messages.stream()." 406 ) 407 return self._with_token_limit_retry( 408 lambda kw: self._stream_and_collect(**kw), 409 request_kwargs, 410 ) 411 412 def _stream_and_collect(self, **kwargs: Any) -> Any: 413 """Stream a Claude request and return the final message. 414 415 Args: 416 **kwargs: Keyword arguments for ``client.messages.stream``. 417 418 Returns: 419 The final Anthropic ``Message`` response object. 420 """ 421 with self.client.messages.stream(**kwargs) as stream: 422 return stream.get_final_message() 423 424 def _with_token_limit_retry( 425 self, 426 create_fn: Callable[[dict[str, Any]], Any], 427 request_kwargs: dict[str, Any], 428 ) -> Any: 429 """Execute a Claude API call with automatic token-limit retry. 430 431 If the initial request is rejected because ``max_tokens`` exceeds 432 the model's supported maximum, retries once with the lower limit 433 extracted from the error message. 434 435 This single method replaces the three near-identical retry methods 436 that existed previously. 437 438 Args: 439 create_fn: A callable that takes the request kwargs dict and 440 performs the API call. 441 request_kwargs: Keyword arguments for the API call. 442 443 Returns: 444 The API response object. 445 446 Raises: 447 anthropic.BadRequestError: If the request fails for a reason 448 other than token limits, or if the retry also fails. 449 """ 450 effective_kwargs: dict[str, Any] = dict(request_kwargs) 451 for _ in range(2): 452 try: 453 return create_fn(effective_kwargs) 454 except self._anthropic.BadRequestError as error: 455 requested_tokens = int(effective_kwargs.get("max_tokens", 0)) 456 retry_token_count = _resolve_completion_token_retry_limit( 457 error=error, 458 requested_tokens=requested_tokens, 459 ) 460 if retry_token_count is None: 461 raise 462 logger.warning( 463 "Claude rejected max_tokens=%d; retrying with max_tokens=%d.", 464 requested_tokens, 465 retry_token_count, 466 ) 467 effective_kwargs["max_tokens"] = retry_token_count 468 return create_fn(effective_kwargs) 469 470 def get_model_info(self) -> dict[str, str]: 471 """Return Claude provider and model metadata. 472 473 Returns: 474 A dictionary with ``"provider"`` and ``"model"`` keys. 475 """ 476 return {"provider": "claude", "model": self.model}
Anthropic Claude provider implementation.
Supports both synchronous and streaming generation, CSV file attachments
via content blocks (base64-encoded PDFs or inline text), and automatic
token-limit retry when max_tokens exceeds the model's maximum.
Attributes:
- api_key (str): The Anthropic API key.
- model (str): The Claude model identifier.
- attach_csv_as_file (bool): Whether to upload CSV artifacts as content blocks.
- request_timeout_seconds (float): HTTP timeout in seconds.
- client: The
anthropic.AnthropicSDK client instance.
60 def __init__( 61 self, 62 api_key: str, 63 model: str = DEFAULT_CLAUDE_MODEL, 64 attach_csv_as_file: bool = True, 65 request_timeout_seconds: float = DEFAULT_CLOUD_REQUEST_TIMEOUT_SECONDS, 66 ) -> None: 67 """Initialize the Claude provider. 68 69 Args: 70 api_key: Anthropic API key. Must be non-empty. 71 model: Claude model identifier. 72 attach_csv_as_file: If ``True``, send CSV artifacts as structured 73 content blocks. 74 request_timeout_seconds: HTTP timeout in seconds. 75 76 Raises: 77 AIProviderError: If the ``anthropic`` SDK is not installed or 78 the API key is empty. 79 """ 80 try: 81 import anthropic 82 except ImportError as error: 83 raise AIProviderError( 84 "anthropic SDK is not installed. Install it with `pip install anthropic`." 85 ) from error 86 87 normalized_api_key = _normalize_api_key_value(api_key) 88 if not normalized_api_key: 89 raise AIProviderError( 90 "Claude API key is not configured. " 91 "Set `ai.claude.api_key` in config.yaml or the ANTHROPIC_API_KEY environment variable." 92 ) 93 94 self._anthropic = anthropic 95 self.api_key = normalized_api_key 96 self.model = model 97 self.attach_csv_as_file = bool(attach_csv_as_file) 98 self._csv_attachment_supported: bool | None = None 99 self.request_timeout_seconds = _resolve_timeout_seconds( 100 request_timeout_seconds, 101 DEFAULT_CLOUD_REQUEST_TIMEOUT_SECONDS, 102 ) 103 self.client = anthropic.Anthropic( 104 api_key=normalized_api_key, 105 timeout=self.request_timeout_seconds, 106 ) 107 logger.info("Initialized Claude provider with model %s (timeout %.1fs)", model, self.request_timeout_seconds)
Initialize the Claude provider.
Arguments:
- api_key: Anthropic API key. Must be non-empty.
- model: Claude model identifier.
- attach_csv_as_file: If
True, send CSV artifacts as structured content blocks. - request_timeout_seconds: HTTP timeout in seconds.
Raises:
- AIProviderError: If the
anthropicSDK is not installed or the API key is empty.
109 def analyze( 110 self, 111 system_prompt: str, 112 user_prompt: str, 113 max_tokens: int = DEFAULT_MAX_TOKENS, 114 ) -> str: 115 """Send a prompt to Claude and return the generated text. 116 117 Args: 118 system_prompt: The system-level instruction text. 119 user_prompt: The user-facing prompt with investigation context. 120 max_tokens: Maximum completion tokens. 121 122 Returns: 123 The generated analysis text. 124 125 Raises: 126 AIProviderError: On any API or network failure. 127 """ 128 return self.analyze_with_attachments( 129 system_prompt=system_prompt, 130 user_prompt=user_prompt, 131 attachments=None, 132 max_tokens=max_tokens, 133 )
Send a prompt to Claude and return the generated text.
Arguments:
- system_prompt: The system-level instruction text.
- user_prompt: The user-facing prompt with investigation context.
- max_tokens: Maximum completion tokens.
Returns:
The generated analysis text.
Raises:
- AIProviderError: On any API or network failure.
135 def analyze_stream( 136 self, 137 system_prompt: str, 138 user_prompt: str, 139 max_tokens: int = DEFAULT_MAX_TOKENS, 140 ) -> Iterator[str]: 141 """Stream generated text chunks from Claude. 142 143 Args: 144 system_prompt: The system-level instruction text. 145 user_prompt: The user-facing prompt with investigation context. 146 max_tokens: Maximum completion tokens. 147 148 Yields: 149 Text chunk strings as they are generated. 150 151 Raises: 152 AIProviderError: On empty response or API failure. 153 """ 154 def _stream() -> Iterator[str]: 155 request_kwargs: dict[str, Any] = { 156 "model": self.model, 157 "max_tokens": max_tokens, 158 "system": system_prompt, 159 "messages": [{"role": "user", "content": user_prompt}], 160 "stream": True, 161 } 162 try: 163 stream = _run_with_rate_limit_retries( 164 request_fn=lambda: self._with_token_limit_retry( 165 lambda kw: self.client.messages.create(**kw), 166 request_kwargs, 167 ), 168 rate_limit_error_type=self._anthropic.RateLimitError, 169 provider_name="Claude", 170 ) 171 emitted = False 172 for event in stream: 173 chunk_text = _extract_anthropic_stream_text(event) 174 if not chunk_text: 175 continue 176 emitted = True 177 yield chunk_text 178 if not emitted: 179 raise AIProviderError("Claude returned an empty response.") 180 except AIProviderError: 181 raise 182 except self._anthropic.APIConnectionError as error: 183 raise AIProviderError( 184 "Unable to connect to Claude API. Check network access and endpoint configuration." 185 ) from error 186 except self._anthropic.AuthenticationError as error: 187 raise AIProviderError( 188 "Claude authentication failed. Check `ai.claude.api_key` or ANTHROPIC_API_KEY." 189 ) from error 190 except self._anthropic.BadRequestError as error: 191 if _is_context_length_error(error): 192 raise AIProviderError( 193 "Claude request exceeded the model context length. Reduce prompt size and retry." 194 ) from error 195 raise AIProviderError(f"Claude request was rejected: {error}") from error 196 except self._anthropic.APIError as error: 197 raise AIProviderError(f"Claude API error: {error}") from error 198 except Exception as error: 199 raise AIProviderError(f"Unexpected Claude provider error: {error}") from error 200 201 return _stream()
Stream generated text chunks from Claude.
Arguments:
- system_prompt: The system-level instruction text.
- user_prompt: The user-facing prompt with investigation context.
- max_tokens: Maximum completion tokens.
Yields:
Text chunk strings as they are generated.
Raises:
- AIProviderError: On empty response or API failure.
203 def analyze_with_attachments( 204 self, 205 system_prompt: str, 206 user_prompt: str, 207 attachments: list[Mapping[str, str]] | None, 208 max_tokens: int = DEFAULT_MAX_TOKENS, 209 ) -> str: 210 """Analyze with optional CSV file attachments via Claude content blocks. 211 212 Args: 213 system_prompt: The system-level instruction text. 214 user_prompt: The user-facing prompt with investigation context. 215 attachments: Optional list of attachment descriptors. 216 max_tokens: Maximum completion tokens. 217 218 Returns: 219 The generated analysis text. 220 221 Raises: 222 AIProviderError: On any API or network failure. 223 """ 224 def _request() -> str: 225 attachment_response = self._request_with_csv_attachments( 226 system_prompt=system_prompt, 227 user_prompt=user_prompt, 228 max_tokens=max_tokens, 229 attachments=attachments, 230 ) 231 if attachment_response: 232 return attachment_response 233 234 effective_prompt = user_prompt 235 if attachments: 236 effective_prompt, inlined = _inline_attachment_data_into_prompt( 237 user_prompt=user_prompt, 238 attachments=attachments, 239 ) 240 if inlined: 241 logger.info("Claude attachment fallback inlined attachment data into prompt.") 242 243 response = self._create_message_with_stream_fallback( 244 system_prompt=system_prompt, 245 messages=[{"role": "user", "content": effective_prompt}], 246 max_tokens=max_tokens, 247 ) 248 text = _extract_anthropic_text(response) 249 if not text: 250 raise AIProviderError("Claude returned an empty response.") 251 return text 252 253 return self._run_claude_request(_request)
Analyze with optional CSV file attachments via Claude content blocks.
Arguments:
- system_prompt: The system-level instruction text.
- user_prompt: The user-facing prompt with investigation context.
- attachments: Optional list of attachment descriptors.
- max_tokens: Maximum completion tokens.
Returns:
The generated analysis text.
Raises:
- AIProviderError: On any API or network failure.
470 def get_model_info(self) -> dict[str, str]: 471 """Return Claude provider and model metadata. 472 473 Returns: 474 A dictionary with ``"provider"`` and ``"model"`` keys. 475 """ 476 return {"provider": "claude", "model": self.model}
Return Claude provider and model metadata.
Returns:
A dictionary with
"provider"and"model"keys.