From 3adaaad8eb11781d40e293901bef256e9b3a376e Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 21 Oct 2025 18:14:58 +0200 Subject: [PATCH] refactored ai core system to attach ai models plug and play --- modules/aicore/aicoreBase.py | 82 + modules/aicore/aicoreModelRegistry.py | 175 +++ modules/aicore/aicoreModelSelectionConfig.py | 180 +++ modules/aicore/aicoreModelSelector.py | 279 ++++ .../aicorePluginAnthropic.py} | 75 +- .../aicorePluginOpenai.py} | 93 +- .../aicorePluginPerplexity.py} | 116 +- .../aicorePluginTavily.py} | 108 +- modules/datamodels/datamodelAi.py | 48 +- .../_BACKUP_NOT_USED_interfaceAiObjects.py | 1372 +++++++++++++++++ modules/interfaces/interfaceAiObjects.py | 881 +++-------- 11 files changed, 2721 insertions(+), 688 deletions(-) create mode 100644 modules/aicore/aicoreBase.py create mode 100644 modules/aicore/aicoreModelRegistry.py create mode 100644 modules/aicore/aicoreModelSelectionConfig.py create mode 100644 modules/aicore/aicoreModelSelector.py rename modules/{connectors/connectorAiAnthropic.py => aicore/aicorePluginAnthropic.py} (82%) rename modules/{connectors/connectorAiOpenai.py => aicore/aicorePluginOpenai.py} (74%) rename modules/{connectors/connectorAiPerplexity.py => aicore/aicorePluginPerplexity.py} (68%) rename modules/{connectors/connectorAiTavily.py => aicore/aicorePluginTavily.py} (79%) create mode 100644 modules/interfaces/_BACKUP_NOT_USED_interfaceAiObjects.py diff --git a/modules/aicore/aicoreBase.py b/modules/aicore/aicoreBase.py new file mode 100644 index 00000000..0f0db21e --- /dev/null +++ b/modules/aicore/aicoreBase.py @@ -0,0 +1,82 @@ +""" +Base connector interface for AI connectors. +All AI connectors should inherit from this class. +""" + +from abc import ABC, abstractmethod +from typing import List, Dict, Any, Optional +from modules.datamodels.datamodelAi import AiModel + + +class BaseConnectorAi(ABC): + """Base class for all AI connectors.""" + + def __init__(self): + self._models_cache: Optional[List[AiModel]] = None + self._last_cache_update: Optional[float] = None + self._cache_ttl: float = 300.0 # 5 minutes cache TTL + + @abstractmethod + def getModels(self) -> List[AiModel]: + """ + Get all available models for this connector. + Should be implemented by each connector. + """ + pass + + @abstractmethod + def getConnectorType(self) -> str: + """ + Get the connector type identifier. + Should return one of: openai, anthropic, perplexity, tavily + """ + pass + + def getCachedModels(self) -> List[AiModel]: + """ + Get cached models with TTL check. + Returns cached models if still valid, otherwise refreshes cache. + """ + import time + + current_time = time.time() + + # Check if cache is valid + if (self._models_cache is not None and + self._last_cache_update is not None and + current_time - self._last_cache_update < self._cache_ttl): + return self._models_cache + + # Refresh cache + self._models_cache = self.getModels() + self._last_cache_update = current_time + + return self._models_cache + + def clearCache(self): + """Clear the models cache.""" + self._models_cache = None + self._last_cache_update = None + + def getModelByName(self, name: str) -> Optional[AiModel]: + """Get a specific model by name.""" + models = self.getCachedModels() + for model in models: + if model.name == name: + return model + return None + + def getModelsByCapability(self, capability: str) -> List[AiModel]: + """Get models that support a specific capability.""" + models = self.getCachedModels() + return [model for model in models if capability in model.capabilities] + + def getModelsByTag(self, tag: str) -> List[AiModel]: + """Get models that have a specific tag.""" + models = self.getCachedModels() + return [model for model in models if tag in model.tags] + + def getAvailableModels(self) -> List[AiModel]: + """Get only available models.""" + models = self.getCachedModels() + return [model for model in models if model.isAvailable] diff --git a/modules/aicore/aicoreModelRegistry.py b/modules/aicore/aicoreModelRegistry.py new file mode 100644 index 00000000..297bf52d --- /dev/null +++ b/modules/aicore/aicoreModelRegistry.py @@ -0,0 +1,175 @@ +""" +Dynamic model registry that collects models from all AI connectors. +Implements plugin-like architecture for connector discovery. +""" + +import logging +import importlib +import os +from typing import Dict, List, Optional, Any +from modules.datamodels.datamodelAi import AiModel +from modules.aicore.aicoreBase import BaseConnectorAi + +logger = logging.getLogger(__name__) + + +class ModelRegistry: + """Dynamic registry for AI models from all connectors.""" + + def __init__(self): + self._models: Dict[str, AiModel] = {} + self._connectors: Dict[str, BaseConnectorAi] = {} + self._last_refresh: Optional[float] = None + self._refresh_interval: float = 300.0 # 5 minutes + + def registerConnector(self, connector: BaseConnectorAi): + """Register a connector and collect its models.""" + connector_type = connector.getConnectorType() + self._connectors[connector_type] = connector + + # Collect models from this connector + try: + models = connector.getCachedModels() + for model in models: + self._models[model.name] = model + logger.debug(f"Registered model: {model.name} from {connector_type}") + except Exception as e: + logger.error(f"Failed to register models from {connector_type}: {e}") + + def discoverConnectors(self) -> List[BaseConnectorAi]: + """Auto-discover connectors by scanning aicorePlugin*.py files.""" + connectors = [] + connector_dir = os.path.dirname(__file__) + + # Scan for connector files + for filename in os.listdir(connector_dir): + if filename.startswith('aicorePlugin') and filename.endswith('.py'): + module_name = filename[:-3] # Remove .py extension + + try: + # Import the module + module = importlib.import_module(f'modules.connectors.{module_name}') + + # Find connector classes (classes that inherit from BaseConnectorAi) + for attr_name in dir(module): + attr = getattr(module, attr_name) + if (isinstance(attr, type) and + issubclass(attr, BaseConnectorAi) and + attr != BaseConnectorAi): + + # Instantiate the connector + connector = attr() + connectors.append(connector) + logger.info(f"Discovered connector: {connector.getConnectorType()}") + + except Exception as e: + logger.warning(f"Failed to discover connector from {filename}: {e}") + + return connectors + + def refreshModels(self, force: bool = False): + """Refresh models from all registered connectors.""" + import time + + current_time = time.time() + + # Check if refresh is needed + if (not force and + self._last_refresh is not None and + current_time - self._last_refresh < self._refresh_interval): + return + + logger.info("Refreshing model registry...") + + # Clear existing models + self._models.clear() + + # Re-register all connectors + for connector in self._connectors.values(): + try: + connector.clearCache() # Clear connector cache + models = connector.getCachedModels() + for model in models: + self._models[model.name] = model + except Exception as e: + logger.error(f"Failed to refresh models from {connector.getConnectorType()}: {e}") + + self._last_refresh = current_time + logger.info(f"Model registry refreshed: {len(self._models)} models available") + + def getModel(self, name: str) -> Optional[AiModel]: + """Get a specific model by name.""" + self.refreshModels() + return self._models.get(name) + + def getModels(self) -> List[AiModel]: + """Get all available models.""" + self.refreshModels() + return list(self._models.values()) + + def getModelsByConnector(self, connector_type: str) -> List[AiModel]: + """Get models from a specific connector.""" + self.refreshModels() + return [model for model in self._models.values() if model.connectorType == connector_type] + + def getModelsByCapability(self, capability: str) -> List[AiModel]: + """Get models that support a specific capability.""" + self.refreshModels() + return [model for model in self._models.values() if capability in model.capabilities] + + def getModelsByTag(self, tag: str) -> List[AiModel]: + """Get models that have a specific tag.""" + self.refreshModels() + return [model for model in self._models.values() if tag in model.tags] + + def getAvailableModels(self) -> List[AiModel]: + """Get only available models.""" + self.refreshModels() + return [model for model in self._models.values() if model.isAvailable] + + def getConnectorForModel(self, model_name: str) -> Optional[BaseConnectorAi]: + """Get the connector instance for a specific model.""" + model = self.getModel(model_name) + if model: + return self._connectors.get(model.connectorType) + return None + + def getModelStats(self) -> Dict[str, Any]: + """Get statistics about the model registry.""" + self.refreshModels() + + stats = { + "total_models": len(self._models), + "available_models": len([m for m in self._models.values() if m.isAvailable]), + "connectors": len(self._connectors), + "by_connector": {}, + "by_capability": {}, + "by_tag": {} + } + + # Count by connector + for model in self._models.values(): + connector = model.connectorType + if connector not in stats["by_connector"]: + stats["by_connector"][connector] = 0 + stats["by_connector"][connector] += 1 + + # Count by capability + for model in self._models.values(): + for capability in model.capabilities: + if capability not in stats["by_capability"]: + stats["by_capability"][capability] = 0 + stats["by_capability"][capability] += 1 + + # Count by tag + for model in self._models.values(): + for tag in model.tags: + if tag not in stats["by_tag"]: + stats["by_tag"][tag] = 0 + stats["by_tag"][tag] += 1 + + return stats + + +# Global registry instance +model_registry = ModelRegistry() diff --git a/modules/aicore/aicoreModelSelectionConfig.py b/modules/aicore/aicoreModelSelectionConfig.py new file mode 100644 index 00000000..6f36e039 --- /dev/null +++ b/modules/aicore/aicoreModelSelectionConfig.py @@ -0,0 +1,180 @@ +""" +Configuration for dynamic model selection rules. +This makes model selection configurable rather than hardcoded. +""" + +from typing import Dict, List, Any, Optional +from dataclasses import dataclass +from modules.datamodels.datamodelAi import OperationType, Priority, ProcessingMode, ModelTags + + +@dataclass +class SelectionRule: + """A rule for model selection.""" + name: str + condition: str # Description of when this rule applies + weight: float # Weight for scoring (higher = more important) + operation_types: List[str] # Operation types this rule applies to + required_tags: List[str] # Required tags for this rule + preferred_tags: List[str] # Preferred tags for this rule + avoid_tags: List[str] # Tags to avoid for this rule + min_quality_rating: Optional[int] = None # Minimum quality rating + max_cost: Optional[float] = None # Maximum cost threshold + min_context_length: Optional[int] = None # Minimum context length required + + +class ModelSelectionConfig: + """Configuration for model selection rules.""" + + def __init__(self): + self.rules = self._loadDefaultRules() + self.fallback_models = self._loadFallbackModels() + + def _loadDefaultRules(self) -> List[SelectionRule]: + """Load default selection rules.""" + return [ + # High quality for planning and analysis + SelectionRule( + name="high_quality_analysis", + condition="Planning or analysis operations requiring high quality", + weight=10.0, + operation_types=[OperationType.GENERATE_PLAN, OperationType.ANALYSE_CONTENT], + required_tags=[ModelTags.TEXT, ModelTags.REASONING, ModelTags.ANALYSIS], + preferred_tags=[ModelTags.HIGH_QUALITY], + avoid_tags=[ModelTags.FAST], + min_quality_rating=8 + ), + + # Fast processing for basic operations + SelectionRule( + name="fast_basic_processing", + condition="Basic operations requiring speed", + weight=8.0, + operation_types=[OperationType.GENERAL], + required_tags=[ModelTags.TEXT, ModelTags.CHAT], + preferred_tags=[ModelTags.FAST], + avoid_tags=[], + min_quality_rating=5 + ), + + # Cost-effective for high-volume operations + SelectionRule( + name="cost_effective_processing", + condition="High-volume operations where cost matters", + weight=7.0, + operation_types=[OperationType.GENERAL, OperationType.GENERATE_CONTENT], + required_tags=[ModelTags.TEXT], + preferred_tags=[ModelTags.COST_EFFECTIVE], + avoid_tags=[], + max_cost=0.01 # $0.01 per 1k tokens + ), + + # Image analysis specific + SelectionRule( + name="image_analysis", + condition="Image analysis operations", + weight=10.0, + operation_types=[OperationType.IMAGE_ANALYSIS], + required_tags=[ModelTags.IMAGE, ModelTags.VISION, ModelTags.MULTIMODAL], + preferred_tags=[ModelTags.HIGH_QUALITY], + avoid_tags=[], + min_quality_rating=8 + ), + + # Web research specific + SelectionRule( + name="web_research", + condition="Web research operations", + weight=9.0, + operation_types=[OperationType.WEB_RESEARCH], + required_tags=[ModelTags.TEXT, ModelTags.ANALYSIS], + preferred_tags=[ModelTags.WEB, ModelTags.SEARCH], + avoid_tags=[], + min_quality_rating=7 + ), + + # Large context requirements + SelectionRule( + name="large_context", + condition="Operations requiring large context", + weight=8.0, + operation_types=[OperationType.GENERAL, OperationType.ANALYSE_CONTENT], + required_tags=[ModelTags.TEXT], + preferred_tags=[], + avoid_tags=[], + min_context_length=100000 # 100k tokens + ) + ] + + def _loadFallbackModels(self) -> Dict[str, Dict[str, Any]]: + """Load fallback model selection criteria.""" + return { + OperationType.GENERAL: { + "priority_order": ["speed", "quality", "cost"], + "required_tags": [ModelTags.TEXT, ModelTags.CHAT], + "min_quality_rating": 5, + "max_cost_per_1k": 0.01 + }, + OperationType.IMAGE_ANALYSIS: { + "priority_order": ["quality", "speed"], + "required_tags": [ModelTags.IMAGE, ModelTags.VISION, ModelTags.MULTIMODAL], + "min_quality_rating": 8, + "max_cost_per_1k": 0.1 + }, + OperationType.IMAGE_GENERATION: { + "priority_order": ["quality", "speed"], + "required_tags": [ModelTags.IMAGE_GENERATION, ModelTags.ART, ModelTags.VISUAL], + "min_quality_rating": 8, + "max_cost_per_1k": 0.1 + }, + OperationType.WEB_RESEARCH: { + "priority_order": ["quality", "speed", "cost"], + "required_tags": [ModelTags.TEXT, ModelTags.ANALYSIS], + "preferred_tags": [ModelTags.WEB, ModelTags.SEARCH], + "min_quality_rating": 7, + "max_cost_per_1k": 0.02 + }, + OperationType.GENERATE_PLAN: { + "priority_order": ["quality", "speed"], + "required_tags": [ModelTags.TEXT, ModelTags.REASONING, ModelTags.ANALYSIS], + "preferred_tags": [ModelTags.HIGH_QUALITY], + "min_quality_rating": 8, + "max_cost_per_1k": 0.1 + }, + OperationType.ANALYSE_CONTENT: { + "priority_order": ["quality", "speed"], + "required_tags": [ModelTags.TEXT, ModelTags.ANALYSIS, ModelTags.REASONING], + "preferred_tags": [ModelTags.HIGH_QUALITY], + "min_quality_rating": 8, + "max_cost_per_1k": 0.1 + } + } + + def getRulesForOperation(self, operation_type: str) -> List[SelectionRule]: + """Get rules that apply to a specific operation type.""" + return [rule for rule in self.rules if operation_type in rule.operation_types] + + def getFallbackCriteria(self, operation_type: str) -> Dict[str, Any]: + """Get fallback selection criteria for a specific operation type.""" + return self.fallback_models.get(operation_type, self.fallback_models[OperationType.GENERAL]) + + def addRule(self, rule: SelectionRule): + """Add a new selection rule.""" + self.rules.append(rule) + + def removeRule(self, rule_name: str): + """Remove a selection rule by name.""" + self.rules = [rule for rule in self.rules if rule.name != rule_name] + + def updateRule(self, rule_name: str, **kwargs): + """Update an existing rule.""" + for rule in self.rules: + if rule.name == rule_name: + for key, value in kwargs.items(): + if hasattr(rule, key): + setattr(rule, key, value) + break + + +# Global configuration instance +model_selection_config = ModelSelectionConfig() diff --git a/modules/aicore/aicoreModelSelector.py b/modules/aicore/aicoreModelSelector.py new file mode 100644 index 00000000..e4c6a36d --- /dev/null +++ b/modules/aicore/aicoreModelSelector.py @@ -0,0 +1,279 @@ +""" +Dynamic model selector using configurable rules and scoring. +""" + +import logging +from typing import List, Optional, Dict, Any, Tuple +from modules.datamodels.datamodelAi import AiModel, AiCallOptions, OperationType, Priority, ProcessingMode, ModelTags +from modules.aicore.aicoreModelSelectionConfig import model_selection_config + +logger = logging.getLogger(__name__) + + +class ModelSelector: + """Dynamic model selector using configurable rules.""" + + def __init__(self): + self.config = model_selection_config + + def selectModel(self, + prompt: str, + context: str, + options: AiCallOptions, + available_models: List[AiModel]) -> Optional[AiModel]: + """ + Select the best model based on configurable rules and scoring. + + Args: + prompt: User prompt + context: Context data + options: AI call options + available_models: List of available models to choose from + + Returns: + Selected model or None if no suitable model found + """ + if not available_models: + logger.warning("No models available for selection") + return None + + logger.info(f"Selecting model for operation: {options.operationType}, priority: {options.priority}") + + # Calculate input size + input_size = len(prompt.encode("utf-8")) + len(context.encode("utf-8")) + + # Get applicable rules + rules = self.config.getRulesForOperation(options.operationType) + logger.debug(f"Found {len(rules)} applicable rules for {options.operationType}") + + # Score each model + scored_models = [] + for model in available_models: + if not model.isAvailable: + continue + + score = self._calculateModelScore(model, input_size, options, rules) + if score > 0: # Only consider models with positive scores + scored_models.append((model, score)) + logger.debug(f"Model {model.name}: score={score:.2f}") + + if not scored_models: + logger.warning("No models passed the selection criteria, trying fallback criteria") + # Try fallback criteria + fallback_criteria = self.getFallbackCriteria(options.operationType) + return self._selectWithFallbackCriteria(available_models, fallback_criteria, input_size, options) + + # Sort by score (highest first) + scored_models.sort(key=lambda x: x[1], reverse=True) + + selected_model = scored_models[0][0] + selected_score = scored_models[0][1] + + logger.info(f"Selected model: {selected_model.name} (score: {selected_score:.2f})") + + # Log selection details + self._logSelectionDetails(selected_model, input_size, options) + + return selected_model + + def _calculateModelScore(self, + model: AiModel, + input_size: int, + options: AiCallOptions, + rules: List) -> float: + """Calculate score for a model based on rules and criteria.""" + score = 0.0 + + # Check basic requirements + if not self._meetsBasicRequirements(model, input_size, options): + return 0.0 + + # Apply rules + for rule in rules: + rule_score = self._applyRule(model, input_size, options, rule) + score += rule_score * rule.weight + + # Apply priority-based scoring + priority_score = self._applyPriorityScoring(model, options) + score += priority_score + + # Apply processing mode scoring + mode_score = self._applyProcessingModeScoring(model, options) + score += mode_score + + # Apply cost constraints + if not self._meetsCostConstraints(model, input_size, options): + score *= 0.1 # Heavily penalize but don't eliminate + + return max(0.0, score) + + def _meetsBasicRequirements(self, model: AiModel, input_size: int, options: AiCallOptions) -> bool: + """Check if model meets basic requirements.""" + # Context length check + if model.contextLength > 0 and input_size > model.contextLength * 0.8: + logger.debug(f"Model {model.name} rejected: input too large ({input_size} > {model.contextLength * 0.8})") + return False + + # Required tags check + if options.requiredTags: + if not all(tag in model.tags for tag in options.requiredTags): + logger.debug(f"Model {model.name} rejected: missing required tags") + return False + + # Capabilities check + if options.modelCapabilities: + if not all(cap in model.capabilities for cap in options.modelCapabilities): + logger.debug(f"Model {model.name} rejected: missing required capabilities") + return False + + # Avoid tags check + for rule in self.config.getRulesForOperation(options.operationType): + if any(tag in model.tags for tag in rule.avoid_tags): + logger.debug(f"Model {model.name} rejected: has avoid tags") + return False + + return True + + def _applyRule(self, model: AiModel, input_size: int, options: AiCallOptions, rule) -> float: + """Apply a specific rule to calculate score contribution.""" + score = 0.0 + + # Required tags match + if all(tag in model.tags for tag in rule.required_tags): + score += 1.0 + + # Preferred tags match + preferred_matches = sum(1 for tag in rule.preferred_tags if tag in model.tags) + if rule.preferred_tags: + score += (preferred_matches / len(rule.preferred_tags)) * 0.5 + + # Quality rating check + if rule.min_quality_rating and model.qualityRating >= rule.min_quality_rating: + score += 0.3 + + # Context length check + if rule.min_context_length and model.contextLength >= rule.min_context_length: + score += 0.2 + + return score + + def _applyPriorityScoring(self, model: AiModel, options: AiCallOptions) -> float: + """Apply priority-based scoring.""" + if options.priority == Priority.SPEED: + return model.speedRating * 0.1 + elif options.priority == Priority.QUALITY: + return model.qualityRating * 0.1 + elif options.priority == Priority.COST: + # Lower cost = higher score + cost_score = max(0, 1.0 - (model.costPer1kTokens * 1000)) + return cost_score * 0.1 + else: # BALANCED + return (model.qualityRating + model.speedRating) * 0.05 + + def _applyProcessingModeScoring(self, model: AiModel, options: AiCallOptions) -> float: + """Apply processing mode scoring.""" + if options.processingMode == ProcessingMode.DETAILED: + if ModelTags.HIGH_QUALITY in model.tags: + return 0.2 + elif options.processingMode == ProcessingMode.BASIC: + if ModelTags.FAST in model.tags: + return 0.2 + + return 0.0 + + def _meetsCostConstraints(self, model: AiModel, input_size: int, options: AiCallOptions) -> bool: + """Check if model meets cost constraints.""" + if options.maxCost is None: + return True + + # Estimate cost + estimated_tokens = input_size / 4 + estimated_cost = (estimated_tokens / 1000) * model.costPer1kTokens + + return estimated_cost <= options.maxCost + + def _logSelectionDetails(self, model: AiModel, input_size: int, options: AiCallOptions): + """Log detailed selection information.""" + logger.info(f"Model Selection Details:") + logger.info(f" Selected: {model.displayName} ({model.name})") + logger.info(f" Connector: {model.connectorType}") + logger.info(f" Operation: {options.operationType}") + logger.info(f" Priority: {options.priority}") + logger.info(f" Processing Mode: {options.processingMode}") + logger.info(f" Input Size: {input_size} bytes") + logger.info(f" Context Length: {model.contextLength}") + logger.info(f" Max Tokens: {model.maxTokens}") + logger.info(f" Quality Rating: {model.qualityRating}/10") + logger.info(f" Speed Rating: {model.speedRating}/10") + logger.info(f" Cost: ${model.costPer1kTokens:.4f}/1k tokens") + logger.info(f" Capabilities: {', '.join(model.capabilities)}") + logger.info(f" Tags: {', '.join(model.tags)}") + + def getFallbackCriteria(self, operation_type: str) -> Dict[str, Any]: + """Get fallback selection criteria for an operation type.""" + return self.config.getFallbackCriteria(operation_type) + + def _selectWithFallbackCriteria(self, + available_models: List[AiModel], + fallback_criteria: Dict[str, Any], + input_size: int, + options: AiCallOptions) -> Optional[AiModel]: + """Select model using fallback criteria when normal selection fails.""" + logger.info("Using fallback criteria for model selection") + + # Filter models by fallback criteria + candidates = [] + for model in available_models: + if not model.isAvailable: + continue + + # Check required tags + if fallback_criteria.get("required_tags"): + if not all(tag in model.tags for tag in fallback_criteria["required_tags"]): + continue + + # Check quality rating + if fallback_criteria.get("min_quality_rating"): + if model.qualityRating < fallback_criteria["min_quality_rating"]: + continue + + # Check cost + if fallback_criteria.get("max_cost_per_1k"): + if model.costPer1kTokens > fallback_criteria["max_cost_per_1k"]: + continue + + # Check context length + if model.contextLength > 0 and input_size > model.contextLength * 0.8: + continue + + candidates.append(model) + + if not candidates: + logger.error("No models available even with fallback criteria") + return None + + # Sort by priority order from fallback criteria + priority_order = fallback_criteria.get("priority_order", ["quality", "speed", "cost"]) + + def get_priority_score(model: AiModel) -> float: + score = 0.0 + for i, priority in enumerate(priority_order): + weight = len(priority_order) - i # Higher weight for earlier priorities + if priority == "quality": + score += model.qualityRating * weight + elif priority == "speed": + score += model.speedRating * weight + elif priority == "cost": + # Lower cost = higher score + score += (1.0 - model.costPer1kTokens * 1000) * weight + return score + + candidates.sort(key=get_priority_score, reverse=True) + selected_model = candidates[0] + + logger.info(f"Fallback selection: {selected_model.name} (score: {get_priority_score(selected_model):.2f})") + return selected_model + + +# Global selector instance +model_selector = ModelSelector() diff --git a/modules/connectors/connectorAiAnthropic.py b/modules/aicore/aicorePluginAnthropic.py similarity index 82% rename from modules/connectors/connectorAiAnthropic.py rename to modules/aicore/aicorePluginAnthropic.py index 16b235e0..8dd2f9ed 100644 --- a/modules/connectors/connectorAiAnthropic.py +++ b/modules/aicore/aicorePluginAnthropic.py @@ -4,6 +4,8 @@ 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__) @@ -17,10 +19,11 @@ def loadConfigData(): "temperature": float(APP_CONFIG.get('Connector_AiAnthropic_TEMPERATURE')), } -class AiAnthropic: +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"] @@ -39,25 +42,51 @@ class AiAnthropic: logger.info(f"Anthropic Connector initialized with model: {self.modelName}") - def _getMaxTokensForModel(self, maxTokens: int = None) -> int: - """Get appropriate max_tokens for the current model.""" - if maxTokens is not None: - return maxTokens - - # Model-specific defaults based on Anthropic's limits - model_name = self.modelName.lower() - if "claude-3-5-sonnet" in model_name: - return 200000 # Claude 3.5 Sonnet max - elif "claude-3-5-haiku" in model_name: - return 200000 # Claude 3.5 Haiku max - elif "claude-3-opus" in model_name: - return 200000 # Claude 3 Opus max - elif "claude-3-sonnet" in model_name: - return 200000 # Claude 3 Sonnet max - elif "claude-3-haiku" in model_name: - return 200000 # Claude 3 Haiku max - else: - return 200000 # Default to maximum for unknown models + 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]: """ @@ -126,8 +155,10 @@ class AiAnthropic: "temperature": temperature, } - # Anthropic requires max_tokens - use model-appropriate value - payload["max_tokens"] = self._getMaxTokensForModel(maxTokens) + # 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 diff --git a/modules/connectors/connectorAiOpenai.py b/modules/aicore/aicorePluginOpenai.py similarity index 74% rename from modules/connectors/connectorAiOpenai.py rename to modules/aicore/aicorePluginOpenai.py index 8aac34cd..89239ebb 100644 --- a/modules/connectors/connectorAiOpenai.py +++ b/modules/aicore/aicorePluginOpenai.py @@ -4,6 +4,8 @@ import httpx 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__) @@ -21,10 +23,11 @@ def loadConfigData(): "temperature": float(APP_CONFIG.get('Connector_AiOpenai_TEMPERATURE')), } -class AiOpenai: +class AiOpenai(BaseConnectorAi): """Connector for communication with the OpenAI API.""" def __init__(self): + super().__init__() # Load configuration self.config = loadConfigData() self.apiKey = self.config["apiKey"] @@ -41,6 +44,87 @@ class AiOpenai: ) logger.info(f"OpenAI Connector initialized with model: {self.modelName}") + def getConnectorType(self) -> str: + """Get the connector type identifier.""" + return "openai" + + def getModels(self) -> List[AiModel]: + """Get all available OpenAI models.""" + return [ + AiModel( + name="openai_callAiBasic", + displayName="GPT-4o", + connectorType="openai", + maxTokens=128000, + contextLength=128000, + costPer1kTokens=0.03, + costPer1kTokensOutput=0.06, + speedRating=8, + qualityRating=9, + capabilities=["text_generation", "chat", "reasoning", "analysis"], + tags=[ModelTags.TEXT, ModelTags.CHAT, ModelTags.REASONING, ModelTags.ANALYSIS], + functionCall=self.callAiBasic, + priority="balanced", + processingMode="advanced", + preferredFor=["general", "analyse_content"], + version="gpt-4o" + ), + AiModel( + name="openai_callAiBasic_gpt35", + displayName="GPT-3.5 Turbo", + connectorType="openai", + maxTokens=16000, + contextLength=16000, + costPer1kTokens=0.0015, + costPer1kTokensOutput=0.002, + speedRating=9, + qualityRating=7, + capabilities=["text_generation", "chat", "reasoning"], + tags=[ModelTags.TEXT, ModelTags.CHAT, ModelTags.REASONING, ModelTags.FAST, ModelTags.COST_EFFECTIVE], + functionCall=self.callAiBasic, + priority="speed", + processingMode="basic", + preferredFor=["general"], + version="gpt-3.5-turbo" + ), + AiModel( + name="openai_callAiImage", + displayName="GPT-4o Vision", + connectorType="openai", + maxTokens=128000, + contextLength=128000, + costPer1kTokens=0.03, + costPer1kTokensOutput=0.06, + speedRating=7, + qualityRating=9, + capabilities=["image_analysis", "vision", "multimodal"], + tags=[ModelTags.IMAGE, ModelTags.VISION, ModelTags.MULTIMODAL], + functionCall=self.callAiImage, + priority="quality", + processingMode="detailed", + preferredFor=["image_analysis"], + version="gpt-4o" + ), + AiModel( + name="openai_generateImage", + displayName="DALL-E 3", + connectorType="openai", + maxTokens=0, # Image generation doesn't use tokens + contextLength=0, + costPer1kTokens=0.04, + costPer1kTokensOutput=0.0, + speedRating=6, + qualityRating=9, + capabilities=["image_generation", "art", "visual_creation"], + tags=[ModelTags.IMAGE_GENERATION, ModelTags.ART, ModelTags.VISUAL], + functionCall=self.generateImage, + priority="quality", + processingMode="detailed", + preferredFor=["image_generation"], + version="dall-e-3" + ) + ] + async def callAiBasic(self, messages: List[Dict[str, Any]], temperature: float = None, maxTokens: int = None) -> str: """ Calls the OpenAI API with the given messages. @@ -70,9 +154,10 @@ class AiOpenai: "temperature": temperature } - # Only add max_tokens if it's explicitly set - if maxTokens is not None: - payload["max_tokens"] = maxTokens + # Add max_tokens - use provided value or throw error + if maxTokens is None: + raise ValueError("maxTokens must be provided for OpenAI API calls") + payload["max_tokens"] = maxTokens response = await self.httpClient.post( self.apiUrl, diff --git a/modules/connectors/connectorAiPerplexity.py b/modules/aicore/aicorePluginPerplexity.py similarity index 68% rename from modules/connectors/connectorAiPerplexity.py rename to modules/aicore/aicorePluginPerplexity.py index 4634cf1d..474756a0 100644 --- a/modules/connectors/connectorAiPerplexity.py +++ b/modules/aicore/aicorePluginPerplexity.py @@ -4,6 +4,8 @@ import asyncio from typing import Dict, Any, List, Union, Optional 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__) @@ -17,10 +19,11 @@ def loadConfigData(): "temperature": float(APP_CONFIG.get('Connector_AiPerplexity_TEMPERATURE')), } -class AiPerplexity: +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"] @@ -39,6 +42,105 @@ class AiPerplexity: logger.info(f"Perplexity Connector initialized with model: {self.modelName}") + def getConnectorType(self) -> str: + """Get the connector type identifier.""" + return "perplexity" + + def getModels(self) -> List[AiModel]: + """Get all available Perplexity models.""" + return [ + AiModel( + name="perplexity_callAiBasic", + displayName="Llama 3.1 Sonar Large 128k", + connectorType="perplexity", + maxTokens=128000, + contextLength=128000, + costPer1kTokens=0.005, + costPer1kTokensOutput=0.005, + speedRating=8, + qualityRating=8, + capabilities=["text_generation", "chat", "reasoning", "web_search"], + tags=[ModelTags.TEXT, ModelTags.CHAT, ModelTags.REASONING, ModelTags.WEB, ModelTags.SEARCH, ModelTags.COST_EFFECTIVE], + functionCall=self.callAiBasic, + priority="balanced", + processingMode="advanced", + preferredFor=["general", "web_research"], + version="llama-3.1-sonar-large-128k-online" + ), + AiModel( + name="perplexity_callAiWithWebSearch", + displayName="Sonar Pro", + connectorType="perplexity", + maxTokens=128000, + contextLength=128000, + costPer1kTokens=0.01, + costPer1kTokensOutput=0.01, + speedRating=7, + qualityRating=9, + capabilities=["text_generation", "web_search", "research"], + tags=[ModelTags.TEXT, ModelTags.WEB, ModelTags.SEARCH, ModelTags.RESEARCH, ModelTags.HIGH_QUALITY], + functionCall=self.callAiWithWebSearch, + priority="quality", + processingMode="detailed", + preferredFor=["web_research"], + version="sonar-pro" + ), + AiModel( + name="perplexity_researchTopic", + displayName="Mistral 7B Instruct", + connectorType="perplexity", + maxTokens=32000, + contextLength=32000, + costPer1kTokens=0.002, + costPer1kTokensOutput=0.002, + speedRating=8, + qualityRating=8, + capabilities=["web_search", "research", "information_gathering"], + tags=[ModelTags.WEB, ModelTags.SEARCH, ModelTags.RESEARCH, ModelTags.INFORMATION, ModelTags.COST_EFFECTIVE], + functionCall=self.researchTopic, + priority="cost", + processingMode="basic", + preferredFor=["web_research"], + version="mistral-7b-instruct" + ), + AiModel( + name="perplexity_answerQuestion", + displayName="Mistral 7B Instruct QA", + connectorType="perplexity", + maxTokens=32000, + contextLength=32000, + costPer1kTokens=0.002, + costPer1kTokensOutput=0.002, + speedRating=8, + qualityRating=8, + capabilities=["web_search", "question_answering", "research"], + tags=[ModelTags.WEB, ModelTags.SEARCH, ModelTags.RESEARCH, ModelTags.COST_EFFECTIVE], + functionCall=self.answerQuestion, + priority="cost", + processingMode="basic", + preferredFor=["web_research"], + version="mistral-7b-instruct" + ), + AiModel( + name="perplexity_getCurrentNews", + displayName="Mistral 7B Instruct News", + connectorType="perplexity", + maxTokens=32000, + contextLength=32000, + costPer1kTokens=0.002, + costPer1kTokensOutput=0.002, + speedRating=8, + qualityRating=8, + capabilities=["web_search", "news", "current_events"], + tags=[ModelTags.WEB, ModelTags.SEARCH, ModelTags.COST_EFFECTIVE], + functionCall=self.getCurrentNews, + priority="cost", + processingMode="basic", + preferredFor=["web_research"], + version="mistral-7b-instruct" + ) + ] + async def callAiBasic(self, messages: List[Dict[str, Any]], temperature: float = None, maxTokens: int = None) -> str: """ Calls the Perplexity API with the given messages. @@ -68,8 +170,10 @@ class AiPerplexity: "temperature": temperature } - # Add max_tokens - use provided value or default to 128000 (Perplexity's typical limit) - payload["max_tokens"] = maxTokens if maxTokens is not None else 128000 + # Add max_tokens - use provided value or throw error + if maxTokens is None: + raise ValueError("maxTokens must be provided for Perplexity API calls") + payload["max_tokens"] = maxTokens response = await self.httpClient.post( self.apiUrl, @@ -134,8 +238,10 @@ class AiPerplexity: "temperature": temperature } - # Add max_tokens - use provided value or default to 128000 (Perplexity's typical limit) - payload["max_tokens"] = maxTokens if maxTokens is not None else 128000 + # Add max_tokens - use provided value or throw error + if maxTokens is None: + raise ValueError("maxTokens must be provided for Perplexity API calls") + payload["max_tokens"] = maxTokens response = await self.httpClient.post( self.apiUrl, diff --git a/modules/connectors/connectorAiTavily.py b/modules/aicore/aicorePluginTavily.py similarity index 79% rename from modules/connectors/connectorAiTavily.py rename to modules/aicore/aicorePluginTavily.py index b7631ea3..5d6b4e20 100644 --- a/modules/connectors/connectorAiTavily.py +++ b/modules/aicore/aicorePluginTavily.py @@ -4,10 +4,12 @@ import logging import asyncio from dataclasses import dataclass -from typing import Optional +from typing import Optional, List from tavily import AsyncTavilyClient from modules.shared.configuration import APP_CONFIG from modules.shared.timezoneUtils import get_utc_timestamp +from modules.aicore.aicoreBase import BaseConnectorAi +from modules.datamodels.datamodelAi import AiModel, ModelTags from modules.datamodels.datamodelWeb import ( WebSearchActionResult, WebSearchActionDocument, @@ -37,16 +39,100 @@ class WebCrawlResult: url: str content: str -@dataclass -class ConnectorWeb: - client: AsyncTavilyClient = None - # Cached settings loaded at initialization time - crawl_timeout: int = 30 - crawl_max_retries: int = 3 - crawl_retry_delay: int = 2 - # Cached web search constraints (camelCase per project style) - webSearchMinResults: int = 1 - webSearchMaxResults: int = 20 +class ConnectorWeb(BaseConnectorAi): + """Tavily web search connector.""" + + def __init__(self): + super().__init__() + self.client: Optional[AsyncTavilyClient] = None + # Cached settings loaded at initialization time + self.crawl_timeout: int = 30 + self.crawl_max_retries: int = 3 + self.crawl_retry_delay: int = 2 + # Cached web search constraints (camelCase per project style) + self.webSearchMinResults: int = 1 + self.webSearchMaxResults: int = 20 + + def getConnectorType(self) -> str: + """Get the connector type identifier.""" + return "tavily" + + def getModels(self) -> List[AiModel]: + """Get all available Tavily models.""" + return [ + AiModel( + name="tavily_search", + displayName="Tavily Search", + connectorType="tavily", + maxTokens=0, # Web search doesn't use tokens + contextLength=0, + costPer1kTokens=0.0, + costPer1kTokensOutput=0.0, + speedRating=8, + qualityRating=8, + capabilities=["web_search", "information_retrieval", "url_discovery"], + tags=[ModelTags.WEB, ModelTags.SEARCH, ModelTags.INFORMATION], + functionCall=self.search, + priority="balanced", + processingMode="basic", + preferredFor=["web_research"], + version="tavily-search" + ), + AiModel( + name="tavily_extract", + displayName="Tavily Extract", + connectorType="tavily", + maxTokens=0, # Web extraction doesn't use tokens + contextLength=0, + costPer1kTokens=0.0, + costPer1kTokensOutput=0.0, + speedRating=6, + qualityRating=8, + capabilities=["web_crawling", "content_extraction", "text_extraction"], + tags=[ModelTags.WEB, ModelTags.EXTRACT, ModelTags.CONTENT], + functionCall=self.crawl, + priority="balanced", + processingMode="basic", + preferredFor=["web_research"], + version="tavily-extract" + ), + AiModel( + name="tavily_crawl", + displayName="Tavily Crawl", + connectorType="tavily", + maxTokens=0, # Web crawling doesn't use tokens + contextLength=0, + costPer1kTokens=0.0, + costPer1kTokensOutput=0.0, + speedRating=6, + qualityRating=8, + capabilities=["web_crawling", "content_extraction", "mapping"], + tags=[ModelTags.WEB, ModelTags.CRAWL, ModelTags.EXTRACT], + functionCall=self.crawl, + priority="balanced", + processingMode="basic", + preferredFor=["web_research"], + version="tavily-crawl" + ), + AiModel( + name="tavily_scrape", + displayName="Tavily Scrape", + connectorType="tavily", + maxTokens=0, # Web scraping doesn't use tokens + contextLength=0, + costPer1kTokens=0.0, + costPer1kTokensOutput=0.0, + speedRating=6, + qualityRating=8, + capabilities=["web_search", "web_crawling", "content_extraction", "information_retrieval"], + tags=[ModelTags.WEB, ModelTags.SEARCH, ModelTags.CRAWL, ModelTags.EXTRACT, ModelTags.CONTENT, ModelTags.INFORMATION], + functionCall=self.scrape, + priority="balanced", + processingMode="basic", + preferredFor=["web_research"], + version="tavily-search-extract" + ) + ] @classmethod async def create(cls): diff --git a/modules/datamodels/datamodelAi.py b/modules/datamodels/datamodelAi.py index dba52e0c..a492c690 100644 --- a/modules/datamodels/datamodelAi.py +++ b/modules/datamodels/datamodelAi.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Dict, Any, Literal +from typing import Optional, List, Dict, Any, Literal, Callable from pydantic import BaseModel, Field @@ -81,6 +81,52 @@ PROCESSING_MODE_PRIORITY_MAPPING = { } +class AiModel(BaseModel): + """Enhanced AI model definition with dynamic capabilities.""" + + # Core identification + name: str = Field(description="Unique model identifier") + displayName: str = Field(description="Human-readable model name") + connectorType: str = Field(description="Type of connector (openai, anthropic, perplexity, tavily, etc.)") + + # Token and context limits + maxTokens: int = Field(description="Maximum tokens this model can generate") + contextLength: int = Field(description="Maximum context length this model can handle") + + # Cost information + costPer1kTokens: float = Field(default=0.0, description="Cost per 1000 input tokens") + costPer1kTokensOutput: float = Field(default=0.0, description="Cost per 1000 output tokens") + + # Performance ratings + speedRating: int = Field(ge=1, le=10, description="Speed rating (1-10, higher = faster)") + qualityRating: int = Field(ge=1, le=10, description="Quality rating (1-10, higher = better)") + + # Capabilities and tags + capabilities: List[str] = Field(description="List of model capabilities") + tags: List[str] = Field(description="List of model tags for filtering") + + # Function reference (not serialized) + functionCall: Optional[Callable] = Field(default=None, exclude=True, description="Function to call for this model") + + # Selection criteria + priority: str = Field(default="balanced", description="Default priority for this model") + processingMode: str = Field(default="basic", description="Default processing mode") + isAvailable: bool = Field(default=True, description="Whether model is currently available") + + # Advanced selection criteria + minContextLength: Optional[int] = Field(default=None, description="Minimum context length required") + maxCost: Optional[float] = Field(default=None, description="Maximum cost this model should be used for") + preferredFor: List[str] = Field(default=[], description="Operation types this model is preferred for") + avoidFor: List[str] = Field(default=[], description="Operation types this model should avoid") + + # Metadata + version: Optional[str] = Field(default=None, description="Model version") + lastUpdated: Optional[str] = Field(default=None, description="Last update timestamp") + + class Config: + arbitrary_types_allowed = True # Allow Callable type + + class ModelCapabilities(BaseModel): """Model capabilities and characteristics for dynamic selection.""" diff --git a/modules/interfaces/_BACKUP_NOT_USED_interfaceAiObjects.py b/modules/interfaces/_BACKUP_NOT_USED_interfaceAiObjects.py new file mode 100644 index 00000000..8f0fc0d0 --- /dev/null +++ b/modules/interfaces/_BACKUP_NOT_USED_interfaceAiObjects.py @@ -0,0 +1,1372 @@ +import logging +import asyncio +from typing import Dict, Any, List, Union, Tuple, Optional +from dataclasses import dataclass +import time + +logger = logging.getLogger(__name__) + +from modules.connectors.aicorePluginOpenai import AiOpenai +from modules.connectors.aicorePluginAnthropic import AiAnthropic +from modules.connectors.aicorePluginPerplexity import AiPerplexity +from modules.connectors.aicorePluginTavily import ConnectorWeb +from modules.datamodels.datamodelAi import ( + AiCallOptions, + AiCallRequest, + AiCallResponse, + OperationType, + ProcessingMode, + Priority, + ModelTags, + OPERATION_TAG_MAPPING, + PROCESSING_MODE_PRIORITY_MAPPING +) +from modules.datamodels.datamodelWeb import ( + WebResearchRequest, + WebResearchActionResult, + WebSearchResultItem, + WebCrawlResultItem, + WebSearchRequest, + WebCrawlRequest, +) +from modules.datamodels.datamodelChat import ActionDocument + + +# Comprehensive model registry with capability tags and function mapping +aiModels: Dict[str, Dict[str, Any]] = { + # OpenAI Models + "openai_callAiBasic": { + "connector": "openai", + "function": "callAiBasic", + "llmName": "gpt-4o", + "contextLength": 128000, + "costPer1kTokens": 0.03, + "costPer1kTokensOutput": 0.06, + "speedRating": 8, + "qualityRating": 9, + "capabilities": ["text_generation", "chat", "reasoning", "analysis"], + "tags": ["text", "chat", "reasoning", "analysis", "general"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.03 + (bytesReceived / 4 / 1000) * 0.06 + }, + "openai_callAiBasic_gpt35": { + "connector": "openai", + "function": "callAiBasic", + "llmName": "gpt-3.5-turbo", + "contextLength": 16000, + "costPer1kTokens": 0.0015, + "costPer1kTokensOutput": 0.002, + "speedRating": 9, + "qualityRating": 7, + "capabilities": ["text_generation", "chat", "reasoning"], + "tags": ["text", "chat", "reasoning", "general", "fast"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.0015 + (bytesReceived / 4 / 1000) * 0.002 + }, + "openai_callAiImage": { + "connector": "openai", + "function": "callAiImage", + "llmName": "gpt-4o", + "contextLength": 128000, + "costPer1kTokens": 0.03, + "costPer1kTokensOutput": 0.06, + "speedRating": 7, + "qualityRating": 9, + "capabilities": ["image_analysis", "vision", "multimodal"], + "tags": ["image", "vision", "multimodal"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.03 + (bytesReceived / 4 / 1000) * 0.06 + }, + "openai_generateImage": { + "connector": "openai", + "function": "generateImage", + "llmName": "dall-e-3", + "contextLength": 0, + "costPer1kTokens": 0.04, + "costPer1kTokensOutput": 0.0, + "speedRating": 6, + "qualityRating": 9, + "capabilities": ["image_generation", "art", "visual_creation"], + "tags": ["image_generation", "art", "visual"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.04 + }, + + # Anthropic Models + "anthropic_callAiBasic": { + "connector": "anthropic", + "function": "callAiBasic", + "llmName": "claude-3-5-sonnet-20241022", + "contextLength": 200000, + "costPer1kTokens": 0.015, + "costPer1kTokensOutput": 0.075, + "speedRating": 7, + "qualityRating": 10, + "capabilities": ["text_generation", "chat", "reasoning", "analysis"], + "tags": ["text", "chat", "reasoning", "analysis", "high_quality"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.015 + (bytesReceived / 4 / 1000) * 0.075 + }, + "anthropic_callAiImage": { + "connector": "anthropic", + "function": "callAiImage", + "llmName": "claude-3-5-sonnet-20241022", + "contextLength": 200000, + "costPer1kTokens": 0.015, + "costPer1kTokensOutput": 0.075, + "speedRating": 7, + "qualityRating": 10, + "capabilities": ["image_analysis", "vision", "multimodal"], + "tags": ["image", "vision", "multimodal", "high_quality"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.015 + (bytesReceived / 4 / 1000) * 0.075 + }, + + # Perplexity Models + "perplexity_callAiBasic": { + "connector": "perplexity", + "function": "callAiBasic", + "llmName": "llama-3.1-sonar-large-128k-online", + "contextLength": 128000, + "costPer1kTokens": 0.005, + "costPer1kTokensOutput": 0.005, + "speedRating": 8, + "qualityRating": 8, + "capabilities": ["text_generation", "chat", "reasoning", "web_search"], + "tags": ["text", "chat", "reasoning", "web_search", "cost_effective"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.005 + (bytesReceived / 4 / 1000) * 0.005 + }, + "perplexity_callAiWithWebSearch": { + "connector": "perplexity", + "function": "callAiWithWebSearch", + "llmName": "sonar-pro", + "contextLength": 128000, + "costPer1kTokens": 0.01, + "costPer1kTokensOutput": 0.01, + "speedRating": 7, + "qualityRating": 9, + "capabilities": ["text_generation", "web_search", "research"], + "tags": ["text", "web_search", "research", "high_quality"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.01 + (bytesReceived / 4 / 1000) * 0.01 + }, + "perplexity_researchTopic": { + "connector": "perplexity", + "function": "researchTopic", + "llmName": "mistral-7b-instruct", + "contextLength": 32000, + "costPer1kTokens": 0.002, + "costPer1kTokensOutput": 0.002, + "speedRating": 8, + "qualityRating": 8, + "capabilities": ["web_search", "research", "information_gathering"], + "tags": ["web_search", "research", "information", "cost_effective"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.002 + (bytesReceived / 4 / 1000) * 0.002 + }, + "perplexity_answerQuestion": { + "connector": "perplexity", + "function": "answerQuestion", + "llmName": "mistral-7b-instruct", + "contextLength": 32000, + "costPer1kTokens": 0.002, + "costPer1kTokensOutput": 0.002, + "speedRating": 8, + "qualityRating": 8, + "capabilities": ["web_search", "question_answering", "research"], + "tags": ["web_search", "qa", "research", "cost_effective"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.002 + (bytesReceived / 4 / 1000) * 0.002 + }, + "perplexity_getCurrentNews": { + "connector": "perplexity", + "function": "getCurrentNews", + "llmName": "mistral-7b-instruct", + "contextLength": 32000, + "costPer1kTokens": 0.002, + "costPer1kTokensOutput": 0.002, + "speedRating": 8, + "qualityRating": 8, + "capabilities": ["web_search", "news", "current_events"], + "tags": ["web_search", "news", "current_events", "cost_effective"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.002 + (bytesReceived / 4 / 1000) * 0.002 + }, + + # Tavily Web Models + "tavily_search": { + "connector": "tavily", + "function": "search", + "llmName": "tavily-search", + "contextLength": 0, + "costPer1kTokens": 0.0, # Not token-based + "costPer1kTokensOutput": 0.0, # Not token-based + "speedRating": 8, + "qualityRating": 8, + "capabilities": ["web_search", "information_retrieval", "url_discovery"], + "tags": ["web", "search", "urls", "information"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived, searchDepth="basic", numRequests=1: ( + # Basic search: 1 credit, Advanced: 2 credits + # Cost per credit: $0.008 + numRequests * (1 if searchDepth == "basic" else 2) * 0.008 + ) + }, + "tavily_extract": { + "connector": "tavily", + "function": "extract", + "llmName": "tavily-extract", + "contextLength": 0, + "costPer1kTokens": 0.0, + "costPer1kTokensOutput": 0.0, + "speedRating": 6, + "qualityRating": 8, + "capabilities": ["web_crawling", "content_extraction", "text_extraction"], + "tags": ["web", "extract", "content"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived, extractionDepth="basic", numSuccessfulUrls=1: ( + # Basic: 1 credit per 5 URLs, Advanced: 2 credits per 5 URLs + # Only charged for successful extractions + (numSuccessfulUrls / 5) * (1 if extractionDepth == "basic" else 2) * 0.008 + ) + }, + "tavily_crawl": { + "connector": "tavily", + "function": "crawl", + "llmName": "tavily-crawl", + "contextLength": 0, + "costPer1kTokens": 0.0, + "costPer1kTokensOutput": 0.0, + "speedRating": 6, + "qualityRating": 8, + "capabilities": ["web_crawling", "content_extraction", "mapping"], + "tags": ["web", "crawl", "map", "extract"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived, numPages=10, extractionDepth="basic", withInstructions=False, numSuccessfulExtractions=10: ( + # Crawl = Mapping + Extraction + # Mapping: 1 credit per 10 pages (2 if with instructions) + # Extraction: 1 credit per 5 successful extractions (2 if advanced) + ((numPages / 10) * (2 if withInstructions else 1) + + (numSuccessfulExtractions / 5) * (1 if extractionDepth == "basic" else 2)) * 0.008 + ) + }, + "tavily_scrape": { + "connector": "tavily", + "function": "scrape", + "llmName": "tavily-search-extract", + "contextLength": 0, + "costPer1kTokens": 0.0, + "costPer1kTokensOutput": 0.0, + "speedRating": 6, + "qualityRating": 8, + "capabilities": ["web_search", "web_crawling", "content_extraction", "information_retrieval"], + "tags": ["web", "search", "crawl", "extract", "content", "information"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived, searchDepth="basic", numSuccessfulUrls=1, extractionDepth="basic": ( + # Combines search + extraction + # Search cost + extraction cost + (1 if searchDepth == "basic" else 2) + + (numSuccessfulUrls / 5) * (1 if extractionDepth == "basic" else 2) + ) * 0.008 + }, + + # Internal Models + "internal_extraction": { + "connector": "internal", + "function": "extract", + "llmName": "internal-extractor", + "contextLength": 0, + "costPer1kTokens": 0.0, + "costPer1kTokensOutput": 0.0, + "speedRating": 8, + "qualityRating": 8, + "capabilities": ["document_extraction", "content_processing"], + "tags": ["internal", "extraction", "document_processing"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: 0.001 + (bytesSent + bytesReceived) / (1024 * 1024) * 0.01 # $0.001 base + $0.01/MB + }, + "internal_generation": { + "connector": "internal", + "function": "generate", + "llmName": "internal-generator", + "contextLength": 0, + "costPer1kTokens": 0.0, + "costPer1kTokensOutput": 0.0, + "speedRating": 7, + "qualityRating": 8, + "capabilities": ["document_generation", "content_creation"], + "tags": ["internal", "generation", "document_creation"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: 0.002 + (bytesReceived / (1024 * 1024)) * 0.005 # $0.002 base + $0.005/MB output + }, + "internal_rendering": { + "connector": "internal", + "function": "render", + "llmName": "internal-renderer", + "contextLength": 0, + "costPer1kTokens": 0.0, + "costPer1kTokensOutput": 0.0, + "speedRating": 6, + "qualityRating": 9, + "capabilities": ["document_rendering", "format_conversion"], + "tags": ["internal", "rendering", "format_conversion"], + "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: 0.003 + (bytesReceived / (1024 * 1024)) * 0.008 # $0.003 base + $0.008/MB output + } +} + + +@dataclass(slots=True) +class AiObjects: + """Centralized AI interface: selects model and calls connector. Includes web functionality.""" + + openaiService: AiOpenai + anthropicService: AiAnthropic + perplexityService: AiPerplexity + tavilyService: ConnectorWeb + + def __post_init__(self) -> None: + if self.openaiService is None: + raise TypeError("openaiService must be provided") + if self.anthropicService is None: + raise TypeError("anthropicService must be provided") + if self.perplexityService is None: + raise TypeError("perplexityService must be provided") + if self.tavilyService is None: + raise TypeError("tavilyService must be provided") + + @classmethod + async def create(cls) -> "AiObjects": + """Create AiObjects instance with all connectors initialized.""" + openaiService = AiOpenai() + anthropicService = AiAnthropic() + perplexityService = AiPerplexity() + tavilyService = await ConnectorWeb.create() + + return cls( + openaiService=openaiService, + anthropicService=anthropicService, + perplexityService=perplexityService, + tavilyService=tavilyService + ) + + def _estimateCost(self, modelInfo: Dict[str, Any], contentSize: int) -> float: + estimatedTokens = contentSize / 4 + inputCost = (estimatedTokens / 1000) * modelInfo["costPer1kTokens"] + outputCost = (estimatedTokens / 1000) * modelInfo["costPer1kTokensOutput"] * 0.1 + return inputCost + outputCost + + + def _selectModel(self, prompt: str, context: str, options: AiCallOptions) -> str: + """Select the best model based on operation type, tags, and requirements.""" + totalSize = len(prompt.encode("utf-8")) + len(context.encode("utf-8")) + candidates: Dict[str, Dict[str, Any]] = {} + + # Determine required tags from operation type + requiredTags = options.requiredTags + if not requiredTags: + requiredTags = OPERATION_TAG_MAPPING.get(options.operationType, [ModelTags.TEXT, ModelTags.CHAT]) + + + # Override priority based on processing mode if not explicitly set + effectivePriority = options.priority + if options.priority == Priority.BALANCED: + effectivePriority = PROCESSING_MODE_PRIORITY_MAPPING.get(options.processingMode, Priority.BALANCED) + + logger.info(f"Model selection - Operation: {options.operationType}, Required tags: {requiredTags}, Priority: {effectivePriority}") + + for name, info in aiModels.items(): + logger.info(f"Checking model: {name}, tags: {info.get('tags', [])}, function: {info.get('function', 'unknown')}") + # Check context length + if info["contextLength"] > 0 and totalSize > info["contextLength"] * 0.8: + continue + + # Check cost constraints + if options.maxCost is not None: + if self._estimateCost(info, totalSize) > options.maxCost: + continue + + # Check required tags/capabilities + modelTags = info.get("tags", []) + if requiredTags and not all(tag in modelTags for tag in requiredTags): + logger.info(f" -> Skipping {name}: missing required tags. Has: {modelTags}, needs: {requiredTags}") + continue + else: + logger.info(f" -> {name} passed tag check") + + # Check processing mode requirements + if options.processingMode == ProcessingMode.DETAILED and ModelTags.FAST in modelTags: + # Skip fast models for detailed processing + continue + + candidates[name] = info + logger.info(f" -> {name} added to candidates") + + logger.info(f"Final candidates: {list(candidates.keys())}") + + if not candidates: + logger.info("No candidates found, using fallback") + # Fallback based on operation type + if options.operationType == OperationType.IMAGE_ANALYSIS: + logger.info("Using fallback: openai_callAiImage") + return "openai_callAiImage" + elif options.operationType == OperationType.IMAGE_GENERATION: + logger.info("Using fallback: openai_generateImage") + return "openai_generateImage" + elif options.operationType == OperationType.WEB_RESEARCH: + logger.info("Using fallback: perplexity_callAiWithWebSearch") + return "perplexity_callAiWithWebSearch" + else: + logger.info("Using fallback: openai_callAiBasic_gpt35") + return "openai_callAiBasic_gpt35" + + # Special handling for planning operations - use Claude for consistency + if options.operationType in [OperationType.GENERATE_PLAN, OperationType.ANALYSE_CONTENT]: + if "anthropic_callAiBasic" in candidates: + logger.info("Planning operation: Selected Claude (anthropic_callAiBasic) for highest quality") + return "anthropic_callAiBasic" + + # Fallback to GPT-4o if Claude not available + if "openai_callAiBasic" in candidates: + logger.info("Planning operation: Selected GPT-4o (openai_callAiBasic) as fallback") + return "openai_callAiBasic" + + # Select based on priority for other operations + if effectivePriority == Priority.SPEED: + selected = max(candidates, key=lambda k: candidates[k]["speedRating"]) + logger.info(f"Selected by SPEED: {selected}") + return selected + elif effectivePriority == Priority.QUALITY: + selected = max(candidates, key=lambda k: candidates[k]["qualityRating"]) + logger.info(f"Selected by QUALITY: {selected}") + return selected + elif effectivePriority == Priority.COST: + selected = min(candidates, key=lambda k: candidates[k]["costPer1kTokens"]) + logger.info(f"Selected by COST: {selected}") + return selected + else: # BALANCED + def balancedScore(name: str) -> float: + info = candidates[name] + return info["qualityRating"] * 0.4 + info["speedRating"] * 0.3 + (10 - info["costPer1kTokens"] * 1000) * 0.3 + + selected = max(candidates, key=balancedScore) + logger.info(f"Selected by BALANCED: {selected}") + return selected + + def _getFallbackModels(self, operationType: str) -> List[str]: + """Get ordered list of fallback models for a given operation type.""" + fallbackMappings = { + OperationType.GENERAL: [ + "openai_callAiBasic_gpt35", # Fast and reliable + "openai_callAiBasic", # High quality + "anthropic_callAiBasic", # Alternative high quality + "perplexity_callAiBasic" # Cost effective + ], + OperationType.IMAGE_ANALYSIS: [ + "openai_callAiImage", # Primary image analysis + "anthropic_callAiImage" # Alternative image analysis + ], + OperationType.IMAGE_GENERATION: [ + "openai_generateImage" # Only image generation model + ], + OperationType.WEB_RESEARCH: [ + "perplexity_callAiWithWebSearch", # Primary web research + "perplexity_callAiBasic", # Alternative with web search + "openai_callAiBasic" # Fallback to general model + ], + OperationType.GENERATE_PLAN: [ + "anthropic_callAiBasic", # Best for planning + "openai_callAiBasic", # High quality alternative + "openai_callAiBasic_gpt35" # Fast fallback + ], + OperationType.ANALYSE_CONTENT: [ + "anthropic_callAiBasic", # Best for analysis + "openai_callAiBasic", # High quality alternative + "openai_callAiBasic_gpt35" # Fast fallback + ] + } + + return fallbackMappings.get(operationType, fallbackMappings[OperationType.GENERAL]) + + def _connectorFor(self, modelName: str): + """Get the appropriate connector for the model.""" + connectorType = aiModels[modelName]["connector"] + if connectorType == "openai": + return self.openaiService + elif connectorType == "anthropic": + return self.anthropicService + elif connectorType == "perplexity": + return self.perplexityService + elif connectorType == "tavily": + return self.tavilyService + else: + raise ValueError(f"Unknown connector type: {connectorType}") + + async def call(self, request: AiCallRequest) -> AiCallResponse: + """Call AI model for text generation with fallback mechanism.""" + + prompt = request.prompt + context = request.context or "" + options = request.options + + # Calculate input bytes + inputBytes = len((prompt + context).encode("utf-8")) + + # Compress optionally (prompt/context) - simple truncation fallback kept here + def maybeTruncate(text: str, limit: int) -> str: + data = text.encode("utf-8") + if len(data) <= limit: + return text + return data[:limit].decode("utf-8", errors="ignore") + "... [truncated]" + + if options.compressPrompt and len(prompt.encode("utf-8")) > 2000: + prompt = maybeTruncate(prompt, 2000) + if options.compressContext and len(context.encode("utf-8")) > 70000: + context = maybeTruncate(context, 70000) + + # Derive generation parameters + temperature = getattr(options, "temperature", None) + if temperature is None: + temperature = 0.2 + maxTokens = getattr(options, "maxTokens", None) + # Don't set artificial limits - let the model use its full context length + # Our continuation system handles stopping early via prompt engineering + + + # Get fallback models for this operation type + fallbackModels = self._getFallbackModels(options.operationType) + + # Try primary model first, then fallbacks + lastError = None + for attempt, modelName in enumerate(fallbackModels): + try: + logger.info(f"Attempting AI call with model: {modelName} (attempt {attempt + 1}/{len(fallbackModels)})") + + # Replace placeholder in prompt for this specific model + context_length = aiModels[modelName].get("contextLength", 0) + if context_length > 0: + token_limit = str(context_length) + else: + token_limit = "16000" # Default for text generation + + # Create a copy of the prompt for this model call + modelPrompt = prompt + if "" in modelPrompt: + modelPrompt = modelPrompt.replace("", token_limit) + logger.debug(f"Replaced with {token_limit} for model {modelName}") + + # Update messages array with replaced content + messages = [] + if context: + messages.append({"role": "system", "content": f"Context from documents:\n{context}"}) + messages.append({"role": "user", "content": modelPrompt}) + + # Start timing + startTime = time.time() + + connector = self._connectorFor(modelName) + functionName = aiModels[modelName]["function"] + + # Call the appropriate function + if functionName == "callAiBasic": + if aiModels[modelName]["connector"] == "openai": + content = await connector.callAiBasic(messages, temperature=temperature, maxTokens=maxTokens) + elif aiModels[modelName]["connector"] == "perplexity": + content = await connector.callAiBasic(messages, temperature=temperature, maxTokens=maxTokens) + else: + response = await connector.callAiBasic(messages, temperature=temperature, maxTokens=maxTokens) + content = response["choices"][0]["message"]["content"] + elif functionName == "callAiWithWebSearch": + # Perplexity web search function + query = modelPrompt + if context: + query = f"Context: {context}\n\nQuery: {modelPrompt}" + content = await connector.callAiWithWebSearch(query) + elif functionName == "researchTopic": + # Perplexity research function + content = await connector.researchTopic(modelPrompt) + elif functionName == "answerQuestion": + # Perplexity question answering function + content = await connector.answerQuestion(modelPrompt, context) + elif functionName == "getCurrentNews": + # Perplexity news function + content = await connector.getCurrentNews(modelPrompt) + else: + raise ValueError(f"Function {functionName} not supported for text generation") + + # Calculate timing and output bytes + endTime = time.time() + processingTime = endTime - startTime + outputBytes = len(content.encode("utf-8")) + + # Calculate price + priceUsd = aiModels[modelName]["calculatePriceUsd"](processingTime, inputBytes, outputBytes) + + logger.info(f"✅ AI call successful with model: {modelName}") + return AiCallResponse( + content=content, + modelName=modelName, + priceUsd=priceUsd, + processingTime=processingTime, + bytesSent=inputBytes, + bytesReceived=outputBytes, + errorCount=0 + ) + + except Exception as e: + lastError = e + # Enhanced error logging with more details + error_details = str(e) + if hasattr(e, 'detail'): + error_details = f"{error_details} (detail: {e.detail})" + if hasattr(e, 'status_code'): + error_details = f"{error_details} (status: {e.status_code})" + + logger.warning(f"❌ AI call failed with model {modelName}: {error_details}") + + # If this is not the last model, try the next one + if attempt < len(fallbackModels) - 1: + logger.info(f"🔄 Trying next fallback model...") + continue + else: + # All models failed + logger.error(f"💥 All {len(fallbackModels)} models failed for operation {options.operationType}") + break + + # All fallback attempts failed - return error response + last_error_details = str(lastError) + if hasattr(lastError, 'detail'): + last_error_details = f"{last_error_details} (detail: {lastError.detail})" + if hasattr(lastError, 'status_code'): + last_error_details = f"{last_error_details} (status: {lastError.status_code})" + + errorMsg = f"All AI models failed for operation {options.operationType}. Last error: {last_error_details}" + logger.error(errorMsg) + return AiCallResponse( + content=errorMsg, + modelName="error", + priceUsd=0.0, + processingTime=0.0, + bytesSent=inputBytes, + bytesReceived=0, + errorCount=1 + ) + + async def callImage(self, prompt: str, imageData: Union[str, bytes], mimeType: str = None, options: AiCallOptions = None) -> AiCallResponse: + """Call AI model for image analysis with fallback mechanism.""" + + if options is None: + options = AiCallOptions(operationType=OperationType.IMAGE_ANALYSIS) + + # Calculate input bytes (prompt + image data) + inputBytes = len(prompt.encode("utf-8")) + len(imageData) if isinstance(imageData, bytes) else len(prompt.encode("utf-8")) + len(str(imageData).encode("utf-8")) + + # Get fallback models for image analysis + fallbackModels = self._getFallbackModels(OperationType.IMAGE_ANALYSIS) + + # Try primary model first, then fallbacks + lastError = None + for attempt, modelName in enumerate(fallbackModels): + try: + logger.info(f"Attempting image analysis with model: {modelName} (attempt {attempt + 1}/{len(fallbackModels)})") + + # Start timing + startTime = time.time() + + connector = self._connectorFor(modelName) + functionName = aiModels[modelName]["function"] + + if functionName == "callAiImage": + content = await connector.callAiImage(prompt, imageData, mimeType) + + # Calculate timing and output bytes + endTime = time.time() + processingTime = endTime - startTime + outputBytes = len(content.encode("utf-8")) + + # Calculate price + priceUsd = aiModels[modelName]["calculatePriceUsd"](processingTime, inputBytes, outputBytes) + + logger.info(f"✅ Image analysis successful with model: {modelName}") + return AiCallResponse( + content=content, + modelName=modelName, + priceUsd=priceUsd, + processingTime=processingTime, + bytesSent=inputBytes, + bytesReceived=outputBytes, + errorCount=0 + ) + else: + raise ValueError(f"Function {functionName} not supported for image analysis") + + except Exception as e: + lastError = e + logger.warning(f"❌ Image analysis failed with model {modelName}: {str(e)}") + + # If this is not the last model, try the next one + if attempt < len(fallbackModels) - 1: + logger.info(f"🔄 Trying next fallback model for image analysis...") + continue + else: + # All models failed + logger.error(f"💥 All {len(fallbackModels)} models failed for image analysis") + break + + # All fallback attempts failed - return error response + errorMsg = f"All AI models failed for image analysis. Last error: {str(lastError)}" + logger.error(errorMsg) + return AiCallResponse( + content=errorMsg, + modelName="error", + priceUsd=0.0, + processingTime=0.0, + bytesSent=inputBytes, + bytesReceived=0, + errorCount=1 + ) + + async def generateImage(self, prompt: str, size: str = "1024x1024", quality: str = "standard", style: str = "vivid", options: AiCallOptions = None) -> AiCallResponse: + """Generate an image using AI.""" + + if options is None: + options = AiCallOptions(operationType=OperationType.IMAGE_GENERATION) + + # Calculate input bytes + inputBytes = len(prompt.encode("utf-8")) + + # Select model for image generation + modelName = self._selectModel(prompt, "", options) + + try: + # Start timing + startTime = time.time() + + connector = self._connectorFor(modelName) + functionName = aiModels[modelName]["function"] + + if functionName == "generateImage": + result = await connector.generateImage(prompt, size, quality, style) + content = str(result) + elif functionName == "generateImageWithVariations": + results = await connector.generateImageWithVariations(prompt, 1, size, quality, style) + result = results[0] if results else {} + content = str(result) + elif functionName == "generateImageWithChat": + content = await connector.generateImageWithChat(prompt, size, quality, style) + else: + raise ValueError(f"Function {functionName} not supported for image generation") + + # Calculate timing and output bytes + endTime = time.time() + processingTime = endTime - startTime + outputBytes = len(content.encode("utf-8")) + + # Calculate price + priceUsd = aiModels[modelName]["calculatePriceUsd"](processingTime, inputBytes, outputBytes) + + logger.info(f"✅ Image generation successful with model: {modelName}") + return AiCallResponse( + content=content, + modelName=modelName, + priceUsd=priceUsd, + processingTime=processingTime, + bytesSent=inputBytes, + bytesReceived=outputBytes, + errorCount=0 + ) + + except Exception as e: + logger.error(f"❌ Image generation failed with model {modelName}: {str(e)}") + return AiCallResponse( + content=f"Image generation failed: {str(e)}", + modelName=modelName, + priceUsd=0.0, + processingTime=0.0, + bytesSent=inputBytes, + bytesReceived=0, + errorCount=1 + ) + + # Web functionality methods - Simple interface to Tavily connector + async def search_websites(self, query: str, max_results: int = 5, **kwargs) -> List[WebSearchResultItem]: + """Search for websites using Tavily.""" + request = WebSearchRequest( + query=query, + max_results=max_results, + **kwargs + ) + result = await self.tavilyService.search(request) + + if result.success and result.documents: + return result.documents[0].documentData.results + return [] + + async def crawl_websites(self, urls: List[str], extract_depth: str = "advanced", format: str = "markdown") -> List[WebCrawlResultItem]: + """Crawl websites using Tavily.""" + from pydantic import HttpUrl + from urllib.parse import urlparse + + # Safely create HttpUrl objects with proper scheme handling + http_urls = [] + for url in urls: + try: + # Ensure URL has a scheme + parsed = urlparse(url) + if not parsed.scheme: + url = f"https://{url}" + + # Use HttpUrl with scheme parameter (this works for all URLs) + http_urls.append(HttpUrl(url, scheme="https")) + + except Exception as e: + logger.warning(f"Skipping invalid URL {url}: {e}") + continue + + if not http_urls: + return [] + + request = WebCrawlRequest( + urls=http_urls, + extract_depth=extract_depth, + format=format + ) + result = await self.tavilyService.crawl(request) + + if result.success and result.documents: + return result.documents[0].documentData.results + return [] + + async def extract_content(self, urls: List[str], extract_depth: str = "advanced", format: str = "markdown") -> Dict[str, str]: + """Extract content from URLs and return as dictionary.""" + crawl_results = await self.crawl_websites(urls, extract_depth, format) + return {str(result.url): result.content for result in crawl_results} + + # Core Web Tools - Clean interface for web operations + async def readPage(self, url: str, extract_depth: str = "advanced") -> Optional[str]: + """Read a single web page and return its content (HTML/Markdown).""" + logger.debug(f"Reading page: {url}") + try: + # URL encode the URL to handle spaces and special characters + from urllib.parse import quote, urlparse, urlunparse + parsed = urlparse(url) + encoded_url = urlunparse(( + parsed.scheme, + parsed.netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment + )) + + # Manually encode query parameters to handle spaces + if parsed.query: + encoded_query = quote(parsed.query, safe='=&') + encoded_url = urlunparse(( + parsed.scheme, + parsed.netloc, + parsed.path, + parsed.params, + encoded_query, + parsed.fragment + )) + + logger.debug(f"URL encoded: {url} -> {encoded_url}") + + content = await self.extract_content([encoded_url], extract_depth, "markdown") + result = content.get(encoded_url) + if result: + logger.debug(f"Successfully read page {encoded_url}: {len(result)} chars") + else: + logger.warning(f"No content returned for page {encoded_url}") + return result + except Exception as e: + logger.warning(f"Failed to read page {url}: {e}") + return None + + async def getUrlsFromPage(self, url: str, extract_depth: str = "advanced") -> List[str]: + """Get all URLs from a web page, with redundancies removed.""" + try: + content = await self.readPage(url, extract_depth) + if not content: + return [] + + links = self._extractLinksFromContent(content, url) + # Remove duplicates while preserving order + seen = set() + unique_links = [] + for link in links: + if link not in seen: + seen.add(link) + unique_links.append(link) + + logger.debug(f"Extracted {len(unique_links)} unique URLs from {url}") + return unique_links + + except Exception as e: + logger.warning(f"Failed to get URLs from page {url}: {e}") + return [] + + def filterUrlsOnlyPages(self, urls: List[str], max_per_domain: int = 10) -> List[str]: + """Filter URLs to get only links for pages to follow (no images, etc.).""" + from urllib.parse import urlparse + + def _isHtmlCandidate(url: str) -> bool: + lower = url.lower() + blocked = ('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.bmp', + '.mp4', '.mp3', '.avi', '.mov', '.mkv', + '.pdf', '.zip', '.rar', '.7z', '.tar', '.gz', + '.css', '.js', '.woff', '.woff2', '.ttf', '.eot') + return not lower.endswith(blocked) + + # Group by domain + domain_links = {} + for link in urls: + domain = urlparse(link).netloc + if domain not in domain_links: + domain_links[domain] = [] + domain_links[domain].append(link) + + # Filter and cap per domain + filtered_links = [] + for domain, domain_link_list in domain_links.items(): + seen = set() + domain_filtered = [] + + for link in domain_link_list: + if link in seen: + continue + if not _isHtmlCandidate(link): + continue + seen.add(link) + domain_filtered.append(link) + if len(domain_filtered) >= max_per_domain: + break + + filtered_links.extend(domain_filtered) + logger.debug(f"Domain {domain}: {len(domain_link_list)} -> {len(domain_filtered)} links") + + return filtered_links + + def _extractLinksFromContent(self, content: str, base_url: str) -> List[str]: + """Extract links from HTML/Markdown content.""" + try: + import re + from urllib.parse import urljoin, urlparse, quote, urlunparse + + def _cleanUrl(url: str) -> str: + """Clean and encode URL to remove spaces and invalid characters.""" + # Remove quotes and extra spaces + url = url.strip().strip('"\'') + + # If it's a relative URL, make it absolute first + if not url.startswith(('http://', 'https://')): + url = urljoin(base_url, url) + + # Parse and re-encode the URL properly + parsed = urlparse(url) + if parsed.query: + # Encode query parameters properly + encoded_query = quote(parsed.query, safe='=&') + url = urlunparse(( + parsed.scheme, + parsed.netloc, + parsed.path, + parsed.params, + encoded_query, + parsed.fragment + )) + + return url + + links = [] + + # Extract HTML links: format + html_link_pattern = r']+href=["\']([^"\']+)["\'][^>]*>' + html_links = re.findall(html_link_pattern, content, re.IGNORECASE) + + for url in html_links: + if url and not url.startswith('#') and not url.startswith('javascript:'): + try: + cleaned_url = _cleanUrl(url) + links.append(cleaned_url) + logger.debug(f"Extracted HTML link: {url} -> {cleaned_url}") + except Exception as e: + logger.debug(f"Failed to clean HTML link {url}: {e}") + + # Extract markdown links: [text](url) format + markdown_link_pattern = r'\[([^\]]+)\]\(([^)]+)\)' + markdown_links = re.findall(markdown_link_pattern, content) + + for text, url in markdown_links: + if url and not url.startswith('#'): + try: + cleaned_url = _cleanUrl(url) + # Only keep URLs from the same domain + if urlparse(cleaned_url).netloc == urlparse(base_url).netloc: + links.append(cleaned_url) + logger.debug(f"Extracted markdown link: {url} -> {cleaned_url}") + except Exception as e: + logger.debug(f"Failed to clean markdown link {url}: {e}") + + # Extract plain URLs in the text + url_pattern = r'https?://[^\s\)]+' + plain_urls = re.findall(url_pattern, content) + + for url in plain_urls: + try: + clean_url = url.rstrip('.,;!?') + cleaned_url = _cleanUrl(clean_url) + if urlparse(cleaned_url).netloc == urlparse(base_url).netloc: + if cleaned_url not in links: # Avoid duplicates + links.append(cleaned_url) + logger.debug(f"Extracted plain URL: {url} -> {cleaned_url}") + except Exception as e: + logger.debug(f"Failed to clean plain URL {url}: {e}") + + logger.debug(f"Total links extracted and cleaned: {len(links)}") + return links + + except Exception as e: + logger.warning(f"Failed to extract links from content: {e}") + return [] + + def _normalizeUrl(self, url: str) -> str: + """Normalize URL to handle variations that should be considered duplicates.""" + if not url: + return url + + # Remove trailing slashes and fragments + url = url.rstrip('/') + if '#' in url: + url = url.split('#')[0] + + # Handle common URL variations + url = url.replace('http://', 'https://') # Normalize protocol + + return url + + async def crawlRecursively(self, urls: List[str], max_depth: int, extract_depth: str = "advanced", max_per_domain: int = 10, global_processed_urls: Optional[set] = None) -> Dict[str, str]: + """ + Recursively crawl URLs up to specified depth. + + Args: + urls: List of starting URLs to crawl + max_depth: Maximum depth to crawl (1=main pages only, 2=main+sub-pages, etc.) + extract_depth: Tavily extract depth setting + max_per_domain: Maximum URLs per domain per level + global_processed_urls: Optional global set to track processed URLs across sessions + + Returns: + Dictionary mapping URL -> content for all crawled pages + """ + logger.info(f"Starting recursive crawl: {len(urls)} starting URLs, max_depth={max_depth}") + + # URL index to track all processed URLs (local + global) + processed_urls = set() + if global_processed_urls is not None: + # Use global index if provided, otherwise create local one + processed_urls = global_processed_urls + logger.info(f"Using global URL index with {len(processed_urls)} already processed URLs") + else: + logger.info("Using local URL index for this crawl session") + + all_content = {} + + # Current level URLs to process + current_level_urls = urls.copy() + + try: + for depth in range(1, max_depth + 1): + logger.info(f"=== DEPTH LEVEL {depth}/{max_depth} ===") + logger.info(f"Processing {len(current_level_urls)} URLs at depth {depth}") + + # URLs found at this level (for next iteration) + next_level_urls = [] + + for url in current_level_urls: + # Normalize URL for duplicate checking + normalized_url = self._normalizeUrl(url) + if normalized_url in processed_urls: + logger.debug(f"URL {url} (normalized: {normalized_url}) already processed, skipping") + continue + + try: + logger.info(f"Processing URL at depth {depth}: {url}") + logger.debug(f"Total processed URLs so far: {len(processed_urls)}") + + # Read page content + content = await self.readPage(url, extract_depth) + if content: + all_content[url] = content + processed_urls.add(normalized_url) + logger.info(f"✓ Successfully processed {url}: {len(content)} chars") + + # Get URLs from this page for next level + page_urls = await self.getUrlsFromPage(url, extract_depth) + logger.info(f"Found {len(page_urls)} URLs on {url}") + + # Filter URLs and add to next level + filtered_urls = self.filterUrlsOnlyPages(page_urls, max_per_domain) + logger.info(f"Filtered to {len(filtered_urls)} valid URLs") + + # Add new URLs to next level (avoiding already processed ones) + new_urls_count = 0 + for new_url in filtered_urls: + normalized_new_url = self._normalizeUrl(new_url) + if normalized_new_url not in processed_urls: + next_level_urls.append(new_url) + new_urls_count += 1 + else: + logger.debug(f"URL {new_url} (normalized: {normalized_new_url}) already processed, skipping") + + logger.info(f"Added {new_urls_count} new URLs to next level from {url}") + else: + logger.warning(f"✗ No content extracted from {url}") + processed_urls.add(normalized_url) # Mark as processed to avoid retry + + except Exception as e: + logger.warning(f"✗ Failed to process URL {url} at depth {depth}: {e}") + processed_urls.add(normalized_url) # Mark as processed to avoid retry + + # Prepare for next iteration + current_level_urls = next_level_urls + logger.info(f"Depth {depth} completed. Found {len(next_level_urls)} URLs for next level") + + # Stop if no more URLs to process + if not current_level_urls: + logger.info(f"No more URLs found at depth {depth}, stopping recursion") + break + + logger.info(f"Recursive crawl completed: {len(all_content)} total pages crawled") + logger.info(f"Total URLs processed (including skipped): {len(processed_urls)}") + logger.info(f"Unique URLs found: {len(all_content)}") + return all_content + + except asyncio.TimeoutError: + logger.warning(f"Crawling timed out, returning partial results: {len(all_content)} pages crawled so far") + return all_content + except Exception as e: + logger.error(f"Crawling failed with error: {e}, returning partial results: {len(all_content)} pages crawled so far") + return all_content + + async def webQuery(self, query: str, context: str = "", options: AiCallOptions = None) -> AiCallResponse: + """Use Perplexity AI to provide the best answers for web-related queries.""" + + if options is None: + options = AiCallOptions(operationType=OperationType.WEB_RESEARCH) + + # Calculate input bytes + inputBytes = len((query + context).encode("utf-8")) + + # Create a comprehensive prompt for web queries + webPrompt = f"""You are an expert web researcher and information analyst. Please provide a comprehensive and accurate answer to the following web-related query. + +Query: {query} + +{f"Additional Context: {context}" if context else ""} + +Please provide: +1. A clear, well-structured answer to the query +2. Key points and important details +3. Relevant insights and analysis +4. Any important considerations or caveats +5. Suggestions for further research if applicable + +Format your response in a clear, professional manner that would be helpful for someone researching this topic.""" + + try: + # Start timing + startTime = time.time() + + # Use Perplexity for web research with search capabilities + response = await self.perplexityService.callAiWithWebSearch(webPrompt) + + # Calculate timing and output bytes + endTime = time.time() + processingTime = endTime - startTime + outputBytes = len(response.encode("utf-8")) + + # Calculate price (use perplexity model pricing) + priceUsd = aiModels["perplexity_callAiWithWebSearch"]["calculatePriceUsd"](processingTime, inputBytes, outputBytes) + + logger.info(f"✅ Web query successful with Perplexity") + return AiCallResponse( + content=response, + modelName="perplexity_callAiWithWebSearch", + priceUsd=priceUsd, + processingTime=processingTime, + bytesSent=inputBytes, + bytesReceived=outputBytes, + errorCount=0 + ) + except Exception as e: + logger.error(f"Perplexity web query failed: {str(e)}") + return AiCallResponse( + content=f"Web query failed: {str(e)}", + modelName="perplexity_callAiWithWebSearch", + priceUsd=0.0, + processingTime=0.0, + bytesSent=inputBytes, + bytesReceived=0, + errorCount=1 + ) + + # Utility methods + async def listAvailableModels(self, connectorType: str = None) -> List[Dict[str, Any]]: + """List available models, optionally filtered by connector type.""" + if connectorType: + return [info for name, info in aiModels.items() if info["connector"] == connectorType] + return list(aiModels.values()) + + async def getModelInfo(self, modelName: str) -> Dict[str, Any]: + """Get information about a specific model.""" + if modelName not in aiModels: + raise ValueError(f"Model {modelName} not found") + return aiModels[modelName] + + async def getModelsByCapability(self, capability: str) -> List[str]: + """Get model names that support a specific capability.""" + return [name for name, info in aiModels.items() if capability in info.get("capabilities", [])] + + async def getModelsByTag(self, tag: str) -> List[str]: + """Get model names that have a specific tag.""" + return [name for name, info in aiModels.items() if tag in info.get("tags", [])] + + async def selectRelevantWebsites(self, websites: List[str], userQuestion: str) -> Tuple[List[str], str]: + """Select most relevant websites using AI analysis. Returns (selected_websites, ai_response).""" + if len(websites) <= 1: + return websites, "Only one website available, no selection needed" + + try: + # Create website summaries for AI analysis + websiteSummaries = [] + for i, url in enumerate(websites, 1): + from urllib.parse import urlparse + domain = urlparse(url).netloc + summary = f"{i}. {url} (Domain: {domain})" + websiteSummaries.append(summary) + + selectionPrompt = f""" + Based on this user request: "{userQuestion}" + + I have {len(websites)} websites found. Please select the most relevant website(s) for this request. + + Available websites: + {chr(10).join(websiteSummaries)} + + Please respond with the website number(s) (1, 2, 3, etc.) that are most relevant. + Format: 1,3,5 (or just 1 for single selection) + """ + + # Use Perplexity to select the best websites + response = await self.webQuery(selectionPrompt) + + # Parse the selection + import re + numbers = re.findall(r'\d+', response) + if numbers: + selectedWebsites = [] + for num in numbers: + index = int(num) - 1 + if 0 <= index < len(websites): + selectedWebsites.append(websites[index]) + + if selectedWebsites: + logger.info(f"AI selected {len(selectedWebsites)} websites") + return selectedWebsites, response + + # Fallback to first website + logger.warning("AI selection failed, using first website") + return websites[:1], f"AI selection failed, fallback to first website. AI response: {response}" + + except Exception as e: + logger.error(f"Error in website selection: {str(e)}") + return websites[:1], f"Error in website selection: {str(e)}" + + async def analyzeContentWithChunking(self, allContent: Dict[str, str], userQuestion: str) -> str: + """Analyze content using AI with chunking for large content.""" + logger.info(f"Analyzing {len(allContent)} websites with AI") + + # Process content in chunks to avoid token limits + chunkSize = 50000 # 50k chars per chunk + allChunks = [] + + for url, content in allContent.items(): + filteredContent = self._filterContent(content) + if len(filteredContent) <= chunkSize: + allChunks.append((url, filteredContent)) + logger.info(f"Content from {url}: {len(filteredContent)} chars (single chunk)") + else: + # Split large content into chunks + chunkCount = (len(filteredContent) + chunkSize - 1) // chunkSize + logger.info(f"Content from {url}: {len(filteredContent)} chars (split into {chunkCount} chunks)") + for i in range(0, len(filteredContent), chunkSize): + chunk = filteredContent[i:i+chunkSize] + chunkNum = i//chunkSize + 1 + allChunks.append((f"{url} (part {chunkNum})", chunk)) + + logger.info(f"Processing {len(allChunks)} content chunks") + + # Analyze each chunk + chunkAnalyses = [] + for i, (url, chunk) in enumerate(allChunks, 1): + logger.info(f"Analyzing chunk {i}/{len(allChunks)}: {url}") + + try: + analysisPrompt = f""" + Analyze this web content and extract relevant information for: {userQuestion} + + Source: {url} + Content: {chunk} + + Please extract key information relevant to the query. + """ + + analysis = await self.webQuery(analysisPrompt) + chunkAnalyses.append(analysis) + logger.info(f"Chunk {i}/{len(allChunks)} analyzed successfully") + + except Exception as e: + logger.error(f"Chunk {i}/{len(allChunks)} error: {e}") + + # Combine all chunk analyses + if chunkAnalyses: + logger.info(f"Combining {len(chunkAnalyses)} chunk analyses") + combinedAnalysis = "\n\n".join(chunkAnalyses) + + # Final synthesis + try: + logger.info("Performing final synthesis of all analyses") + synthesisPrompt = f""" + Based on these partial analyses, provide a comprehensive answer to: {userQuestion} + + Partial analyses: + {combinedAnalysis} + + Please provide a clear, well-structured answer to the query. + """ + + finalAnalysis = await self.webQuery(synthesisPrompt) + logger.info("Final synthesis completed successfully") + return finalAnalysis + + except Exception as e: + logger.error(f"Synthesis error: {e}") + return combinedAnalysis + else: + logger.error("No content could be analyzed") + return "No content could be analyzed" + + def _filterContent(self, content: str) -> str: + """Filter out navigation, ads, and other nonsense content.""" + lines = content.split('\n') + filteredLines = [] + + for line in lines: + line = line.strip() + # Skip empty lines + if not line: + continue + # Skip navigation elements + if any(skip in line.lower() for skip in [ + 'toggle navigation', 'log in', 'sign up', 'cookies', 'privacy policy', + 'terms of service', 'subscribe', 'newsletter', 'follow us', 'share this', + 'advertisement', 'sponsored', 'banner', 'popup', 'modal' + ]): + continue + # Skip image references without context + if line.startswith('![Image') and '](' in line: + continue + # Skip pure links without context + if line.startswith('[') and line.endswith(')') and '---' in line: + continue + # Keep meaningful content + if len(line) > 10: # Skip very short lines + filteredLines.append(line) + + return '\n'.join(filteredLines) + diff --git a/modules/interfaces/interfaceAiObjects.py b/modules/interfaces/interfaceAiObjects.py index 337a2878..8aeedd4b 100644 --- a/modules/interfaces/interfaceAiObjects.py +++ b/modules/interfaces/interfaceAiObjects.py @@ -6,10 +6,9 @@ import time logger = logging.getLogger(__name__) -from modules.connectors.connectorAiOpenai import AiOpenai -from modules.connectors.connectorAiAnthropic import AiAnthropic -from modules.connectors.connectorAiPerplexity import AiPerplexity -from modules.connectors.connectorAiTavily import ConnectorWeb +# No more hardcoded imports - everything is discovered dynamically +from modules.aicore.aicoreModelRegistry import model_registry +from modules.aicore.aicoreModelSelector import model_selector from modules.datamodels.datamodelAi import ( AiCallOptions, AiCallRequest, @@ -32,461 +31,62 @@ from modules.datamodels.datamodelWeb import ( from modules.datamodels.datamodelChat import ActionDocument -# Comprehensive model registry with capability tags and function mapping -aiModels: Dict[str, Dict[str, Any]] = { - # OpenAI Models - "openai_callAiBasic": { - "connector": "openai", - "function": "callAiBasic", - "llmName": "gpt-4o", - "contextLength": 128000, - "costPer1kTokens": 0.03, - "costPer1kTokensOutput": 0.06, - "speedRating": 8, - "qualityRating": 9, - "capabilities": ["text_generation", "chat", "reasoning", "analysis"], - "tags": ["text", "chat", "reasoning", "analysis", "general"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.03 + (bytesReceived / 4 / 1000) * 0.06 - }, - "openai_callAiBasic_gpt35": { - "connector": "openai", - "function": "callAiBasic", - "llmName": "gpt-3.5-turbo", - "contextLength": 16000, - "costPer1kTokens": 0.0015, - "costPer1kTokensOutput": 0.002, - "speedRating": 9, - "qualityRating": 7, - "capabilities": ["text_generation", "chat", "reasoning"], - "tags": ["text", "chat", "reasoning", "general", "fast"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.0015 + (bytesReceived / 4 / 1000) * 0.002 - }, - "openai_callAiImage": { - "connector": "openai", - "function": "callAiImage", - "llmName": "gpt-4o", - "contextLength": 128000, - "costPer1kTokens": 0.03, - "costPer1kTokensOutput": 0.06, - "speedRating": 7, - "qualityRating": 9, - "capabilities": ["image_analysis", "vision", "multimodal"], - "tags": ["image", "vision", "multimodal"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.03 + (bytesReceived / 4 / 1000) * 0.06 - }, - "openai_generateImage": { - "connector": "openai", - "function": "generateImage", - "llmName": "dall-e-3", - "contextLength": 0, - "costPer1kTokens": 0.04, - "costPer1kTokensOutput": 0.0, - "speedRating": 6, - "qualityRating": 9, - "capabilities": ["image_generation", "art", "visual_creation"], - "tags": ["image_generation", "art", "visual"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.04 - }, - - # Anthropic Models - "anthropic_callAiBasic": { - "connector": "anthropic", - "function": "callAiBasic", - "llmName": "claude-3-5-sonnet-20241022", - "contextLength": 200000, - "costPer1kTokens": 0.015, - "costPer1kTokensOutput": 0.075, - "speedRating": 7, - "qualityRating": 10, - "capabilities": ["text_generation", "chat", "reasoning", "analysis"], - "tags": ["text", "chat", "reasoning", "analysis", "high_quality"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.015 + (bytesReceived / 4 / 1000) * 0.075 - }, - "anthropic_callAiImage": { - "connector": "anthropic", - "function": "callAiImage", - "llmName": "claude-3-5-sonnet-20241022", - "contextLength": 200000, - "costPer1kTokens": 0.015, - "costPer1kTokensOutput": 0.075, - "speedRating": 7, - "qualityRating": 10, - "capabilities": ["image_analysis", "vision", "multimodal"], - "tags": ["image", "vision", "multimodal", "high_quality"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.015 + (bytesReceived / 4 / 1000) * 0.075 - }, - - # Perplexity Models - "perplexity_callAiBasic": { - "connector": "perplexity", - "function": "callAiBasic", - "llmName": "llama-3.1-sonar-large-128k-online", - "contextLength": 128000, - "costPer1kTokens": 0.005, - "costPer1kTokensOutput": 0.005, - "speedRating": 8, - "qualityRating": 8, - "capabilities": ["text_generation", "chat", "reasoning", "web_search"], - "tags": ["text", "chat", "reasoning", "web_search", "cost_effective"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.005 + (bytesReceived / 4 / 1000) * 0.005 - }, - "perplexity_callAiWithWebSearch": { - "connector": "perplexity", - "function": "callAiWithWebSearch", - "llmName": "sonar-pro", - "contextLength": 128000, - "costPer1kTokens": 0.01, - "costPer1kTokensOutput": 0.01, - "speedRating": 7, - "qualityRating": 9, - "capabilities": ["text_generation", "web_search", "research"], - "tags": ["text", "web_search", "research", "high_quality"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.01 + (bytesReceived / 4 / 1000) * 0.01 - }, - "perplexity_researchTopic": { - "connector": "perplexity", - "function": "researchTopic", - "llmName": "mistral-7b-instruct", - "contextLength": 32000, - "costPer1kTokens": 0.002, - "costPer1kTokensOutput": 0.002, - "speedRating": 8, - "qualityRating": 8, - "capabilities": ["web_search", "research", "information_gathering"], - "tags": ["web_search", "research", "information", "cost_effective"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.002 + (bytesReceived / 4 / 1000) * 0.002 - }, - "perplexity_answerQuestion": { - "connector": "perplexity", - "function": "answerQuestion", - "llmName": "mistral-7b-instruct", - "contextLength": 32000, - "costPer1kTokens": 0.002, - "costPer1kTokensOutput": 0.002, - "speedRating": 8, - "qualityRating": 8, - "capabilities": ["web_search", "question_answering", "research"], - "tags": ["web_search", "qa", "research", "cost_effective"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.002 + (bytesReceived / 4 / 1000) * 0.002 - }, - "perplexity_getCurrentNews": { - "connector": "perplexity", - "function": "getCurrentNews", - "llmName": "mistral-7b-instruct", - "contextLength": 32000, - "costPer1kTokens": 0.002, - "costPer1kTokensOutput": 0.002, - "speedRating": 8, - "qualityRating": 8, - "capabilities": ["web_search", "news", "current_events"], - "tags": ["web_search", "news", "current_events", "cost_effective"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.002 + (bytesReceived / 4 / 1000) * 0.002 - }, - - # Tavily Web Models - "tavily_search": { - "connector": "tavily", - "function": "search", - "llmName": "tavily-search", - "contextLength": 0, - "costPer1kTokens": 0.0, # Not token-based - "costPer1kTokensOutput": 0.0, # Not token-based - "speedRating": 8, - "qualityRating": 8, - "capabilities": ["web_search", "information_retrieval", "url_discovery"], - "tags": ["web", "search", "urls", "information"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived, searchDepth="basic", numRequests=1: ( - # Basic search: 1 credit, Advanced: 2 credits - # Cost per credit: $0.008 - numRequests * (1 if searchDepth == "basic" else 2) * 0.008 - ) - }, - "tavily_extract": { - "connector": "tavily", - "function": "extract", - "llmName": "tavily-extract", - "contextLength": 0, - "costPer1kTokens": 0.0, - "costPer1kTokensOutput": 0.0, - "speedRating": 6, - "qualityRating": 8, - "capabilities": ["web_crawling", "content_extraction", "text_extraction"], - "tags": ["web", "extract", "content"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived, extractionDepth="basic", numSuccessfulUrls=1: ( - # Basic: 1 credit per 5 URLs, Advanced: 2 credits per 5 URLs - # Only charged for successful extractions - (numSuccessfulUrls / 5) * (1 if extractionDepth == "basic" else 2) * 0.008 - ) - }, - "tavily_crawl": { - "connector": "tavily", - "function": "crawl", - "llmName": "tavily-crawl", - "contextLength": 0, - "costPer1kTokens": 0.0, - "costPer1kTokensOutput": 0.0, - "speedRating": 6, - "qualityRating": 8, - "capabilities": ["web_crawling", "content_extraction", "mapping"], - "tags": ["web", "crawl", "map", "extract"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived, numPages=10, extractionDepth="basic", withInstructions=False, numSuccessfulExtractions=10: ( - # Crawl = Mapping + Extraction - # Mapping: 1 credit per 10 pages (2 if with instructions) - # Extraction: 1 credit per 5 successful extractions (2 if advanced) - ((numPages / 10) * (2 if withInstructions else 1) + - (numSuccessfulExtractions / 5) * (1 if extractionDepth == "basic" else 2)) * 0.008 - ) - }, - "tavily_scrape": { - "connector": "tavily", - "function": "scrape", - "llmName": "tavily-search-extract", - "contextLength": 0, - "costPer1kTokens": 0.0, - "costPer1kTokensOutput": 0.0, - "speedRating": 6, - "qualityRating": 8, - "capabilities": ["web_search", "web_crawling", "content_extraction", "information_retrieval"], - "tags": ["web", "search", "crawl", "extract", "content", "information"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived, searchDepth="basic", numSuccessfulUrls=1, extractionDepth="basic": ( - # Combines search + extraction - # Search cost + extraction cost - (1 if searchDepth == "basic" else 2) + - (numSuccessfulUrls / 5) * (1 if extractionDepth == "basic" else 2) - ) * 0.008 - }, - - # Internal Models - "internal_extraction": { - "connector": "internal", - "function": "extract", - "llmName": "internal-extractor", - "contextLength": 0, - "costPer1kTokens": 0.0, - "costPer1kTokensOutput": 0.0, - "speedRating": 8, - "qualityRating": 8, - "capabilities": ["document_extraction", "content_processing"], - "tags": ["internal", "extraction", "document_processing"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: 0.001 + (bytesSent + bytesReceived) / (1024 * 1024) * 0.01 # $0.001 base + $0.01/MB - }, - "internal_generation": { - "connector": "internal", - "function": "generate", - "llmName": "internal-generator", - "contextLength": 0, - "costPer1kTokens": 0.0, - "costPer1kTokensOutput": 0.0, - "speedRating": 7, - "qualityRating": 8, - "capabilities": ["document_generation", "content_creation"], - "tags": ["internal", "generation", "document_creation"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: 0.002 + (bytesReceived / (1024 * 1024)) * 0.005 # $0.002 base + $0.005/MB output - }, - "internal_rendering": { - "connector": "internal", - "function": "render", - "llmName": "internal-renderer", - "contextLength": 0, - "costPer1kTokens": 0.0, - "costPer1kTokensOutput": 0.0, - "speedRating": 6, - "qualityRating": 9, - "capabilities": ["document_rendering", "format_conversion"], - "tags": ["internal", "rendering", "format_conversion"], - "calculatePriceUsd": lambda processingTime, bytesSent, bytesReceived: 0.003 + (bytesReceived / (1024 * 1024)) * 0.008 # $0.003 base + $0.008/MB output - } -} +# Dynamic model registry - models are now loaded from connectors via aicore system @dataclass(slots=True) class AiObjects: - """Centralized AI interface: selects model and calls connector. Includes web functionality.""" - - openaiService: AiOpenai - anthropicService: AiAnthropic - perplexityService: AiPerplexity - tavilyService: ConnectorWeb + """Centralized AI interface: dynamically discovers and uses AI models. Includes web functionality.""" def __post_init__(self) -> None: - if self.openaiService is None: - raise TypeError("openaiService must be provided") - if self.anthropicService is None: - raise TypeError("anthropicService must be provided") - if self.perplexityService is None: - raise TypeError("perplexityService must be provided") - if self.tavilyService is None: - raise TypeError("tavilyService must be provided") + # Auto-discover and register all available connectors + self._discoverAndRegisterConnectors() + + def _discoverAndRegisterConnectors(self): + """Auto-discover and register all available AI connectors.""" + logger.info("Auto-discovering AI connectors...") + + # Use the model registry's built-in discovery mechanism + discovered_connectors = model_registry.discoverConnectors() + + # Register each discovered connector + for connector in discovered_connectors: + model_registry.registerConnector(connector) + logger.info(f"Registered connector: {connector.getConnectorType()}") + + logger.info(f"Total connectors registered: {len(discovered_connectors)}") + logger.info("All AI connectors registered with dynamic model registry") @classmethod async def create(cls) -> "AiObjects": - """Create AiObjects instance with all connectors initialized.""" - openaiService = AiOpenai() - anthropicService = AiAnthropic() - perplexityService = AiPerplexity() - tavilyService = await ConnectorWeb.create() - - return cls( - openaiService=openaiService, - anthropicService=anthropicService, - perplexityService=perplexityService, - tavilyService=tavilyService - ) + """Create AiObjects instance with auto-discovered connectors.""" + # No need to manually create connectors - they're auto-discovered + return cls() - def _estimateCost(self, modelInfo: Dict[str, Any], contentSize: int) -> float: - estimatedTokens = contentSize / 4 - inputCost = (estimatedTokens / 1000) * modelInfo["costPer1kTokens"] - outputCost = (estimatedTokens / 1000) * modelInfo["costPer1kTokensOutput"] * 0.1 - return inputCost + outputCost def _selectModel(self, prompt: str, context: str, options: AiCallOptions) -> str: - """Select the best model based on operation type, tags, and requirements.""" - totalSize = len(prompt.encode("utf-8")) + len(context.encode("utf-8")) - candidates: Dict[str, Dict[str, Any]] = {} + """Select the best model using dynamic model selection system.""" + # Get available models from the dynamic registry + available_models = model_registry.getAvailableModels() - # Determine required tags from operation type - requiredTags = options.requiredTags - if not requiredTags: - requiredTags = OPERATION_TAG_MAPPING.get(options.operationType, [ModelTags.TEXT, ModelTags.CHAT]) + if not available_models: + logger.error("No models available in the registry") + raise ValueError("No AI models available") + # Use the dynamic model selector + selected_model = model_selector.selectModel(prompt, context, options, available_models) - # Override priority based on processing mode if not explicitly set - effectivePriority = options.priority - if options.priority == Priority.BALANCED: - effectivePriority = PROCESSING_MODE_PRIORITY_MAPPING.get(options.processingMode, Priority.BALANCED) + if not selected_model: + logger.error("No suitable model found for the given criteria") + raise ValueError("No suitable AI model found") - logger.info(f"Model selection - Operation: {options.operationType}, Required tags: {requiredTags}, Priority: {effectivePriority}") - - for name, info in aiModels.items(): - logger.info(f"Checking model: {name}, tags: {info.get('tags', [])}, function: {info.get('function', 'unknown')}") - # Check context length - if info["contextLength"] > 0 and totalSize > info["contextLength"] * 0.8: - continue - - # Check cost constraints - if options.maxCost is not None: - if self._estimateCost(info, totalSize) > options.maxCost: - continue - - # Check required tags/capabilities - modelTags = info.get("tags", []) - if requiredTags and not all(tag in modelTags for tag in requiredTags): - logger.info(f" -> Skipping {name}: missing required tags. Has: {modelTags}, needs: {requiredTags}") - continue - else: - logger.info(f" -> {name} passed tag check") - - # Check processing mode requirements - if options.processingMode == ProcessingMode.DETAILED and ModelTags.FAST in modelTags: - # Skip fast models for detailed processing - continue - - candidates[name] = info - logger.info(f" -> {name} added to candidates") - - logger.info(f"Final candidates: {list(candidates.keys())}") - - if not candidates: - logger.info("No candidates found, using fallback") - # Fallback based on operation type - if options.operationType == OperationType.IMAGE_ANALYSIS: - logger.info("Using fallback: openai_callAiImage") - return "openai_callAiImage" - elif options.operationType == OperationType.IMAGE_GENERATION: - logger.info("Using fallback: openai_generateImage") - return "openai_generateImage" - elif options.operationType == OperationType.WEB_RESEARCH: - logger.info("Using fallback: perplexity_callAiWithWebSearch") - return "perplexity_callAiWithWebSearch" - else: - logger.info("Using fallback: openai_callAiBasic_gpt35") - return "openai_callAiBasic_gpt35" - - # Special handling for planning operations - use Claude for consistency - if options.operationType in [OperationType.GENERATE_PLAN, OperationType.ANALYSE_CONTENT]: - if "anthropic_callAiBasic" in candidates: - logger.info("Planning operation: Selected Claude (anthropic_callAiBasic) for highest quality") - return "anthropic_callAiBasic" - - # Fallback to GPT-4o if Claude not available - if "openai_callAiBasic" in candidates: - logger.info("Planning operation: Selected GPT-4o (openai_callAiBasic) as fallback") - return "openai_callAiBasic" - - # Select based on priority for other operations - if effectivePriority == Priority.SPEED: - selected = max(candidates, key=lambda k: candidates[k]["speedRating"]) - logger.info(f"Selected by SPEED: {selected}") - return selected - elif effectivePriority == Priority.QUALITY: - selected = max(candidates, key=lambda k: candidates[k]["qualityRating"]) - logger.info(f"Selected by QUALITY: {selected}") - return selected - elif effectivePriority == Priority.COST: - selected = min(candidates, key=lambda k: candidates[k]["costPer1kTokens"]) - logger.info(f"Selected by COST: {selected}") - return selected - else: # BALANCED - def balancedScore(name: str) -> float: - info = candidates[name] - return info["qualityRating"] * 0.4 + info["speedRating"] * 0.3 + (10 - info["costPer1kTokens"] * 1000) * 0.3 - - selected = max(candidates, key=balancedScore) - logger.info(f"Selected by BALANCED: {selected}") - return selected + logger.info(f"Selected model: {selected_model.name} ({selected_model.displayName})") + return selected_model.name - def _getFallbackModels(self, operationType: str) -> List[str]: - """Get ordered list of fallback models for a given operation type.""" - fallbackMappings = { - OperationType.GENERAL: [ - "openai_callAiBasic_gpt35", # Fast and reliable - "openai_callAiBasic", # High quality - "anthropic_callAiBasic", # Alternative high quality - "perplexity_callAiBasic" # Cost effective - ], - OperationType.IMAGE_ANALYSIS: [ - "openai_callAiImage", # Primary image analysis - "anthropic_callAiImage" # Alternative image analysis - ], - OperationType.IMAGE_GENERATION: [ - "openai_generateImage" # Only image generation model - ], - OperationType.WEB_RESEARCH: [ - "perplexity_callAiWithWebSearch", # Primary web research - "perplexity_callAiBasic", # Alternative with web search - "openai_callAiBasic" # Fallback to general model - ], - OperationType.GENERATE_PLAN: [ - "anthropic_callAiBasic", # Best for planning - "openai_callAiBasic", # High quality alternative - "openai_callAiBasic_gpt35" # Fast fallback - ], - OperationType.ANALYSE_CONTENT: [ - "anthropic_callAiBasic", # Best for analysis - "openai_callAiBasic", # High quality alternative - "openai_callAiBasic_gpt35" # Fast fallback - ] - } - - return fallbackMappings.get(operationType, fallbackMappings[OperationType.GENERAL]) - - def _connectorFor(self, modelName: str): - """Get the appropriate connector for the model.""" - connectorType = aiModels[modelName]["connector"] - if connectorType == "openai": - return self.openaiService - elif connectorType == "anthropic": - return self.anthropicService - elif connectorType == "perplexity": - return self.perplexityService - elif connectorType == "tavily": - return self.tavilyService - else: - raise ValueError(f"Unknown connector type: {connectorType}") async def call(self, request: AiCallRequest) -> AiCallResponse: - """Call AI model for text generation with fallback mechanism.""" + """Call AI model for text generation using dynamic model selection.""" prompt = request.prompt context = request.context or "" @@ -516,124 +116,101 @@ class AiObjects: # Our continuation system handles stopping early via prompt engineering - # Get fallback models for this operation type - fallbackModels = self._getFallbackModels(options.operationType) - - # Try primary model first, then fallbacks - lastError = None - for attempt, modelName in enumerate(fallbackModels): - try: - logger.info(f"Attempting AI call with model: {modelName} (attempt {attempt + 1}/{len(fallbackModels)})") - - # Replace placeholder in prompt for this specific model - context_length = aiModels[modelName].get("contextLength", 0) - if context_length > 0: - token_limit = str(context_length) - else: - token_limit = "16000" # Default for text generation - - # Create a copy of the prompt for this model call - modelPrompt = prompt - if "" in modelPrompt: - modelPrompt = modelPrompt.replace("", token_limit) - logger.debug(f"Replaced with {token_limit} for model {modelName}") - - # Update messages array with replaced content - messages = [] - if context: - messages.append({"role": "system", "content": f"Context from documents:\n{context}"}) - messages.append({"role": "user", "content": modelPrompt}) - - # Start timing - startTime = time.time() - - connector = self._connectorFor(modelName) - functionName = aiModels[modelName]["function"] - - # Call the appropriate function - if functionName == "callAiBasic": - if aiModels[modelName]["connector"] == "openai": - content = await connector.callAiBasic(messages, temperature=temperature, maxTokens=maxTokens) - elif aiModels[modelName]["connector"] == "perplexity": - content = await connector.callAiBasic(messages, temperature=temperature, maxTokens=maxTokens) - else: - response = await connector.callAiBasic(messages, temperature=temperature, maxTokens=maxTokens) - content = response["choices"][0]["message"]["content"] - elif functionName == "callAiWithWebSearch": - # Perplexity web search function + try: + # Select the best model using dynamic selection + modelName = self._selectModel(prompt, context, options) + selectedModel = model_registry.getModel(modelName) + + if not selectedModel: + raise ValueError(f"Selected model {modelName} not found in registry") + # Replace placeholder in prompt for this specific model + context_length = selectedModel.contextLength + if context_length > 0: + token_limit = str(context_length) + else: + token_limit = "16000" # Default for text generation + + # Create a copy of the prompt for this model call + modelPrompt = prompt + if "" in modelPrompt: + modelPrompt = modelPrompt.replace("", token_limit) + logger.debug(f"Replaced with {token_limit} for model {modelName}") + + # Update messages array with replaced content + messages = [] + if context: + messages.append({"role": "system", "content": f"Context from documents:\n{context}"}) + messages.append({"role": "user", "content": modelPrompt}) + + # Start timing + startTime = time.time() + + # Get the connector for this model + connector = model_registry.getConnectorForModel(modelName) + if not connector: + raise ValueError(f"No connector found for model {modelName}") + + # Call the model's function directly + if selectedModel.functionCall: + # Use the model's function call directly + if modelName.startswith("perplexity_callAiWithWebSearch"): query = modelPrompt if context: query = f"Context: {context}\n\nQuery: {modelPrompt}" - content = await connector.callAiWithWebSearch(query) - elif functionName == "researchTopic": - # Perplexity research function - content = await connector.researchTopic(modelPrompt) - elif functionName == "answerQuestion": - # Perplexity question answering function - content = await connector.answerQuestion(modelPrompt, context) - elif functionName == "getCurrentNews": - # Perplexity news function - content = await connector.getCurrentNews(modelPrompt) + content = await selectedModel.functionCall(query, temperature=temperature, maxTokens=maxTokens) + elif modelName.startswith("perplexity_researchTopic"): + content = await selectedModel.functionCall(modelPrompt) + elif modelName.startswith("perplexity_answerQuestion"): + content = await selectedModel.functionCall(modelPrompt, context) + elif modelName.startswith("perplexity_getCurrentNews"): + content = await selectedModel.functionCall(modelPrompt) else: - raise ValueError(f"Function {functionName} not supported for text generation") + # Standard callAiBasic + if selectedModel.connectorType == "anthropic": + response = await selectedModel.functionCall(messages, temperature=temperature, maxTokens=maxTokens) + content = response["choices"][0]["message"]["content"] + else: + content = await selectedModel.functionCall(messages, temperature=temperature, maxTokens=maxTokens) + else: + raise ValueError(f"Model {modelName} has no function call defined") - # Calculate timing and output bytes - endTime = time.time() - processingTime = endTime - startTime - outputBytes = len(content.encode("utf-8")) - - # Calculate price - priceUsd = aiModels[modelName]["calculatePriceUsd"](processingTime, inputBytes, outputBytes) - - logger.info(f"✅ AI call successful with model: {modelName}") - return AiCallResponse( - content=content, - modelName=modelName, - priceUsd=priceUsd, - processingTime=processingTime, - bytesSent=inputBytes, - bytesReceived=outputBytes, - errorCount=0 - ) - - except Exception as e: - lastError = e - # Enhanced error logging with more details - error_details = str(e) - if hasattr(e, 'detail'): - error_details = f"{error_details} (detail: {e.detail})" - if hasattr(e, 'status_code'): - error_details = f"{error_details} (status: {e.status_code})" - - logger.warning(f"❌ AI call failed with model {modelName}: {error_details}") - - # If this is not the last model, try the next one - if attempt < len(fallbackModels) - 1: - logger.info(f"🔄 Trying next fallback model...") - continue - else: - # All models failed - logger.error(f"💥 All {len(fallbackModels)} models failed for operation {options.operationType}") - break - - # All fallback attempts failed - return error response - last_error_details = str(lastError) - if hasattr(lastError, 'detail'): - last_error_details = f"{last_error_details} (detail: {lastError.detail})" - if hasattr(lastError, 'status_code'): - last_error_details = f"{last_error_details} (status: {lastError.status_code})" + # Calculate timing and output bytes + endTime = time.time() + processingTime = endTime - startTime + outputBytes = len(content.encode("utf-8")) - errorMsg = f"All AI models failed for operation {options.operationType}. Last error: {last_error_details}" - logger.error(errorMsg) - return AiCallResponse( - content=errorMsg, - modelName="error", - priceUsd=0.0, - processingTime=0.0, - bytesSent=inputBytes, - bytesReceived=0, - errorCount=1 - ) + # Calculate price using model's cost information + estimated_tokens = inputBytes / 4 + priceUsd = (estimated_tokens / 1000) * selectedModel.costPer1kTokens + (outputBytes / 4 / 1000) * selectedModel.costPer1kTokensOutput + + logger.info(f"✅ AI call successful with model: {modelName}") + logger.info(f" Processing time: {processingTime:.2f}s") + logger.info(f" Input: {inputBytes} bytes, Output: {outputBytes} bytes") + logger.info(f" Estimated cost: ${priceUsd:.4f}") + + return AiCallResponse( + success=True, + content=content, + model=modelName, + processingTime=processingTime, + priceUsd=priceUsd, + bytesSent=inputBytes, + bytesReceived=outputBytes, + errorCount=0 + ) + + except Exception as e: + logger.error(f"❌ AI call failed: {e}") + return AiCallResponse( + success=False, + content=f"AI call failed: {str(e)}", + model="none", + processingTime=0.0, + priceUsd=0.0, + bytesSent=inputBytes, + bytesReceived=0, + errorCount=1 + ) async def callImage(self, prompt: str, imageData: Union[str, bytes], mimeType: str = None, options: AiCallOptions = None) -> AiCallResponse: """Call AI model for image analysis with fallback mechanism.""" @@ -644,70 +221,61 @@ class AiObjects: # Calculate input bytes (prompt + image data) inputBytes = len(prompt.encode("utf-8")) + len(imageData) if isinstance(imageData, bytes) else len(prompt.encode("utf-8")) + len(str(imageData).encode("utf-8")) - # Get fallback models for image analysis - fallbackModels = self._getFallbackModels(OperationType.IMAGE_ANALYSIS) - - # Try primary model first, then fallbacks - lastError = None - for attempt, modelName in enumerate(fallbackModels): - try: - logger.info(f"Attempting image analysis with model: {modelName} (attempt {attempt + 1}/{len(fallbackModels)})") - - # Start timing - startTime = time.time() - - connector = self._connectorFor(modelName) - functionName = aiModels[modelName]["function"] - - if functionName == "callAiImage": - content = await connector.callAiImage(prompt, imageData, mimeType) - - # Calculate timing and output bytes - endTime = time.time() - processingTime = endTime - startTime - outputBytes = len(content.encode("utf-8")) - - # Calculate price - priceUsd = aiModels[modelName]["calculatePriceUsd"](processingTime, inputBytes, outputBytes) - - logger.info(f"✅ Image analysis successful with model: {modelName}") - return AiCallResponse( - content=content, - modelName=modelName, - priceUsd=priceUsd, - processingTime=processingTime, - bytesSent=inputBytes, - bytesReceived=outputBytes, - errorCount=0 - ) - else: - raise ValueError(f"Function {functionName} not supported for image analysis") - - except Exception as e: - lastError = e - logger.warning(f"❌ Image analysis failed with model {modelName}: {str(e)}") - - # If this is not the last model, try the next one - if attempt < len(fallbackModels) - 1: - logger.info(f"🔄 Trying next fallback model for image analysis...") - continue - else: - # All models failed - logger.error(f"💥 All {len(fallbackModels)} models failed for image analysis") - break - - # All fallback attempts failed - return error response - errorMsg = f"All AI models failed for image analysis. Last error: {str(lastError)}" - logger.error(errorMsg) - return AiCallResponse( - content=errorMsg, - modelName="error", - priceUsd=0.0, - processingTime=0.0, - bytesSent=inputBytes, - bytesReceived=0, - errorCount=1 - ) + try: + # Select the best model for image analysis + modelName = self._selectModel(prompt, "", options) + selectedModel = model_registry.getModel(modelName) + + if not selectedModel: + raise ValueError(f"Selected model {modelName} not found in registry") + + # Get the connector for this model + connector = model_registry.getConnectorForModel(modelName) + if not connector: + raise ValueError(f"No connector found for model {modelName}") + + # Start timing + startTime = time.time() + + # Call the model's function directly + if selectedModel.functionCall: + content = await selectedModel.functionCall(prompt, imageData, mimeType) + else: + raise ValueError(f"Model {modelName} has no function call defined") + + # Calculate timing and output bytes + endTime = time.time() + processingTime = endTime - startTime + outputBytes = len(content.encode("utf-8")) + + # Calculate price using model's cost information + estimated_tokens = inputBytes / 4 + priceUsd = (estimated_tokens / 1000) * selectedModel.costPer1kTokens + (outputBytes / 4 / 1000) * selectedModel.costPer1kTokensOutput + + logger.info(f"✅ Image analysis successful with model: {modelName}") + return AiCallResponse( + success=True, + content=content, + model=modelName, + processingTime=processingTime, + priceUsd=priceUsd, + bytesSent=inputBytes, + bytesReceived=outputBytes, + errorCount=0 + ) + + except Exception as e: + logger.error(f"❌ Image analysis failed: {e}") + return AiCallResponse( + success=False, + content=f"Image analysis failed: {str(e)}", + model="none", + processingTime=0.0, + priceUsd=0.0, + bytesSent=inputBytes, + bytesReceived=0, + errorCount=1 + ) async def generateImage(self, prompt: str, size: str = "1024x1024", quality: str = "standard", style: str = "vivid", options: AiCallOptions = None) -> AiCallResponse: """Generate an image using AI.""" @@ -718,42 +286,45 @@ class AiObjects: # Calculate input bytes inputBytes = len(prompt.encode("utf-8")) - # Select model for image generation - modelName = self._selectModel(prompt, "", options) - try: + # Select the best model for image generation + modelName = self._selectModel(prompt, "", options) + selectedModel = model_registry.getModel(modelName) + + if not selectedModel: + raise ValueError(f"Selected model {modelName} not found in registry") + + # Get the connector for this model + connector = model_registry.getConnectorForModel(modelName) + if not connector: + raise ValueError(f"No connector found for model {modelName}") + # Start timing startTime = time.time() - connector = self._connectorFor(modelName) - functionName = aiModels[modelName]["function"] - - if functionName == "generateImage": - result = await connector.generateImage(prompt, size, quality, style) + # Call the model's function directly + if selectedModel.functionCall: + result = await selectedModel.functionCall(prompt, size, quality, style) content = str(result) - elif functionName == "generateImageWithVariations": - results = await connector.generateImageWithVariations(prompt, 1, size, quality, style) - result = results[0] if results else {} - content = str(result) - elif functionName == "generateImageWithChat": - content = await connector.generateImageWithChat(prompt, size, quality, style) else: - raise ValueError(f"Function {functionName} not supported for image generation") + raise ValueError(f"Model {modelName} has no function call defined") # Calculate timing and output bytes endTime = time.time() processingTime = endTime - startTime outputBytes = len(content.encode("utf-8")) - # Calculate price - priceUsd = aiModels[modelName]["calculatePriceUsd"](processingTime, inputBytes, outputBytes) + # Calculate price using model's cost information + estimated_tokens = inputBytes / 4 + priceUsd = (estimated_tokens / 1000) * selectedModel.costPer1kTokens + (outputBytes / 4 / 1000) * selectedModel.costPer1kTokensOutput logger.info(f"✅ Image generation successful with model: {modelName}") return AiCallResponse( + success=True, content=content, - modelName=modelName, - priceUsd=priceUsd, + model=modelName, processingTime=processingTime, + priceUsd=priceUsd, bytesSent=inputBytes, bytesReceived=outputBytes, errorCount=0 @@ -779,7 +350,11 @@ class AiObjects: max_results=max_results, **kwargs ) - result = await self.tavilyService.search(request) + # Get Tavily connector from registry + tavily_connector = model_registry.getConnectorForModel("tavily_search") + if not tavily_connector: + raise ValueError("Tavily connector not available") + result = await tavily_connector.search(request) if result.success and result.documents: return result.documents[0].documentData.results @@ -814,7 +389,11 @@ class AiObjects: extract_depth=extract_depth, format=format ) - result = await self.tavilyService.crawl(request) + # Get Tavily connector from registry + tavily_connector = model_registry.getConnectorForModel("tavily_crawl") + if not tavily_connector: + raise ValueError("Tavily connector not available") + result = await tavily_connector.crawl(request) if result.success and result.documents: return result.documents[0].documentData.results @@ -1163,15 +742,23 @@ Format your response in a clear, professional manner that would be helpful for s startTime = time.time() # Use Perplexity for web research with search capabilities - response = await self.perplexityService.callAiWithWebSearch(webPrompt) + perplexity_connector = model_registry.getConnectorForModel("perplexity_callAiWithWebSearch") + if not perplexity_connector: + raise ValueError("Perplexity connector not available") + response = await perplexity_connector.callAiWithWebSearch(webPrompt) # Calculate timing and output bytes endTime = time.time() processingTime = endTime - startTime outputBytes = len(response.encode("utf-8")) - # Calculate price (use perplexity model pricing) - priceUsd = aiModels["perplexity_callAiWithWebSearch"]["calculatePriceUsd"](processingTime, inputBytes, outputBytes) + # Calculate price using Perplexity model pricing + perplexity_model = model_registry.getModel("perplexity_callAiWithWebSearch") + if perplexity_model: + estimated_tokens = inputBytes / 4 + priceUsd = (estimated_tokens / 1000) * perplexity_model.costPer1kTokens + (outputBytes / 4 / 1000) * perplexity_model.costPer1kTokensOutput + else: + priceUsd = 0.0 logger.info(f"✅ Web query successful with Perplexity") return AiCallResponse( @@ -1198,23 +785,27 @@ Format your response in a clear, professional manner that would be helpful for s # Utility methods async def listAvailableModels(self, connectorType: str = None) -> List[Dict[str, Any]]: """List available models, optionally filtered by connector type.""" + models = model_registry.getAvailableModels() if connectorType: - return [info for name, info in aiModels.items() if info["connector"] == connectorType] - return list(aiModels.values()) + return [model.dict() for model in models if model.connectorType == connectorType] + return [model.dict() for model in models] async def getModelInfo(self, modelName: str) -> Dict[str, Any]: """Get information about a specific model.""" - if modelName not in aiModels: + model = model_registry.getModel(modelName) + if not model: raise ValueError(f"Model {modelName} not found") - return aiModels[modelName] + return model.dict() async def getModelsByCapability(self, capability: str) -> List[str]: """Get model names that support a specific capability.""" - return [name for name, info in aiModels.items() if capability in info.get("capabilities", [])] + models = model_registry.getModelsByCapability(capability) + return [model.name for model in models] async def getModelsByTag(self, tag: str) -> List[str]: """Get model names that have a specific tag.""" - return [name for name, info in aiModels.items() if tag in info.get("tags", [])] + models = model_registry.getModelsByTag(tag) + return [model.name for model in models] async def selectRelevantWebsites(self, websites: List[str], userQuestion: str) -> Tuple[List[str], str]: """Select most relevant websites using AI analysis. Returns (selected_websites, ai_response)."""