""" Minimal OpenRouter REST client (sync). Reads OPENROUTER_API_KEY / OPENROUTER_MODEL / OPENROUTER_BASE_URL from env. """ from __future__ import annotations import json import os from typing import Any, Dict, List, Optional import httpx def _flatten_message_content(content: Any) -> str: """ OpenAI-kompatibles Chat-Completion kann `content` als String oder als Liste strukturierter Blöcke liefern (z. B. Anthropic über OpenRouter/Bedrock). """ if content is None: return "" if isinstance(content, str): return content.strip() if isinstance(content, list): parts: List[str] = [] for block in content: if isinstance(block, str): parts.append(block) elif isinstance(block, dict): t = block.get("type") txt = block.get("text") if txt is None and t == "text": txt = block.get("content") if isinstance(txt, str): parts.append(txt) elif txt is not None: parts.append(str(txt)) return "".join(parts).strip() if isinstance(content, dict): txt = content.get("text") if txt is None: txt = content.get("content") if isinstance(txt, str): return txt.strip() if isinstance(txt, list): return _flatten_message_content(txt) return str(content).strip() class OpenRouterError(Exception): """Upstream or transport failure.""" def openrouter_chat_completion( *, api_key: str, model: str, user_content: str, system_content: Optional[str] = None, timeout_sec: float = 120.0, temperature: float = 0.25, site_url: Optional[str] = None, app_title: Optional[str] = None, ) -> str: """ Returns assistant message content (plain string). Caller validates empty responses. """ base = (os.getenv("OPENROUTER_BASE_URL") or "").strip().rstrip("/") or "https://openrouter.ai/api/v1" url = f"{base}/chat/completions" headers: Dict[str, str] = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } referer = (site_url or os.getenv("APP_URL") or "").strip() if referer: headers["HTTP-Referer"] = referer title = (app_title or os.getenv("OPENROUTER_APP_TITLE") or "Shinkan Jinkendo").strip() if title: headers["X-Title"] = title messages: List[Dict[str, str]] = [] if system_content and str(system_content).strip(): messages.append({"role": "system", "content": str(system_content).strip()}) messages.append({"role": "user", "content": user_content}) payload: Dict[str, Any] = { "model": model, "messages": messages, "temperature": temperature, } try: with httpx.Client(timeout=timeout_sec) as client: resp = client.post(url, headers=headers, json=payload) except httpx.RequestError as e: raise OpenRouterError(str(e)) from e if resp.status_code >= 400: detail = "" try: j = resp.json() detail = ( str(j.get("error", {}).get("message")) if isinstance(j.get("error"), dict) else str(j.get("message") or j) ) except Exception: detail = (resp.text or "")[:600] raise OpenRouterError(f"HTTP {resp.status_code}: {detail}".strip()) try: data = resp.json() except json.JSONDecodeError as e: raise OpenRouterError("Ungueltige JSON-Antwort von OpenRouter") from e choices = data.get("choices") if isinstance(data, dict) else None if not choices or not isinstance(choices, list): raise OpenRouterError("OpenRouter: keine choices in Antwort") msg0 = choices[0] if choices else {} inner = msg0.get("message") if isinstance(msg0, dict) else None raw: Any = None if isinstance(inner, dict): raw = inner.get("content") if raw is None and inner.get("refusal") is not None: raw = inner.get("refusal") elif isinstance(inner, str): raw = inner if raw is None and isinstance(msg0, dict): raw = msg0.get("content") content = _flatten_message_content(raw) return content.strip() def normalize_openrouter_env() -> tuple[str, str]: key = (os.getenv("OPENROUTER_API_KEY") or "").strip() model = (os.getenv("OPENROUTER_MODEL") or "anthropic/claude-sonnet-4").strip() return key, model