# Copyright (c) 2025 Patrick Motsch # All rights reserved. import logging import httpx import os from typing import Dict, Any, List from fastapi import HTTPException from modules.shared.configuration import APP_CONFIG from modules.aicore.aicoreBase import BaseConnectorAi from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings # Configure logger logger = logging.getLogger(__name__) def loadConfigData(): """Load configuration data for Anthropic connector""" return { "apiKey": APP_CONFIG.get('Connector_AiAnthropic_API_SECRET'), } class AiAnthropic(BaseConnectorAi): """Connector for communication with the Anthropic API.""" def __init__(self): super().__init__() # Load configuration self.config = loadConfigData() self.apiKey = self.config["apiKey"] # HttpClient for API calls # Timeout set to 600 seconds (10 minutes) for complex requests that may take longer # Document generation and complex AI operations can take significantly longer self.httpClient = httpx.AsyncClient( timeout=600.0, headers={ "x-api-key": self.apiKey, "anthropic-version": "2023-06-01", # Anthropic API Version "Content-Type": "application/json" } ) logger.info("Anthropic Connector initialized") def getConnectorType(self) -> str: """Get the connector type identifier.""" return "anthropic" def getModels(self) -> List[AiModel]: # return [] # TODO: DEBUG TO TURN ON AFTER TESTING # Get all available Anthropic models. return [ AiModel( name="claude-sonnet-4-5-20250929", displayName="Anthropic Claude Sonnet 4.5", connectorType="anthropic", apiUrl="https://api.anthropic.com/v1/messages", temperature=0.2, maxTokens=8192, contextLength=200000, costPer1kTokensInput=0.015, costPer1kTokensOutput=0.075, speedRating=6, # Slower due to high-quality processing qualityRating=10, # Best quality available # capabilities removed (not used in business logic) functionCall=self.callAiBasic, priority=PriorityEnum.QUALITY, processingMode=ProcessingModeEnum.DETAILED, operationTypes=createOperationTypeRatings( (OperationTypeEnum.PLAN, 9), (OperationTypeEnum.DATA_ANALYSE, 10), (OperationTypeEnum.DATA_GENERATE, 9), (OperationTypeEnum.DATA_EXTRACT, 8) ), version="claude-sonnet-4-5-20250929", calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.015 + (bytesReceived / 4 / 1000) * 0.075 ), AiModel( name="claude-sonnet-4-5-20250929", displayName="Anthropic Claude Sonnet 4.5 Vision", connectorType="anthropic", apiUrl="https://api.anthropic.com/v1/messages", temperature=0.2, maxTokens=8192, contextLength=200000, costPer1kTokensInput=0.015, costPer1kTokensOutput=0.075, speedRating=6, qualityRating=10, functionCall=self.callAiImage, priority=PriorityEnum.QUALITY, processingMode=ProcessingModeEnum.DETAILED, operationTypes=createOperationTypeRatings( (OperationTypeEnum.IMAGE_ANALYSE, 10) ), version="claude-sonnet-4-5-20250929", calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.015 + (bytesReceived / 4 / 1000) * 0.075 ) ] async def callAiBasic(self, modelCall: AiModelCall) -> AiModelResponse: """ Calls the Anthropic API with the given messages using standardized pattern. Args: modelCall: AiModelCall with messages and options Returns: AiModelResponse with content and metadata Raises: HTTPException: For errors in API communication """ try: # Extract parameters from modelCall messages = modelCall.messages model = modelCall.model options = modelCall.options temperature = getattr(options, "temperature", None) if temperature is None: temperature = model.temperature maxTokens = model.maxTokens # Transform OpenAI-style messages to Anthropic format: # - Move any 'system' role content to top-level 'system' # - Keep only 'user'/'assistant' messages in the list system_contents: List[str] = [] converted_messages: List[Dict[str, Any]] = [] for m in messages: role = m.get("role") content = m.get("content", "") if role == "system": # Collect system content; Anthropic expects top-level 'system' if isinstance(content, list): # Join text parts if provided as blocks joined = "\n\n".join( [ (part.get("text") if isinstance(part, dict) else str(part)) for part in content ] ) system_contents.append(joined) else: system_contents.append(str(content)) continue # For Anthropic, content can be a string; pass through strings, collapse blocks if isinstance(content, list): # Collapse to text if blocks are provided collapsed = "\n\n".join( [ (part.get("text") if isinstance(part, dict) else str(part)) for part in content ] ) converted_messages.append({"role": role, "content": collapsed}) else: converted_messages.append({"role": role, "content": content}) system_prompt = "\n\n".join([s for s in system_contents if s]) if system_contents else None # Create Anthropic API payload payload: Dict[str, Any] = { "model": model.name, "messages": converted_messages, "temperature": temperature, } # Anthropic requires max_tokens - use provided value or throw error if maxTokens is None: raise ValueError("maxTokens must be provided for Anthropic API calls") payload["max_tokens"] = maxTokens if system_prompt: payload["system"] = system_prompt response = await self.httpClient.post( model.apiUrl, json=payload ) if response.status_code != 200: error_detail = f"Anthropic API error: {response.status_code} - {response.text}" logger.error(error_detail) # Provide more specific error messages based on status code if response.status_code == 529: error_message = "Anthropic API is currently overloaded. Please try again in a few minutes." elif response.status_code == 429: error_message = "Rate limit exceeded. Please wait before making another request." elif response.status_code == 401: error_message = "Invalid API key. Please check your Anthropic API configuration." elif response.status_code == 400: error_message = f"Invalid request to Anthropic API: {response.text}" else: error_message = f"Anthropic API error ({response.status_code}): {response.text}" raise HTTPException(status_code=500, detail=error_message) # Parse response anthropicResponse = response.json() # Extract content from response content = "" if "content" in anthropicResponse: if isinstance(anthropicResponse["content"], list): # Content is a list of parts (in newer API versions) for part in anthropicResponse["content"]: if part.get("type") == "text": content += part.get("text", "") else: # Direct content as string (in older API versions) content = anthropicResponse["content"] # Debug logging for empty responses if not content or content.strip() == "": logger.warning(f"Anthropic API returned empty content. Full response: {anthropicResponse}") content = "[Anthropic API returned empty response]" # Return standardized response return AiModelResponse( content=content, success=True, modelId=model.name, metadata={"response_id": anthropicResponse.get("id", "")} ) except Exception as e: error_msg = str(e) if str(e) else f"{type(e).__name__}" error_detail = f"Error calling Anthropic API: {error_msg}" if hasattr(e, 'detail') and e.detail: error_detail += f" | Detail: {e.detail}" if hasattr(e, 'status_code'): error_detail += f" | Status: {e.status_code}" logger.error(error_detail, exc_info=True) raise HTTPException(status_code=500, detail=error_detail) async def callAiImage(self, modelCall: AiModelCall) -> AiModelResponse: """ Analyzes an image using Anthropic's vision capabilities using standardized pattern. Args: modelCall: AiModelCall with messages and image data in options Returns: AiModelResponse with analysis content """ try: # Extract parameters from messages for Anthropic Vision API messages = modelCall.messages model = modelCall.model # Verify messages contain image data if not messages or not messages[0].get("content"): raise ValueError("No messages provided for image analysis") logger.info(f"callAiImage called with {len(messages)} message(s)...") # Extract text prompt and image data from messages # Messages format: [{"role": "user", "content": [{"type": "text", "text": "..."}, {"type": "image_url", "image_url": {"url": "data:..."}}]}] userContent = messages[0]["content"] if not isinstance(userContent, list): raise ValueError("Expected content to be a list for vision") textPrompt = "" imageUrl = None for contentItem in userContent: if contentItem.get("type") == "text": textPrompt = contentItem.get("text", "") or "" elif contentItem.get("type") == "image_url": imageUrlDict = contentItem.get("image_url") if imageUrlDict and isinstance(imageUrlDict, dict): imageUrl = imageUrlDict.get("url", "") or "" else: imageUrl = None if not imageUrl or not imageUrl.startswith("data:"): raise ValueError("No image data found in messages") # Extract base64 data and mime type from data URL # Format: data:image/jpeg;base64,/9j/4AAQSkZ... parts = imageUrl.split(";base64,") if len(parts) != 2: raise ValueError("Invalid image data URL format") mimeType = parts[0].replace("data:", "") base64Data = parts[1] # Convert to Anthropic's vision format anthropicMessages = [{ "role": "user", "content": [ {"type": "text", "text": textPrompt}, { "type": "image", "source": { "type": "base64", "media_type": mimeType, "data": base64Data } } ] }] # Call Anthropic API directly for vision import time import base64 startTime = time.time() # Prepare system prompt if available systemPrompt = None for msg in messages: if msg.get("role") == "system": systemContent = msg.get("content") if isinstance(systemContent, list): textParts = [] for item in systemContent: if item.get("type") == "text": textValue = item.get("text") if textValue is not None: textParts.append(str(textValue)) if textParts: systemPrompt = "\n".join(textParts) elif systemContent is not None: systemPrompt = str(systemContent) break # Get parameters from model (consistent with callAiBasic) maxTokens = model.maxTokens if hasattr(model, 'maxTokens') else 8192 temperature = model.temperature if hasattr(model, 'temperature') else 0.2 # Prepare API payload payload = { "model": model.name, # Use standard model.name "max_tokens": maxTokens, "messages": anthropicMessages } if systemPrompt: payload["system"] = systemPrompt # Set temperature from model payload["temperature"] = temperature # Make API call with headers from httpClient (which includes anthropic-version) response = await self.httpClient.post( "https://api.anthropic.com/v1/messages", json=payload ) if response.status_code != 200: errorText = response.text logger.error(f"Anthropic API error: {response.status_code} - {errorText}") raise HTTPException(status_code=response.status_code, detail=f"Anthropic API error: {errorText}") # Parse response result = response.json() content = result["content"][0]["text"] if result.get("content") else "" endTime = time.time() processingTime = endTime - startTime # Calculate cost inputTokens = result.get("usage", {}).get("input_tokens", 0) outputTokens = result.get("usage", {}).get("output_tokens", 0) # Return standardized response return AiModelResponse( content=content, success=True, modelId=model.name, processingTime=processingTime ) except Exception as e: logger.error(f"Error during image analysis: {str(e)}", exc_info=True) return AiModelResponse( content="", success=False, error=f"Error during image analysis: {str(e)}" )