Sanvi Jain
Implement entity-level PII remediation controls (BLOCK, MASK, REWRITE, IGNORE) for specific PII types in the Streamlit UI and proxy backend
f39e162 | import time | |
| import logging | |
| import threading | |
| import httpx | |
| from typing import List, Dict, Any, Optional, Tuple | |
| import tiktoken | |
| import litellm | |
| from litellm import Router | |
| from litellm.integrations.custom_logger import CustomLogger | |
| from .config import ProxyConfig, ModelEndpointConfig | |
| logger = logging.getLogger("proxy.router") | |
| class LiteLLMProxyRouter: | |
| """ | |
| Core OOP Routing Engine wrapping LiteLLM's Router. | |
| Implements Token Per Request (TPR) checks, load balancing, fallback routing, and token usage optimization. | |
| """ | |
| def __init__(self, config: ProxyConfig): | |
| self.config = config | |
| # Configure litellm global settings | |
| litellm.telemetry = False | |
| litellm.drop_params = True # Safely drop unsupported params per provider | |
| # Convert our configurations to the shape LiteLLM expects | |
| model_list = self.config.to_litellm_model_list() | |
| # Initialize LiteLLM's core Router | |
| logger.info(f"Initializing LiteLLM Router with strategy: {self.config.routing_strategy}") | |
| self.router = Router( | |
| model_list=model_list, | |
| routing_strategy=self.config.routing_strategy, | |
| num_retries=self.config.num_retries, | |
| timeout=self.config.timeout, | |
| fallbacks=self.config.general_fallbacks, | |
| context_window_fallbacks=self.config.context_window_fallbacks | |
| ) | |
| # Local metrics tracking for the microservice | |
| self.metrics_lock = threading.Lock() | |
| self.metrics = { | |
| "total_requests": 0, | |
| "successful_requests": 0, | |
| "failed_requests": 0, | |
| "total_input_tokens": 0, | |
| "total_output_tokens": 0, | |
| "provider_calls": {}, # tracks calls per physical model | |
| "fallback_events": 0, | |
| } | |
| self.usage_history = [] | |
| # Initialize Redis connection for centralized load balancing | |
| try: | |
| import os | |
| import redis | |
| redis_host = os.environ.get("REDIS_HOST", "localhost") | |
| redis_port = int(os.environ.get("REDIS_PORT", 6379)) | |
| self.redis_client = redis.Redis(host=redis_host, port=redis_port, decode_responses=True, socket_timeout=1.0) | |
| self.redis_client.ping() | |
| logger.info(f"Successfully connected to Redis at {redis_host}:{redis_port} for load-balancing usage tracking.") | |
| except Exception as e: | |
| logger.warning(f"Could not connect to Redis: {e}. Falling back to dynamic in-memory Redis simulation.") | |
| self.redis_client = None | |
| self.routing_logs = [] | |
| # Initialize Tiktoken for TPR (Tokens Per Request) estimation | |
| try: | |
| self.tokenizer = tiktoken.get_encoding("cl100k_base") | |
| logger.info("Tiktoken tokenizer initialized successfully.") | |
| except Exception as e: | |
| logger.warning(f"Failed to initialize tiktoken, falling back to character approximation: {e}") | |
| self.tokenizer = None | |
| # Team Bring-Your-Own Guardrails Registry | |
| self.team_guardrails = {} | |
| self.guardrail_instances = {} | |
| # PII Guardrail state parameters | |
| self.pii_enabled = False | |
| self.pii_action = "MASK" | |
| self.pii_policy = None | |
| self.pii_guardrail = None | |
| # Priority Preference Routing State Parameters | |
| self.preference_enabled = False | |
| self.preference_list = [] | |
| self.credit_limits = { | |
| "groq/llama-3.1-8b-instant": 0.05, | |
| "cerebras/llama3.1-8b": 0.05, | |
| "groq/llama-3.3-70b-versatile": 0.05, | |
| "together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo": 0.05, | |
| "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo": 0.05, | |
| "ollama/llama3.1": 0.05 | |
| } | |
| self.accumulated_spend = { | |
| "groq/llama-3.1-8b-instant": 0.0, | |
| "cerebras/llama3.1-8b": 0.0, | |
| "groq/llama-3.3-70b-versatile": 0.0, | |
| "together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo": 0.0, | |
| "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo": 0.0, | |
| "ollama/llama3.1": 0.0 | |
| } | |
| self.log_event("Class-based LiteLLM Proxy online. Load balancer initialized.", "routing") | |
| def log_event(self, message: str, type: str = "routing"): | |
| """Logs an event and pushes it to a thread-safe rolling buffer for the telemetry UI.""" | |
| logger.info(message) | |
| with self.metrics_lock: | |
| self.routing_logs.append({ | |
| "timestamp": time.strftime("%H:%M:%S"), | |
| "message": message, | |
| "type": type | |
| }) | |
| if len(self.routing_logs) > 50: | |
| self.routing_logs.pop(0) | |
| def _get_pii_guardrail(self): | |
| if self.pii_guardrail is None: | |
| from guardrails.deberta_pii_guardrail import DeBERTaPIIGuardrail | |
| self.pii_guardrail = DeBERTaPIIGuardrail() | |
| return self.pii_guardrail | |
| async def _sanitize_response(self, response: Dict[str, Any], guardrailed_query: Optional[str] = None) -> Dict[str, Any]: | |
| if guardrailed_query is not None: | |
| if not isinstance(response, dict): | |
| try: | |
| response["guardrailed_query"] = guardrailed_query | |
| except TypeError: | |
| if hasattr(response, "dict"): | |
| response = response.dict() | |
| elif hasattr(response, "model_dump"): | |
| response = response.model_dump() | |
| else: | |
| response = dict(response) | |
| response["guardrailed_query"] = guardrailed_query | |
| else: | |
| response["guardrailed_query"] = guardrailed_query | |
| if not self.pii_enabled: | |
| return response | |
| try: | |
| choices = response.get("choices") | |
| if not choices: | |
| return response | |
| content = choices[0].get("message", {}).get("content", "") | |
| if not content or not isinstance(content, str): | |
| return response | |
| guardrail = self._get_pii_guardrail() | |
| from guardrails.deberta_pii_guardrail import _Config, SERVER_DEFAULT_POLICY, SERVER_OLLAMA_MODEL | |
| policy = self.pii_policy if self.pii_policy is not None else {k: self.pii_action for k in SERVER_DEFAULT_POLICY.keys()} | |
| cfg = _Config( | |
| policy=policy, | |
| default_action=self.pii_action, | |
| threshold=0.4, | |
| rewrite_model=SERVER_OLLAMA_MODEL, | |
| apply_to="both", | |
| labels=list(policy.keys()) | |
| ) | |
| cleaned, was_modified, block_reason = await guardrail.process(content, cfg) | |
| if block_reason: | |
| choices[0]["message"]["content"] = "[Response suppressed: model output contained blocked PII]" | |
| self.log_event("[PII Guardrail] Output blocked due to PII violation.", "warning") | |
| elif was_modified: | |
| choices[0]["message"]["content"] = cleaned | |
| self.log_event("[PII Guardrail] Post-call sanitized response output.", "warning") | |
| except Exception as e: | |
| logger.warning(f"Error sanitizing output: {e}") | |
| return response | |
| def _prune_usage_history(self, now: float): | |
| """Removes usage records older than 60 seconds.""" | |
| cutoff = now - 60.0 | |
| self.usage_history = [r for r in self.usage_history if r["timestamp"] > cutoff] | |
| def track_request_usage(self, physical_model: str, tokens: int): | |
| """ | |
| Logs a completed request, tracking input/output tokens and timestamp in Redis or local memory. | |
| """ | |
| now = time.time() | |
| if self.redis_client: | |
| try: | |
| # Store dynamic rate indicators inside Redis sorted sets | |
| key_tpm = f"litellm:tpm:{physical_model}" | |
| key_rpm = f"litellm:rpm:{physical_model}" | |
| # ZADD payload using sliding score window matching the timestamp | |
| self.redis_client.zadd(key_tpm, {f"{now}:{tokens}": now}) | |
| self.redis_client.zadd(key_rpm, {f"{now}": now}) | |
| # Expire raw telemetry indexes after 65 seconds | |
| self.redis_client.expire(key_tpm, 65) | |
| self.redis_client.expire(key_rpm, 65) | |
| return | |
| except Exception as e: | |
| logger.error(f"[Redis Telemetry Error] Track request failure: {e}") | |
| # In-memory fallback | |
| with self.metrics_lock: | |
| self.usage_history.append({ | |
| "timestamp": now, | |
| "model": physical_model, | |
| "tokens": tokens | |
| }) | |
| def get_endpoint_usage(self, physical_model: str) -> Tuple[int, int]: | |
| """Returns the current (TPM, RPM) usage in the last 60 seconds for the given physical model.""" | |
| now = time.time() | |
| cutoff = now - 60.0 | |
| if self.redis_client: | |
| try: | |
| key_tpm = f"litellm:tpm:{physical_model}" | |
| key_rpm = f"litellm:rpm:{physical_model}" | |
| # Remove timestamps older than 60 seconds to prune sliding window | |
| self.redis_client.zremrangebyscore(key_tpm, 0, cutoff) | |
| self.redis_client.zremrangebyscore(key_rpm, 0, cutoff) | |
| # Compute TPM | |
| tpm = 0 | |
| members = self.redis_client.zrange(key_tpm, 0, -1) | |
| for member in members: | |
| if ":" in member: | |
| try: | |
| tpm += int(member.split(":")[1]) | |
| except ValueError: | |
| pass | |
| # Compute RPM | |
| rpm = self.redis_client.zcard(key_rpm) | |
| return tpm, rpm | |
| except Exception as e: | |
| logger.error(f"[Redis Telemetry Error] Get endpoint usage failure: {e}") | |
| # In-memory fallback | |
| with self.metrics_lock: | |
| self._prune_usage_history(now) | |
| tpm = 0 | |
| rpm = 0 | |
| for r in self.usage_history: | |
| if r["model"] == physical_model: | |
| tpm += r["tokens"] | |
| rpm += 1 | |
| return tpm, rpm | |
| def get_fallbacks_for_model(self, virtual_model: str) -> List[str]: | |
| """Resolves target fallback clusters from the configuration.""" | |
| fallbacks = [] | |
| for fb_dict in self.config.general_fallbacks: | |
| if isinstance(fb_dict, dict) and virtual_model in fb_dict: | |
| fallbacks.extend(fb_dict[virtual_model]) | |
| return fallbacks | |
| def estimate_tokens(self, text: str) -> int: | |
| """Estimates token count of a given string using tiktoken or robust approximation.""" | |
| if not text: | |
| return 0 | |
| if self.tokenizer: | |
| try: | |
| return len(self.tokenizer.encode(text)) | |
| except Exception: | |
| pass | |
| # Fallback: Llama/Mistral models average ~4 characters per token | |
| return max(1, len(text) // 4) | |
| def estimate_request_tokens(self, messages: List[Dict[str, str]]) -> int: | |
| """Estimates the total token count of incoming chat messages.""" | |
| total = 0 | |
| for m in messages: | |
| content = m.get("content", "") | |
| role = m.get("role", "") | |
| total += self.estimate_tokens(content) + self.estimate_tokens(role) + 4 | |
| return total + 2 # overhead | |
| def classify_prompt_complexity(self, messages: List[Dict[str, str]], required_context: int) -> str: | |
| """ | |
| Classifies prompt complexity into 'low', 'medium', or 'high' based on: | |
| 1. Context window requirement (required_context) | |
| 2. Semantic features (presence of reasoning/coding keywords, structural code, etc.) | |
| """ | |
| # Feature 1: Context requirement | |
| if required_context > 8192: | |
| return "high" | |
| # Feature 2: Semantic check on the latest user message | |
| user_msgs = [m.get("content", "") for m in messages if m.get("role") == "user"] | |
| latest_user_msg = user_msgs[-1] if user_msgs else "" | |
| # Keywords suggesting high complexity (reasoning, coding, architecture, deep math) | |
| high_complexity_keywords = [ | |
| "code", "python", "javascript", "c++", "rust", "java", "html", "css", "sql", "git", | |
| "algorithm", "function", "refactor", "debug", "optimize", "regex", "database", | |
| "proof", "theorem", "math", "calculus", "derive", "solve", "equation", | |
| "analyze", "evaluate", "architecture", "design pattern", "system design", | |
| "compare and contrast", "step by step", "reasoning", "logical deduction" | |
| ] | |
| # Keywords suggesting medium complexity (formatting, summarizing, translations, drafting) | |
| medium_complexity_keywords = [ | |
| "summar", "summary", "report", "translation", "translate", "synopsis", "outline", "draft", | |
| "rewrite", "rephrase", "format", "extract", "list", "bullet points", "email", | |
| "explain", "what is", "how does" | |
| ] | |
| msg_lower = latest_user_msg.lower() | |
| # Count high-complexity indicators (keywords or code-like patterns) | |
| high_count = sum(1 for kw in high_complexity_keywords if kw in msg_lower) | |
| # Check for code blocks (```) or braces/indentation suggesting code | |
| if "```" in msg_lower or (msg_lower.count("{") > 2 and msg_lower.count("}") > 2) or "def " in msg_lower or "import " in msg_lower: | |
| high_count += 3 | |
| medium_count = sum(1 for kw in medium_complexity_keywords if kw in msg_lower) | |
| if high_count >= 2 or (high_count >= 1 and required_context > 2048): | |
| return "high" | |
| elif medium_count >= 1 or required_context > 1024 or len(latest_user_msg) > 500: | |
| return "medium" | |
| else: | |
| return "low" | |
| async def execute_chat_completion( | |
| self, | |
| model: str, | |
| messages: List[Dict[str, str]], | |
| **kwargs | |
| ) -> Dict[str, Any]: | |
| """ | |
| Executes a chat completion request, applying local PII Guardrail shielding, | |
| TPM/TPR evaluation limits, and automatic multi-cluster routing. | |
| """ | |
| with self.metrics_lock: | |
| self.metrics["total_requests"] += 1 | |
| if self.pii_enabled: | |
| guardrail = self._get_pii_guardrail() | |
| from guardrails.deberta_pii_guardrail import _Config, SERVER_DEFAULT_POLICY, SERVER_OLLAMA_MODEL | |
| policy = self.pii_policy if self.pii_policy is not None else {k: self.pii_action for k in SERVER_DEFAULT_POLICY.keys()} | |
| cfg = _Config( | |
| policy=policy, | |
| default_action=self.pii_action, | |
| threshold=0.4, | |
| rewrite_model=SERVER_OLLAMA_MODEL, | |
| apply_to="both", | |
| labels=list(policy.keys()) | |
| ) | |
| import copy | |
| sanitized_messages = copy.deepcopy(messages) | |
| for msg in sanitized_messages: | |
| content = msg.get("content") | |
| if isinstance(content, str): | |
| cleaned, was_modified, block_reason = await guardrail.process(content, cfg) | |
| if block_reason: | |
| raise ValueError(block_reason) | |
| if was_modified: | |
| msg["content"] = cleaned | |
| self.log_event(f"[PII Guardrail] Pre-call sanitized input role={msg.get('role')}.", "warning") | |
| else: | |
| sanitized_messages = messages | |
| # Extract the post-guardrail user query | |
| user_msgs = [m for m in sanitized_messages if m.get("role") == "user"] | |
| guardrailed_query = user_msgs[-1]["content"] if user_msgs else None | |
| estimated_prompt_tokens = self.estimate_request_tokens(sanitized_messages) | |
| max_tokens = kwargs.get("max_tokens", 1000) | |
| required_context = estimated_prompt_tokens + max_tokens | |
| # Classify complexity | |
| complexity = self.classify_prompt_complexity(sanitized_messages, required_context) | |
| self.log_event( | |
| f"[Analysis] New request on '{model}'. Size: {estimated_prompt_tokens} prompt + {max_tokens} response = {required_context} required TPR. " | |
| f"Prompt Complexity classified as: '{complexity.upper()}'.", | |
| "routing" | |
| ) | |
| # 2. Select target endpoints and apply proactive TPR & TPM/RPM load-balancing/routing | |
| selected_cluster = model | |
| selected_endpoint = None | |
| is_fallback_triggered = False | |
| if self.preference_enabled and self.preference_list: | |
| for p_model in self.preference_list: | |
| # Find matching endpoint in config | |
| ep = next((e for e in self.config.endpoints if e.model == p_model), None) | |
| if not ep: | |
| continue | |
| # Check credit limit | |
| current_spend = self.accumulated_spend.get(p_model, 0.0) | |
| limit = self.credit_limits.get(p_model, 9999.0) | |
| if current_spend >= limit: | |
| self.log_event(f"[Preference Route] Model '{p_model}' skipped: credit limit reached (spent ${current_spend:.6f} >= limit ${limit:.6f}).", "warning") | |
| continue | |
| # Check TPR | |
| if required_context > ep.tpr: | |
| self.log_event(f"[Preference Route] Model '{p_model}' skipped: required context {required_context} exceeds limit {ep.tpr}.", "warning") | |
| continue | |
| # Check TPM / RPM | |
| current_tpm, current_rpm = self.get_endpoint_usage(ep.model) | |
| if current_tpm + required_context > ep.tpm: | |
| self.log_event(f"[Preference Route] Model '{p_model}' skipped: current TPM {current_tpm} + required {required_context} exceeds limit {ep.tpm}.", "warning") | |
| continue | |
| if current_rpm + 1 > ep.rpm: | |
| self.log_event(f"[Preference Route] Model '{p_model}' skipped: current RPM {current_rpm} + 1 exceeds limit {ep.rpm}.", "warning") | |
| continue | |
| selected_endpoint = ep | |
| selected_cluster = ep.model_name | |
| self.log_event(f"[Preference Selection] Node '{p_model}' selected as highest priority active preferred LLM (spent: ${current_spend:.6f}/${limit:.6f}).", "success") | |
| break | |
| if not selected_endpoint: | |
| self.log_event("[Preference Route Exhaustion] All preferred models exceeded credit limits or TPR constraints! Falling back to the first preference to prevent hard outage.", "error") | |
| p_model = self.preference_list[0] | |
| ep = next((e for e in self.config.endpoints if e.model == p_model), None) | |
| if ep: | |
| selected_endpoint = ep | |
| selected_cluster = ep.model_name | |
| if not selected_endpoint: | |
| search_clusters = [model] | |
| search_clusters.extend(self.get_fallbacks_for_model(model)) | |
| for cluster in search_clusters: | |
| endpoints = self.config.get_endpoints_for_model(cluster) | |
| if not endpoints: | |
| continue | |
| suitable_endpoints = [] | |
| for ep in endpoints: | |
| # Check TPR | |
| if required_context > ep.tpr: | |
| self.log_event(f"[TPR Limit] Node '{ep.model}' rejected: required context {required_context} exceeds limit {ep.tpr}.", "warning") | |
| continue | |
| # Check TPM & RPM | |
| current_tpm, current_rpm = self.get_endpoint_usage(ep.model) | |
| if current_tpm + required_context > ep.tpm: | |
| self.log_event(f"[TPM Limit] Node '{ep.model}' rejected: current TPM {current_tpm} + required {required_context} exceeds limit {ep.tpm}.", "warning") | |
| continue | |
| if current_rpm + 1 > ep.rpm: | |
| self.log_event(f"[RPM Limit] Node '{ep.model}' rejected: current RPM {current_rpm} + 1 exceeds limit {ep.rpm}.", "warning") | |
| continue | |
| suitable_endpoints.append((ep, current_tpm, current_rpm)) | |
| if suitable_endpoints: | |
| # Complexity-Aware and Cost-Aware Multi-Objective Load Balancing: | |
| # 1. Primary Objective: Minimize tier mismatch penalty (align with classified complexity) | |
| # 2. Secondary Objective: Minimize cost_per_million (least credit usage) | |
| # 3. Tertiary Objective: Minimize utilization (balanced resource usage) | |
| def get_suitability_score(item): | |
| ep, t_used, r_used = item | |
| tier_map = {"low": 1, "medium": 2, "high": 3} | |
| p_tier = tier_map.get(complexity, 2) | |
| ep_tier = tier_map.get(ep.complexity_tier, 2) | |
| tier_mismatch = abs(p_tier - ep_tier) | |
| # Strong penalty if high complexity prompt is sent to a low reasoning node | |
| if complexity == "high" and ep.complexity_tier == "low": | |
| tier_mismatch += 5.0 | |
| # Strong penalty if low complexity prompt is sent to an expensive high-tier node | |
| if complexity == "low" and ep.complexity_tier == "high": | |
| tier_mismatch += 5.0 | |
| util = max(t_used / ep.tpm, r_used / ep.rpm) | |
| return (tier_mismatch, ep.cost_per_million, util) | |
| selected_endpoint, t_used, r_used = min(suitable_endpoints, key=get_suitability_score) | |
| selected_cluster = cluster | |
| if cluster != model: | |
| is_fallback_triggered = True | |
| with self.metrics_lock: | |
| self.metrics["fallback_events"] += 1 | |
| self.log_event(f"[Fallback Route] Overload/limit on '{model}'. Cascading to cluster '{cluster}'.", "warning") | |
| self.log_event( | |
| f"[Complexity-Aware Selection] Node '{selected_endpoint.model}' selected in cluster '{cluster}' " | |
| f"(Complexity Tier: {selected_endpoint.complexity_tier.upper()}, Cost: ${selected_endpoint.cost_per_million}/M tokens, TPM usage: {t_used}/{selected_endpoint.tpm}).", | |
| "success" | |
| ) | |
| break | |
| # Relax TPR constraint if prompt is extremely large and exceeds all limits | |
| if not selected_endpoint: | |
| all_eps = [] | |
| for cluster in search_clusters: | |
| all_eps.extend(self.config.get_endpoints_for_model(cluster)) | |
| if all_eps: | |
| max_tpr = max(e.tpr for e in all_eps) | |
| best_eps = [e for e in all_eps if e.tpr == max_tpr] | |
| backup_eps = [e for e in best_eps if e.model_name == "backup-cluster"] | |
| selected_endpoint = backup_eps[0] if backup_eps else best_eps[0] | |
| selected_cluster = selected_endpoint.model_name | |
| if selected_cluster != model: | |
| is_fallback_triggered = True | |
| with self.metrics_lock: | |
| self.metrics["fallback_events"] += 1 | |
| self.log_event(f"[TPR Overlimit] Required {required_context} exceeds all limits. Escalating to highest capacity node: '{selected_endpoint.model}'.", "warning") | |
| else: | |
| raise ValueError(f"No active endpoints configured for model routing group '{model}'.") | |
| # Determine if we execute in Mock Sandbox Mode | |
| is_mock = ( | |
| kwargs.get("mock_sandbox", False) or | |
| (selected_endpoint and selected_endpoint.api_key and "mock" in selected_endpoint.api_key.lower()) | |
| ) | |
| if is_mock: | |
| response = self._execute_mock_sandbox_completion( | |
| virtual_model=selected_cluster, | |
| endpoint=selected_endpoint, | |
| estimated_prompt_tokens=estimated_prompt_tokens, | |
| max_tokens=max_tokens, | |
| messages=sanitized_messages | |
| ) | |
| response["prompt_complexity"] = complexity | |
| response = await self._sanitize_response(response, guardrailed_query) | |
| return response | |
| # Real API Execution via LiteLLM Router - directly let LITELLM route the model! | |
| try: | |
| self.log_event(f"[API Dispatch] Sending request to backend '{selected_endpoint.model}' in cluster '{selected_cluster}'...", "routing") | |
| start_time = time.time() | |
| # Clean kwargs | |
| litellm_kwargs = kwargs.copy() | |
| litmm_kwargs_to_pop = ["mock_sandbox", "fallbacks"] | |
| for k in litmm_kwargs_to_pop: | |
| litellm_kwargs.pop(k, None) | |
| # We pass the selected target cluster to the LiteLLM Router so it uses its config | |
| # and automatically selects the active backend under that cluster! | |
| response = self.router.completion( | |
| model=selected_cluster, | |
| messages=sanitized_messages, | |
| **litellm_kwargs | |
| ) | |
| latency = time.time() - start_time | |
| actual_routed_model = response.get("model", selected_endpoint.model) | |
| self.log_event(f"[Success] API call completed by '{actual_routed_model}' in {latency:.2f}s.", "success") | |
| # Update metrics and usage history | |
| usage = response.get("usage", {}) | |
| input_tokens = usage.get("prompt_tokens", estimated_prompt_tokens) | |
| output_tokens = usage.get("completion_tokens", 0) | |
| total_tokens = input_tokens + output_tokens | |
| self.track_request_usage(actual_routed_model, total_tokens) | |
| self._update_success_metrics(actual_routed_model, input_tokens, output_tokens) | |
| try: | |
| response["prompt_complexity"] = complexity | |
| except Exception: | |
| pass | |
| response = await self._sanitize_response(response, guardrailed_query) | |
| return response | |
| except Exception as e: | |
| self.log_event(f"[API Error] Backend '{selected_endpoint.model}' failed: {e}. Trying fallback execution chain...", "error") | |
| # Try immediate physical model failover! | |
| # 1. First try other endpoints inside the CURRENT active cluster (Intra-Cluster Failover) | |
| current_cluster_eps = self.config.get_endpoints_for_model(selected_cluster) | |
| for alt_ep in current_cluster_eps: | |
| if alt_ep.model == selected_endpoint.model: | |
| continue | |
| try: | |
| self.log_event(f"[Intra-Cluster Failover] Cascading to alternate backend '{alt_ep.model}' in current cluster '{selected_cluster}'...", "warning") | |
| start_time = time.time() | |
| response = self.router.completion( | |
| model=selected_cluster, | |
| messages=sanitized_messages, | |
| **litellm_kwargs | |
| ) | |
| latency = time.time() - start_time | |
| actual_routed_model = response.get("model", alt_ep.model) | |
| self.log_event(f"[Intra-Cluster Success] Succeeded on '{actual_routed_model}' in {latency:.2f}s.", "success") | |
| usage = response.get("usage", {}) | |
| input_tokens = usage.get("prompt_tokens", estimated_prompt_tokens) | |
| output_tokens = usage.get("completion_tokens", 0) | |
| total_tokens = input_tokens + output_tokens | |
| self.track_request_usage(actual_routed_model, total_tokens) | |
| with self.metrics_lock: | |
| self.metrics["fallback_events"] += 1 | |
| self._update_success_metrics(actual_routed_model, input_tokens, output_tokens) | |
| try: | |
| response["prompt_complexity"] = complexity | |
| except Exception: | |
| pass | |
| response = await self._sanitize_response(response, guardrailed_query) | |
| return response | |
| except Exception as intra_ex: | |
| self.log_event(f"[Intra-Cluster Failover Error] Alternate backend '{alt_ep.model}' also failed: {intra_ex}.", "error") | |
| # 2. Loop through other clusters | |
| for fallback_cluster in search_clusters: | |
| if fallback_cluster == selected_cluster: | |
| continue | |
| fallback_eps = self.config.get_endpoints_for_model(fallback_cluster) | |
| if not fallback_eps: | |
| continue | |
| alt_ep = fallback_eps[0] | |
| try: | |
| self.log_event(f"[Failover API Dispatch] Cascading to alternate backend '{alt_ep.model}' in '{fallback_cluster}'...", "warning") | |
| start_time = time.time() | |
| response = self.router.completion( | |
| model=fallback_cluster, | |
| messages=sanitized_messages, | |
| **litellm_kwargs | |
| ) | |
| latency = time.time() - start_time | |
| actual_routed_model = response.get("model", alt_ep.model) | |
| self.log_event(f"[Failover Success] Alternate backend '{actual_routed_model}' succeeded in {latency:.2f}s.", "success") | |
| usage = response.get("usage", {}) | |
| input_tokens = usage.get("prompt_tokens", estimated_prompt_tokens) | |
| output_tokens = usage.get("completion_tokens", 0) | |
| total_tokens = input_tokens + output_tokens | |
| self.track_request_usage(actual_routed_model, total_tokens) | |
| with self.metrics_lock: | |
| self.metrics["fallback_events"] += 1 | |
| self._update_success_metrics(actual_routed_model, input_tokens, output_tokens) | |
| try: | |
| response["prompt_complexity"] = complexity | |
| except Exception: | |
| pass | |
| response = await self._sanitize_response(response, guardrailed_query) | |
| return response | |
| except Exception as ex: | |
| self.log_event(f"[Failover API Error] Alternate backend '{alt_ep.model}' also failed: {ex}.", "error") | |
| with self.metrics_lock: | |
| self.metrics["failed_requests"] += 1 | |
| raise RuntimeError(f"All backends in the routing chain failed. Last error: {e}") | |
| def _update_success_metrics(self, model: str, input_tokens: int, output_tokens: int): | |
| """Updates metrics safely across threads.""" | |
| cost = 0.0 | |
| total_spent = 0.0 | |
| with self.metrics_lock: | |
| self.metrics["successful_requests"] += 1 | |
| self.metrics["total_input_tokens"] += input_tokens | |
| self.metrics["total_output_tokens"] += output_tokens | |
| self.metrics["provider_calls"][model] = self.metrics["provider_calls"].get(model, 0) + 1 | |
| # Find the cost per million for this physical model | |
| cost_per_mil = 0.1 | |
| for ep in self.config.endpoints: | |
| if ep.model == model: | |
| cost_per_mil = ep.cost_per_million | |
| break | |
| total_tokens = input_tokens + output_tokens | |
| cost = (total_tokens * cost_per_mil) / 1_000_000.0 | |
| # Update accumulated spend | |
| self.accumulated_spend[model] = self.accumulated_spend.get(model, 0.0) + cost | |
| total_spent = self.accumulated_spend[model] | |
| self.log_event(f"[Cost Auditing] Spend on '{model}' increased by ${cost:.6f}. Total spent: ${total_spent:.6f}.", "success") | |
| def _execute_mock_sandbox_completion( | |
| self, | |
| virtual_model: str, | |
| endpoint: ModelEndpointConfig, | |
| estimated_prompt_tokens: int, | |
| max_tokens: int, | |
| messages: List[Dict[str, str]] | |
| ) -> Dict[str, Any]: | |
| """Simulates a model completion response instantly for local development and validation.""" | |
| logger.info(f"[MOCK SANDBOX] Simulating completion for '{endpoint.model}'") | |
| time.sleep(0.1) # Simulate minimal network latency | |
| last_message = messages[-1].get("content", "") if messages else "Hello" | |
| mock_reply = ( | |
| f"[LiteLLM Proxy Mock - {endpoint.model}]\n" | |
| f"Routing Group: {virtual_model}\n" | |
| f"Optimized TPR Context: {endpoint.tpr} max tokens.\n" | |
| f"Acknowledged request: '{last_message[:200]}...'" | |
| ) | |
| output_tokens = self.estimate_tokens(mock_reply) | |
| total_tokens = estimated_prompt_tokens + output_tokens | |
| self.track_request_usage(endpoint.model, total_tokens) | |
| self._update_success_metrics(endpoint.model, estimated_prompt_tokens, output_tokens) | |
| self.log_event(f"[Mock Success] Simulated reply from '{endpoint.model}' in cluster '{virtual_model}' (Tokens: {total_tokens}).", "success") | |
| # Structure the dict like an OpenAI / LiteLLM chat.completion response | |
| return { | |
| "id": f"chatcmpl-mock-{int(time.time())}", | |
| "object": "chat.completion", | |
| "created": int(time.time()), | |
| "model": endpoint.model, | |
| "choices": [ | |
| { | |
| "index": 0, | |
| "message": { | |
| "role": "assistant", | |
| "content": mock_reply | |
| }, | |
| "finish_reason": "stop" | |
| } | |
| ], | |
| "usage": { | |
| "prompt_tokens": estimated_prompt_tokens, | |
| "completion_tokens": output_tokens, | |
| "total_tokens": estimated_prompt_tokens + output_tokens | |
| } | |
| } | |
| def get_metrics(self) -> Dict[str, Any]: | |
| """Thread-safe getter for router metrics.""" | |
| with self.metrics_lock: | |
| m = self.metrics.copy() | |
| m["logs"] = list(self.routing_logs) | |
| return m | |