feat(plugins): add OpenRouter Image API surface to openrouter image_gen backend

This commit is contained in:
AI Staff
2026-08-09 17:55:28 +00:00
committed by Teknium
parent 49cc3708e5
commit d6e6e8b602
3 changed files with 1209 additions and 15 deletions
+881 -13
View File
@@ -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": "<json>"}}``
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",
+2 -2
View File
@@ -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:
@@ -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