379 lines
No EOL
16 KiB
Python
379 lines
No EOL
16 KiB
Python
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)}"
|
|
) |