"""OpenRouter image-generation provider. OpenRouter is an aggregator that routes requests to many model vendors. For image generation, the usable path is their chat-completions endpoint with ``modalities=["image","text"]`` — models like ``google/gemini-2.5-flash-image`` (a.k.a. "Nano Banana") return the generated image as a ``data:``-prefixed base64 URL inside the message ``images`` array; other models may return ``image_url``-style pointers. Why this provider is worth adding: * Unlocks Gemini image models without needing a Google Cloud project. * Lets users concentrate AI spend on a single OpenRouter key rather than managing separate OpenAI / Stability / Replicate accounts. * Supported models (as of F.X.fix 2026-04-18): - ``google/gemini-2.5-flash-image`` (default, GA) - ``google/gemini-2.5-flash-image-preview`` (DEPRECATED; 404 on fresh OpenRouter accounts — kept as a recognised alias so we can emit ``PROVIDER_MODEL_DEPRECATED`` with a clear hint) - Any other OpenRouter model that returns image parts in ``message.images[]`` — the parser is tolerant of newer entries. The default model is picked to cover the most common use case (hero images for WordPress / WooCommerce posts); callers can override via ``GenerationRequest.model``. """ from __future__ import annotations import asyncio import base64 import logging import os import time from typing import Any import aiohttp from plugins.ai_image.providers.base import ( BaseImageProvider, GenerationRequest, GenerationResult, ProviderError, ) _logger = logging.getLogger("mcphub.ai_image.openrouter") _API_URL = "https://openrouter.ai/api/v1/chat/completions" _MODELS_URL = "https://openrouter.ai/api/v1/models" # GA endpoint. The preview alias (...-image-preview) was deprecated after # Google promoted the model to GA — new OpenRouter accounts get 404 on # the preview ID. _DEFAULT_MODEL = "google/gemini-2.5-flash-image" _DEPRECATED_MODELS: dict[str, str] = { "google/gemini-2.5-flash-image-preview": "google/gemini-2.5-flash-image", } _RETRY_STATUS = {429, 500, 502, 503, 504} _MAX_RETRIES = 3 _REQUEST_TIMEOUT = 120 # USD-per-image price table. Figures are conservative upper bounds from # public OpenRouter pricing (2026-04); operators who negotiate custom # rates can override via ``OPENROUTER_PRICING_OVERRIDE`` (JSON map of # model_id -> price USD). _MODEL_PRICING: dict[str, float] = { "google/gemini-2.5-flash-image": 0.00039, "google/gemini-2.5-flash-image-preview": 0.00039, "google/imagen-3.0-generate": 0.04, "google/imagen-3.0-fast": 0.02, "openai/dall-e-3": 0.04, "openai/dall-e-3-hd": 0.08, "openai/gpt-image-1": 0.017, "black-forest-labs/flux-1.1-pro": 0.04, "black-forest-labs/flux-pro": 0.055, "black-forest-labs/flux-schnell": 0.003, "stability-ai/stable-diffusion-3.5-large": 0.065, } def _pricing_table() -> dict[str, float]: """Return the effective price table, merging env override if present.""" import json as _json override = os.environ.get("OPENROUTER_PRICING_OVERRIDE", "").strip() if not override: return _MODEL_PRICING try: parsed = _json.loads(override) except Exception as exc: # noqa: BLE001 _logger.warning("OPENROUTER_PRICING_OVERRIDE is not valid JSON: %s", exc) return _MODEL_PRICING if not isinstance(parsed, dict): return _MODEL_PRICING merged = dict(_MODEL_PRICING) for k, v in parsed.items(): try: merged[str(k)] = float(v) except (TypeError, ValueError): continue return merged def _cost_for(model: str) -> float | None: """Return USD cost for a known model, else None (logs at debug).""" price = _pricing_table().get(model) if price is None: _logger.debug("openrouter: no pricing entry for model %r", model) return price # Module-level cache for list_image_models() — 1h TTL is plenty; model # catalog drift is measured in days, not minutes. _MODELS_CACHE_TTL_SECONDS = 3600 _models_cache: dict[str, Any] = {"fetched_at": 0.0, "payload": None} class OpenRouterProvider(BaseImageProvider): name = "openrouter" async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult: if not api_key: raise ProviderError("PROVIDER_AUTH", "OpenRouter API key is missing.") requested_model = request.model or _DEFAULT_MODEL replacement = _DEPRECATED_MODELS.get(requested_model) if replacement is not None: # Auto-substitute deprecated → GA so per-site defaults pinned # before the GA promotion don't break the generate-and-upload # flow. Logged at WARNING so operators notice and can # repin the site default in the dashboard. _logger.warning( "openrouter: model %r is deprecated; using %r instead", requested_model, replacement, ) model = replacement else: model = requested_model # Chat-completions shape with image modality declared. The # prompt is sent as the single user message; size hints are # advisory — Gemini picks an output size internally. user_content: str = request.prompt if request.negative_prompt: user_content = f"{user_content}\n\nAvoid: {request.negative_prompt}" payload: dict[str, Any] = { "model": model, "messages": [{"role": "user", "content": user_content}], "modalities": ["image", "text"], } body = await self._post_with_retry(api_key, payload) image_url = _extract_image_url(body) if not image_url: raise ProviderError( "PROVIDER_BAD_RESPONSE", "OpenRouter response contained no image data. Is the " f"model '{model}' configured for image output?", {"model": model}, ) image_bytes, mime = await _materialise_image(image_url) if not image_bytes: raise ProviderError( "PROVIDER_BAD_RESPONSE", "OpenRouter image URL could not be fetched / decoded.", {"model": model}, ) # Derive a useful filename suffix from the mime type. ext = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}.get(mime, "png") meta: dict[str, Any] = {"model": model, "size": request.size} # Attach OpenRouter usage if present so audit logs can correlate. usage = body.get("usage") or {} if isinstance(usage, dict): for k in ("prompt_tokens", "completion_tokens", "total_tokens"): if k in usage: meta[k] = usage[k] return GenerationResult( data=image_bytes, mime=mime, filename=f"openrouter-{model.replace('/', '-')}.{ext}", meta=meta, cost_usd=_cost_for(model), ) async def _post_with_retry(self, api_key: str, payload: dict[str, Any]) -> dict[str, Any]: headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", # OpenRouter recommends identifying the calling app for # observability. Using a stable string so admins can spot # traffic from MCPHub in their OpenRouter dashboard. "HTTP-Referer": "https://github.com/airano-ir/mcphub", "X-Title": "MCPHub", } last_error: str = "" delay = 1.0 timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT) async with aiohttp.ClientSession(timeout=timeout) as session: for attempt in range(1, _MAX_RETRIES + 1): try: async with session.post(_API_URL, json=payload, headers=headers) as resp: if resp.status == 200: return await resp.json() text = await resp.text() last_error = text[:500] if resp.status == 401: raise ProviderError( "PROVIDER_AUTH", "OpenRouter rejected the API key (401).", {"body": last_error}, ) if resp.status == 400: raise ProviderError( "PROVIDER_BAD_REQUEST", f"OpenRouter rejected request (400): {last_error}", {"body": last_error}, ) if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES: _logger.warning( "OpenRouter %d, retry %d/%d", resp.status, attempt, _MAX_RETRIES ) await asyncio.sleep(delay) delay *= 2 continue if resp.status == 429: raise ProviderError( "PROVIDER_QUOTA", "OpenRouter quota/rate-limit hit after retries.", {"status": 429, "body": last_error}, ) raise ProviderError( "PROVIDER_UNAVAILABLE", f"OpenRouter upstream error HTTP {resp.status}.", {"status": resp.status, "body": last_error}, ) except TimeoutError as exc: last_error = f"timeout: {exc}" if attempt < _MAX_RETRIES: await asyncio.sleep(delay) delay *= 2 continue raise ProviderError( "PROVIDER_TIMEOUT", "OpenRouter request timed out after retries.", ) from exc raise ProviderError( "PROVIDER_UNAVAILABLE", f"OpenRouter call failed after {_MAX_RETRIES} attempts: {last_error}", ) async def list_image_models( self, api_key: str | None = None, *, force: bool = False ) -> list[dict[str, Any]]: """Return OpenRouter catalog entries whose modality includes image. The catalog drifts slowly (days) so we cache the whole filtered list at module scope for 1h. ``api_key`` is optional — the ``/v1/models`` endpoint is public, but authenticated callers see per-account availability flags. On any upstream error the previous cache (if any) is returned; otherwise an empty list. """ now = time.time() payload = _models_cache.get("payload") fetched_at = _models_cache.get("fetched_at", 0.0) if not force and payload is not None and (now - fetched_at) < _MODELS_CACHE_TTL_SECONDS: return list(payload) headers = { "HTTP-Referer": "https://github.com/airano-ir/mcphub", "X-Title": "MCPHub", } if api_key: headers["Authorization"] = f"Bearer {api_key}" timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT) try: async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(_MODELS_URL, headers=headers) as resp: if resp.status != 200: _logger.warning("openrouter /v1/models HTTP %s", resp.status) return list(payload or []) body = await resp.json() except Exception as exc: # noqa: BLE001 _logger.warning("openrouter /v1/models fetch failed: %s", exc) return list(payload or []) raw_models = body.get("data") or [] pricing = _pricing_table() filtered: list[dict[str, Any]] = [] for item in raw_models: if not isinstance(item, dict): continue if not _model_is_image(item): continue model_id = str(item.get("id") or "") if not model_id or model_id in _DEPRECATED_MODELS: continue filtered.append( { "id": model_id, "name": item.get("name") or model_id, "description": (item.get("description") or "")[:400], "context_length": item.get("context_length"), "input_modalities": _modalities_of(item, "input"), "output_modalities": _modalities_of(item, "output"), "price_per_image_usd": pricing.get(model_id), } ) filtered.sort(key=lambda m: m["id"]) _models_cache["fetched_at"] = now _models_cache["payload"] = filtered return list(filtered) def _modalities_of(item: dict[str, Any], side: str) -> list[str]: """Extract input/output modality list from an OpenRouter model entry.""" arch = item.get("architecture") or {} if isinstance(arch, dict): key = "input_modalities" if side == "input" else "output_modalities" mods = arch.get(key) if isinstance(mods, list): return [str(m) for m in mods if isinstance(m, str)] modality = arch.get("modality") if isinstance(modality, str): parts = modality.split("->") if len(parts) == 2: idx = 0 if side == "input" else 1 return [p.strip() for p in parts[idx].split("+") if p.strip()] return [] def _model_is_image(item: dict[str, Any]) -> bool: """True when an OpenRouter model entry emits image output.""" out = _modalities_of(item, "output") if any("image" in m.lower() for m in out): return True # Legacy catalog rows without structured modalities — fall back to a # name/description heuristic so we don't silently drop valid models. blob = " ".join(str(item.get(k, "") or "") for k in ("id", "name", "description")).lower() return "image" in blob and "gen" in blob def _extract_image_url(body: dict[str, Any]) -> str | None: """Pull the first image URL out of an OpenRouter chat response. The wire format varies by model vendor. We recognise: * ``choices[0].message.images[i].image_url.url`` — a string that may be a ``data:image/...;base64,...`` URL or an https URL. * ``choices[0].message.content`` when it is a list of parts, each of shape ``{type: "image_url", image_url: {url: ...}}`` — OpenAI-compatible multimodal reply shape. Returns None if no usable reference was found. """ choices = body.get("choices") or [] if not isinstance(choices, list) or not choices: return None message = (choices[0] or {}).get("message") or {} # Shape 1: message.images (Gemini via OpenRouter). for entry in message.get("images") or []: url = _image_url_from_entry(entry) if url: return url # Shape 2: message.content as list of parts. content = message.get("content") if isinstance(content, list): for part in content: if not isinstance(part, dict): continue if part.get("type") == "image_url": url = _image_url_from_entry(part) if url: return url return None def _image_url_from_entry(entry: Any) -> str | None: """Handle both ``{image_url: {url: ...}}`` and ``{image_url: "..."}``.""" if not isinstance(entry, dict): return None iu = entry.get("image_url") if isinstance(iu, str): return iu if isinstance(iu, dict): u = iu.get("url") return u if isinstance(u, str) else None # Some providers emit a bare "url" key. u = entry.get("url") return u if isinstance(u, str) else None async def _materialise_image(url: str) -> tuple[bytes, str]: """Turn an image URL (data: or https) into raw bytes + MIME. Returns ``(b"", "")`` on any failure so the caller can emit a uniform ``PROVIDER_BAD_RESPONSE`` error. """ if url.startswith("data:"): try: header, payload = url.split(",", 1) except ValueError: return b"", "" mime = "image/png" rest = header[len("data:") :] if header.startswith("data:") else header if ";" in rest: mime = rest.split(";", 1)[0] or mime elif rest: mime = rest if "base64" in header: try: return base64.b64decode(payload), mime except Exception: return b"", mime return payload.encode("latin-1", errors="ignore"), mime if url.startswith("http://") or url.startswith("https://"): timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(url) as resp: if resp.status != 200: return b"", "" mime = resp.headers.get("Content-Type", "image/png").split(";", 1)[0].strip() return await resp.read(), mime or "image/png" return b"", "" # --------------------------------------------------------------------------- # Starlette handler — GET /api/providers/openrouter/models # --------------------------------------------------------------------------- async def api_openrouter_models(request: Any) -> Any: """Return the cached OpenRouter image-model catalog. Auth: same OAuth user session guard as the other /api/ endpoints. Query params: * ``force=1`` — bypass the 1h module cache. * ``site_id=`` — if present, the user's OpenRouter key for that site is used as bearer so availability flags reflect the operator's account; otherwise the endpoint is fetched unauthed. """ from starlette.responses import JSONResponse from core.dashboard.routes import _require_user_session user_session, redirect = _require_user_session(request) if redirect or user_session is None: return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401) force = request.query_params.get("force") in {"1", "true", "True"} api_key: str | None = None site_id = (request.query_params.get("site_id") or "").strip() if site_id: try: from core.database import get_database from core.site_api import get_site_provider_key db = get_database() site = await db.get_site(site_id, user_session["user_id"]) if site is not None: api_key = await get_site_provider_key(site_id, "openrouter") except Exception as exc: # noqa: BLE001 _logger.debug("openrouter models: site key lookup skipped: %s", exc) provider = OpenRouterProvider() models = await provider.list_image_models(api_key=api_key, force=force) return JSONResponse({"ok": True, "provider": "openrouter", "models": models})