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:
@@ -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().
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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