import logging import httpx from typing import 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, AiCallPromptWebSearch, AiCallPromptWebCrawl from modules.datamodels.datamodelTools import CountryCodes # Configure logger logger = logging.getLogger(__name__) def loadConfigData(): """Load configuration data for Perplexity connector""" return { "apiKey": APP_CONFIG.get('Connector_AiPerplexity_API_SECRET'), } class AiPerplexity(BaseConnectorAi): """Connector for communication with the Perplexity 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={ "Authorization": f"Bearer {self.apiKey}", "Content-Type": "application/json", "Accept": "application/json" } ) logger.info("Perplexity Connector initialized") def getConnectorType(self) -> str: """Get the connector type identifier.""" return "perplexity" def _convertIsoCodeToCountryName(self, isoCode: str) -> str: """ Convert ISO-2 country code to Perplexity country name. Uses centralized CountryCodes mapping. """ return CountryCodes.getForPerplexity(isoCode) def getModels(self) -> List[AiModel]: """Get all available Perplexity models.""" return [ AiModel( name="sonar", displayName="Perplexity Sonar", connectorType="perplexity", apiUrl="https://api.perplexity.ai/chat/completions", temperature=0.2, maxTokens=4000, contextLength=32000, costPer1kTokensInput=0.005, costPer1kTokensOutput=0.005, speedRating=8, qualityRating=8, # capabilities removed (not used in business logic) functionCall=self._routeWebOperation, priority=PriorityEnum.BALANCED, processingMode=ProcessingModeEnum.ADVANCED, operationTypes=createOperationTypeRatings( (OperationTypeEnum.WEB_SEARCH, 9), (OperationTypeEnum.WEB_CRAWL, 7) ), version="sonar", calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.005 + (bytesReceived / 4 / 1000) * 0.005 ), AiModel( name="sonar-pro", displayName="Perplexity Sonar Pro", connectorType="perplexity", apiUrl="https://api.perplexity.ai/chat/completions", temperature=0.2, maxTokens=4000, contextLength=32000, costPer1kTokensInput=0.01, costPer1kTokensOutput=0.01, speedRating=6, # Slower due to AI analysis qualityRating=10, # Best AI analysis quality # capabilities removed (not used in business logic) functionCall=self._routeWebOperation, priority=PriorityEnum.QUALITY, processingMode=ProcessingModeEnum.DETAILED, operationTypes=createOperationTypeRatings( (OperationTypeEnum.WEB_SEARCH, 9), (OperationTypeEnum.WEB_CRAWL, 8) ), version="sonar-pro", calculatePriceUsd=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.01 + (bytesReceived / 4 / 1000) * 0.01 ) ] async def callAiBasic(self, modelCall: AiModelCall) -> AiModelResponse: """ Calls the Perplexity 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 payload = { "model": model.name, "messages": messages, "temperature": temperature, "max_tokens": maxTokens } response = await self.httpClient.post( model.apiUrl, json=payload ) if response.status_code != 200: errorDetail = f"Perplexity API error: {response.status_code} - {response.text}" logger.error(errorDetail) # Provide more specific error messages based on status code if response.status_code == 429: errorMessage = "Rate limit exceeded. Please wait before making another request." elif response.status_code == 401: errorMessage = "Invalid API key. Please check your Perplexity API configuration." elif response.status_code == 400: errorMessage = f"Invalid request to Perplexity API: {response.text}" else: errorMessage = f"Perplexity API error ({response.status_code}): {response.text}" raise HTTPException(status_code=500, detail=errorMessage) apiResponse = response.json() content = apiResponse["choices"][0]["message"]["content"] return AiModelResponse( content=content, success=True, modelId=model.name, metadata={"response_id": apiResponse.get("id", "")} ) except Exception as e: logger.error(f"Error calling Perplexity API: {str(e)}") raise HTTPException(status_code=500, detail=f"Error calling Perplexity API: {str(e)}") async def _testConnection(self) -> bool: """ Tests the connection to the Perplexity API. Returns: True if connection is successful, False otherwise """ try: # Try a simple test message testMessages = [ {"role": "user", "content": "Hello, please respond with just 'OK' to confirm the connection works."} ] # Create a model call for testing from modules.datamodels.datamodelAi import AiCallOptions model = self.getModels()[0] # Get first model for testing testCall = AiModelCall( messages=testMessages, model=model, options=AiCallOptions() ) response = await self.callAiBasic(testCall) return response.success and len(response.content.strip()) > 0 except Exception as e: logger.error(f"Perplexity connection test failed: {str(e)}") return False async def _routeWebOperation(self, modelCall: AiModelCall) -> AiModelResponse: """ Route web operation based on operation type. Args: modelCall: AiModelCall with messages and options Returns: AiModelResponse based on operation type """ operationType = modelCall.options.operationType if operationType == OperationTypeEnum.WEB_SEARCH: return await self.webSearch(modelCall) elif operationType == OperationTypeEnum.WEB_CRAWL: return await self.webCrawl(modelCall) else: # Fallback to basic call return await self.callAiBasic(modelCall) async def webSearch(self, modelCall: AiModelCall) -> AiModelResponse: """ WEB_SEARCH operation - returns list of URLs based on search query. Args: modelCall: AiModelCall with AiCallPromptWebSearch as prompt Returns: AiModelResponse with JSON list of URLs """ try: # Extract parameters messages = modelCall.messages model = modelCall.model options = modelCall.options temperature = getattr(options, "temperature", None) or model.temperature maxTokens = model.maxTokens # Parse prompt JSON promptContent = messages[0]["content"] if messages else "" import json promptData = json.loads(promptContent) # Create Pydantic model webSearchPrompt = AiCallPromptWebSearch(**promptData) # Convert ISO country code to country name countryName = webSearchPrompt.country if countryName: countryName = self._convertIsoCodeToCountryName(countryName) # Build search request for Perplexity searchPrompt = f"""Search the web for: {webSearchPrompt.instruction} Return a JSON array of {webSearchPrompt.maxNumberPages} most relevant URLs. {'' if not countryName else f'Focus on results from {countryName}.'} {'' if not webSearchPrompt.timeRange else f'Limit to results from the last {webSearchPrompt.timeRange}'} {'' if not webSearchPrompt.language else f'Return results in {webSearchPrompt.language} language'} Return ONLY a JSON array of URLs, no additional text: [ "https://example1.com/page", "https://example2.com/article", "https://example3.com/resource" ]""" payload = { "model": model.name, "messages": [{"role": "user", "content": searchPrompt}], "temperature": temperature, "max_tokens": maxTokens } response = await self.httpClient.post(model.apiUrl, json=payload) if response.status_code != 200: raise HTTPException(status_code=500, detail=f"Perplexity Web Search API error: {response.text}") apiResponse = response.json() content = apiResponse["choices"][0]["message"]["content"] return AiModelResponse( content=content, success=True, modelId=model.name, metadata={"response_id": apiResponse.get("id", ""), "operation": "WEB_SEARCH"} ) except Exception as e: logger.error(f"Error in Perplexity web search: {str(e)}") raise HTTPException(status_code=500, detail=f"Error in Perplexity web search: {str(e)}") async def webCrawl(self, modelCall: AiModelCall) -> AiModelResponse: """ WEB_CRAWL operation - crawls ONE URL and returns content. Args: modelCall: AiModelCall with AiCallPromptWebCrawl as prompt Returns: AiModelResponse with crawl results as JSON object """ try: # Extract parameters messages = modelCall.messages model = modelCall.model options = modelCall.options temperature = getattr(options, "temperature", None) or model.temperature maxTokens = model.maxTokens # Parse prompt JSON promptContent = messages[0]["content"] if messages else "" import json promptData = json.loads(promptContent) # Create Pydantic model webCrawlPrompt = AiCallPromptWebCrawl(**promptData) # Build crawl request for Perplexity - ONE URL crawlPrompt = f"""Crawl and extract content from this URL based on the instruction: INSTRUCTION: '{webCrawlPrompt.instruction}' URL to crawl (maxDepth={webCrawlPrompt.maxDepth}): {webCrawlPrompt.url} Extract and return the relevant content based on the instruction. Return as JSON object with this structure: {{ "url": "{webCrawlPrompt.url}", "title": "Page title", "content": "Extracted content relevant to the instruction" }} Return ONLY valid JSON, no additional text.""" payload = { "model": model.name, "messages": [{"role": "user", "content": crawlPrompt}], "temperature": temperature, "max_tokens": maxTokens } response = await self.httpClient.post(model.apiUrl, json=payload) if response.status_code != 200: raise HTTPException(status_code=500, detail=f"Perplexity Web Crawl API error: {response.text}") apiResponse = response.json() content = apiResponse["choices"][0]["message"]["content"] # Parse JSON content and ensure it's a single object import json try: parsedContent = json.loads(content) # Ensure it's a single object, not an array if isinstance(parsedContent, list): parsedContent = parsedContent[0] if parsedContent else {} except: # If not JSON, create structured response parsedContent = {"url": webCrawlPrompt.url, "title": "", "content": content} # Return as JSON string return AiModelResponse( content=json.dumps(parsedContent, indent=2), success=True, modelId=model.name, metadata={"response_id": apiResponse.get("id", ""), "operation": "WEB_CRAWL", "url": webCrawlPrompt.url} ) except Exception as e: logger.error(f"Error in Perplexity web crawl: {str(e)}") raise HTTPException(status_code=500, detail=f"Error in Perplexity web crawl: {str(e)}")