import logging import httpx import os from typing import Dict, Any, List, Union 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 self.httpClient = httpx.AsyncClient( timeout=120.0, # Longer timeout for complex requests 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]: """Get all available Anthropic models.""" return [ AiModel( name="claude-3-5-sonnet-20241022", displayName="Anthropic Claude 3.5 Sonnet", connectorType="anthropic", apiUrl="https://api.anthropic.com/v1/messages", temperature=0.2, maxTokens=200000, 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-3-5-sonnet-20241022", calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.015 + (bytesReceived / 4 / 1000) * 0.075 ), AiModel( name="claude-3-5-sonnet-20241022-vision", displayName="Anthropic Claude 3.5 Sonnet Vision", connectorType="anthropic", apiUrl="https://api.anthropic.com/v1/messages", temperature=0.2, maxTokens=200000, 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.callAiImage, priority=PriorityEnum.QUALITY, processingMode=ProcessingModeEnum.DETAILED, operationTypes=createOperationTypeRatings( (OperationTypeEnum.IMAGE_ANALYSE, 10) ), version="claude-3-5-sonnet-20241022", 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 = options.get("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: logger.error(f"Error calling Anthropic API: {str(e)}") raise HTTPException(status_code=500, detail=f"Error calling Anthropic API: {str(e)}") 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 modelCall messages = modelCall.messages model = modelCall.model options = modelCall.options prompt = messages[0]["content"] if messages else "" imageData = options.get("imageData") mimeType = options.get("mimeType") # Debug logging logger.info(f"callAiImage called with imageData type: {type(imageData)}, length: {len(imageData) if imageData else 0}, mimeType: {mimeType}") # Distinguish between file path and binary data if isinstance(imageData, str): # Check if it's base64 encoded data or a file path if len(imageData) > 100 and not os.path.exists(imageData): # It's likely base64 encoded data logger.info("Treating imageData as base64 encoded string") base64Data = imageData if not mimeType: mimeType = "image/png" else: # It's a file path - import filehandling only when needed logger.info(f"Treating imageData as file path: {imageData}") from modules import agentserviceFilemanager as fileHandler base64Data, autoMimeType = fileHandler.encodeFileToBase64(imageData) mimeType = mimeType or autoMimeType else: # It's binary data logger.info("Treating imageData as binary data") import base64 base64Data = base64.b64encode(imageData).decode('utf-8') # MIME type must be specified for binary data if not mimeType: # Fallback to generic image type mimeType = "image/png" # Prepare the payload for the Vision API messages = [ { "role": "user", "content": [ {"type": "text", "text": prompt}, { "type": "image_url", "image_url": { "url": f"data:{mimeType};base64,{base64Data}" } } ] } ] # Create a modelCall for the basic AI function basicModelCall = AiModelCall( messages=messages, model=model ) # Use the existing callAiBasic function with the Vision model response = await self.callAiBasic(basicModelCall) # Return the standardized response return response 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)}" )