Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions plugins/image_gen/openrouter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@
# image model first, then fall back to Gemini 3 Pro Image if the OpenAI model
# is access-gated / unavailable / times out on this endpoint.
#
# Explicit override (OPENROUTER_IMAGE_MODEL or image_gen.<provider>.model):
# use exactly that model (no auto fallback), so power users keep full control.
# Explicit override (OPENROUTER_IMAGE_MODEL, image_gen.<provider>.model, or
# image_gen.model from ``hermes tools``): use exactly that model (no auto
# fallback), so power users keep full control.
DEFAULT_MODEL = "openai/gpt-5.4-image-2"
_FALLBACK_MODEL = "google/gemini-3-pro-image"
_DEFAULT_MODEL_CHAIN = (DEFAULT_MODEL, _FALLBACK_MODEL)
Expand Down Expand Up @@ -243,16 +244,23 @@ def default_model(self) -> Optional[str]:
def get_setup_schema(self) -> Dict[str, Any]:
return dict(self._setup_schema)

def _resolve_model(self) -> str:
"""Pick the image model: env override → config → :data:`DEFAULT_MODEL`."""
return self._resolve_model_chain()[0]
def _resolve_model(self, explicit: Optional[str] = None) -> str:
"""Pick the image model (first of :meth:`_resolve_model_chain`)."""
return self._resolve_model_chain(explicit)[0]

def _resolve_model_chain(self) -> list[str]:
def _resolve_model_chain(self, explicit: Optional[str] = None) -> list[str]:
"""Ordered model attempts for this request.

Explicit user/model config means "use this exact model", so no fallback.
Without overrides we run the quality-first default chain.
Precedence: explicit caller override (the ``model`` kwarg) → the
provider's ``*_IMAGE_MODEL`` env override → scoped
``image_gen.<provider>.model`` → top-level ``image_gen.model`` (written
by ``hermes tools``) → the quality-first default chain.

Any explicit user/model selection means "use this exact model", so no
fallback. Only the bare default chain carries a Gemini fallback.
"""
if isinstance(explicit, str) and explicit.strip():
return [explicit.strip()]
env_override = os.environ.get(self._model_env_var, "").strip()
if env_override:
return [env_override]
Expand All @@ -262,6 +270,9 @@ def _resolve_model_chain(self) -> list[str]:
value = scoped.get("model")
if isinstance(value, str) and value.strip():
return [value.strip()]
top = cfg.get("model")
if isinstance(top, str) and top.strip():
return [top.strip()]
return _dedupe_models(list(_DEFAULT_MODEL_CHAIN))

def generate(
Expand Down Expand Up @@ -297,7 +308,7 @@ def generate(
aspect_ratio=aspect_ratio,
)

model_chain = self._resolve_model_chain()
model_chain = self._resolve_model_chain(kwargs.get("model"))
aspect = resolve_aspect_ratio(aspect_ratio)
or_aspect = _ASPECT_RATIOS.get(aspect, "1:1")

Expand Down
31 changes: 31 additions & 0 deletions tests/plugins/image_gen/test_openrouter_compat_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,26 @@ def test_model_config_override(self):
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
assert _openrouter()._resolve_model() == "google/gemini-3.1-flash-image-preview"

def test_model_top_level_config_override(self):
cfg = {"model": "openai/gpt-image-2"}
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
assert _openrouter()._resolve_model_chain() == ["openai/gpt-image-2"]

def test_nous_honors_top_level_model(self):
from plugins.image_gen.openrouter import _build_providers

cfg = {"model": "openai/gpt-image-2"}
nous = {p.name: p for p in _build_providers()}["nous"]
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
assert nous._resolve_model_chain() == ["openai/gpt-image-2"]

def test_explicit_model_kwarg_wins_over_config(self):
cfg = {"model": "openai/gpt-image-2"}
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
assert _openrouter()._resolve_model_chain("google/gemini-3-pro-image") == [
"google/gemini-3-pro-image"
]


# ---------------------------------------------------------------------------
# Helpers
Expand Down Expand Up @@ -267,6 +287,17 @@ def test_auth_header(self):
headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer sk-or-test"

def test_generate_uses_model_kwarg_from_dispatch(self):
"""image_generate passes image_gen.model as a model kwarg — honor it."""
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
result = _openrouter().generate(prompt="a pet", model="openai/gpt-image-2")

assert result["success"] is True
assert result["model"] == "openai/gpt-image-2"
assert mock_post.call_args.kwargs["json"]["model"] == "openai/gpt-image-2"

def test_posts_to_resolved_base_url(self):
"""Nous routes to its own base URL — proves the same code serves both."""
nous_runtime = _runtime_ok(
Expand Down
Loading