| import asyncio |
| from datetime import datetime |
| from enum import Enum |
| import hashlib |
| import json |
| import logging |
| import os |
| import re |
| import uuid |
| from typing import Any, AsyncGenerator, Dict, List, Optional |
|
|
| |
| try: |
| from openai import AsyncOpenAI, OpenAI |
| except ImportError: |
| OpenAI = None |
| AsyncOpenAI = None |
|
|
| try: |
| import instructor |
| INSTRUCTOR_AVAILABLE = True |
| except ImportError: |
| instructor = None |
| INSTRUCTOR_AVAILABLE = False |
|
|
| |
| from core.benchmarks import get_quality_score, get_capability_score |
| from core.byok_endpoints import get_byok_manager |
| from core.cost_config import ( |
| BYOK_ENABLED_PLANS, |
| MODEL_TIER_RESTRICTIONS, |
| get_llm_cost) |
| from core.database import get_db_session |
| from core.dynamic_pricing_fetcher import ( |
| get_pricing_fetcher, |
| refresh_pricing_cache) |
| from core.llm.cache_aware_router import CacheAwareRouter |
| from core.llm.cognitive_tier_service import CognitiveTierService |
| from core.llm.cognitive_tier_system import CognitiveTier, CognitiveClassifier |
| from core.llm_usage_tracker import llm_usage_tracker |
| from core.lux_config import lux_config |
| from core.models import GovernanceDocument, AgentExecution, Tenant, Workspace, ModelCatalog |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class QueryComplexity(Enum): |
| """Query complexity levels for cost-based routing""" |
| SIMPLE = "simple" |
| MODERATE = "moderate" |
| COMPLEX = "complex" |
| ADVANCED = "advanced" |
|
|
|
|
| |
| PROVIDER_TIERS = { |
| |
| "budget": ["deepseek", "moonshot", "glm"], |
| |
| "mid": ["anthropic", "gemini", "mistral"], |
| |
| "premium": ["openai", "anthropic"], |
| |
| "code": ["deepseek", "openai"], |
| "math": ["deepseek", "openai"], |
| "creative": ["anthropic", "openai"], |
| } |
|
|
| |
| COST_EFFICIENT_MODELS = { |
| "openai": { |
| QueryComplexity.SIMPLE: "o4-mini", |
| QueryComplexity.MODERATE: "o4-mini", |
| QueryComplexity.COMPLEX: "o3-mini", |
| QueryComplexity.ADVANCED: "o3", |
| }, |
| "anthropic": { |
| QueryComplexity.SIMPLE: "claude-3-haiku-20240307", |
| QueryComplexity.MODERATE: "claude-3-haiku-20240307", |
| QueryComplexity.COMPLEX: "claude-3-5-sonnet", |
| QueryComplexity.ADVANCED: "claude-4-opus", |
| }, |
| "deepseek": { |
| QueryComplexity.SIMPLE: "deepseek-chat", |
| QueryComplexity.MODERATE: "deepseek-chat", |
| QueryComplexity.COMPLEX: "deepseek-v3.2", |
| QueryComplexity.ADVANCED: "deepseek-v3.2-speciale", |
| }, |
| "gemini": { |
| QueryComplexity.SIMPLE: "gemini-3-flash", |
| QueryComplexity.MODERATE: "gemini-3-flash", |
| QueryComplexity.COMPLEX: "gemini-3-flash", |
| QueryComplexity.ADVANCED: "gemini-3-pro", |
| }, |
| "moonshot": { |
| QueryComplexity.SIMPLE: "qwen-3-7b", |
| QueryComplexity.MODERATE: "qwen-3-7b", |
| QueryComplexity.COMPLEX: "qwen-3-max", |
| QueryComplexity.ADVANCED: "qwen-3-max", |
| }, |
| "minimax": { |
| QueryComplexity.SIMPLE: "MiniMax-M2.7-highspeed", |
| QueryComplexity.MODERATE: "MiniMax-M2.7-highspeed", |
| QueryComplexity.COMPLEX: "MiniMax-M2.7", |
| QueryComplexity.ADVANCED: "MiniMax-M2.7", |
| }, |
| "lux": { |
| QueryComplexity.SIMPLE: "lux-1.0", |
| QueryComplexity.MODERATE: "lux-1.0", |
| QueryComplexity.COMPLEX: "lux-1.0", |
| QueryComplexity.ADVANCED: "lux-1.0", |
| }, |
| "qwen": { |
| QueryComplexity.SIMPLE: "qwen-plus", |
| QueryComplexity.MODERATE: "qwen-plus", |
| QueryComplexity.COMPLEX: "qwen-plus", |
| QueryComplexity.ADVANCED: "qwen-max", |
| }, |
| } |
|
|
|
|
| |
| MODELS_WITHOUT_TOOLS = { |
| "deepseek-v3.2-speciale", |
| } |
|
|
| |
| MIN_QUALITY_BY_TIER = { |
| CognitiveTier.MICRO: 0, |
| CognitiveTier.STANDARD: 80, |
| CognitiveTier.VERSATILE: 86, |
| CognitiveTier.HEAVY: 90, |
| CognitiveTier.COMPLEX: 94, |
| } |
|
|
| |
| REASONING_MODELS_WITHOUT_VISION = { |
| "deepseek-v3.2", |
| "deepseek-v3.2-speciale", |
| "o3", |
| "o3-mini", |
| "deepseek-chat", |
| "MiniMax-M2.7" |
| } |
|
|
| VISION_ONLY_MODELS = { |
| "janus-pro-7b", |
| "janus-pro-1.3b", |
| } |
|
|
|
|
| class BYOKHandler: |
| """ |
| Handler for LLM interactions using BYOK system with intelligent cost optimization. |
| Automatically routes queries to the most cost-effective provider based on complexity. |
| |
| Phase 68-04: MiniMax M2.5 Integration |
| - Positioned in STANDARD tier with estimated $1/M pricing |
| - API access may be closed - graceful fallback to next provider |
| - Quality score 88 (between gemini-2.0-flash @ 86 and deepseek-chat @ 80) |
| - Native agent support, no prompt caching |
| """ |
| def __init__( |
| self, |
| workspace_id: str = "default", |
| tenant_id: str = "default", |
| provider_id: str = "auto", |
| cognitive_classifier: Optional[CognitiveClassifier] = None, |
| cache_router: Optional[CacheAwareRouter] = None, |
| db_session=None, |
| tier_service: Optional[CognitiveTierService] = None |
| ): |
| self.workspace_id = workspace_id |
| self.tenant_id = tenant_id |
| self.default_provider_id = provider_id if provider_id != "auto" else None |
| self.clients: Dict[str, Any] = {} |
| self.async_clients: Dict[str, Any] = {} |
| self.byok_manager = get_byok_manager() |
|
|
| |
| self.cognitive_classifier = cognitive_classifier or CognitiveClassifier() |
| self._initialize_clients() |
|
|
| |
| self.cache_router = cache_router or CacheAwareRouter(get_pricing_fetcher()) |
|
|
| |
| if db_session is not None: |
| self.db_session = db_session |
| else: |
| try: |
| self.db_session = get_db_session().__enter__() |
| except Exception as e: |
| logger.warning(f"Could not create database session for tier service: {e}") |
| self.db_session = None |
| self.tier_service = tier_service or CognitiveTierService(workspace_id, self.db_session, tenant_id=tenant_id) |
|
|
| |
| self.excluded_models = set() |
| self._refresh_excluded_cache() |
|
|
| |
| from core.provider_health_monitor import get_provider_health_monitor |
| self.health_monitor = get_provider_health_monitor() |
| self.async_clients = self.async_clients or {} |
|
|
| def _get_provider_fallback_order(self, primary_provider: str) -> List[str]: |
| """ |
| Get provider fallback order for resilience. |
| |
| Provider priority based on reliability and cost: |
| 1. deepseek - Primary (most reliable, cost-effective) |
| 2. openai - Fallback (most reliable but expensive) |
| 3. moonshot - Fallback |
| 4. minimax - Fallback (Phase 68 integration) |
| 5. deepinfra - Last resort |
| |
| Args: |
| primary_provider: The requested provider to try first |
| |
| Returns: |
| List of provider IDs in fallback order |
| """ |
| |
| available_providers = list(self.async_clients.keys()) if self.async_clients else list(self.clients.keys()) |
|
|
| if not available_providers: |
| return [] |
|
|
| |
| priority_order = ["deepseek", "openai", "moonshot", "minimax", "deepinfra"] |
|
|
| |
| fallback_order = [] |
|
|
| |
| if primary_provider in available_providers: |
| fallback_order.append(primary_provider) |
|
|
| |
| for provider in priority_order: |
| if provider in available_providers and provider not in fallback_order: |
| fallback_order.append(provider) |
|
|
| |
| for provider in available_providers: |
| if provider not in fallback_order: |
| fallback_order.append(provider) |
|
|
| return fallback_order |
|
|
| def _refresh_excluded_cache(self): |
| """Cache models with exclude_from_general_routing=True""" |
| try: |
| with get_db_session() as db: |
| excluded = db.query(ModelCatalog.model_id).filter( |
| ModelCatalog.exclude_from_general_routing == True |
| ).all() |
| self.excluded_models = {m[0] for m in excluded} |
| logger.debug(f"Refreshed excluded models cache: {len(self.excluded_models)} models excluded") |
| except Exception as e: |
| logger.warning(f"Failed to refresh excluded models cache: {e}") |
| self.excluded_models = set() |
|
|
| def _filter_by_capabilities(self, model_id: str, required_capability: Optional[str]) -> bool: |
| """ |
| Check if model has the required capability. |
| |
| Args: |
| model_id: Model identifier |
| required_capability: Required capability (e.g., "computer_use", "vision", "tools") |
| |
| Returns: |
| True if model has capability or no requirement, False otherwise |
| """ |
| if not required_capability: |
| return True |
|
|
| try: |
| with get_db_session() as db: |
| model = db.query(ModelCatalog).filter_by(model_id=model_id).first() |
| if not model: |
| return True |
| capabilities = model.capabilities or ["chat"] |
| return required_capability in capabilities |
| except Exception as e: |
| logger.warning(f"Failed to check capabilities for {model_id}: {e}") |
| return True |
|
|
| def _filter_by_health(self, provider_id: str) -> bool: |
| """ |
| Check if provider is healthy enough for routing. |
| |
| Args: |
| provider_id: Provider identifier |
| |
| Returns: |
| True if provider is healthy (score >= 0.5) or unknown, False otherwise |
| """ |
| if provider_id not in self.health_monitor.health_scores: |
| return True |
| return self.health_monitor.get_health_score(provider_id) >= 0.5 |
|
|
| def _initialize_clients(self) -> None: |
| """Initialize clients for all available providers""" |
| if not OpenAI: |
| logger.warning("OpenAI package not installed. LLM features may be limited.") |
| return |
|
|
| |
| providers_config = { |
| "openai": {"base_url": None}, |
| "deepseek": {"base_url": "https://api.deepseek.com/v1"}, |
| "moonshot": {"base_url": "https://api.moonshot.cn/v1"}, |
| "deepinfra": {"base_url": "https://api.deepinfra.com/v1/openai"}, |
| "minimax": {"base_url": "https://api.minimax.io/v1"}, |
| "lux": {"base_url": None}, |
| "qwen": {"base_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, |
| } |
|
|
| |
| self.async_clients: Dict[str, Any] = {} |
|
|
| |
| if "lux" in providers_config: |
| |
| api_key = lux_config.get_anthropic_key() or self.byok_manager.get_api_key("lux") |
| if api_key: |
| try: |
| self.clients["lux"] = OpenAI(api_key=api_key) |
| if AsyncOpenAI: |
| self.async_clients["lux"] = AsyncOpenAI(api_key=api_key) |
| logger.info("Initialized LUX provider with Anthropic client") |
| except Exception as e: |
| logger.error(f"Failed to initialize LUX client: {e}") |
| |
| del providers_config["lux"] |
|
|
| for provider_id, config in providers_config.items(): |
| |
| if self.byok_manager.is_configured(self.workspace_id, provider_id): |
| api_key = self.byok_manager.get_api_key(provider_id) |
| try: |
| self.clients[provider_id] = OpenAI( |
| api_key=api_key, |
| base_url=config["base_url"] |
| ) |
| if AsyncOpenAI: |
| self.async_clients[provider_id] = AsyncOpenAI( |
| api_key=api_key, |
| base_url=config["base_url"] |
| ) |
| logger.info(f"Initialized BYOK client for {provider_id}") |
| except Exception as e: |
| logger.error(f"Failed to initialize {provider_id} client: {e}") |
| else: |
| |
| env_key = f"{provider_id.upper()}_API_KEY" |
| api_key = os.getenv(env_key) |
| if api_key: |
| try: |
| if config.get("base_url"): |
| self.clients[provider_id] = OpenAI( |
| api_key=api_key, |
| base_url=config["base_url"] |
| ) |
| if AsyncOpenAI: |
| self.async_clients[provider_id] = AsyncOpenAI( |
| api_key=api_key, |
| base_url=config["base_url"] |
| ) |
| else: |
| self.clients[provider_id] = OpenAI(api_key=api_key) |
| if AsyncOpenAI: |
| self.async_clients[provider_id] = AsyncOpenAI(api_key=api_key) |
| logger.info(f"Initialized BYOK client for {provider_id}") |
| except Exception as e: |
| logger.error(f"Failed to initialize {provider_id} client: {e}") |
|
|
| def get_context_window(self, model_name: str) -> int: |
| """ |
| Get the context window size for a model from dynamic pricing data. |
| Returns a safe default if not found. |
| """ |
| try: |
| fetcher = get_pricing_fetcher() |
| pricing = fetcher.get_model_price(model_name) |
| if pricing: |
| |
| return pricing.get("max_input_tokens") or pricing.get("max_tokens") or 4096 |
| except Exception as e: |
| logger.debug(f"Could not get context window for {model_name}: {e}") |
| |
| |
| CONTEXT_DEFAULTS = { |
| "gpt-4o": 128000, |
| "gpt-4o-mini": 128000, |
| "gpt-4": 8192, |
| "claude-3": 200000, |
| "deepseek-chat": 32768, |
| "deepseek-reasoner": 32768, |
| "gemini": 1000000, |
| } |
| for key, size in CONTEXT_DEFAULTS.items(): |
| if key in model_name.lower(): |
| return size |
| return 4096 |
|
|
| def truncate_to_context(self, text: str, model_name: str, reserve_tokens: int = 1000) -> str: |
| """ |
| Truncate text to fit within the model's context window. |
| Reserves tokens for the response. |
| """ |
| context_window = self.get_context_window(model_name) |
| max_input_tokens = context_window - reserve_tokens |
| |
| |
| max_chars = max_input_tokens * 4 |
| |
| if len(text) <= max_chars: |
| return text |
| |
| |
| truncated = text[:max_chars - 100] |
| truncated += "\n\n[... Content truncated to fit context window ...]" |
| logger.warning(f"Truncated prompt from {len(text)} to {len(truncated)} chars for {model_name}") |
| return truncated |
|
|
| def analyze_query_complexity(self, prompt: str, task_type: Optional[str] = None) -> QueryComplexity: |
| """ |
| Analyze query complexity to determine optimal provider routing. |
| Uses a robust regex-based heuristic with expanded vocabulary. |
| """ |
| |
| estimated_tokens = len(prompt) / 4 |
| complexity_score = 0 |
| |
| if estimated_tokens >= 2000: |
| complexity_score += 3 |
| elif estimated_tokens >= 500: |
| complexity_score += 2 |
| elif estimated_tokens >= 100: |
| complexity_score += 1 |
|
|
| |
| |
| patterns = { |
| "simple": (r"\b(hello|hi|thanks|greetings|summarize|translate|list|what is|who is|define|how do i|simplify|brief|basic|short|quick|simple)\b", -2), |
| "moderate": (r"\b(analyze|compare|evaluate|synthesize|explain|describe|detailed|background|concept|history|nuance|opinion|critique|pros and cons|advantages|disadvantages)\b", 1), |
| "technical": (r"\b(calculate|equation|formula|solve|integral|derivative|calculus|geometry|algebra|math|maths|theorem|statistics|probability|regression|vector|matrix|tensor|log|exp|pow|sqrt|abs|sin|cos|tan|pi|infinity|prime|physics|chemistry|biology|science)\b", 3), |
| "code": (r"\b(code|coding|function|class|method|script|scripting|debug|debugging|optimize|optimization|refactor|refactoring|snippet|implementation|interface|api|endpoint|webhook|database|sql|postgresql|mongodb|redis|schema|migration|json|xml|yaml|config|docker|kubernetes|aws|lambda|gcp|azure|def|var|let|const|import|return|print|async|await|try|except|catch|throw|public|private|static|final|struct|typedef|typedefs)\b", 3), |
| "advanced": (r"\b(architecture|architecting|security audit|vulnerability|cryptography|encryption|decryption|authentication|authorization|auth|oauth|jwt|performance|bottleneck|concurrency|multithread|parallel|distributed|scale|scaling|load balance|cluster|proprietary|reverse engineer|obfuscate|obfuscation|enterprise|global|large-scale)\b", 5) |
| } |
|
|
| |
| if "```" in prompt: |
| complexity_score += 3 |
|
|
| for name, (pattern, weight) in patterns.items(): |
| if re.search(pattern, prompt, re.IGNORECASE): |
| complexity_score += weight |
|
|
| |
| if task_type: |
| if task_type in ["code", "analysis", "reasoning"]: |
| complexity_score += 2 |
| elif task_type in ["chat", "general"]: |
| complexity_score -= 1 |
|
|
| |
| |
| if complexity_score <= 0: |
| return QueryComplexity.SIMPLE |
| elif complexity_score == 1: |
| return QueryComplexity.MODERATE |
| elif complexity_score <= 4: |
| return QueryComplexity.COMPLEX |
| else: |
| return QueryComplexity.ADVANCED |
|
|
| async def get_optimal_provider( |
| self, |
| complexity: QueryComplexity, |
| task_type: Optional[str] = None, |
| prefer_cost: bool = True, |
| tenant_plan: str = "free", |
| is_managed_service: bool = True, |
| requires_tools: bool = False, |
| requires_structured: bool = False, |
| turn_index: int = 0 |
| ) -> tuple[str, str]: |
| """Get the single most optimal provider and model.""" |
| options = await self.get_ranked_providers( |
| complexity, task_type, prefer_cost, tenant_plan, |
| is_managed_service, requires_tools, requires_structured, |
| turn_index=turn_index |
| ) |
| if options: |
| return options[0] |
| |
| |
| if self.clients: |
| provider_id = list(self.clients.keys())[0] |
| return provider_id, "gpt-4o-mini" |
| |
| raise ValueError("No LLM providers available. Please configure BYOK keys.") |
|
|
| async def get_ranked_providers( |
| self, |
| complexity: QueryComplexity, |
| task_type: Optional[str] = None, |
| prefer_cost: bool = True, |
| tenant_plan: str = "free", |
| is_managed_service: bool = True, |
| requires_tools: bool = False, |
| requires_structured: bool = False, |
| estimated_tokens: int = 1000, |
| workspace_id: str = "default", |
| cognitive_tier: Optional[CognitiveTier] = None, |
| required_capability: Optional[str] = None, |
| turn_index: int = 0 |
| ) -> List[tuple[str, str]]: |
| """ |
| Get a ranked list of providers and models using the BPC (Benchmark-Price-Capability) algorithm. |
| This objectively ranks models based on their value proposition. |
| |
| Cache-Aware Extension (Deterministic): |
| Uses turn_index (0 = first turn, 1+ = repeat turns) to determine whether |
| to use full input price or cached input price. |
| |
| Phase 68 Extension: |
| When cognitive_tier is provided, uses CognitiveTier-based quality filtering instead of |
| QueryComplexity. This enables more granular 5-tier quality control. |
| |
| Phase 226.4-04 Extension: |
| When required_capability is provided, filters models by capability (e.g., "computer_use", "vision", "tools") |
| and uses capability-specific quality scores. Also filters out excluded models and unhealthy providers. |
| |
| Args: |
| complexity: Query complexity level |
| task_type: Optional task type hint |
| prefer_cost: Whether to prefer cost over quality |
| tenant_plan: Tenant plan for model restrictions |
| cognitive_tier: Optional CognitiveTier for 5-tier quality filtering (Phase 68) |
| is_managed_service: Whether this is managed service or BYOK |
| requires_tools: Whether model must support tool calling |
| requires_structured: Whether model must support structured output |
| estimated_tokens: Estimated input token count (for cache hit prediction) |
| workspace_id: Workspace ID for cache history lookup |
| required_capability: Optional capability requirement (e.g., "computer_use", "vision", "tools") |
| turn_index: Interaction turn (0 = creation, 1+ = reuse) |
| |
| Returns: |
| List of (provider, model) tuples ranked by value score |
| """ |
| ranked_options = [] |
| |
| |
| try: |
| fetcher = get_pricing_fetcher() |
| |
| |
| MIN_CONTEXT_BY_COMPLEXITY = { |
| QueryComplexity.SIMPLE: 4000, |
| QueryComplexity.MODERATE: 8000, |
| QueryComplexity.COMPLEX: 16000, |
| QueryComplexity.ADVANCED: 32000 |
| } |
| min_context = MIN_CONTEXT_BY_COMPLEXITY.get(complexity, 8000) |
|
|
| |
| |
| if cognitive_tier is not None: |
| min_quality = MIN_QUALITY_BY_TIER.get(cognitive_tier, 0) |
| logger.debug(f"Using CognitiveTier {cognitive_tier.value} quality threshold: {min_quality}") |
| else: |
| MIN_QUALITY_BY_COMPLEXITY = { |
| QueryComplexity.SIMPLE: 0, |
| QueryComplexity.MODERATE: 80, |
| QueryComplexity.COMPLEX: 88, |
| QueryComplexity.ADVANCED: 94 |
| } |
| min_quality = MIN_QUALITY_BY_COMPLEXITY.get(complexity, 0) |
| |
| available_providers = list(self.clients.keys()) |
| candidates = [] |
| |
| |
| for model_id, pricing in fetcher.pricing_cache.items(): |
| litellm_provider = pricing.get("litellm_provider", "").lower() |
| |
| |
| active_provider = next((p for p in available_providers if p in model_id.lower() or p == litellm_provider), None) |
| if not active_provider: |
| continue |
| |
| |
| context_window = pricing.get("max_input_tokens") or pricing.get("max_tokens") or 0 |
| if context_window < min_context: |
| continue |
|
|
| |
| if not self._filter_by_capabilities(model_id, required_capability): |
| continue |
|
|
| |
| if not required_capability and model_id in self.excluded_models: |
| continue |
|
|
| |
| if not self._filter_by_health(active_provider): |
| continue |
|
|
| |
| if required_capability: |
| quality_score = get_capability_score(model_id, required_capability) |
| else: |
| quality_score = get_quality_score(model_id) |
|
|
| if quality_score < min_quality: |
| continue |
|
|
| |
| |
|
|
| |
| effective_cost = await self.cache_router.calculate_effective_cost( |
| model_id, active_provider, estimated_tokens, turn_index=turn_index |
| ) |
|
|
| |
| normalized_cost = max(effective_cost, 1e-9) |
|
|
| |
| |
| value_score = (quality_score ** 2) / (normalized_cost * 1e6) |
| |
| candidates.append({ |
| "provider": active_provider, |
| "model": model_id, |
| "value_score": value_score, |
| "quality": quality_score, |
| "cost": effective_cost |
| }) |
| |
| |
| candidates.sort(key=lambda x: x["value_score"], reverse=True) |
| |
| |
| allowed_models = MODEL_TIER_RESTRICTIONS.get(tenant_plan.lower(), MODEL_TIER_RESTRICTIONS["free"]) if is_managed_service else "*" |
| |
| def is_model_approved(model_id: str, allowed_list: any) -> bool: |
| if allowed_list == "*" or "*" in allowed_list: |
| return True |
| |
| |
| model_id_lower = model_id.lower() |
| |
| |
| if (requires_tools or requires_structured) and any(m in model_id_lower for m in MODELS_WITHOUT_TOOLS): |
| return False |
|
|
| return any(m.lower() in model_id_lower for m in allowed_list) |
|
|
| for c in candidates: |
| if is_model_approved(c["model"], allowed_models): |
| ranked_options.append((c["provider"], c["model"])) |
| |
| if ranked_options: |
| logger.info(f"BPC Ranking Successful for {complexity.value}: Top model {ranked_options[0][1]} (Value: {candidates[0]['value_score']:.2f})") |
| return ranked_options |
| |
| except Exception as e: |
| logger.debug(f"BPC ranking failed, falling back to static mapping: {e}") |
| |
| |
| if complexity == QueryComplexity.SIMPLE: |
| provider_priority = ["deepseek", "minimax", "qwen", "moonshot", "gemini", "openai", "anthropic"] |
| elif complexity == QueryComplexity.MODERATE: |
| provider_priority = ["deepseek", "minimax", "qwen", "gemini", "moonshot", "openai", "anthropic"] |
| elif complexity == QueryComplexity.COMPLEX: |
| provider_priority = ["gemini", "deepseek", "anthropic", "qwen", "minimax", "openai", "moonshot"] |
| else: |
| provider_priority = ["openai", "deepseek", "anthropic", "qwen", "gemini", "moonshot", "minimax"] |
| |
| for provider_id in provider_priority: |
| if provider_id in self.clients: |
| models = COST_EFFICIENT_MODELS.get(provider_id, {}) |
| model = models.get(complexity, "gpt-4o-mini") |
| |
| if not is_managed_service: |
| |
| if (requires_tools or requires_structured) and model in MODELS_WITHOUT_TOOLS: |
| |
| if provider_id == "deepseek" and model == "deepseek-v3.2-speciale": |
| model = "deepseek-r2" |
| else: |
| continue |
|
|
| ranked_options.append((provider_id, model)) |
| continue |
|
|
| allowed_models = MODEL_TIER_RESTRICTIONS.get(tenant_plan.lower(), MODEL_TIER_RESTRICTIONS["free"]) |
| |
| |
| if (requires_tools or requires_structured) and model in MODELS_WITHOUT_TOOLS: |
| |
| if provider_id == "deepseek" and model == "deepseek-v3.2-speciale": |
| model = "deepseek-r2" |
| else: |
| continue |
|
|
| if "*" in allowed_models or model in allowed_models: |
| ranked_options.append((provider_id, model)) |
| |
| |
| if "qwen" in self.clients: |
| qwen_option = next(((p, m) for p, m in ranked_options if p == "qwen"), None) |
| if qwen_option: |
| ranked_options.remove(qwen_option) |
| ranked_options.insert(0, qwen_option) |
|
|
| return ranked_options |
|
|
| async def generate_response( |
| self, |
| prompt: str, |
| system_instruction: str = "You are a helpful assistant.", |
| model_type: str = "auto", |
| temperature: float = 0.7, |
| task_type: Optional[str] = None, |
| prefer_cost: bool = True, |
| agent_id: Optional[str] = None, |
| chain_id: Optional[str] = None, |
| image_payload: Optional[str] = None, |
| turn_index: int = 0 |
| ) -> str: |
| """ |
| Generate a response using cost-optimized provider routing. |
| Supports multimodal inputs (text + image) via `image_payload`. |
| """ |
| |
| if self._is_trial_restricted(): |
| logger.warning(f"AI Blocked: Trial expired for workspace {self.workspace_id}") |
| return "Trial Expired: Your free trial has ended. Please upgrade your plan in settings to continue using AI agents." |
| if not self.clients: |
| if task_type == "agentic": |
| |
| if "Check my inbox" in prompt or "analyze" in prompt.lower() or "market" in prompt.lower(): |
| return json.dumps({ |
| "thought": "The user wants a full end-to-end machinery quote and client analysis. I will start by performing the market analysis.", |
| "plan_update": ["Perform market analysis for brennan.ca", "Read inbound emails", "Calculate quote and save to Excel", "Update CRM", "Send final email with meeting invite"], |
| "action": "perform_market_analysis", |
| "action_input": {"client_url": "brennan.ca", "product_name": "5-Axis CNC Mill"}, |
| "log": "> Starting Market Analysis for Brennan.ca...", |
| "deliverable": None |
| }) |
| return json.dumps({ |
| "thought": "LLM not initialized, but running in agentic demo mode.", |
| "action": "DONE", |
| "log": "AI Employee Demo Mode active (No API Keys found)." |
| }) |
| return "LLM Client not initialized (No API Keys configured)." |
| |
| |
| if llm_usage_tracker.is_budget_exceeded(self.workspace_id): |
| logger.warning(f"AI Generation Blocked: Budget exceeded for workspace {self.workspace_id}") |
| return "🚨 BUDGET EXCEEDED: Your AI usage has reached 100% of your limit. Please increase your budget in Settings to continue." |
|
|
| try: |
| |
| |
| with get_db_session() as db: |
| try: |
| tenant_plan = "free" |
| is_managed = True |
|
|
| workspace = db.query(Workspace).filter(Workspace.id == self.workspace_id).first() |
| if workspace and workspace.tenant_id: |
| tenant = db.query(Tenant).filter(Tenant.id == (self.tenant_id if self.tenant_id != "default" else workspace.tenant_id)).first() |
| if tenant: |
| |
| plan_type = tenant.plan_type |
| tenant_plan = plan_type.value if hasattr(plan_type, 'value') else str(plan_type).lower() |
|
|
| |
| complexity = self.analyze_query_complexity(prompt, task_type) |
|
|
| |
| requires_tools = agent_id is not None or task_type == "agentic" |
|
|
| |
| temp_provider_id, _ = await self.get_optimal_provider( |
| complexity, task_type, prefer_cost, tenant_plan, |
| is_managed_service=True, requires_tools=requires_tools, |
| turn_index=turn_index |
| ) |
|
|
| tenant_key = self.byok_manager.get_tenant_api_key(self.tenant_id, temp_provider_id) |
| if tenant_key: |
| is_managed = False |
| elif tenant_plan.lower() in [p.lower() for p in BYOK_ENABLED_PLANS]: |
| is_managed = False |
|
|
| |
| |
| if is_managed and tenant_plan.lower() == "free" and task_type != "agentic": |
| |
| if not self.clients: |
| return "🚨 PLAN RESTRICTION: Managed AI is not available on the Free plan. Please add your own API key in Settings or upgrade to a Pro plan to continue." |
| except Exception as e: |
| logger.warning(f"Failed to fetch tenant plan: {e}") |
|
|
| |
| if task_type == "agentic" and self.clients: |
| is_managed = False |
| tenant_plan = "enterprise" |
| logger.info("Using local/BYOK mode for agentic task demo") |
|
|
| |
| complexity = self.analyze_query_complexity(prompt, task_type) |
| |
| |
| requires_tools = agent_id is not None or task_type == "agentic" |
| |
| |
| |
| |
| requires_vision = image_payload is not None |
| |
| |
| options = await self.get_ranked_providers( |
| complexity, task_type, prefer_cost, tenant_plan, is_managed, |
| requires_tools=requires_tools, requires_structured=False, |
| turn_index=turn_index |
| ) |
|
|
| |
| if requires_vision: |
| |
| primary_provider, primary_model = options[0] if options else (None, None) |
| |
| if primary_model and any(m in primary_model.lower() for m in REASONING_MODELS_WITHOUT_VISION): |
| logger.info(f"Coordinating vision for non-vision reasoning model: {primary_model}") |
| vision_desc = await self._get_coordinated_vision_description( |
| image_payload=image_payload, |
| tenant_plan=tenant_plan, |
| is_managed=is_managed |
| ) |
| if vision_desc: |
| mapping_instr = ( |
| "\n[COORDINATE MAPPING]:\n" |
| "The coordinates below are on a normalized 1000x1000 grid. " |
| "The browser viewport is 1280 pixels wide. " |
| "To click an element at [x, y], use browser_click_coords(x*1.28, y*H) where H is approximately 0.72*1.28.\n" |
| ) |
| prompt = f"[VISUAL CONTEXT ANALYSIS]:\n{vision_desc}\n{mapping_instr}\n\n[USER REQUEST]:\n{prompt}" |
| |
| image_payload = None |
| requires_vision = False |
|
|
| |
| if requires_vision: |
| |
| if task_type == "pdf_ocr": |
| |
| preferred_ocr = [(p, m) for p, m in options if "deepinfra" in p.lower() or "deepseek" in p.lower() or ("deepseek" in m.lower() and "ocr" in m.lower())] |
| if preferred_ocr: |
| options = preferred_ocr |
| logger.info(f"Prioritizing {preferred_ocr[0][0]} for PDF OCR task") |
|
|
| |
| |
| vision_models = ["gpt-4o", "gemini-3-flash", "gemini-2.0-flash", "gemini-1.5-flash", "gemini-1.5-pro", "claude-3-5-sonnet", "claude-3-opus", "gpt-4-turbo", "deepseek", "deepinfra", "lux"] |
| vision_options = [] |
| for prov, mod in options: |
| if any(v in mod.lower() for v in vision_models): |
| vision_options.append((prov, mod)) |
| |
| if vision_options: |
| options = vision_options |
| elif not any("deepseek" in p.lower() for p, m in options): |
| |
| logger.warning("No standard vision models found in ranked options. Defaulting to GPT-4o.") |
| options = [("openai", "gpt-4o")] |
| |
| if not options: |
| return "No eligible LLM providers found for your current plan." |
|
|
| last_error = None |
| for provider_id, model in options: |
| try: |
| import time |
| request_start = time.time() |
| client = self.clients[provider_id] |
| |
| |
| messages = [] |
| messages.append({"role": "system", "content": system_instruction}) |
| |
| if image_payload: |
| |
| user_content = [ |
| {"type": "text", "text": prompt}, |
| { |
| "type": "image_url", |
| "image_url": { |
| "url": image_payload if image_payload.startswith("http") else f"data:image/jpeg;base64,{image_payload}" |
| } |
| } |
| ] |
| messages.append({"role": "user", "content": user_content}) |
| logger.info(f"Adding visual payload to request for {model}") |
| else: |
| messages.append({"role": "user", "content": prompt}) |
|
|
| |
| response = client.chat.completions.create( |
| model=model, |
| messages=messages, |
| temperature=temperature |
| ) |
| |
| result = response.choices[0].message.content |
| |
| |
| usage = getattr(response, 'usage', None) |
| if usage: |
| input_tokens = getattr(usage, 'prompt_tokens', 0) |
| output_tokens = getattr(usage, 'completion_tokens', 0) |
| |
| |
| try: |
| fetcher = get_pricing_fetcher() |
| cost = fetcher.estimate_cost(model, input_tokens, output_tokens) |
| |
| |
| reference_cost = fetcher.estimate_cost("gpt-4o", input_tokens, output_tokens) |
| savings_usd = max(0, reference_cost - cost) if reference_cost and cost is not None else 0.0 |
| |
| |
| if cost is None: |
| cost = get_llm_cost(model, input_tokens, output_tokens) |
| |
| ref_cost_static = get_llm_cost("gpt-4o", input_tokens, output_tokens) |
| savings_usd = max(0, ref_cost_static - cost) |
| |
| if cost and cost > 0: |
| |
| llm_usage_tracker.record( |
| workspace_id=self.workspace_id, |
| provider=provider_id, |
| model=model, |
| input_tokens=input_tokens, |
| output_tokens=output_tokens, |
| cost_usd=cost, |
| savings_usd=savings_usd, |
| agent_id=agent_id, |
| chain_id=chain_id, |
| complexity=complexity.value, |
| is_managed_service=is_managed |
| ) |
| logger.info(f"LLM Cost Attributed ({'Managed' if is_managed else 'BYOK'}): {model} - ${cost:.6f} (Saved: ${savings_usd:.6f})") |
| except Exception as cost_err: |
| logger.warning(f"Could not attribute LLM cost: {cost_err}") |
|
|
| |
| |
| try: |
| prompt_hash = hashlib.sha256(f"{self.workspace_id}:{provider_id}:{model}".encode()).hexdigest() |
|
|
| |
| was_cached = False |
| if hasattr(usage, 'prompt_cache_hit_tokens'): |
| |
| was_cached = getattr(usage, 'prompt_cache_hit_tokens', 0) > 0 |
| elif hasattr(response, 'cache_controls'): |
| |
| was_cached = True |
|
|
| |
| self.cache_router.record_cache_outcome(prompt_hash, self.workspace_id, was_cached) |
| logger.debug(f"Cache outcome recorded: {prompt_hash[:16]} -> {was_cached}") |
| except Exception as cache_err: |
| logger.debug(f"Could not record cache outcome: {cache_err}") |
|
|
| |
| logger.info(f"BYOK Logic: complexity={complexity.value}, provider={provider_id}, model={model}") |
|
|
| |
| latency_ms = (time.time() - request_start) * 1000 |
| self.health_monitor.record_call(provider_id, success=True, latency_ms=latency_ms) |
|
|
| return result |
|
|
| except Exception as attempt_err: |
| logger.warning(f"Attempt failed for {provider_id}/{model}: {attempt_err}") |
| last_error = attempt_err |
|
|
| |
| try: |
| latency_ms = (time.time() - request_start) * 1000 |
| self.health_monitor.record_call(provider_id, success=False, latency_ms=latency_ms) |
| except: |
| pass |
| continue |
| |
| return f"All providers failed. Last error: {str(last_error)}" |
| |
| except Exception as e: |
| logger.error(f"LLM Generation failed: {e}") |
| return f"Error generating response: {str(e)}" |
|
|
| async def generate_with_cognitive_tier( |
| self, |
| prompt: str, |
| system_instruction: str = "You are a helpful assistant.", |
| task_type: Optional[str] = None, |
| user_tier_override: Optional[str] = None, |
| agent_id: Optional[str] = None, |
| image_payload: Optional[str] = None |
| ) -> Dict[str, Any]: |
| """ |
| Generate response using full cognitive tier pipeline. |
| |
| Phase 68-06: Integrates CognitiveTierService for end-to-end intelligent routing. |
| |
| Pipeline: |
| 1. Select cognitive tier (classification + workspace preferences) |
| 2. Check budget constraints (monthly + per-request) |
| 3. Get optimal model (cache-aware cost scoring) |
| 4. Generate with automatic escalation on quality issues |
| |
| Args: |
| prompt: The user query |
| system_instruction: System prompt for the LLM |
| task_type: Optional task type hint (code, chat, analysis, etc.) |
| user_tier_override: Optional user-specified tier (bypasses classification) |
| agent_id: Optional agent ID for cost tracking |
| image_payload: Optional base64/URL image for multimodal input |
| |
| Returns: |
| Dictionary with keys: |
| - response: Generated text response |
| - tier: Cognitive tier used |
| - provider: Provider ID used |
| - model: Model name used |
| - cost_cents: Estimated cost in cents |
| - escalated: Whether escalation occurred |
| |
| Example: |
| >>> handler = BYOKHandler() |
| >>> result = await handler.generate_with_cognitive_tier( |
| ... "explain quantum computing", |
| ... task_type="analysis" |
| ... ) |
| >>> print(result["response"]) |
| >>> print(f"Tier: {result['tier']}, Model: {result['model']}") |
| """ |
| request_id = str(uuid.uuid4()) |
|
|
| |
| tier = self.tier_service.select_tier(prompt, task_type, user_tier_override) |
|
|
| |
| estimated_cost = self.tier_service.calculate_request_cost(prompt, tier, None) |
| if not self.tier_service.check_budget_constraint(estimated_cost.get('cost_cents', 0)): |
| logger.warning(f"Budget exceeded for request {request_id}") |
| return { |
| "error": "Budget exceeded", |
| "tier": tier.value, |
| "estimated_cost_cents": estimated_cost.get('cost_cents', 0) |
| } |
|
|
| |
| estimated_tokens = len(prompt) // 4 |
| requires_tools = agent_id is not None or task_type == "agentic" |
|
|
| provider_id, model = self.tier_service.get_optimal_model( |
| tier, estimated_tokens, requires_tools |
| ) |
|
|
| if not provider_id or not model: |
| logger.warning(f"No models available for tier: {tier.value}") |
| return { |
| "error": "No models available for this tier", |
| "tier": tier.value |
| } |
|
|
| |
| current_tier = tier |
| max_escalations = 2 |
| escalated = False |
|
|
| for attempt in range(max_escalations + 1): |
| try: |
| |
| response = await self.generate_response( |
| prompt=prompt, |
| system_instruction=system_instruction, |
| model_type=model, |
| task_type=task_type, |
| agent_id=agent_id, |
| image_payload=image_payload |
| ) |
|
|
| |
| should_escalate, reason, target_tier = self.tier_service.handle_escalation( |
| current_tier, None, None, False, request_id |
| ) |
|
|
| if not should_escalate: |
| |
| return { |
| "response": response, |
| "tier": current_tier.value, |
| "provider": provider_id, |
| "model": model, |
| "cost_cents": estimated_cost.get('cost_cents', 0), |
| "escalated": escalated, |
| "request_id": request_id |
| } |
|
|
| |
| logger.info( |
| f"Escalating request {request_id} from {current_tier.value} " |
| f"to {target_tier.value} (reason: {reason.value})" |
| ) |
| current_tier = target_tier |
| escalated = True |
|
|
| |
| provider_id, model = self.tier_service.get_optimal_model( |
| current_tier, estimated_tokens, requires_tools |
| ) |
|
|
| if not provider_id or not model: |
| logger.warning(f"No models available for escalated tier: {current_tier.value}") |
| |
| return { |
| "response": response, |
| "tier": tier.value, |
| "provider": provider_id, |
| "model": model, |
| "cost_cents": estimated_cost.get('cost_cents', 0), |
| "escalated": escalated, |
| "request_id": request_id |
| } |
|
|
| except Exception as e: |
| |
| is_rate_limited = "rate limit" in str(e).lower() |
|
|
| should_escalate, reason, target_tier = self.tier_service.handle_escalation( |
| current_tier, None, str(e), is_rate_limited, request_id |
| ) |
|
|
| if should_escalate and target_tier and attempt < max_escalations: |
| logger.warning( |
| f"Escalating request {request_id} due to error: {reason.value}" |
| ) |
| current_tier = target_tier |
| escalated = True |
|
|
| |
| provider_id, model = self.tier_service.get_optimal_model( |
| current_tier, estimated_tokens, requires_tools |
| ) |
|
|
| if not provider_id or not model: |
| |
| return { |
| "error": str(e), |
| "tier": current_tier.value, |
| "escalated": escalated |
| } |
|
|
| continue |
|
|
| |
| logger.error(f"Generation failed after {attempt + 1} attempts: {e}") |
| return { |
| "error": str(e), |
| "tier": current_tier.value, |
| "escalated": escalated |
| } |
|
|
| |
| return { |
| "response": "Max escalation limit reached", |
| "tier": current_tier.value, |
| "escalated": escalated |
| } |
|
|
| async def generate_structured_response( |
| self, |
| prompt: str, |
| system_instruction: str, |
| response_model: Any, |
| temperature: float = 0.2, |
| task_type: Optional[str] = None, |
| agent_id: Optional[str] = None, |
| chain_id: Optional[str] = None, |
| image_payload: Optional[str] = None |
| ) -> Any: |
| """ |
| Generate a structured response using instructor with tenant-aware routing. |
| Works with both BYOK and Managed AI. |
| Supports multimodal inputs via `image_payload`. |
| |
| Args: |
| prompt: The user prompt |
| system_instruction: System instruction for the LLM |
| response_model: Pydantic model class for structured output |
| temperature: Sampling temperature |
| task_type: Optional task type hint |
| agent_id: Optional agent ID for cost tracking |
| image_payload: Optional Base64 image string or URL |
| |
| Returns: |
| Instance of response_model or None if parsing fails |
| """ |
| |
| if self._is_trial_restricted(): |
| logger.warning(f"AI Blocked: Trial expired for workspace {self.workspace_id}") |
| return None |
| |
| if not self.clients: |
| logger.warning("No LLM clients available") |
| return None |
| |
| try: |
| |
| if not INSTRUCTOR_AVAILABLE: |
| logger.warning("Instructor not available, falling back to raw response") |
| return None |
| |
| |
| with get_db_session() as db: |
| try: |
| tenant_plan = "free" |
| is_managed = True |
|
|
| workspace = db.query(Workspace).filter(Workspace.id == self.workspace_id).first() |
| if workspace and workspace.tenant_id: |
| tenant = db.query(Tenant).filter(Tenant.id == workspace.tenant_id).first() |
| if tenant: |
| plan_type = tenant.plan_type |
| tenant_plan = plan_type.value if hasattr(plan_type, 'value') else str(plan_type).lower() |
|
|
| |
| complexity = self.analyze_query_complexity(prompt, task_type) |
| temp_provider_id, _ = self.get_optimal_provider(complexity, task_type, True, tenant_plan, is_managed_service=True) |
|
|
| tenant_key = self.byok_manager.get_tenant_api_key(tenant.id, temp_provider_id) |
| if tenant_key: |
| is_managed = False |
| elif tenant_plan.lower() in [p.lower() for p in BYOK_ENABLED_PLANS]: |
| is_managed = False |
| except Exception as e: |
| logger.warning(f"Failed to get tenant plan: {e}") |
| |
| |
| if is_managed and tenant_plan.lower() == "free": |
| logger.warning(f"Managed AI blocked for free tier workspace {self.workspace_id}") |
| return None |
| |
| |
| complexity = self.analyze_query_complexity(prompt, task_type) |
| |
| |
| requires_tools = agent_id is not None or task_type == "agentic" |
| |
| |
| requires_vision = image_payload is not None |
| |
| options = self.get_ranked_providers( |
| complexity, task_type, True, tenant_plan, is_managed, |
| requires_tools=True, requires_structured=True |
| ) |
|
|
| |
| if image_payload: |
| primary_provider, primary_model = options[0] if options else (None, None) |
| if primary_model and any(m in primary_model.lower() for m in REASONING_MODELS_WITHOUT_VISION): |
| logger.info(f"Coordinating vision (structured) for non-vision reasoning model: {primary_model}") |
| vision_desc = await self._get_coordinated_vision_description( |
| image_payload=image_payload, |
| tenant_plan=tenant_plan, |
| is_managed=is_managed |
| ) |
| if vision_desc: |
| mapping_instr = ( |
| "\n[COORDINATE MAPPING]:\n" |
| "The coordinates below are on a normalized 1000x1000 grid. " |
| "The browser viewport is 1280 pixels wide. " |
| "To click an element at [x, y], use browser_click_coords(x*1.28, y*H) where H is approximately 0.72*1.28.\n" |
| ) |
| prompt = f"[VISUAL CONTEXT ANALYSIS]:\n{vision_desc}\n{mapping_instr}\n\n[USER REQUEST]:\n{prompt}" |
| image_payload = None |
| |
| |
| if requires_vision: |
| vision_models = ["gpt-4o", "gemini-3-flash", "gemini-2.0-flash", "gemini-1.5-flash", "gemini-1.5-pro", "claude-3-5-sonnet", "claude-3-opus", "gpt-4-turbo"] |
| vision_options = [] |
| for prov, mod in options: |
| if any(v in mod.lower() for v in vision_models): |
| vision_options.append((prov, mod)) |
| |
| if vision_options: |
| options = vision_options |
| else: |
| logger.warning("No standard vision models found for structured output. Defaulting to GPT-4o.") |
| options = [("openai", "gpt-4o")] |
|
|
| if not options: |
| return None |
|
|
| last_error = None |
| for provider_id, model in options: |
| try: |
| |
| client = self.clients[provider_id] |
| instructor_client = instructor.from_openai(client) |
| |
| |
| context_window = self.get_context_window(model) |
| if len(prompt) > context_window * 3: |
| prompt = self.truncate_to_context(prompt, model, reserve_tokens=1500) |
| logger.info(f"Truncated prompt for model {model} (context: {context_window} tokens)") |
| |
| |
| logger.info(f"Structured generation ({tenant_plan}, {'Managed' if is_managed else 'BYOK'}): {provider_id}/{model}") |
| |
| |
| messages = [] |
| messages.append({"role": "system", "content": system_instruction}) |
| |
| if image_payload: |
| |
| user_content = [ |
| {"type": "text", "text": prompt}, |
| { |
| "type": "image_url", |
| "image_url": { |
| "url": image_payload if image_payload.startswith("http") else f"data:image/jpeg;base64,{image_payload}" |
| } |
| } |
| ] |
| messages.append({"role": "user", "content": user_content}) |
| logger.info(f"Adding visual payload to STRUCTURED request for {model}") |
| else: |
| messages.append({"role": "user", "content": prompt}) |
|
|
| result = instructor_client.chat.completions.create( |
| model=model, |
| response_model=response_model, |
| messages=messages, |
| temperature=temperature, |
| max_tokens=1000 |
| ) |
| |
| |
| try: |
| |
| usage = getattr(result, "_raw_response", {}).usage if hasattr(result, "_raw_response") else None |
| if not usage and hasattr(result, "usage"): |
| usage = result.usage |
|
|
| if usage: |
| input_tokens = usage.prompt_tokens |
| output_tokens = usage.completion_tokens |
|
|
| fetcher = get_pricing_fetcher() |
| cost = fetcher.estimate_cost(model, input_tokens, output_tokens) |
|
|
| if cost and cost > 0: |
| llm_usage_tracker.record( |
| workspace_id=self.workspace_id, |
| provider=provider_id, |
| model=model, |
| input_tokens=input_tokens, |
| output_tokens=output_tokens, |
| cost_usd=cost, |
| agent_id=agent_id, |
| chain_id=chain_id, |
| complexity=complexity.value, |
| is_managed_service=is_managed |
| ) |
| except Exception as cost_err: |
| logger.warning(f"Could not attribute structured LLM cost: {cost_err}") |
| |
| return result |
| except Exception as attempt_err: |
| logger.warning(f"Structured attempt failed for {provider_id}/{model}: {attempt_err}") |
| last_error = attempt_err |
| continue |
| |
| logger.error(f"All structured providers failed. Last error: {last_error}") |
| return None |
| |
| except Exception as e: |
| logger.error(f"Structured generation failed: {e}") |
| return None |
|
|
|
|
| async def generate_transcription( |
| self, |
| file: Any, |
| model: str = "whisper-1", |
| language: Optional[str] = None, |
| prompt: Optional[str] = None, |
| response_format: str = "json" |
| ) -> Dict[str, Any]: |
| """ |
| Transcribe audio to text using OpenAI Whisper. |
| Uses BYOK keys for the 'openai' provider. |
| """ |
| |
| provider_id = "openai" |
| client = self.async_clients.get(provider_id) or self.clients.get(provider_id) |
| |
| if not client: |
| raise ValueError(f"OpenAI provider not configured for transcription. Please add an API key.") |
|
|
| try: |
| |
| |
| raw_client = getattr(client, "client", client) |
| |
| response = await raw_client.audio.transcriptions.create( |
| model=model, |
| file=file, |
| language=language, |
| prompt=prompt, |
| response_format=response_format |
| ) |
| |
| |
| text = response.text if hasattr(response, "text") else str(response) |
| |
| return { |
| "text": text, |
| "model": model, |
| "provider": provider_id |
| } |
| except Exception as e: |
| logger.error(f"Whisper transcription failed: {e}") |
| raise |
|
|
| def get_available_providers(self) -> List[str]: |
|
|
| """Get list of providers with valid API keys""" |
| return list(self.clients.keys()) |
|
|
| def get_routing_info(self, prompt: str, task_type: Optional[str] = None) -> Dict[str, Any]: |
| """Get routing decision info without making an API call (useful for UI)""" |
| complexity = self.analyze_query_complexity(prompt, task_type) |
| try: |
| provider_id, model = self.get_optimal_provider(complexity, task_type) |
| |
| |
| estimated_cost = None |
| try: |
| fetcher = get_pricing_fetcher() |
| pricing = fetcher.get_model_price(model) |
| if pricing: |
| |
| input_tokens = len(prompt) // 4 |
| output_tokens = 500 |
| estimated_cost = fetcher.estimate_cost(model, input_tokens, output_tokens) |
| except Exception as e: |
| logger.warning(f"Cost estimation failed for model {model}: {e}") |
| estimated_cost = None |
| |
| return { |
| "complexity": complexity.value, |
| "selected_provider": provider_id, |
| "selected_model": model, |
| "available_providers": self.get_available_providers(), |
| "cost_tier": "budget" if provider_id in PROVIDER_TIERS["budget"] else "mid" if provider_id in PROVIDER_TIERS["mid"] else "premium", |
| "estimated_cost_usd": estimated_cost |
| } |
| except ValueError as e: |
| return { |
| "complexity": complexity.value, |
| "error": str(e), |
| "available_providers": [] |
| } |
|
|
| async def refresh_pricing(self, force: bool = False) -> Dict[str, Any]: |
| """Refresh dynamic pricing data from LiteLLM and OpenRouter""" |
| try: |
| pricing = await refresh_pricing_cache(force=force) |
| return {"status": "success", "model_count": len(pricing)} |
| except Exception as e: |
| logger.error(f"Failed to refresh pricing: {e}") |
| return {"status": "error", "message": str(e)} |
|
|
| def get_provider_comparison(self) -> Dict[str, Any]: |
| """Get cost comparison across all providers using dynamic pricing""" |
| try: |
| fetcher = get_pricing_fetcher() |
| return fetcher.compare_providers() |
| except Exception as e: |
| logger.warning(f"Could not get provider comparison: {e}") |
| |
| return { |
| "openai": {"avg_cost_per_token": 0.00003, "tier": "premium"}, |
| "anthropic": {"avg_cost_per_token": 0.000025, "tier": "premium"}, |
| "deepseek": {"avg_cost_per_token": 0.000002, "tier": "budget"}, |
| "moonshot": {"avg_cost_per_token": 0.000003, "tier": "budget"}, |
| } |
|
|
| def get_cheapest_models(self, limit: int = 5) -> List[Dict[str, Any]]: |
| """Get the cheapest models available""" |
| try: |
| fetcher = get_pricing_fetcher() |
| return fetcher.get_cheapest_models(limit=limit) |
| except Exception as e: |
| logger.warning(f"Could not get cheapest models: {e}") |
| return [] |
| async def _get_coordinated_vision_description(self, image_payload: str, tenant_plan: str, is_managed: bool) -> Optional[str]: |
| """ |
| Calls a vision-only model to extract a semantic description of an image. |
| This allows non-vision reasoning models to understand visual context. |
| """ |
| |
| |
| |
| if "google_flash" in self.clients: |
| provider = "google_flash" |
| model = "gemini-2.0-flash" if "gemini-2.0" in str(self.clients["google_flash"]) else "gemini-1.5-flash" |
| |
| elif provider in self.clients: |
| provider = "deepseek" |
| model = "janus-pro-7b" |
| |
| else: |
| provider = "openai" |
| model = "gpt-4o-mini" |
|
|
| try: |
| client = self.clients.get(provider) |
| if not client: return None |
|
|
| logger.info(f"Extracting visual description using {model}...") |
|
|
| messages = [ |
| { |
| "role": "system", |
| "content": "You are a visual analysis specialist. Your goal is to describe a browser screenshot for an AI agent that cannot see it. " |
| "For every interactive element (buttons, links, inputs, icons, etc.), you MUST provide: " |
| "1. A name or label. " |
| "2. A brief description of its function. " |
| "3. Its precise coordinates as [x, y] center points on a normalized grid from 0 to 1000 " |
| "(where [0, 0] is top-left and [1000, 1000] is bottom-right). " |
| "Format elements as a clear list. Also describe the overall layout and active notifications." |
| }, |
| { |
| "role": "user", |
| "content": [ |
| {"type": "text", "text": "Analyze this screenshot and provide a semantic list of interactive elements with [x, y] coordinates on a 1000x1000 grid."}, |
| { |
| "type": "image_url", |
| "image_url": { |
| "url": image_payload if image_payload.startswith("http") else f"data:image/jpeg;base64,{image_payload}" |
| } |
| } |
| ] |
| } |
| ] |
|
|
| response = client.chat.completions.create( |
| model=model, |
| messages=messages, |
| max_tokens=500 |
| ) |
|
|
| desc = response.choices[0].message.content |
| return desc |
| except Exception as e: |
| logger.error(f"Coordinated vision extraction failed: {e}") |
| return None |
|
|
| async def stream_completion( |
| self, |
| messages: List[Dict], |
| model: str, |
| provider_id: str, |
| temperature: float = 0.7, |
| max_tokens: int = 1000, |
| agent_id: Optional[str] = None, |
| db = None |
| ) -> AsyncGenerator[str, None]: |
| """ |
| Stream LLM responses token-by-token with optional governance tracking. |
| |
| Includes automatic provider fallback on failure for improved resilience. |
| |
| Args: |
| messages: Chat messages in OpenAI format |
| model: Model name |
| provider_id: Provider identifier (e.g., "openai", "deepseek") |
| temperature: Sampling temperature |
| max_tokens: Maximum tokens to generate |
| agent_id: Optional agent ID for governance tracking |
| db: Optional database session for governance tracking |
| |
| Yields: |
| Individual tokens as they arrive from the LLM |
| """ |
| if not self.async_clients and not self.clients: |
| raise ValueError("No clients initialized. Streaming unavailable.") |
|
|
| |
| provider_order = self._get_provider_fallback_order(provider_id) |
|
|
| if not provider_order: |
| raise ValueError(f"No available providers for streaming. Requested: {provider_id}") |
|
|
| |
| governance_enabled = os.getenv("STREAMING_GOVERNANCE_ENABLED", "true").lower() == "true" |
| agent_execution = None |
|
|
| last_error = None |
|
|
| |
| for attempt_provider_id in provider_order: |
| |
| client = self.async_clients.get(attempt_provider_id) |
| if not client: |
| client = self.clients.get(attempt_provider_id) |
|
|
| if not client: |
| logger.warning(f"No client available for provider: {attempt_provider_id}") |
| continue |
|
|
| logger.info(f"Attempting stream with provider: {attempt_provider_id} (requested: {provider_id})") |
|
|
| try: |
| import time |
| request_start = time.time() |
| |
| if agent_execution is None and agent_id and governance_enabled and db: |
| agent_execution = AgentExecution( |
| agent_id=agent_id, |
| workspace_id=self.workspace_id, |
| status="running", |
| input_summary=f"LLM stream: {model} ({attempt_provider_id})", |
| triggered_by="llm_stream" |
| ) |
| db.add(agent_execution) |
| db.commit() |
| db.refresh(agent_execution) |
|
|
| logger.debug(f"Created agent execution {agent_execution.id} for LLM stream") |
|
|
| |
| stream = await client.chat.completions.create( |
| model=model, |
| messages=messages, |
| temperature=temperature, |
| max_tokens=max_tokens, |
| stream=True |
| ) |
|
|
| token_count = 0 |
| async for chunk in stream: |
| if chunk.choices: |
| delta = chunk.choices[0].delta |
| if hasattr(delta, 'content') and delta.content: |
| token_count += 1 |
| yield delta.content |
|
|
| |
| if agent_execution and governance_enabled and db: |
| try: |
| agent_execution.status = "completed" |
| agent_execution.output_summary = f"Generated {token_count} tokens via {model} ({attempt_provider_id})" |
| agent_execution.completed_at = datetime.now() |
| db.commit() |
|
|
| |
| from core.agent_governance_service import AgentGovernanceService |
| governance = AgentGovernanceService(db) |
| await governance.record_outcome(agent_id, success=True) |
|
|
| logger.info(f"Completed LLM stream execution {agent_execution.id} via {attempt_provider_id}") |
| except Exception as tracking_error: |
| logger.error(f"Failed to track LLM stream completion: {tracking_error}") |
|
|
| |
| latency_ms = (time.time() - request_start) * 1000 |
| self.health_monitor.record_call(attempt_provider_id, success=True, latency_ms=latency_ms) |
|
|
| |
| return |
|
|
| except Exception as e: |
| last_error = e |
| logger.warning(f"Streaming failed for {attempt_provider_id}/{model}: {e}") |
|
|
| |
| try: |
| latency_ms = (time.time() - request_start) * 1000 |
| self.health_monitor.record_call(attempt_provider_id, success=False, latency_ms=latency_ms) |
| except: |
| pass |
|
|
| |
| if attempt_provider_id != provider_order[-1]: |
| logger.info(f"Falling back to next provider...") |
| continue |
|
|
| |
| break |
|
|
| |
| logger.error(f"All {len(provider_order)} providers failed for {model}. Last error: {last_error}") |
|
|
| if agent_execution and governance_enabled and db: |
| try: |
| agent_execution.status = "failed" |
| agent_execution.error_message = f"All providers failed. Last: {str(last_error)}" |
| agent_execution.completed_at = datetime.now() |
| db.commit() |
|
|
| |
| from core.agent_governance_service import AgentGovernanceService |
| governance = AgentGovernanceService(db) |
| await governance.record_outcome(agent_id, success=False) |
|
|
| except Exception as tracking_error: |
| logger.error(f"Failed to track LLM stream failure: {tracking_error}") |
|
|
| |
| yield f"\n\n[Error: All LLM providers failed. Last error: {str(last_error)}]" |
|
|
| async def generate_embedding( |
| self, |
| text: str, |
| model: str, |
| provider: str = "openai" |
| ) -> List[float]: |
| """ |
| Generate embedding vector for a single text string using managed clients. |
| |
| Args: |
| text: Text to embed |
| model: Model identifier |
| provider: Provider identifier ("openai" or "cohere") |
| |
| Returns: |
| List of floats representing the embedding vector |
| """ |
| client = self.async_clients.get(provider) or self.clients.get(provider) |
| if not client: |
| raise ValueError(f"No client available for provider: {provider}") |
|
|
| logger.info(f"Attempting embedding with provider: {provider} (model: {model})") |
| |
| try: |
| if provider == "openai": |
| response = await client.embeddings.create(model=model, input=text) |
| return response.data[0].embedding |
| elif provider == "cohere": |
| |
| response = await client.embed(texts=[text], model=model, input_type="search_document") |
| return response.embeddings[0] |
| else: |
| raise ValueError(f"Provider {provider} does not support embeddings via BYOKHandler yet.") |
| except Exception as e: |
| logger.error(f"Embedding generation failed for {provider}: {e}") |
| raise |
|
|
| async def generate_embeddings_batch( |
| self, |
| texts: List[str], |
| model: str, |
| provider: str = "openai" |
| ) -> List[List[float]]: |
| """ |
| Generate embeddings for multiple texts in batch using managed clients. |
| """ |
| client = self.async_clients.get(provider) or self.clients.get(provider) |
| if not client: |
| raise ValueError(f"No client available for provider: {provider}") |
|
|
| logger.info(f"Attempting batch embedding with provider: {provider} (model: {model}, count: {len(texts)})") |
| |
| try: |
| if provider == "openai": |
| response = await client.embeddings.create(model=model, input=texts) |
| return [item.embedding for item in response.data] |
| elif provider == "cohere": |
| response = await client.embed(texts=texts, model=model, input_type="search_document") |
| return [emb for emb in response.embeddings] |
| else: |
| raise ValueError(f"Provider {provider} does not support batch embeddings via BYOKHandler yet.") |
| except Exception as e: |
| logger.error(f"Batch embedding generation failed for {provider}: {e}") |
| raise |
|
|
| def classify_cognitive_tier(self, prompt: str, task_type: Optional[str] = None) -> CognitiveTier: |
| """ |
| Classify a query into a cognitive tier using the 5-tier system. |
| |
| Phase 68: Wrapper method for CognitiveClassifier to enable easy cognitive |
| tier classification from BYOKHandler instances. |
| |
| Args: |
| prompt: The query text to classify |
| task_type: Optional task type hint (code, chat, analysis, etc.) |
| |
| Returns: |
| CognitiveTier classification for the query |
| |
| Example: |
| >>> handler = BYOKHandler() |
| >>> tier = handler.classify_cognitive_tier("explain quantum computing") |
| >>> print(tier.value) # 'standard' or 'versatile' |
| """ |
| return self.cognitive_classifier.classify(prompt, task_type) |
|
|
| def _is_trial_restricted(self) -> bool: |
| """ |
| Check if the workspace has trial restrictions. |
| Returns False for now (can be enhanced later). |
| """ |
| try: |
| with get_db_session() as db: |
| workspace = db.query(Workspace).filter(Workspace.id == self.workspace_id).first() |
| if workspace and hasattr(workspace, 'trial_ended') and workspace.trial_ended: |
| return True |
| return False |
| except Exception as e: |
| logger.debug(f"Could not check trial restriction: {e}") |
| return False |
|
|