feat(image-gen): OpenRouter image picker lists every live image-output model; xAI edits honor dispatched model

- plugins/image_gen/openrouter: list_models() now queries the endpoint's
  /models catalog filtered to output_modalities containing "image"
  (per-backend 5-min cache, 10s timeout, static 2-model chain as offline
  fallback; openrouter/auto* router pseudo-models excluded). Every image
  model OpenRouter serves — including future releases — is selectable in
  `hermes tools` with no code change. Applies to Nous Portal too via the
  shared provider class.
- plugins/image_gen/xai: forward the dispatched model kwarg into
  _resolve_edit_model() so an explicitly selected edit-capable model is
  honored on /images/edits (extends the salvaged #55893 fix to the edit
  path; text-only models still fall back to quality).
- Tests: OpenRouter live-catalog filtering/exclusions/order, offline
  fallback, cache single-fetch; xAI edit-kwarg forwarding incl. the
  text-only-hijack negative case.

Live-verified against openrouter.ai: 9 image-output models returned and
rendered, matching the public models?output_modalities=image listing.
This commit is contained in:
Teknium
2026-08-19 01:46:44 -07:00
parent 008d469991
commit 9c6ecf2ca7
4 changed files with 195 additions and 6 deletions

View File

@@ -173,6 +173,73 @@ def _dedupe_models(models: list[str]) -> list[str]:
return out
# Curated metadata for well-known image models; anything else discovered via
# the live catalog gets a generic strengths line.
_KNOWN_MODEL_META = {
DEFAULT_MODEL: {
"display": "OpenAI GPT-5.4 Image 2",
"strengths": "Highest fidelity; best prompt adherence; slower on OpenRouter",
},
_FALLBACK_MODEL: {
"display": "Gemini 3 Pro Image",
"strengths": "Fast, reliable fallback with good layout adherence",
},
}
# Router pseudo-models advertise image output but are not image models.
_EXCLUDED_MODEL_PREFIXES = ("openrouter/auto",)
_LIVE_CACHE_TTL = 300.0
_LIVE_TIMEOUT = 10.0
def _fetch_live_image_models(base_url: str, api_key: str) -> List[Dict[str, Any]]:
"""List image-output models from the endpoint's ``/models`` catalog.
Filters on ``architecture.output_modalities`` containing ``image`` and
drops router pseudo-models (``openrouter/auto*``). Raises on failure —
callers fall back to the static default chain.
"""
import requests
response = requests.get(
f"{base_url}/models",
headers={"Authorization": f"Bearer {api_key}"} if api_key else {},
timeout=_LIVE_TIMEOUT,
)
response.raise_for_status()
entries = response.json().get("data") or []
out: List[Dict[str, Any]] = []
for entry in entries:
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()
if model_id.startswith(_EXCLUDED_MODEL_PREFIXES):
continue
arch_raw = entry.get("architecture")
arch: Dict[str, Any] = arch_raw if isinstance(arch_raw, dict) else {}
if "image" not in (arch.get("output_modalities") or []):
continue
meta = _KNOWN_MODEL_META.get(model_id, {})
out.append(
{
"id": model_id,
"display": meta.get("display", entry.get("name") or model_id),
"strengths": meta.get(
"strengths", "Image-output model (from live OpenRouter catalog)"
),
"input_modalities": arch.get("input_modalities") or [],
}
)
# Stable order: defaults first, then alphabetical.
priority = {DEFAULT_MODEL: 0, _FALLBACK_MODEL: 1}
out.sort(key=lambda m: (priority.get(m["id"], 2), m["id"]))
return out
class OpenRouterCompatImageProvider(ImageGenProvider):
"""Image generation over an OpenRouter-compatible chat-completions endpoint.
@@ -197,6 +264,7 @@ class OpenRouterCompatImageProvider(ImageGenProvider):
self._config_key = config_key
self._model_env_var = model_env_var
self._setup_schema = setup_schema
self._live_models_cache: Optional[tuple] = None
@property
def name(self) -> str:
@@ -229,6 +297,16 @@ class OpenRouterCompatImageProvider(ImageGenProvider):
}
def list_models(self) -> List[Dict[str, Any]]:
"""Picker catalog: live image-output models, static chain as fallback.
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``.
"""
live = self._live_models()
if live:
return live
return [
{
"id": DEFAULT_MODEL,
@@ -242,6 +320,26 @@ class OpenRouterCompatImageProvider(ImageGenProvider):
},
]
def _live_models(self) -> List[Dict[str, Any]]:
"""Cached live catalog for this backend (``[]`` when unreachable)."""
import time
cached = self._live_models_cache
if cached is not None and time.monotonic() - cached[1] < _LIVE_CACHE_TTL:
return cached[0]
models: List[Dict[str, Any]] = []
try:
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:
models = _fetch_live_image_models(base_url, api_key)
except Exception as exc: # noqa: BLE001 - offline/unauth → static fallback
logger.debug("%s live image model catalog unavailable: %s", self._name, exc)
models = []
self._live_models_cache = (models, time.monotonic())
return models
def default_model(self) -> Optional[str]:
# This is the catalog default, not the effective runtime model.
# Runtime overrides are resolved separately by _resolve_model_chain().

View File

@@ -225,15 +225,15 @@ def _resolve_model(caller_model: Optional[str] = None) -> Tuple[str, Dict[str, A
return DEFAULT_MODEL, catalog.get(DEFAULT_MODEL, _MODELS[DEFAULT_MODEL])
def _resolve_edit_model() -> str:
def _resolve_edit_model(caller_model: Optional[str] = None) -> str:
"""Model for ``/v1/images/edits`` requests.
An explicitly selected model (env or config) that accepts image input
is honored for edits; otherwise fall back to the quality model, which
xAI documents as the edit-capable baseline.
An explicitly selected model (caller kwarg, env, or config) that accepts
image input is honored for edits; otherwise fall back to the quality
model, which xAI documents as the edit-capable baseline.
"""
catalog = _catalog()
explicit = os.environ.get("XAI_IMAGE_MODEL") or (
explicit = caller_model or os.environ.get("XAI_IMAGE_MODEL") or (
_load_xai_config().get("model") if isinstance(_load_xai_config().get("model"), str) else None
)
if explicit and explicit in catalog:
@@ -430,7 +430,7 @@ class XAIImageGenProvider(ImageGenProvider):
# is honored; otherwise the documented quality baseline is used.
# The source image may be a public URL or a base64 data URI;
# local file paths are converted to a data URI here.
edit_model = _resolve_edit_model()
edit_model = _resolve_edit_model(kwargs.get("model"))
try:
image_fields = [_xai_image_field(source) for source in source_images]
except Exception as exc:

View File

@@ -129,6 +129,80 @@ class TestProviderClass:
]
# ---------------------------------------------------------------------------
# Live model catalog
# ---------------------------------------------------------------------------
def _mock_models_response(entries):
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.return_value = {"data": entries}
return resp
class TestLiveCatalog:
def test_live_catalog_lists_all_image_output_models(self):
"""Every image-output model on the endpoint is selectable — including
ones released after this code shipped."""
entries = [
{
"id": "openai/gpt-5.4-image-2",
"name": "GPT-5.4 Image 2",
"architecture": {"output_modalities": ["image"], "input_modalities": ["text", "image"]},
},
{
"id": "some-lab/brand-new-image-model",
"name": "Brand New",
"architecture": {"output_modalities": ["image", "text"], "input_modalities": ["text"]},
},
{
"id": "openai/gpt-5.4", # text-only: excluded
"architecture": {"output_modalities": ["text"], "input_modalities": ["text"]},
},
{
"id": "openrouter/auto", # router pseudo-model: excluded
"architecture": {"output_modalities": ["image", "text"], "input_modalities": ["text"]},
},
]
provider = _openrouter()
with patch(_RUNTIME, return_value=_runtime_ok()), patch(
"requests.get", return_value=_mock_models_response(entries)
):
models = provider.list_models()
ids = [m["id"] for m in models]
assert "openai/gpt-5.4-image-2" in ids
assert "some-lab/brand-new-image-model" in ids
assert "openai/gpt-5.4" not in ids
assert "openrouter/auto" not in ids
# Default chain models sort first.
assert ids[0] == "openai/gpt-5.4-image-2"
def test_live_failure_falls_back_to_static_chain(self):
provider = _openrouter()
with patch(_RUNTIME, side_effect=RuntimeError("no creds")):
models = provider.list_models()
from plugins.image_gen.openrouter import DEFAULT_MODEL, _FALLBACK_MODEL
assert [m["id"] for m in models] == [DEFAULT_MODEL, _FALLBACK_MODEL]
def test_live_catalog_is_cached(self):
provider = _openrouter()
entries = [
{
"id": "openai/gpt-5.4-image-2",
"architecture": {"output_modalities": ["image"], "input_modalities": ["text"]},
}
]
with patch(_RUNTIME, return_value=_runtime_ok()), patch(
"requests.get", return_value=_mock_models_response(entries)
) as mock_get:
provider.list_models()
provider.list_models()
assert mock_get.call_count == 1
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

View File

@@ -221,6 +221,23 @@ class TestLiveCatalog:
monkeypatch.delenv("XAI_IMAGE_MODEL", raising=False)
assert xai_mod._resolve_edit_model() == "grok-imagine-image-quality"
def test_edit_model_honors_caller_kwarg(self, monkeypatch):
"""The dispatched model kwarg reaches the edit path too."""
import plugins.image_gen.xai as xai_mod
live = {
"grok-imagine-image-2.0": {"input_modalities": ["text", "image"], "aliases": []},
"grok-imagine-image-quality": {"input_modalities": ["text", "image"], "aliases": []},
}
monkeypatch.setattr(xai_mod, "_fetch_live_models", lambda: live)
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
monkeypatch.delenv("XAI_IMAGE_MODEL", raising=False)
assert xai_mod._resolve_edit_model("grok-imagine-image-2.0") == "grok-imagine-image-2.0"
# Text-only caller model must not hijack the edit path.
live["grok-imagine-image-2.0"]["input_modalities"] = ["text"]
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
assert xai_mod._resolve_edit_model("grok-imagine-image-2.0") == "grok-imagine-image-quality"
# ---------------------------------------------------------------------------
# Generate tests