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, ModelTags # Configure logger logger = logging.getLogger(__name__) def loadConfigData(): """Load configuration data for Anthropic connector""" return { "apiKey": APP_CONFIG.get('Connector_AiAnthropic_API_SECRET'), "apiUrl": APP_CONFIG.get('Connector_AiAnthropic_API_URL'), "modelName": APP_CONFIG.get('Connector_AiAnthropic_MODEL_NAME'), "temperature": float(APP_CONFIG.get('Connector_AiAnthropic_TEMPERATURE')), } 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"] self.apiUrl = self.config["apiUrl"] self.modelName = self.config["modelName"] # 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(f"Anthropic Connector initialized with model: {self.modelName}") def getConnectorType(self) -> str: """Get the connector type identifier.""" return "anthropic" def getModels(self) -> List[AiModel]: """Get all available Anthropic models.""" return [ AiModel( name="anthropic_callAiBasic", displayName="Claude 3.5 Sonnet", connectorType="anthropic", maxTokens=200000, contextLength=200000, costPer1kTokens=0.015, costPer1kTokensOutput=0.075, speedRating=7, qualityRating=10, capabilities=["text_generation", "chat", "reasoning", "analysis"], tags=[ModelTags.TEXT, ModelTags.CHAT, ModelTags.REASONING, ModelTags.ANALYSIS, ModelTags.HIGH_QUALITY], functionCall=self.callAiBasic, priority="quality", processingMode="detailed", preferredFor=["generate_plan", "analyse_content"], version="claude-3-5-sonnet-20241022" ), AiModel( name="anthropic_callAiImage", displayName="Claude 3.5 Sonnet Vision", connectorType="anthropic", maxTokens=200000, contextLength=200000, costPer1kTokens=0.015, costPer1kTokensOutput=0.075, speedRating=7, qualityRating=10, capabilities=["image_analysis", "vision", "multimodal"], tags=[ModelTags.IMAGE, ModelTags.VISION, ModelTags.MULTIMODAL, ModelTags.HIGH_QUALITY], functionCall=self.callAiImage, priority="quality", processingMode="detailed", preferredFor=["image_analysis"], version="claude-3-5-sonnet-20241022" ) ] async def callAiBasic(self, messages: List[Dict[str, Any]], temperature: float = None, maxTokens: int = None) -> Dict[str, Any]: """ Calls the Anthropic API with the given messages. Args: messages: List of messages in OpenAI format (role, content) temperature: Temperature for response generation (0.0-1.0) maxTokens: Maximum number of tokens in the response Returns: The response in OpenAI format Raises: HTTPException: For errors in API communication """ try: # Use parameters from configuration if none were overridden if temperature is None: temperature = self.config.get("temperature", 0.2) # Don't set maxTokens from config - let the model use its full context length # Our continuation system handles stopping early via prompt engineering # 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": self.modelName, "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( self.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 in OpenAI format return { "id": anthropicResponse.get("id", ""), "object": "chat.completion", "created": anthropicResponse.get("created", 0), "model": anthropicResponse.get("model", self.modelName), "choices": [ { "message": { "role": "assistant", "content": content }, "index": 0, "finish_reason": "stop" } ] } 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, prompt: str, imageData: Union[str, bytes], mimeType: str = None) -> str: """ Analyzes an image using Anthropic's vision capabilities. Args: imageData: Either a file path (str) or image data (bytes) mimeType: The MIME type of the image (optional, only for binary data) prompt: The prompt for analysis Returns: The analysis response as text """ try: # 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}" } } ] } ] # Use the existing callAiBasic function with the Vision model response = await self.callAiBasic(messages) # Extract and return content with proper error handling try: content = response["choices"][0]["message"]["content"] if content is None or content.strip() == "": return "[AI returned empty response for image analysis]" return content except (KeyError, IndexError, TypeError) as e: logger.error(f"Error extracting content from AI response: {str(e)}") logger.error(f"Response structure: {response}") return f"[Error extracting AI response: {str(e)}]" except Exception as e: logger.error(f"Error during image analysis: {str(e)}", exc_info=True) return f"[Error during image analysis: {str(e)}]"