diff --git a/plugins/image_gen/openrouter/__init__.py b/plugins/image_gen/openrouter/__init__.py index 3899f5fa93..56e4c6bf7a 100644 --- a/plugins/image_gen/openrouter/__init__.py +++ b/plugins/image_gen/openrouter/__init__.py @@ -17,16 +17,58 @@ Reference grounding is the reason pet sprite generation cares about this backend: each animation row must stay the same character as the chosen base frame, which only works on models that accept image input. Gemini Flash Image ("nano-banana") does, so both providers advertise image-to-image support. + +Two request surfaces +-------------------- +OpenRouter ships a *second*, entirely separate image surface: the **Dedicated +Image API** at ``POST /api/v1/images/generations``, with its own catalog +(``GET /api/v1/images/models``) and the full OpenAI-style parameter set. +``openai/gpt-image-2``, ``openai/gpt-image-1-mini``, ``krea/krea-2-*``, +``qwen/qwen-image-3-pro``, ``microsoft/mai-image-2.5*`` and +``x-ai/grok-imagine-image-quality`` are reachable *only* there. + +Why routing is not "is it in the image catalog?" + The two catalogs **overlap**. As of 2026-08 ``GET /images/models`` lists 40 + models, and that list includes this backend's own chat-completions defaults + (``openai/gpt-5.4-image-2`` and ``google/gemini-3-pro-image``). Routing on + catalog membership alone would therefore move every existing default call + off ``/chat/completions`` — a silent behaviour change for setups that work + today, and one that changes how reference images are passed (content parts + vs ``input_references``). + +So the surface is chosen conservatively, per model, by +:func:`_select_surface`: + +* ``image_gen.openrouter.surface`` / ``OPENROUTER_IMAGE_API_SURFACE`` forces + ``images`` or ``chat`` when an operator wants to decide themselves; +* otherwise (``auto``, the default) only ids in :data:`_IMAGE_API_MODELS` — + curated, and none of them reachable over chat-completions — take the new + path. The chat defaults stay pinned to chat-completions no matter what the + catalog says; +* an id in neither group keeps today's behaviour, and the cached, best-effort + ``GET /images/models`` probe is used only to log a one-line hint that the + model is available on the Image API and how to switch. Nothing reroutes + itself behind the operator's back, and a failed probe costs nothing. + +On the Image API path the request gains exact per-model aspect ratios plus +``resolution`` / ``quality`` / ``background`` / ``seed`` / ``n`` / +``output_compression``, and up to 16 reference images instead of 3. + +The Image API is OpenRouter-only: Nous Portal proxies the chat-completions +protocol and has no ``/images/generations`` route, so the second surface is +enabled per provider (see ``supports_image_api``) and stays off for Nous. """ from __future__ import annotations import base64 +import json import logging import mimetypes import os +import time from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from agent.image_gen_provider import ( DEFAULT_ASPECT_RATIO, @@ -240,6 +282,525 @@ def _fetch_live_image_models(base_url: str, api_key: str) -> List[Dict[str, Any] return out +# =========================================================================== +# OpenRouter Dedicated Image API (POST /images/generations) +# =========================================================================== +# +# Everything below serves the second surface described in the module docstring. +# The chat-completions path above is untouched. + +#: Env prefix for the Image API knobs (``OPENROUTER_IMAGE_API_QUALITY`` …). +#: Deliberately distinct from ``OPENROUTER_IMAGE_MODEL``, which selects the +#: model on either surface. +_IMAGE_API_ENV_PREFIX = "OPENROUTER_IMAGE_API_" + +#: Connect budget, separate from the read budget. If DNS + TCP + TLS hasn't +#: completed in 20s the endpoint is effectively down, and waiting out the full +#: generation timeout only delays the error. +_IMAGE_API_CONNECT_TIMEOUT = 20.0 + +#: How long a fetched ``/images/models`` catalog stays usable. The catalog only +#: grows, and a stale entry costs one wasted 404 at worst. +_CATALOG_TTL_SECONDS = 900.0 + +#: Cached catalog probes, keyed by base URL: ``(fetched_at, model_ids)``. An +#: empty set is cached too, so a failing endpoint is probed once per TTL rather +#: than on every call. +_CATALOG_CACHE: Dict[str, Tuple[float, frozenset]] = {} + +_GEMINI_RATIOS = ( + "1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", + "4:5", "5:4", "8:1", "9:16", "16:9", "21:9", +) +_MAI_RATIOS = ("1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "auto") +_KREA_RATIOS = ("1:1", "4:3", "3:2", "16:9", "4:5", "2:3", "9:16") + +#: Curated Image API models and the parameters each one declares in +#: ``GET /images/models`` (catalog snapshot 2026-08). The catalog is larger and +#: keeps growing; an id missing from here still works — it just gets no +#: per-model parameter filtering, and it costs one cached catalog probe to +#: recognise. Keys mirror the payload field they gate; an empty tuple means the +#: model has no such knob. +_IMAGE_API_MODELS: Dict[str, Dict[str, Any]] = { + "google/gemini-3.1-flash-lite-image": { + "display": "Nano Banana 2 Lite (Gemini 3.1 Flash Lite Image)", + "strengths": "Cheap and fast; 14 exact aspect ratios; 14 reference images", + "aspect_ratios": _GEMINI_RATIOS, + "resolutions": ("1K",), + "quality": (), "background": (), "output_format": (), + "compression": False, "seed": False, "max_n": 1, "max_refs": 14, + }, + "google/gemini-3.1-flash-image": { + "display": "Nano Banana 2 (Gemini 3.1 Flash Image)", + "strengths": "Same ratios as Lite plus resolution control (512/1K/2K/4K)", + "aspect_ratios": _GEMINI_RATIOS, + "resolutions": ("512", "1K", "2K", "4K"), + "quality": (), "background": (), "output_format": (), + "compression": False, "seed": False, "max_n": 1, "max_refs": 14, + }, + "openai/gpt-image-2": { + "display": "OpenAI GPT Image 2", + "strengths": "Best editing fidelity; up to 16 references; strongest prompt adherence", + "aspect_ratios": ("1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16", "21:9", "auto"), + "resolutions": (), + "quality": ("auto", "low", "medium", "high"), + "background": ("auto", "opaque"), "output_format": (), + "compression": True, "seed": False, "max_n": 10, "max_refs": 16, + }, + "openai/gpt-image-1-mini": { + "display": "OpenAI GPT Image 1 Mini", + "strengths": "The only model here with background=transparent (cut-out PNG)", + "aspect_ratios": ("1:1", "3:2", "2:3", "auto"), + "resolutions": (), + "quality": ("auto", "low", "medium", "high"), + "background": ("auto", "transparent", "opaque"), "output_format": (), + "compression": True, "seed": False, "max_n": 10, "max_refs": 16, + }, + "microsoft/mai-image-2.5": { + "display": "Microsoft MAI-Image-2.5", + "strengths": "Standard ratios; a good second opinion next to Gemini", + "aspect_ratios": _MAI_RATIOS, "resolutions": (), + "quality": (), "background": (), "output_format": (), + "compression": False, "seed": False, "max_n": 1, "max_refs": 1, + }, + "microsoft/mai-image-2.5-pro": { + "display": "Microsoft MAI-Image-2.5 Pro", + "strengths": "Reach for it when gpt-image-2 misses the brief", + "aspect_ratios": _MAI_RATIOS, "resolutions": (), + "quality": (), "background": (), "output_format": (), + "compression": False, "seed": False, "max_n": 1, "max_refs": 1, + }, + "x-ai/grok-imagine-image-quality": { + "display": "Grok Imagine (Image Quality)", + "strengths": "Photoreal; widest exotic-ratio set (9:19.5, 20:9, 2:1 …); 1K/2K", + "aspect_ratios": ( + "1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2", + "9:19.5", "19.5:9", "9:20", "20:9", "1:2", "2:1", "auto", + ), + "resolutions": ("1K", "2K"), + "quality": (), "background": (), "output_format": (), + "compression": False, "seed": False, "max_n": 1, "max_refs": 3, + }, + "krea/krea-2-medium": { + "display": "Krea 2 Medium", + "strengths": "Realistic, expressive styles; deterministic via seed", + "aspect_ratios": _KREA_RATIOS, "resolutions": ("1K",), + "quality": (), "background": (), "output_format": (), + "compression": False, "seed": True, "max_n": 1, "max_refs": 1, + }, + "krea/krea-2-medium-turbo": { + "display": "Krea 2 Medium Turbo", + "strengths": "Cheapest here — bulk content, cards, thumbnails; seed support", + "aspect_ratios": _KREA_RATIOS, "resolutions": ("1K",), + "quality": (), "background": (), "output_format": (), + "compression": False, "seed": True, "max_n": 1, "max_refs": 1, + }, + "qwen/qwen-image-3-pro": { + "display": "Qwen Image 3 Pro", + "strengths": "Precise small text and detail rendering; n up to 6; 1K/2K; seed", + "aspect_ratios": ( + "1:1", "1:2", "1:4", "2:1", "2:3", "3:2", "3:4", + "4:1", "4:3", "4:5", "5:4", "9:16", "16:9", + ), + "resolutions": ("1K", "2K"), + "quality": (), "background": (), "output_format": (), + "compression": False, "seed": True, "max_n": 6, "max_refs": 4, + }, +} + +#: Applied to a catalog model this table doesn't describe, so a newly released +#: id still generates instead of erroring out. Empty ``aspect_ratios`` means +#: "enum unknown" and the field is omitted rather than guessed. +_UNKNOWN_IMAGE_API_MODEL: Dict[str, Any] = { + "display": "", "strengths": "", + "aspect_ratios": (), "resolutions": (), + "quality": (), "background": (), "output_format": (), + "compression": False, "seed": False, "max_n": 1, "max_refs": 16, +} + +#: The union of exact ratios the endpoint's validator accepts across all +#: models. Used to sanity-check an override aimed at a model whose own enum we +#: don't know. +_ENDPOINT_ASPECT_RATIOS = frozenset({ + "1:1", "1:2", "1:4", "1:8", "2:1", "2:3", "3:2", "3:4", "4:1", "4:3", + "4:5", "5:4", "8:1", "9:16", "16:9", "9:19.5", "19.5:9", "9:20", "20:9", + "9:21", "21:9", "auto", +}) + +#: Semantic ratio → exact ratios, best first. The first one the model supports +#: wins, so ``landscape`` lands on 16:9 where it exists and degrades to 3:2 on +#: ``gpt-image-1-mini``, which has no 16:9 at all. +_ASPECT_PREFERENCES: Dict[str, Tuple[str, ...]] = { + "landscape": ("16:9", "3:2", "4:3", "5:4", "21:9", "2:1", "19.5:9", "20:9", "4:1", "8:1"), + "portrait": ("9:16", "2:3", "3:4", "4:5", "9:21", "1:2", "9:19.5", "9:20", "1:4", "1:8"), + "square": ("1:1",), +} + +_MEDIA_TYPE_EXTENSIONS = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/webp": "webp", + "image/gif": "gif", + "image/svg+xml": "svg", +} + +#: HTTP statuses worth retrying on the next model of the chain. 400 is our own +#: payload being wrong and would repeat; 401/403 are account-level and are +#: answered before this set is consulted. 502 matters because the Image API +#: bills all-or-nothing and reports a failed (unbilled) generation that way. +_IMAGE_API_FALLBACK_STATUSES = frozenset({402, 404, 408, 409, 425, 429, 500, 502, 503, 504}) + + +def _image_api_model_meta(model_id: str) -> Dict[str, Any]: + """Catalog metadata for *model_id*, or permissive defaults when unknown.""" + return _IMAGE_API_MODELS.get(model_id, _UNKNOWN_IMAGE_API_MODEL) + + +def _fetch_image_api_catalog(base_url: str, api_key: str) -> frozenset: + """Model ids served by ``GET {base_url}/images/models``, cached per base URL. + + Best-effort by design: any failure caches and returns an empty set, which + routes the call to chat-completions. Guessing the other way would send a + chat-completions id to ``/images/generations`` and turn a working setup + into a 404. + """ + import requests + + cached = _CATALOG_CACHE.get(base_url) + if cached and (time.monotonic() - cached[0]) < _CATALOG_TTL_SECONDS: + return cached[1] + + ids: set = set() + try: + response = requests.get( + f"{base_url}/images/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=(_IMAGE_API_CONNECT_TIMEOUT, 30.0), + ) + response.raise_for_status() + body = response.json() + entries = body.get("data") if isinstance(body, dict) else None + for entry in entries if isinstance(entries, list) else []: + model_id = entry.get("id") if isinstance(entry, dict) else None + if isinstance(model_id, str) and model_id.strip(): + ids.add(model_id.strip()) + except Exception as exc: # noqa: BLE001 - probe must never break generation + logger.debug("image API catalog probe failed for %s: %s", base_url, exc) + + resolved = frozenset(ids) + _CATALOG_CACHE[base_url] = (time.monotonic(), resolved) + return resolved + + +#: Ids that stay on ``/chat/completions`` whatever the image catalog says. +#: Both are *in* that catalog, but they are this backend's tested defaults and +#: rerouting them would be a silent behaviour change — see the module docstring. +_CHAT_ONLY_MODELS = frozenset({DEFAULT_MODEL, _FALLBACK_MODEL}) + +#: Ids we have already hinted about, so the log line appears once per process +#: rather than on every call. +_HINTED_MODELS: set = set() + + +def _select_surface(model_id: str, base_url: str, api_key: str, config_key: str) -> str: + """Return ``"images"`` or ``"chat"`` for *model_id*. + + Deterministic and offline in the default case: the decision comes from + :data:`_IMAGE_API_MODELS` and :data:`_CHAT_ONLY_MODELS`, never from the + network. The catalog probe runs only for an id in neither table, and only + to produce a hint — it does not change the route. + """ + if not model_id: + return "chat" + + forced = _image_api_setting("surface", None, config_key) + if isinstance(forced, str) and forced.strip().lower() in {"images", "chat"}: + return forced.strip().lower() + + if model_id in _CHAT_ONLY_MODELS: + return "chat" + if model_id in _IMAGE_API_MODELS: + return "images" + + # Unknown id: keep today's behaviour, but say so once if the dedicated API + # could serve it better. + if model_id not in _HINTED_MODELS: + _HINTED_MODELS.add(model_id) + if model_id in _fetch_image_api_catalog(base_url, api_key): + logger.info( + "model '%s' is available on the OpenRouter Image API, which supports " + "exact aspect ratios, resolution/quality/background/seed and up to 16 " + "reference images. Staying on chat-completions; set " + "image_gen.%s.surface: images (or OPENROUTER_IMAGE_API_SURFACE=images) " + "to use it.", + model_id, config_key, + ) + return "chat" + + +def _image_api_setting(name: str, explicit: Any, config_key: str) -> Any: + """Resolve one Image API knob: call kwarg → env → scoped config. + + ``name`` is the payload field (``quality``); the env var checked is + ``OPENROUTER_IMAGE_API_QUALITY``. Blank strings count as unset, so an empty + env var doesn't shadow config. + """ + if explicit is not None and not (isinstance(explicit, str) and not explicit.strip()): + return explicit + env_value = os.environ.get(f"{_IMAGE_API_ENV_PREFIX}{name.upper()}", "").strip() + if env_value: + return env_value + cfg = _load_image_gen_config() + scoped = cfg.get(config_key) + value = scoped.get(name) if isinstance(scoped, dict) else None + if isinstance(value, str): + return value.strip() or None + return value + + +def _coerce_int(value: Any) -> Optional[int]: + """Best-effort int (env vars arrive as strings); ``None`` when not numeric.""" + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + try: + return int(value.strip()) + except ValueError: + return None + return None + + +def _pick_exact_aspect_ratio( + semantic: str, + meta: Dict[str, Any], + forced: Optional[str], + notes: List[str], +) -> Optional[str]: + """Choose the exact ``aspect_ratio`` to send, or ``None`` to omit it. + + *forced* is an exact ratio from config/env/kwarg. It wins when the model + supports it; otherwise the downgrade is noted and the per-model mapping of + *semantic* applies. + """ + supported: Tuple[str, ...] = tuple(meta.get("aspect_ratios") or ()) + + if isinstance(forced, str) and forced.strip(): + value = forced.strip() + if (supported and value in supported) or (not supported and value in _ENDPOINT_ASPECT_RATIOS): + return value + notes.append( + f"requested aspect_ratio '{value}' is unsupported by this model; " + f"used the '{semantic}' mapping instead" + ) + + if not supported: + # A model outside the table: we don't know its enum, and an + # out-of-enum aspect_ratio is a hard 400 (unlike an unknown + # *parameter*, which the endpoint ignores). Omitting the field lets the + # model apply its own default instead of failing every call. + notes.append( + "model is not in this backend's catalog, so its aspect_ratio enum is " + f"unknown; the field was omitted and '{semantic}' was not applied" + ) + return None + + for candidate in _ASPECT_PREFERENCES.get(semantic, ()): + if candidate in supported: + return candidate + + if "auto" in supported: + return "auto" + return supported[0] if supported else None + + +def _image_api_enum( + name: str, + explicit: Any, + meta: Dict[str, Any], + config_key: str, + notes: List[str], +) -> Optional[str]: + """Resolve an enum knob and drop it when this model doesn't accept it.""" + value = _image_api_setting(name, explicit, config_key) + if not isinstance(value, str) or not value.strip(): + return None + value = value.strip() + allowed: Tuple[str, ...] = tuple(meta.get(name) or ()) + if not allowed: + notes.append(f"'{name}' is not supported by this model; dropped") + return None + if value not in allowed: + notes.append( + f"'{name}={value}' is not valid for this model " + f"(accepts {', '.join(allowed)}); dropped" + ) + return None + return value + + +def _build_image_api_payload( + *, + model_id: str, + prompt: str, + semantic_aspect: str, + references: List[str], + config_key: str, + kwargs: Dict[str, Any], +) -> Tuple[Dict[str, Any], List[str]]: + """Assemble the ``/images/generations`` body. Returns ``(payload, notes)``. + + Every optional knob is filtered against what the model declares, because a + parameter the model doesn't know is silently *ignored* by the endpoint — + which would otherwise let a caller believe ``background=transparent`` took + effect on a model that has no transparency. + """ + meta = _image_api_model_meta(model_id) + notes: List[str] = [] + payload: Dict[str, Any] = {"model": model_id, "prompt": prompt} + + forced_ratio = _image_api_setting( + "aspect_ratio", kwargs.get("aspect_ratio_exact"), config_key + ) + ratio = _pick_exact_aspect_ratio(semantic_aspect, meta, forced_ratio, notes) + if ratio: + payload["aspect_ratio"] = ratio + + resolution = _image_api_setting("resolution", kwargs.get("resolution"), config_key) + if isinstance(resolution, str) and resolution.strip(): + allowed = tuple(meta.get("resolutions") or ()) + value = resolution.strip() + if allowed and value in allowed: + payload["resolution"] = value + elif allowed: + notes.append( + f"'resolution={value}' is not valid for this model " + f"(accepts {', '.join(allowed)}); dropped" + ) + else: + notes.append("'resolution' is not supported by this model; dropped") + + for enum_name, explicit in ( + ("quality", kwargs.get("quality")), + ("background", kwargs.get("background")), + ("output_format", kwargs.get("output_format")), + ): + value = _image_api_enum(enum_name, explicit, meta, config_key, notes) + if value: + payload[enum_name] = value + + compression = _coerce_int( + _image_api_setting("output_compression", kwargs.get("output_compression"), config_key) + ) + if compression is not None: + if meta.get("compression"): + payload["output_compression"] = max(0, min(100, compression)) + else: + notes.append("'output_compression' is not supported by this model; dropped") + + seed = _coerce_int(_image_api_setting("seed", kwargs.get("seed"), config_key)) + if seed is not None: + if meta.get("seed"): + payload["seed"] = seed + else: + notes.append("'seed' is not supported by this model; dropped") + + count = _coerce_int(_image_api_setting("n", kwargs.get("n"), config_key)) + if count is not None and count > 1: + max_n = int(meta.get("max_n") or 1) + if count > max_n: + notes.append(f"'n={count}' exceeds this model's cap of {max_n}; clamped") + payload["n"] = max(1, min(count, max_n)) + + if references: + max_refs = int(meta.get("max_refs") or 0) + usable = references[:max_refs] if max_refs else [] + if len(references) > len(usable): + notes.append( + f"{len(references)} reference image(s) supplied but this model " + f"accepts {max_refs}; extras dropped" + ) + if usable: + payload["input_references"] = [ + {"type": "image_url", "image_url": {"url": url}} for url in usable + ] + + return payload, notes + + +def _extract_image_api_error(response: Any, fallback: str) -> str: + """Flatten either error shape this endpoint produces into one line. + + ``{"error": {"message", "code"}}`` covers routing / auth / unknown model; + ``{"success": false, "error": {"name": "ZodError", "message": ""}}`` + covers request validation, where ``message`` is a JSON-encoded array of + issues that is unreadable as-is. + """ + if response is None: + return fallback + try: + body = response.json() + except Exception: # noqa: BLE001 - non-JSON error body + text = getattr(response, "text", "") or "" + return text[:300] or fallback + + error = body.get("error") if isinstance(body, dict) else None + if isinstance(error, str) and error.strip(): + return error.strip() + if not isinstance(error, dict): + return json.dumps(body)[:300] if body else fallback + + message = error.get("message") + if error.get("name") == "ZodError" and isinstance(message, str): + try: + issues = json.loads(message) + except Exception: # noqa: BLE001 + return message[:300] + parts: List[str] = [] + for issue in issues if isinstance(issues, list) else []: + if not isinstance(issue, dict): + continue + field = ".".join(str(p) for p in (issue.get("path") or [])) or "request" + parts.append(f"{field}: {issue.get('message') or 'invalid'}") + return "; ".join(parts)[:400] or message[:300] + + if isinstance(message, str) and message.strip(): + return message.strip()[:300] + return json.dumps(error)[:300] + + +def _extension_for(media_type: Optional[str], fallback: str = "png") -> str: + if isinstance(media_type, str): + return _MEDIA_TYPE_EXTENSIONS.get(media_type.split(";", 1)[0].strip().lower(), fallback) + return fallback + + +def _model_slug(model_id: str) -> str: + """Filename-safe fragment for the cache prefix (``openai/gpt-image-2`` …).""" + return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in model_id) + + +def _save_image_api_entry(entry: Dict[str, Any], prefix: str) -> Optional[str]: + """Persist one ``data[]`` entry to the image cache; return its path. + + ``None`` when the entry carries neither base64 nor a URL. Raises on write + failure so the caller can report ``io_error``. + """ + b64 = entry.get("b64_json") + if isinstance(b64, str) and b64.strip(): + return str(save_b64_image(b64, prefix=prefix, extension=_extension_for(entry.get("media_type")))) + + url = entry.get("url") + if isinstance(url, str) and url.strip(): + return str(save_url_image(url.strip(), prefix=prefix)) + + return None + + class OpenRouterCompatImageProvider(ImageGenProvider): """Image generation over an OpenRouter-compatible chat-completions endpoint. @@ -257,6 +818,7 @@ class OpenRouterCompatImageProvider(ImageGenProvider): config_key: str, model_env_var: str, setup_schema: Dict[str, Any], + supports_image_api: bool = False, ) -> None: self._name = provider_name self._display = display_name @@ -265,6 +827,11 @@ class OpenRouterCompatImageProvider(ImageGenProvider): self._model_env_var = model_env_var self._setup_schema = setup_schema self._live_models_cache: Optional[tuple] = None + self._image_api_models_cache: Optional[tuple] = None + # OpenRouter only: Nous Portal proxies the chat-completions protocol + # and has no /images/generations route, so routing a model there would + # turn a working setup into a 404. + self._supports_image_api = supports_image_api @property def name(self) -> str: @@ -291,23 +858,45 @@ class OpenRouterCompatImageProvider(ImageGenProvider): def capabilities(self) -> Dict[str, Any]: # Both text-to-image and image-to-image (reference grounding) — the # latter is what makes this backend usable for pet sprite rows. + max_refs = _MAX_REFERENCE_IMAGES + if self._supports_image_api: + # Report the cap of the model that would actually service the next + # call, so the tool schema advertises the right number. Image API + # models take far more references than chat-completions does. + chain = self._resolve_model_chain() + resolved = chain[0] if chain else "" + if resolved in _IMAGE_API_MODELS: + max_refs = int(_image_api_model_meta(resolved).get("max_refs") or max_refs) return { "modalities": ["text", "image"], - "max_reference_images": _MAX_REFERENCE_IMAGES, + "max_reference_images": max_refs, } def list_models(self) -> List[Dict[str, Any]]: - """Picker catalog: live image-output models, static chain as fallback. + """Picker catalog: the endpoint's full live image-model surface. - Fetches the endpoint's ``/models`` catalog filtered to - ``output_modalities`` containing ``image`` (5-min cache per backend), - so every image model OpenRouter serves — including ones released - after this code shipped — is selectable in ``hermes tools``. + For Image-API-capable backends (OpenRouter) this is the union of the + dedicated ``GET /images/models`` catalog (40+ models: Seedream, Flux, + Recraft, Qwen, MAI, Krea, ...) and the chat-completions image models, + so every image model the endpoint serves — including ones released + after this code shipped — is selectable in ``hermes tools``. Nous + Portal (no ``/images`` route) lists the chat-completions catalog only. + Offline fallback: the static default chain plus the curated Image API + snapshot. """ - live = self._live_models() - if live: - return live - return [ + merged: Dict[str, Dict[str, Any]] = {} + if self._supports_image_api: + for entry in self._image_api_live_models(): + merged[entry["id"]] = entry + for entry in self._live_models(): + merged.setdefault(entry["id"], entry) + if merged: + priority = {DEFAULT_MODEL: 0, _FALLBACK_MODEL: 1} + return sorted( + merged.values(), key=lambda m: (priority.get(m["id"], 2), m["id"]) + ) + + models = [ { "id": DEFAULT_MODEL, "display": "OpenAI GPT-5.4 Image 2", @@ -319,6 +908,70 @@ class OpenRouterCompatImageProvider(ImageGenProvider): "strengths": "Fast, reliable fallback with good layout adherence", }, ] + if self._supports_image_api: + # Curated snapshot keeps the Image API models pickable offline. + models.extend( + { + "id": model_id, + "display": meta["display"], + "strengths": f"{meta['strengths']} (Image API)", + } + for model_id, meta in _IMAGE_API_MODELS.items() + if model_id not in (DEFAULT_MODEL, _FALLBACK_MODEL) + ) + return models + + def _image_api_live_models(self) -> List[Dict[str, Any]]: + """Cached live ``GET /images/models`` entries (``[]`` when unreachable). + + Curated metadata from :data:`_IMAGE_API_MODELS` wins for known ids; + unknown (newly released) models get the API-provided name and a + generic strengths line so they are pickable the day they launch. + """ + import time + + cached = self._image_api_models_cache + if cached is not None and time.monotonic() - cached[1] < _LIVE_CACHE_TTL: + return cached[0] + models: List[Dict[str, Any]] = [] + try: + import requests + + runtime = self._resolve_runtime() + api_key = str(runtime.get("api_key") or "").strip() + base_url = str(runtime.get("base_url") or "").strip().rstrip("/") + if base_url: + response = requests.get( + f"{base_url}/images/models", + headers={"Authorization": f"Bearer {api_key}"} if api_key else {}, + timeout=_LIVE_TIMEOUT, + ) + response.raise_for_status() + for entry in response.json().get("data") or []: + if not isinstance(entry, dict): + continue + model_id = entry.get("id") + if not isinstance(model_id, str) or not model_id.strip(): + continue + model_id = model_id.strip() + meta = _IMAGE_API_MODELS.get(model_id, {}) + arch_raw = entry.get("architecture") + arch: Dict[str, Any] = arch_raw if isinstance(arch_raw, dict) else {} + models.append( + { + "id": model_id, + "display": meta.get("display", entry.get("name") or model_id), + "strengths": meta.get( + "strengths", "Image API model (from live OpenRouter catalog)" + ), + "input_modalities": arch.get("input_modalities") or [], + } + ) + except Exception as exc: # noqa: BLE001 - offline/unauth → fallback path + logger.debug("%s live Image API catalog unavailable: %s", self._name, exc) + models = [] + self._image_api_models_cache = (models, time.monotonic()) + return models def _live_models(self) -> List[Dict[str, Any]]: """Cached live catalog for this backend (``[]`` when unreachable).""" @@ -379,6 +1032,189 @@ class OpenRouterCompatImageProvider(ImageGenProvider): return [top.strip()] return _dedupe_models(list(_DEFAULT_MODEL_CHAIN)) + def _generate_via_image_api( + self, + *, + model_id: str, + prompt: str, + semantic_aspect: str, + references: List[str], + base_url: str, + headers: Dict[str, str], + kwargs: Dict[str, Any], + ) -> Dict[str, Any]: + """Serve one model through ``POST {base_url}/images/generations``. + + Returns the usual success/error dict. A failure that the caller may + retry on the next model of the chain carries a private ``_retryable`` + flag, which :meth:`generate` strips before returning. + """ + import requests + + def _fail(error: str, error_type: str, retryable: bool = False) -> Dict[str, Any]: + response = error_response( + error=error, + error_type=error_type, + provider=self._name, + model=model_id, + prompt=prompt, + aspect_ratio=semantic_aspect, + ) + if retryable: + response["_retryable"] = True + return response + + # References become data URIs here rather than reusing the chat path's + # `content`, because that one is clamped to 3 and these models take up + # to 16. + usable_refs: List[str] = [] + unreadable: List[str] = [] + try: + for ref in references: + part = _to_image_url_part(ref) + if part: + usable_refs.append(part) + else: + unreadable.append(str(ref)) + except Exception as exc: # noqa: BLE001 - blocked by the file-safety guard + return _fail(f"Could not load reference image: {exc}", "io_error") + + # An edit whose every source image failed to load must not quietly + # become a text-to-image generation: that bills a new picture unrelated + # to what the caller asked to edit, and success=True hides it. + if unreadable and not usable_refs: + return _fail( + "Could not read the reference image(s) requested for editing: " + + ", ".join(unreadable) + + ". Refusing to silently fall back to text-to-image.", + "io_error", + ) + + payload, notes = _build_image_api_payload( + model_id=model_id, + prompt=prompt, + semantic_aspect=semantic_aspect, + references=usable_refs, + config_key=self._config_key, + kwargs=kwargs, + ) + if unreadable: + notes.insert(0, f"dropped unreadable reference image(s): {', '.join(unreadable)}") + + timeout = _REQUEST_TIMEOUT + configured = _image_api_setting("timeout", kwargs.get("timeout"), self._config_key) + try: + if configured is not None: + timeout = max(1.0, float(configured)) + except (TypeError, ValueError): + logger.debug("%s: ignoring non-numeric image API timeout %r", self._name, configured) + + endpoint = f"{base_url}/images/generations" + try: + response = requests.post( + endpoint, + headers=headers, + json=payload, + # (connect, read): an unreachable endpoint fails in seconds + # instead of burning the whole read budget on a connection that + # will never be established. + timeout=(min(_IMAGE_API_CONNECT_TIMEOUT, timeout), timeout), + ) + response.raise_for_status() + except requests.HTTPError as exc: + resp = exc.response + status = resp.status_code if resp is not None else 0 + message = _extract_image_api_error(resp, str(exc)) + logger.error( + "%s image API generation failed (%s) on %s: %s", + self._name, status, model_id, message, + ) + # 401/403 are answered first so one account-level rejection does + # not get two different error_types depending on where in the chain + # it landed. + if status in (401, 403): + return _fail(f"{self._display} rejected the API key ({status}): {message}", "auth_error") + if status == 404: + return _fail( + f"Model '{model_id}' does not exist on the OpenRouter Image API " + f"(its catalog is separate from chat-completions — check " + f"GET {base_url}/images/models).", + "model_access", + retryable=True, + ) + return _fail( + f"{self._display} image generation failed ({status}): {message}", + "api_error", + retryable=status in _IMAGE_API_FALLBACK_STATUSES, + ) + except requests.Timeout: + return _fail( + f"{self._display} image generation timed out ({int(timeout)}s)", + "timeout", + retryable=True, + ) + except requests.ConnectionError as exc: + return _fail(f"{self._display} connection error: {exc}", "connection_error") + except requests.RequestException as exc: + return _fail(f"{self._display} request failed: {exc}", "api_error") + + try: + body = response.json() + except Exception as exc: # noqa: BLE001 + return _fail(f"{self._display} returned invalid JSON: {exc}", "invalid_response") + + entries = body.get("data") if isinstance(body, dict) else None + entries = [e for e in entries if isinstance(e, dict)] if isinstance(entries, list) else [] + if not entries: + return _fail( + f"{self._display} returned no image data for '{model_id}'.", + "empty_response", + retryable=True, + ) + + prefix = f"{self._name}_{_model_slug(model_id)}" + try: + saved = [p for p in (_save_image_api_entry(e, prefix) for e in entries) if p] + except Exception as exc: # noqa: BLE001 + return _fail(f"Could not save generated image: {exc}", "io_error") + + if not saved: + return _fail( + f"{self._display} response carried neither b64_json nor url.", + "empty_response", + ) + + extra: Dict[str, Any] = { + "endpoint": "images/generations", + "exact_aspect_ratio": payload.get("aspect_ratio"), + } + for key in ("resolution", "quality", "background", "output_format", "seed", "n"): + if key in payload: + extra[key] = payload[key] + if len(saved) > 1: + extra["additional_images"] = saved[1:] + if usable_refs: + extra["reference_images_used"] = len(payload.get("input_references") or []) + if notes: + extra["notes"] = notes + + usage = body.get("usage") if isinstance(body, dict) else None + if isinstance(usage, dict): + if isinstance(usage.get("cost"), (int, float)): + extra["cost_usd"] = usage["cost"] + if isinstance(usage.get("total_tokens"), int): + extra["total_tokens"] = usage["total_tokens"] + + return success_response( + image=saved[0], + model=model_id, + prompt=prompt, + aspect_ratio=semantic_aspect, + provider=self._name, + modality="image" if usable_refs else "text", + extra=extra, + ) + def generate( self, prompt: str, @@ -442,13 +1278,44 @@ class OpenRouterCompatImageProvider(ImageGenProvider): } last_error: Optional[Dict[str, Any]] = None for i, model_id in enumerate(model_chain): + is_last = i == len(model_chain) - 1 + + # Surface selection. An Image API model cannot be served by + # /chat/completions (and vice versa), so this is a routing + # decision, not a preference. + if self._supports_image_api and _select_surface( + model_id, base_url, api_key, self._config_key + ) == "images": + outcome = self._generate_via_image_api( + model_id=model_id, + prompt=prompt, + semantic_aspect=aspect, + references=references, + base_url=base_url, + headers=headers, + kwargs=kwargs, + ) + if outcome.get("success"): + return outcome + if is_last or not outcome.get("_retryable"): + outcome.pop("_retryable", None) + return outcome + outcome.pop("_retryable", None) + logger.info( + "%s model %s failed on the image API; retrying with fallback %s", + self._name, + model_id, + model_chain[i + 1], + ) + last_error = outcome + continue + payload: Dict[str, Any] = { "model": model_id, "modalities": ["image", "text"], "messages": [{"role": "user", "content": content}], "image_config": {"aspect_ratio": or_aspect}, } - is_last = i == len(model_chain) - 1 try: response = requests.post( f"{base_url}/chat/completions", @@ -590,10 +1457,11 @@ def _build_providers() -> List[OpenRouterCompatImageProvider]: runtime_name="openrouter", config_key="openrouter", model_env_var="OPENROUTER_IMAGE_MODEL", + supports_image_api=True, setup_schema={ "name": "OpenRouter (image)", "badge": "paid", - "tag": "Gemini Flash Image & more via OpenRouter; uses OPENROUTER_API_KEY", + "tag": "Gemini Flash Image, gpt-image-2, Krea 2, Qwen Image 3 & more via OpenRouter; uses OPENROUTER_API_KEY", "env_vars": [ { "key": "OPENROUTER_API_KEY", diff --git a/plugins/image_gen/openrouter/plugin.yaml b/plugins/image_gen/openrouter/plugin.yaml index 3e5c3ec901..bff7f11540 100644 --- a/plugins/image_gen/openrouter/plugin.yaml +++ b/plugins/image_gen/openrouter/plugin.yaml @@ -1,6 +1,6 @@ name: openrouter -version: 1.0.0 -description: "OpenRouter + Nous Portal image generation (chat-completions image output; reference-grounded). Text-to-image and image-to-image." +version: 1.1.0 +description: "OpenRouter + Nous Portal image generation. Chat-completions image output (reference-grounded) plus OpenRouter's Dedicated Image API (/images/generations) for gpt-image-2, Krea 2, Qwen Image 3 Pro, MAI-Image-2.5 and Grok Imagine — exact per-model aspect ratios, resolution/quality/background/seed/n, up to 16 reference images. Text-to-image and image-to-image." author: Hermes Agent kind: backend requires_env: diff --git a/tests/plugins/image_gen/test_openrouter_compat_provider.py b/tests/plugins/image_gen/test_openrouter_compat_provider.py index 717af024f5..0349b2629a 100644 --- a/tests/plugins/image_gen/test_openrouter_compat_provider.py +++ b/tests/plugins/image_gen/test_openrouter_compat_provider.py @@ -380,6 +380,332 @@ class TestGenerate: # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Dedicated Image API surface (POST /images/generations) +# --------------------------------------------------------------------------- + + +def _openrouter_image_api(): + """The provider as `_build_providers` really configures it (surface on).""" + from plugins.image_gen.openrouter import _build_providers + + return {p.name: p for p in _build_providers()}["openrouter"] + + +def _mock_image_api_response(entries=None, usage=None): + resp = MagicMock() + resp.status_code = 200 + resp.raise_for_status = MagicMock() + body = {"created": 0, "data": entries if entries is not None else [ + {"b64_json": "dGVzdA==", "media_type": "image/png"} + ]} + if usage is not None: + body["usage"] = usage + resp.json.return_value = body + return resp + + +class TestImageApiSurface: + @pytest.fixture(autouse=True) + def _isolate(self, monkeypatch): + """No config bleed, no catalog cache bleed between tests.""" + import plugins.image_gen.openrouter as mod + + mod._CATALOG_CACHE.clear() + mod._HINTED_MODELS.clear() + monkeypatch.setattr(mod, "_load_image_gen_config", lambda: {}) + for knob in ("QUALITY", "BACKGROUND", "RESOLUTION", "SEED", "N", + "ASPECT_RATIO", "TIMEOUT", "SURFACE"): + monkeypatch.delenv(f"OPENROUTER_IMAGE_API_{knob}", raising=False) + monkeypatch.delenv("OPENROUTER_IMAGE_MODEL", raising=False) + yield + mod._CATALOG_CACHE.clear() + mod._HINTED_MODELS.clear() + + # -- routing --------------------------------------------------------- + + def test_curated_model_routes_without_any_probe(self): + """The static table answers the common case offline.""" + from plugins.image_gen.openrouter import _select_surface + + with patch("requests.get", side_effect=AssertionError("must not probe")): + assert _select_surface("openai/gpt-image-2", "https://x/api/v1", "k", "openrouter") == "images" + + def test_chat_defaults_stay_on_chat_even_though_the_catalog_lists_them(self): + """The regression this guards: /images/models is a superset that + includes DEFAULT_MODEL and _FALLBACK_MODEL. Routing on catalog + membership would silently move every existing default call.""" + from plugins.image_gen.openrouter import ( + DEFAULT_MODEL, + _FALLBACK_MODEL, + _select_surface, + ) + + catalog = MagicMock() + catalog.raise_for_status = MagicMock() + catalog.json.return_value = { + "data": [{"id": DEFAULT_MODEL}, {"id": _FALLBACK_MODEL}] + } + with patch("requests.get", return_value=catalog): + assert _select_surface(DEFAULT_MODEL, "https://x/api/v1", "k", "openrouter") == "chat" + assert _select_surface(_FALLBACK_MODEL, "https://x/api/v1", "k", "openrouter") == "chat" + + def test_unknown_catalog_model_is_hinted_once_but_not_rerouted(self): + from plugins.image_gen.openrouter import _select_surface + + catalog = MagicMock() + catalog.raise_for_status = MagicMock() + catalog.json.return_value = {"data": [{"id": "brandnew/model-9"}]} + with patch("requests.get", return_value=catalog) as mock_get: + assert _select_surface("brandnew/model-9", "https://x/api/v1", "k", "openrouter") == "chat" + assert _select_surface("brandnew/model-9", "https://x/api/v1", "k", "openrouter") == "chat" + # Hinted once, and the probe never repeats for the same id. + assert mock_get.call_count == 1 + + def test_failed_probe_costs_nothing(self): + from plugins.image_gen.openrouter import _select_surface + + with patch("requests.get", side_effect=OSError("network down")): + assert _select_surface("unknown/model", "https://x/api/v1", "k", "openrouter") == "chat" + + def test_surface_can_be_forced_both_ways(self, monkeypatch): + from plugins.image_gen.openrouter import DEFAULT_MODEL, _select_surface + + monkeypatch.setenv("OPENROUTER_IMAGE_API_SURFACE", "images") + with patch("requests.get", side_effect=AssertionError("must not probe")): + assert _select_surface(DEFAULT_MODEL, "https://x/api/v1", "k", "openrouter") == "images" + + monkeypatch.setenv("OPENROUTER_IMAGE_API_SURFACE", "chat") + with patch("requests.get", side_effect=AssertionError("must not probe")): + assert _select_surface("openai/gpt-image-2", "https://x/api/v1", "k", "openrouter") == "chat" + + def test_image_api_model_posts_to_images_generations(self): + with patch(_RUNTIME, return_value=_runtime_ok()), \ + patch("requests.post", return_value=_mock_image_api_response()) as mock_post, \ + patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/i.png")): + result = _openrouter_image_api().generate( + prompt="a red square", aspect_ratio="square", model="openai/gpt-image-2" + ) + + assert result["success"] is True + assert mock_post.call_args[0][0] == "https://openrouter.ai/api/v1/images/generations" + payload = mock_post.call_args.kwargs["json"] + assert payload["model"] == "openai/gpt-image-2" + assert payload["prompt"] == "a red square" + assert payload["aspect_ratio"] == "1:1" + assert "messages" not in payload and "modalities" not in payload + assert result["endpoint"] == "images/generations" + + def test_chat_model_still_uses_chat_completions(self): + """The new surface must not capture the existing default chain.""" + with patch(_RUNTIME, return_value=_runtime_ok()), \ + patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \ + patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")): + result = _openrouter_image_api().generate(prompt="a pet") + + assert result["success"] is True + assert mock_post.call_args[0][0].endswith("/chat/completions") + + def test_nous_never_uses_the_image_api(self): + """Nous Portal proxies chat-completions and has no /images route.""" + from plugins.image_gen.openrouter import _build_providers + + nous_runtime = _runtime_ok( + provider="nous", base_url="https://inference.nousresearch.com/v1", api_key="nous-tok" + ) + with patch(_RUNTIME, return_value=nous_runtime), \ + patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \ + patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")): + nous = {p.name: p for p in _build_providers()}["nous"] + result = nous.generate(prompt="a pet", model="openai/gpt-image-2") + + assert result["success"] is True + assert mock_post.call_args[0][0] == "https://inference.nousresearch.com/v1/chat/completions" + + # -- per-model parameter filtering ------------------------------------ + + def test_aspect_ratio_is_mapped_per_model(self): + from plugins.image_gen.openrouter import _build_image_api_payload + + gemini, _ = _build_image_api_payload( + model_id="google/gemini-3.1-flash-lite-image", prompt="p", + semantic_aspect="landscape", references=[], config_key="openrouter", kwargs={}, + ) + mini, _ = _build_image_api_payload( + model_id="openai/gpt-image-1-mini", prompt="p", + semantic_aspect="landscape", references=[], config_key="openrouter", kwargs={}, + ) + # gpt-image-1-mini has no 16:9 at all, so landscape degrades to 3:2. + assert gemini["aspect_ratio"] == "16:9" + assert mini["aspect_ratio"] == "3:2" + + def test_unsupported_parameter_is_dropped_and_explained(self): + """The endpoint silently ignores unknown fields, so we must filter.""" + from plugins.image_gen.openrouter import _build_image_api_payload + + payload, notes = _build_image_api_payload( + model_id="openai/gpt-image-2", prompt="p", semantic_aspect="square", + references=[], config_key="openrouter", kwargs={"background": "transparent"}, + ) + assert "background" not in payload + assert any("background" in n for n in notes) + + payload, notes = _build_image_api_payload( + model_id="openai/gpt-image-1-mini", prompt="p", semantic_aspect="square", + references=[], config_key="openrouter", kwargs={"background": "transparent"}, + ) + assert payload["background"] == "transparent" + + def test_n_is_clamped_to_the_model_cap(self): + from plugins.image_gen.openrouter import _build_image_api_payload + + payload, notes = _build_image_api_payload( + model_id="qwen/qwen-image-3-pro", prompt="p", semantic_aspect="square", + references=[], config_key="openrouter", kwargs={"n": 20}, + ) + assert payload["n"] == 6 + assert any("cap of 6" in n for n in notes) + + def test_unknown_model_omits_the_aspect_ratio(self): + """An out-of-enum ratio is a hard 400, so never guess one.""" + from plugins.image_gen.openrouter import _build_image_api_payload + + payload, notes = _build_image_api_payload( + model_id="brandnew/model-9", prompt="p", semantic_aspect="landscape", + references=[], config_key="openrouter", kwargs={}, + ) + assert "aspect_ratio" not in payload + assert any("catalog" in n for n in notes) + + def test_env_knob_applies(self, monkeypatch): + from plugins.image_gen.openrouter import _build_image_api_payload + + monkeypatch.setenv("OPENROUTER_IMAGE_API_QUALITY", "high") + payload, _ = _build_image_api_payload( + model_id="openai/gpt-image-2", prompt="p", semantic_aspect="square", + references=[], config_key="openrouter", kwargs={}, + ) + assert payload["quality"] == "high" + + # -- references -------------------------------------------------------- + + def test_references_use_the_per_model_cap(self, tmp_path): + """Image API models take far more references than chat's 3.""" + refs = [] + for i in range(5): + p = tmp_path / f"r{i}.png" + p.write_bytes(b"\x89PNG\r\n") + refs.append(str(p)) + + with patch(_RUNTIME, return_value=_runtime_ok()), \ + patch("requests.post", return_value=_mock_image_api_response()) as mock_post, \ + patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/i.png")): + result = _openrouter_image_api().generate( + prompt="edit", model="openai/gpt-image-2", reference_image_urls=refs + ) + + payload = mock_post.call_args.kwargs["json"] + assert len(payload["input_references"]) == 5 # chat would have clamped to 3 + assert payload["input_references"][0]["image_url"]["url"].startswith("data:image/png;base64,") + assert result["modality"] == "image" + + def test_unreadable_sole_reference_fails_instead_of_degrading(self): + """Degrading an edit to text-to-image bills an unrelated picture.""" + with patch(_RUNTIME, return_value=_runtime_ok()), \ + patch("requests.post") as mock_post: + result = _openrouter_image_api().generate( + prompt="edit this", model="openai/gpt-image-2", + image_url="/nonexistent/definitely-missing.png", + ) + + assert result["success"] is False + assert result["error_type"] == "io_error" + mock_post.assert_not_called() + + # -- response handling ------------------------------------------------- + + def test_cost_and_extras_are_surfaced(self): + with patch(_RUNTIME, return_value=_runtime_ok()), \ + patch("requests.post", return_value=_mock_image_api_response( + usage={"cost": 0.0336, "total_tokens": 1128})), \ + patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/i.png")): + result = _openrouter_image_api().generate( + prompt="p", aspect_ratio="portrait", model="krea/krea-2-medium" + ) + + assert result["cost_usd"] == 0.0336 + assert result["total_tokens"] == 1128 + assert result["exact_aspect_ratio"] == "9:16" + assert result["image"] == "/tmp/i.png" + + def test_multiple_images_land_in_additional_images(self): + entries = [ + {"b64_json": "AA==", "media_type": "image/png"}, + {"b64_json": "BB==", "media_type": "image/png"}, + ] + with patch(_RUNTIME, return_value=_runtime_ok()), \ + patch("requests.post", return_value=_mock_image_api_response(entries)), \ + patch("plugins.image_gen.openrouter.save_b64_image", + side_effect=[Path("/tmp/a.png"), Path("/tmp/b.png")]): + result = _openrouter_image_api().generate(prompt="p", model="openai/gpt-image-2") + + assert result["image"] == "/tmp/a.png" + assert result["additional_images"] == ["/tmp/b.png"] + + def test_empty_data_is_typed(self): + with patch(_RUNTIME, return_value=_runtime_ok()), \ + patch("requests.post", return_value=_mock_image_api_response([])): + result = _openrouter_image_api().generate(prompt="p", model="openai/gpt-image-2") + + assert result["success"] is False + assert result["error_type"] == "empty_response" + + def test_zod_validation_error_is_flattened(self): + from plugins.image_gen.openrouter import _extract_image_api_error + + resp = MagicMock() + resp.json.return_value = { + "success": False, + "error": { + "name": "ZodError", + "message": '[{"path":["aspect_ratio"],"message":"Invalid option"}]', + }, + } + assert _extract_image_api_error(resp, "fb").startswith("aspect_ratio: Invalid option") + + def test_auth_error_is_not_retried_as_api_error(self): + import requests as req_lib + + resp = MagicMock() + resp.status_code = 401 + resp.text = "Unauthorized" + resp.json.return_value = {"error": {"message": "Invalid API key"}} + resp.raise_for_status.side_effect = req_lib.HTTPError(response=resp) + + with patch(_RUNTIME, return_value=_runtime_ok()), \ + patch("requests.post", return_value=resp): + result = _openrouter_image_api().generate(prompt="p", model="openai/gpt-image-2") + + assert result["success"] is False + assert result["error_type"] == "auth_error" + assert "_retryable" not in result + + def test_catalog_models_are_offered_only_by_openrouter(self): + from plugins.image_gen.openrouter import _IMAGE_API_MODELS, _build_providers + + by_name = {p.name: p for p in _build_providers()} + openrouter_ids = {m["id"] for m in by_name["openrouter"].list_models()} + nous_ids = {m["id"] for m in by_name["nous"].list_models()} + assert "openai/gpt-image-2" in openrouter_ids + assert set(_IMAGE_API_MODELS) <= openrouter_ids + assert not (set(_IMAGE_API_MODELS) & nous_ids) + + def test_default_model_is_unchanged_by_the_new_surface(self): + from plugins.image_gen.openrouter import DEFAULT_MODEL + + assert _openrouter_image_api().default_model() == DEFAULT_MODEL + + class TestRegistration: def test_register_both(self): from plugins.image_gen.openrouter import register