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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user