import asyncio import base64 import httpx import structlog logger = structlog.get_logger() class FallbackRouter: """ Asynchronous fallback routing engine for Sentinel. Provides backup API inference paths (OpenRouter, OpenBMB, OpenAI) when local edge/GPU Modal execution fails or times out. """ def __init__(self, openrouter_key: str, openbmb_key: str, openai_key: str): """ Initializes the FallbackRouter with required keys. """ self.openrouter_key = openrouter_key self.openbmb_key = openbmb_key self.openai_key = openai_key # Initialize an AsyncClient with a 30s timeout self.client = httpx.AsyncClient(timeout=30.0) async def close(self): """ Closes the HTTP client session. """ await self.client.aclose() async def _handle_http_error(self, provider_name: str, error: Exception) -> bool: """ Helper method to handle exceptions. If a rate limit (HTTP 429) is encountered, waits 2 seconds. Returns True if a retry/fallback should continue, False otherwise. """ if isinstance(error, httpx.HTTPStatusError): status_code = error.response.status_code logger.warn( "HTTP Error during fallback call", provider=provider_name, status_code=status_code, error=str(error) ) if status_code == 429: logger.info("Rate limit (429) hit. Backing off for 2.0s...", provider=provider_name) await asyncio.sleep(2.0) elif isinstance(error, httpx.TimeoutException): logger.warn("Request timed out during fallback call", provider=provider_name, error=str(error)) else: logger.error("Unexpected error during fallback call", provider=provider_name, error=str(error)) return True async def fallback_reason(self, prompt: str, system_prompt: str = "") -> dict: """ Executes text reasoning using fallback models. Primary: OpenRouter (nvidia/nemotron-3-nano-30b-a3b:free) — 30B MoE, free tier Secondary: OpenAI (gpt-4o-mini) """ fallback_chain = [] # 1. Try OpenRouter (Nemotron-3-Nano-30B-A3B MoE, free tier) if self.openrouter_key: fallback_chain.append("openrouter") try: logger.info("Attempting reasoning fallback via OpenRouter (Nemotron-30B-A3B)...") headers = { "Authorization": f"Bearer {self.openrouter_key}", "Content-Type": "application/json", "HTTP-Referer": "https://huggingface.co/spaces", "X-Title": "Sentinel Safety Guardian" } payload = { "model": "nvidia/nemotron-3-nano-30b-a3b:free", "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt} ], "max_tokens": 512, "temperature": 0.4 } response = await self.client.post( "https://openrouter.ai/api/v1/chat/completions", headers=headers, json=payload ) response.raise_for_status() result = response.json() text = result["choices"][0]["message"]["content"] tokens = result.get("usage", {}).get("total_tokens", 0) logger.info("OpenRouter reasoning fallback successful", model="nemotron-3-nano-30b-a3b:free") return { "text": text, "tokens": tokens, "model": "openrouter:nemotron-3-nano-30b-a3b:free", "fallback_chain": fallback_chain } except Exception as e: await self._handle_http_error("openrouter", e) else: logger.warn("OpenRouter API key missing. Skipping to next fallback.") # 2. Try OpenAI (gpt-4o-mini) if self.openai_key: fallback_chain.append("openai") try: logger.info("Attempting reasoning fallback via OpenAI (gpt-4o-mini)...") headers = { "Authorization": f"Bearer {self.openai_key}", "Content-Type": "application/json" } payload = { "model": "gpt-4o-mini", "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt} ] } response = await self.client.post( "https://api.openai.com/v1/chat/completions", headers=headers, json=payload ) response.raise_for_status() result = response.json() text = result["choices"][0]["message"]["content"] tokens = result.get("usage", {}).get("total_tokens", 0) logger.info("OpenAI reasoning fallback successful") return { "text": text, "tokens": tokens, "model": "openai:gpt-4o-mini", "fallback_chain": fallback_chain } except Exception as e: await self._handle_http_error("openai", e) else: logger.warn("OpenAI API key missing. Cannot execute backup reasoning fallback.") # If all providers fail logger.error("All reasoning fallbacks failed.") return { "error": "all_providers_failed", "text": "I'm having trouble thinking right now, please try again in a moment.", "fallback_chain": fallback_chain } async def fallback_see(self, image_base64: str, question: str) -> dict: """ Executes vision-language inference using fallback models. Primary: OpenBMB API (MiniCPM-V-4.6-Instruct) Secondary: OpenAI (gpt-4o-mini) """ fallback_chain = [] # Ensure correct prefix for base64 image data URI if not image_base64.startswith("data:"): image_data_uri = f"data:image/jpeg;base64,{image_base64}" else: image_data_uri = image_base64 # 1. Try OpenBMB (ModelBest API) if self.openbmb_key: fallback_chain.append("openbmb") try: logger.info("Attempting vision fallback via OpenBMB (ModelBest)...") headers = { "Authorization": f"Bearer {self.openbmb_key}", "Content-Type": "application/json" } payload = { "model": "MiniCPM-V-4.6-Instruct", "messages": [ { "role": "user", "content": [ {"type": "image_url", "image_url": {"url": image_data_uri}}, {"type": "text", "text": question} ] } ] } response = await self.client.post( "https://api.modelbest.cn/v1/chat/completions", headers=headers, json=payload ) response.raise_for_status() result = response.json() text = result["choices"][0]["message"]["content"] tokens = result.get("usage", {}).get("total_tokens", 0) logger.info("OpenBMB vision fallback successful") return { "text": text, "tokens": tokens, "model": "openbmb:minicpm-v-4.6", "fallback_chain": fallback_chain } except Exception as e: await self._handle_http_error("openbmb", e) else: logger.warn("OpenBMB API key missing. Skipping to next fallback.") # 2. Try OpenAI (gpt-4o-mini vision) if self.openai_key: fallback_chain.append("openai") try: logger.info("Attempting vision fallback via OpenAI (gpt-4o-mini)...") headers = { "Authorization": f"Bearer {self.openai_key}", "Content-Type": "application/json" } payload = { "model": "gpt-4o-mini", "messages": [ { "role": "user", "content": [ {"type": "text", "text": question}, {"type": "image_url", "image_url": {"url": image_data_uri}} ] } ] } response = await self.client.post( "https://api.openai.com/v1/chat/completions", headers=headers, json=payload ) response.raise_for_status() result = response.json() text = result["choices"][0]["message"]["content"] tokens = result.get("usage", {}).get("total_tokens", 0) logger.info("OpenAI vision fallback successful") return { "text": text, "tokens": tokens, "model": "openai:gpt-4o-mini-vision", "fallback_chain": fallback_chain } except Exception as e: await self._handle_http_error("openai", e) else: logger.warn("OpenAI API key missing. Cannot execute backup vision fallback.") # If all providers fail logger.error("All vision fallbacks failed.") return { "error": "all_providers_failed", "text": "I'm having trouble seeing right now, please check my connection and try again.", "fallback_chain": fallback_chain } async def fallback_tts(self, text: str) -> dict: """ Synthesizes warning announcement audio using OpenAI TTS API. Model: tts-1, Voice: alloy """ if not self.openai_key: logger.error("Cannot perform TTS fallback: OpenAI API key is missing.") return {"error": "openai_key_missing", "text": "OpenAI API key missing."} try: logger.info("Attempting TTS fallback via OpenAI...") headers = { "Authorization": f"Bearer {self.openai_key}", "Content-Type": "application/json" } payload = { "model": "tts-1", "input": text, "voice": "alloy" } response = await self.client.post( "https://api.openai.com/v1/audio/speech", headers=headers, json=payload ) response.raise_for_status() audio_bytes = response.content audio_base64 = base64.b64encode(audio_bytes).decode('utf-8') logger.info("OpenAI TTS fallback successful") return { "audio_base64": audio_base64, "model": "openai-tts-1" } except Exception as e: logger.error("OpenAI TTS fallback failed", error=str(e)) return {"error": f"TTS fallback failed: {str(e)}"}