"""Shared OpenRouter image-generation adapter (dedicated /api/v1/images API). Same safety posture as ai_client: secrets only from env, bounded retries for transient failures, telemetry without prompt content, user-safe errors. LOREIFY_FAKE_AI=1 returns an embedded placeholder image (local testing only). """ import base64 import logging import os import time import httpx from .ai_client import AIUnavailable, USER_ERROR, NO_KEY_ERROR log = logging.getLogger("lf.image") # 8x8 solid-gold PNG placeholder for LOREIFY_FAKE_AI local pipeline tests. _FAKE_PNG = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFklEQVR4nGP8v4rhPwMRgIkY" "RaMKKVMIAJVzAx2Vr/xnAAAAAElFTkSuQmCC" ) 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_IMAGE_MODEL", "krea/krea-2-large"), "timeout": float(os.environ.get("OPENROUTER_IMAGE_TIMEOUT_SECONDS", "120")), } def generate_image(prompt: str) -> tuple[bytes, str]: """Generate one image. Returns (image_bytes, media_type). Raises AIUnavailable with a user-safe message on any failure. """ if os.environ.get("LOREIFY_FAKE_AI") == "1": time.sleep(0.4) return _FAKE_PNG, "image/png" cfg = _cfg() if not cfg["api_key"]: raise AIUnavailable(NO_KEY_ERROR) headers = { "Authorization": f"Bearer {cfg['api_key']}", "HTTP-Referer": "https://loreify.ai", "X-Title": "Loreify D&D Tools", "Content-Type": "application/json", } # krea models advertise no extra supported_parameters — keep the body minimal. body = {"model": cfg["model"], "prompt": prompt} delay = 2.0 last_exc = None for attempt in range(3): start = time.time() try: resp = httpx.post(f"{cfg['base_url']}/images", headers=headers, json=body, timeout=cfg["timeout"]) except (httpx.TimeoutException, httpx.TransportError) as exc: last_exc = exc log.info("image call attempt=%d error_class=%s", 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), 20.0) if retry_after and retry_after.isdigit() else delay log.info("image call attempt=%d status=%d retry_in=%.1f", attempt, resp.status_code, wait) time.sleep(wait) delay *= 2 continue if resp.status_code != 200: log.info("image call status=%d error_class=http", resp.status_code) raise AIUnavailable(USER_ERROR) payload = resp.json() data = payload.get("data") or [] if not data or not data[0].get("b64_json"): log.info("image call ok but empty data") raise AIUnavailable(USER_ERROR) usage = payload.get("usage") or {} log.info( "image ok provider=openrouter model=%s latency=%.2fs tokens=%s cost=%s", cfg["model"], latency, usage.get("total_tokens"), usage.get("cost"), ) media_type = data[0].get("media_type") or "image/png" try: return base64.b64decode(data[0]["b64_json"]), media_type except Exception: raise AIUnavailable(USER_ERROR) raise last_exc if isinstance(last_exc, AIUnavailable) else AIUnavailable(USER_ERROR)