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(' 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)."""