feat(plugins): add OpenRouter Image API surface to openrouter image_gen backend
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user