| 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 |
| |
| |
| 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 = [] |
| |
| |
| 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.") |
|
|
| |
| 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.") |
|
|
| |
| 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 = [] |
| |
| |
| if not image_base64.startswith("data:"): |
| image_data_uri = f"data:image/jpeg;base64,{image_base64}" |
| else: |
| image_data_uri = image_base64 |
|
|
| |
| 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.") |
|
|
| |
| 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.") |
|
|
| |
| 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)}"} |
|
|