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
@@ -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
# ---------------------------------------------------------------------------
@@ -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