diff --git a/plugins/image_gen/openrouter/__init__.py b/plugins/image_gen/openrouter/__init__.py index 15417737b2..3899f5fa93 100644 --- a/plugins/image_gen/openrouter/__init__.py +++ b/plugins/image_gen/openrouter/__init__.py @@ -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(). diff --git a/plugins/image_gen/xai/__init__.py b/plugins/image_gen/xai/__init__.py index 081748ed1b..f35ffacfd0 100644 --- a/plugins/image_gen/xai/__init__.py +++ b/plugins/image_gen/xai/__init__.py @@ -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: diff --git a/tests/plugins/image_gen/test_openrouter_compat_provider.py b/tests/plugins/image_gen/test_openrouter_compat_provider.py index 5429ddba42..717af024f5 100644 --- a/tests/plugins/image_gen/test_openrouter_compat_provider.py +++ b/tests/plugins/image_gen/test_openrouter_compat_provider.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/plugins/image_gen/test_xai_provider.py b/tests/plugins/image_gen/test_xai_provider.py index 0db7f1c20b..723f54937e 100644 --- a/tests/plugins/image_gen/test_xai_provider.py +++ b/tests/plugins/image_gen/test_xai_provider.py @@ -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