"""Shared OpenRouter inference adapter for all AI-powered Spaces. - OpenAI-compatible chat-completions interface. - Secrets only from environment (HF Space Secrets). Never logged. - Structured JSON output validated against a spec (shared.validation); one controlled repair attempt on invalid output. - Bounded retries for transient failures only (429/5xx/timeouts), honoring Retry-After. No automatic fallback on schema/safety failures; deliberate fallback model only for provider-availability failures. - Telemetry to stdout: provider, model, latency, usage, finish reason, error class. Prompt content is never logged. - LOREIFY_FAKE_AI=1 serves the caller-supplied fixture (local testing without a key; never set this on a deployed Space). """ import json import logging import os import time import httpx from .validation import validate log = logging.getLogger("lf.ai") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s") class AIUnavailable(Exception): """User-safe failure. str(exc) is shown to the user.""" def _cfg(): return { "api_key": os.environ.get("OPENROUTER_API_KEY", ""), "base_url": os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"), "model": os.environ.get("OPENROUTER_MODEL", "google/gemini-3.6-flash"), "fallback_model": os.environ.get("OPENROUTER_FALLBACK_MODEL", "openai/gpt-4o-mini"), "timeout": float(os.environ.get("OPENROUTER_TIMEOUT_SECONDS", "60")), # Generous default: reasoning-capable models (e.g. Gemini Flash) spend # completion tokens on thinking before the JSON; a tight cap truncates. "max_tokens": int(os.environ.get("OPENROUTER_MAX_TOKENS", "6000")), "temperature": float(os.environ.get("OPENROUTER_TEMPERATURE", "0.9")), } USER_ERROR = ( "The generator hit a snag and couldn't finish. Nothing was charged to you — " "please try again in a moment." ) NO_KEY_ERROR = ( "The generator isn't configured yet (missing API credentials). " "If you run this Space, add the OPENROUTER_API_KEY secret." ) def _post_completion(model: str, messages: list, cfg: dict, max_tokens: int): """One provider call with bounded retries for transient failures.""" headers = { "Authorization": f"Bearer {cfg['api_key']}", "HTTP-Referer": "https://loreify.ai", "X-Title": "Loreify D&D Tools", "Content-Type": "application/json", } body = { "model": model, "messages": messages, "max_tokens": max_tokens, "temperature": cfg["temperature"], "response_format": {"type": "json_object"}, } delay = 1.5 last_exc = None for attempt in range(3): start = time.time() try: resp = httpx.post( f"{cfg['base_url']}/chat/completions", headers=headers, json=body, timeout=cfg["timeout"], ) except (httpx.TimeoutException, httpx.TransportError) as exc: last_exc = exc log.info("call model=%s attempt=%d error_class=%s", model, attempt, type(exc).__name__) time.sleep(delay) delay *= 2 continue latency = time.time() - start if resp.status_code == 429 or resp.status_code >= 500: last_exc = AIUnavailable(USER_ERROR) retry_after = resp.headers.get("retry-after") wait = min(float(retry_after), 15.0) if retry_after and retry_after.isdigit() else delay log.info("call model=%s attempt=%d status=%d retry_in=%.1f", model, attempt, resp.status_code, wait) time.sleep(wait) delay *= 2 continue if resp.status_code != 200: log.info("call model=%s status=%d error_class=http", model, resp.status_code) raise AIUnavailable(USER_ERROR) data = resp.json() choice = (data.get("choices") or [{}])[0] usage = data.get("usage") or {} log.info( "call ok provider=openrouter model=%s latency=%.2fs prompt_tokens=%s " "completion_tokens=%s finish=%s", data.get("model", model), latency, usage.get("prompt_tokens"), usage.get("completion_tokens"), choice.get("finish_reason"), ) content = (choice.get("message") or {}).get("content") or "" if not content: raise AIUnavailable(USER_ERROR) return content raise last_exc if isinstance(last_exc, AIUnavailable) else AIUnavailable(USER_ERROR) def _parse_json(text: str): text = text.strip() if text.startswith("```"): text = text.strip("`") if text.startswith("json"): text = text[4:] start, end = text.find("{"), text.rfind("}") if start == -1 or end == -1: raise ValueError("no JSON object found") return json.loads(text[start : end + 1]) def generate_json( system_prompt: str, user_prompt: str, spec: dict, fixture: dict | None = None, max_tokens: int | None = None, ) -> dict: """Generate a spec-valid JSON object. Raises AIUnavailable on failure.""" if os.environ.get("LOREIFY_FAKE_AI") == "1" and fixture is not None: time.sleep(0.4) # simulate latency so loading states are testable return fixture cfg = _cfg() if not cfg["api_key"]: raise AIUnavailable(NO_KEY_ERROR) tokens = min(max_tokens or cfg["max_tokens"], cfg["max_tokens"]) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] models = [cfg["model"]] + ([cfg["fallback_model"]] if cfg["fallback_model"] else []) content = None for i, model in enumerate(models): try: content = _post_completion(model, messages, cfg, tokens) break except AIUnavailable: if i == len(models) - 1: raise log.info("falling back model=%s -> %s", model, models[i + 1]) active_model = model # Parse + validate, with one controlled repair attempt. for attempt in ("primary", "repair"): try: data = _parse_json(content) problems = validate(data, spec) except (ValueError, json.JSONDecodeError) as exc: data, problems = None, [f"invalid JSON: {exc}"] if not problems: return data log.info("schema attempt=%s problems=%d", attempt, len(problems)) if attempt == "repair": raise AIUnavailable(USER_ERROR) repair_messages = messages + [ {"role": "assistant", "content": content}, { "role": "user", "content": ( "Your previous response did not match the required JSON schema. " "Problems: " + "; ".join(problems[:10]) + ". " "Respond again with ONLY the corrected JSON object, no prose." ), }, ] content = _post_completion(active_model, repair_messages, cfg, tokens) raise AIUnavailable(USER_ERROR) # unreachable