"""T27 LLM Router - sovereign-first LiteLLM proxy for catalog AI. Per v4.0 §T28 (News analysis), §T29 (Report generation). Self-hosted LiteLLM proxy at litellm.rugmunch.io routes to: - DeepSeek-V3 (cost-effective analysis) - Qwen (summaries) - Local Llama-3 (free-tier fallback) We never call OpenAI directly. If LiteLLM is unreachable, the catalog operations that need LLM analysis return None/empty with a logged warning rather than failing the whole request. """ from __future__ import annotations import logging import os import httpx log = logging.getLogger(__name__) # ── Config ───────────────────────────────────────────────────────── LITELLM_URL = os.getenv("LITELLM_URL", "http://litellm.rugmunch.io") LITELLM_API_KEY = os.getenv("LITELLM_API_KEY", "") DEFAULT_MODEL = os.getenv("LITELLM_DEFAULT_MODEL", "deepseek-v3") NEWS_ANALYSIS_PROMPT = """You are an analyst at RugMunch Intelligence, a crypto scam-detection platform. Analyze the following news item and produce a structured Markdown summary. NEWS ITEM: - Title: {title} - Source: {source} - Published: {published_at} - Body: {body_truncated_to_2000_chars} Produce a summary with these sections (use Markdown headers): ## Summary 2-3 sentence plain-English summary. ## Affected Tokens List any tokens mentioned, with their chain and address if known. ## Affected Wallets List any wallets mentioned. ## Sentiment One of: bullish | bearish | neutral | risk-elevating | risk-reducing 1-sentence justification. ## RugMunch Action What should our platform do in response? Options: - (none) - (flag mentioned tokens for re-scan) - (alert subscribers) - (update deployer reputation) - (cross-chain entity resolution trigger) Be concise. Do not speculate beyond what the article says. """ class LLMRouter: """Async client for the self-hosted LiteLLM proxy. Falls back to None if the proxy is unreachable - catalog operations that need LLM output will skip the AI analysis but still complete the rest of the workflow. """ def __init__(self, url: str | None = None, api_key: str | None = None) -> None: self.url = (url or LITELLM_URL).rstrip("/") self.api_key = api_key or LITELLM_API_KEY self._client: httpx.AsyncClient | None = None async def _get_client(self) -> httpx.AsyncClient: if self._client is None: headers = {"Content-Type": "application/json"} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" self._client = httpx.AsyncClient( base_url=self.url, headers=headers, timeout=30.0 ) return self._client async def chat( self, prompt: str, model: str | None = None, max_tokens: int = 800, temperature: float = 0.3, ) -> str | None: """Send a chat completion. Returns text or None on failure.""" try: client = await self._get_client() r = await client.post( "/chat/completions", json={ "model": model or DEFAULT_MODEL, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens, "temperature": temperature, }, ) if r.status_code != 200: log.warning("llm_http_%d: %s", r.status_code, r.text[:200]) return None data = r.json() return data["choices"][0]["message"]["content"] except Exception as e: log.warning("llm_chat_fail: %s", e) return None async def analyze_news( self, news_item: NewsItem # type: ignore # noqa: F821 -- pre-existing bug, see fix(f821) tracking issue ) -> str | None: """Generate AI analysis for a NewsItem. Per v4.0 §T28.""" prompt = NEWS_ANALYSIS_PROMPT.format( title=news_item.title, source=news_item.source, published_at=news_item.published_at.isoformat(), body_truncated_to_2000_chars=(news_item.body_markdown or news_item.summary)[:2000], ) return await self.chat(prompt, max_tokens=800, temperature=0.3)