From 009fae34adaf16a2425236616038c2e3a1eaadd8 Mon Sep 17 00:00:00 2001 From: dejay2 Date: Fri, 24 Apr 2026 20:11:05 +0100 Subject: [PATCH] feat(image_gen): multi-reference input support, starting with openai-codex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in capability contract so providers can accept reference images. The agent-facing `image_generate` tool schema grows an optional `references` field (list of local file paths); the dispatcher forwards it only when the active provider advertises `supports_references=True`, otherwise rejects early with `references_unsupported` instead of silently dropping it. The openai-codex provider opts in and honours references by attaching each file as an `input_image` content item on the user message, labelled in the prompt ("Reference image 1 is provided below.") per OpenAI's best-practice guidance for gpt-image-2 composition. Up to MAX_REFERENCES (16) are sent, matching the documented upstream limit. Empirically verified against the live Codex backend: two references combine correctly (subject + subject → merged scene), and a style-from-one-reference + subject-from-another composition preserves both inputs. This contradicts the assumption baked into the existing baoyu-comic skill (which documents `image_generate` as prompt-only) — that guidance is now accurate only when the configured provider doesn't support references. - agent/image_gen_provider.py: add `supports_references` property (default False) and document the `references` kwarg contract. - plugins/image_gen/openai-codex/__init__.py: set `supports_references=True`, add `_load_reference_images` + `_build_user_content`, wire the references kwarg through `generate()` with invalid-argument / invalid-reference error handling. - tools/image_generation_tool.py: expose `references` in the tool schema, forward via the dispatcher, return `references_unsupported` for FAL and any plugin provider that doesn't opt in. - tests: new `TestReferences` class (7 cases) in the openai-codex tests, new `TestReferencesDispatch` class (4 cases) in the dispatcher tests, and the schema invariant test updated to match the new contract. Full image_gen suite stays green (133 passed). No breaking changes: existing providers (fal, openai, xai) unaffected — they inherit `supports_references=False` and the dispatcher shields them from reference payloads. --- agent/image_gen_provider.py | 19 +++ plugins/image_gen/openai-codex/__init__.py | 122 +++++++++++++- .../image_gen/test_openai_codex_provider.py | 152 ++++++++++++++++++ tests/tools/test_image_generation.py | 17 +- .../test_image_generation_plugin_dispatch.py | 106 ++++++++++++ tools/image_generation_tool.py | 68 +++++++- 6 files changed, 470 insertions(+), 14 deletions(-) diff --git a/agent/image_gen_provider.py b/agent/image_gen_provider.py index 47f65c1b3435..59e633863b61 100644 --- a/agent/image_gen_provider.py +++ b/agent/image_gen_provider.py @@ -76,6 +76,18 @@ def is_available(self) -> bool: """ return True + @property + def supports_references(self) -> bool: + """True when :meth:`generate` accepts a ``references`` kwarg. + + When True, the ``image_generate`` tool schema exposes a ``references`` + field and the dispatcher forwards user-supplied image paths to this + provider. When False (the default), the dispatcher rejects calls that + include references with a clear ``references_unsupported`` error + instead of silently dropping them. + """ + return False + def list_models(self) -> List[Dict[str, Any]]: """Return catalog entries for ``hermes tools`` model picker. @@ -140,6 +152,13 @@ def generate( or :func:`error_response`. ``kwargs`` may contain forward-compat parameters future versions of the schema will expose — implementations should ignore unknown keys. + + Known optional kwargs (implementations opt-in by overriding the + matching capability property): + + - ``references``: ``list[str]`` of local image file paths, forwarded + only when :attr:`supports_references` is True. Typically used for + image-to-image editing or multi-reference composition. """ diff --git a/plugins/image_gen/openai-codex/__init__.py b/plugins/image_gen/openai-codex/__init__.py index ab524dbdd759..8ef9cdf503dc 100644 --- a/plugins/image_gen/openai-codex/__init__.py +++ b/plugins/image_gen/openai-codex/__init__.py @@ -7,6 +7,13 @@ authenticated with Codex/ChatGPT generate images without configuring a separate ``OPENAI_API_KEY``. +Also supports **multi-reference input** (up to 16 images): callers pass +``references=[path1, path2, ...]`` to :meth:`OpenAICodexImageGenProvider.generate` +and each file becomes an ``input_image`` content item on the user message. The +Codex ``image_generation`` tool sees them and uses them for style transfer, +compositing, or image-to-image editing — verified empirically against the +live backend. + Selection precedence for the tier (first hit wins): 1. ``OPENAI_IMAGE_MODEL`` env var (escape hatch for scripts / tests) @@ -19,8 +26,11 @@ from __future__ import annotations +import base64 import logging -from typing import Any, Dict, List, Optional, Tuple +import mimetypes +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple from agent.image_gen_provider import ( DEFAULT_ASPECT_RATIO, @@ -63,6 +73,10 @@ DEFAULT_MODEL = "gpt-image-2-medium" +# Upper bound on the number of reference images accepted in one call — +# matches OpenAI's documented cap for gpt-image-2 edit/composition flows. +MAX_REFERENCES = 16 + _SIZES = { "landscape": "1536x1024", "square": "1024x1024", @@ -161,7 +175,70 @@ def _build_codex_client(): return None -def _collect_image_b64(client: Any, *, prompt: str, size: str, quality: str) -> Optional[str]: +def _load_reference_images( + paths: Sequence[Any], +) -> Tuple[List[Tuple[str, str]], Optional[str]]: + """Load reference images from disk into (mime, base64) pairs. + + Returns ``(loaded, error)``. ``loaded`` is capped at :data:`MAX_REFERENCES`. + ``error`` is a short string describing the first invalid entry, or None + when every path resolved to a readable image file. + """ + loaded: List[Tuple[str, str]] = [] + for raw in paths: + if len(loaded) >= MAX_REFERENCES: + break + if not isinstance(raw, (str, Path)): + return loaded, f"reference path must be a string, got {type(raw).__name__}" + path = Path(str(raw)).expanduser() + if not path.is_file(): + return loaded, f"reference not found: {path}" + mime, _ = mimetypes.guess_type(path.name) + if not mime or not mime.startswith("image/"): + mime = "image/png" + try: + encoded = base64.b64encode(path.read_bytes()).decode() + except OSError as exc: + return loaded, f"could not read reference {path}: {exc}" + loaded.append((mime, encoded)) + return loaded, None + + +def _build_user_content( + prompt: str, + references: Sequence[Tuple[str, str]], +) -> List[Dict[str, Any]]: + """Build the Responses API ``content`` array for the user message. + + Multiple references are labelled in the prompt ("Reference image 1 …") + so the user's instructions can refer to them by index — matches OpenAI's + published best practice for multi-reference composition. + """ + if references: + labelled = prompt + "\n\n" + "\n".join( + f"Reference image {i + 1} is provided below." + for i in range(len(references)) + ) + else: + labelled = prompt + + content: List[Dict[str, Any]] = [{"type": "input_text", "text": labelled}] + for mime, b64 in references: + content.append({ + "type": "input_image", + "image_url": f"data:{mime};base64,{b64}", + }) + return content + + +def _collect_image_b64( + client: Any, + *, + prompt: str, + size: str, + quality: str, + references: Sequence[Tuple[str, str]] = (), +) -> Optional[str]: """Stream a Codex Responses image_generation call and return the b64 image.""" image_b64: Optional[str] = None @@ -172,7 +249,7 @@ def _collect_image_b64(client: Any, *, prompt: str, size: str, quality: str) -> input=[{ "type": "message", "role": "user", - "content": [{"type": "input_text", "text": prompt}], + "content": _build_user_content(prompt, references), }], tools=[{ "type": "image_generation", @@ -230,6 +307,13 @@ def name(self) -> str: def display_name(self) -> str: return "OpenAI (Codex auth)" + @property + def supports_references(self) -> bool: + # The Codex ``image_generation`` tool accepts ``input_image`` content + # items on the user message and uses them for composition, style + # transfer, and edits — verified empirically against the live backend. + return True + def is_available(self) -> bool: if not _read_codex_access_token(): return False @@ -307,6 +391,31 @@ def generate( tier_id, meta = _resolve_model() size = _SIZES.get(aspect, _SIZES["square"]) + raw_refs = kwargs.get("references") or [] + if not isinstance(raw_refs, (list, tuple)): + return error_response( + error=( + f"references must be a list of image paths, got " + f"{type(raw_refs).__name__}" + ), + error_type="invalid_argument", + provider="openai-codex", + model=tier_id, + prompt=prompt, + aspect_ratio=aspect, + ) + + references, ref_error = _load_reference_images(raw_refs) + if ref_error is not None: + return error_response( + error=ref_error, + error_type="invalid_reference", + provider="openai-codex", + model=tier_id, + prompt=prompt, + aspect_ratio=aspect, + ) + client = _build_codex_client() if client is None: return error_response( @@ -324,6 +433,7 @@ def generate( prompt=prompt, size=size, quality=meta["quality"], + references=references, ) except Exception as exc: logger.debug("Codex image generation failed", exc_info=True) @@ -364,7 +474,11 @@ def generate( prompt=prompt, aspect_ratio=aspect, provider="openai-codex", - extra={"size": size, "quality": meta["quality"]}, + extra={ + "size": size, + "quality": meta["quality"], + "references": len(references), + }, ) diff --git a/tests/plugins/image_gen/test_openai_codex_provider.py b/tests/plugins/image_gen/test_openai_codex_provider.py index 3c8cf86c0a6f..f7dc335572f3 100644 --- a/tests/plugins/image_gen/test_openai_codex_provider.py +++ b/tests/plugins/image_gen/test_openai_codex_provider.py @@ -283,6 +283,158 @@ def _boom(**kwargs): assert "cloudflare 403" in result["error"] +# ── References (multi-reference image input) ─────────────────────────────── + + +def _write_png(path: Path) -> Path: + path.write_bytes(bytes.fromhex(_PNG_HEX)) + return path + + +class TestReferences: + def test_supports_references_flag_is_true(self, provider): + # The Codex image_generation tool accepts input_image content items; + # the flag is how the dispatcher knows it can forward user-supplied + # reference paths without silently dropping them. + assert provider.supports_references is True + + def test_references_become_input_image_content_items( + self, provider, monkeypatch, tmp_path + ): + monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token") + + ref1 = _write_png(tmp_path / "ref1.png") + ref2 = _write_png(tmp_path / "ref2.png") + + captured: dict = {} + + def _stream(**kwargs): + captured.update(kwargs) + output_item = SimpleNamespace( + type="image_generation_call", + status="generating", + id="ig_test", + result=_b64_png(), + ) + done_event = SimpleNamespace(type="response.output_item.done", item=output_item) + final_response = SimpleNamespace(output=[], status="completed", output_text="") + return _FakeStream([done_event], final_response) + + fake_client = SimpleNamespace(responses=SimpleNamespace(stream=_stream)) + monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client) + + result = provider.generate( + "combine these two objects", + aspect_ratio="square", + references=[str(ref1), str(ref2)], + ) + assert result["success"] is True + assert result["references"] == 2 + + content = captured["input"][0]["content"] + # First item is the labelled prompt, then one input_image per reference. + assert content[0]["type"] == "input_text" + assert "Reference image 1" in content[0]["text"] + assert "Reference image 2" in content[0]["text"] + image_items = [c for c in content if c["type"] == "input_image"] + assert len(image_items) == 2 + for item in image_items: + assert item["image_url"].startswith("data:image/") + assert ";base64," in item["image_url"] + + def test_references_cap_at_max(self, provider, monkeypatch, tmp_path): + monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token") + + # 20 valid PNG paths — the plugin should only forward the first 16. + paths = [str(_write_png(tmp_path / f"ref{i}.png")) for i in range(20)] + + captured: dict = {} + + def _stream(**kwargs): + captured.update(kwargs) + output_item = SimpleNamespace( + type="image_generation_call", + status="generating", + id="ig_test", + result=_b64_png(), + ) + done_event = SimpleNamespace(type="response.output_item.done", item=output_item) + final_response = SimpleNamespace(output=[], status="completed", output_text="") + return _FakeStream([done_event], final_response) + + fake_client = SimpleNamespace(responses=SimpleNamespace(stream=_stream)) + monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client) + + result = provider.generate("test", references=paths) + assert result["success"] is True + assert result["references"] == codex_plugin.MAX_REFERENCES + + image_items = [ + c for c in captured["input"][0]["content"] if c["type"] == "input_image" + ] + assert len(image_items) == codex_plugin.MAX_REFERENCES + + def test_missing_reference_returns_invalid_reference( + self, provider, monkeypatch, tmp_path + ): + monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token") + monkeypatch.setattr( + codex_plugin, + "_build_codex_client", + lambda: SimpleNamespace( + responses=SimpleNamespace( + stream=lambda **kw: pytest.fail("stream must not be called on invalid ref") + ) + ), + ) + + result = provider.generate( + "test", + references=[str(tmp_path / "does-not-exist.png")], + ) + assert result["success"] is False + assert result["error_type"] == "invalid_reference" + assert "does-not-exist.png" in result["error"] + + def test_non_list_references_rejected(self, provider, monkeypatch): + monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token") + + result = provider.generate("test", references="not-a-list") + assert result["success"] is False + assert result["error_type"] == "invalid_argument" + + def test_zero_references_behaves_like_prompt_only( + self, provider, monkeypatch + ): + # Regression guard: an explicit empty list must not add Reference-image + # labelling to the prompt or any input_image items. + monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token") + + captured: dict = {} + + def _stream(**kwargs): + captured.update(kwargs) + output_item = SimpleNamespace( + type="image_generation_call", + status="generating", + id="ig_test", + result=_b64_png(), + ) + done_event = SimpleNamespace(type="response.output_item.done", item=output_item) + final_response = SimpleNamespace(output=[], status="completed", output_text="") + return _FakeStream([done_event], final_response) + + fake_client = SimpleNamespace(responses=SimpleNamespace(stream=_stream)) + monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client) + + result = provider.generate("a cat", references=[]) + assert result["success"] is True + assert result["references"] == 0 + content = captured["input"][0]["content"] + assert content[0]["text"] == "a cat" + assert all(c["type"] != "input_image" for c in content) + + # ── Plugin entry point ────────────────────────────────────────────────────── diff --git a/tests/tools/test_image_generation.py b/tests/tools/test_image_generation.py index b24e6bc1fcc2..6567b994a467 100644 --- a/tests/tools/test_image_generation.py +++ b/tests/tools/test_image_generation.py @@ -363,11 +363,22 @@ def test_empty_aspect_defaults_to_landscape(self, image_tool): class TestRegistryIntegration: - def test_schema_exposes_only_prompt_and_aspect_ratio_to_agent(self, image_tool): + def test_schema_exposes_only_agent_level_fields(self, image_tool): """The agent-facing schema must stay tight — model selection is a - user-level config choice, not an agent-level arg.""" + user-level config choice, not an agent-level arg. References are + allowed because they're per-call, not a config knob.""" props = image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["properties"] - assert set(props.keys()) == {"prompt", "aspect_ratio"} + assert set(props.keys()) == {"prompt", "aspect_ratio", "references"} + + def test_prompt_is_the_only_required_field(self, image_tool): + # References are additive — a plain text-to-image call must still + # work without them. + assert image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["required"] == ["prompt"] + + def test_references_schema_shape(self, image_tool): + refs = image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["properties"]["references"] + assert refs["type"] == "array" + assert refs["items"] == {"type": "string"} def test_aspect_ratio_enum_is_three_values(self, image_tool): enum = image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["properties"]["aspect_ratio"]["enum"] diff --git a/tests/tools/test_image_generation_plugin_dispatch.py b/tests/tools/test_image_generation_plugin_dispatch.py index fa8ca9d959c9..815cfcd9ad0f 100644 --- a/tests/tools/test_image_generation_plugin_dispatch.py +++ b/tests/tools/test_image_generation_plugin_dispatch.py @@ -97,3 +97,109 @@ def fake_ensure_plugins_discovered(force=False): assert payload["success"] is True assert payload["provider"] == "codex" assert payload["aspect_ratio"] == "portrait" + + +class _FakeReferenceCapableProvider(ImageGenProvider): + """Provider that opts in to references — records what the dispatcher forwards.""" + + def __init__(self): + self.last_call: dict = {} + + @property + def name(self) -> str: + return "ref-capable" + + @property + def supports_references(self) -> bool: + return True + + def generate(self, prompt, aspect_ratio="landscape", **kwargs): + self.last_call = { + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "references": kwargs.get("references"), + } + return { + "success": True, + "image": "/tmp/ref.png", + "model": "ref-model", + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "provider": "ref-capable", + "references": len(kwargs.get("references") or []), + } + + +class TestReferencesDispatch: + def test_references_forwarded_to_capable_provider(self, monkeypatch, tmp_path): + from tools import image_generation_tool + from hermes_cli import plugins as plugins_module + from agent import image_gen_registry as registry_module + + prov = _FakeReferenceCapableProvider() + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(image_generation_tool, "_read_configured_image_provider", lambda: "ref-capable") + monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda force=False: None) + monkeypatch.setattr(registry_module, "get_provider", lambda name: prov) + + dispatched = image_generation_tool._dispatch_to_plugin_provider( + "merge these", + "square", + references=["/path/a.png", "/path/b.png"], + ) + payload = json.loads(dispatched) + + assert payload["success"] is True + assert prov.last_call["references"] == ["/path/a.png", "/path/b.png"] + + def test_references_rejected_for_non_capable_provider(self, monkeypatch, tmp_path): + from tools import image_generation_tool + from hermes_cli import plugins as plugins_module + from agent import image_gen_registry as registry_module + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(image_generation_tool, "_read_configured_image_provider", lambda: "codex") + monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda force=False: None) + monkeypatch.setattr(registry_module, "get_provider", lambda name: _FakeCodexProvider()) + + dispatched = image_generation_tool._dispatch_to_plugin_provider( + "merge these", + "square", + references=["/path/a.png"], + ) + payload = json.loads(dispatched) + + assert payload["success"] is False + assert payload["error_type"] == "references_unsupported" + + def test_references_rejected_for_fal_fallback(self, monkeypatch, tmp_path): + # FAL is the in-tree default; the dispatcher short-circuits to None + # for it, but references must still be rejected so the caller gets + # an actionable error instead of a silently dropped kwarg. + from tools import image_generation_tool + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(image_generation_tool, "_read_configured_image_provider", lambda: "fal") + + dispatched = image_generation_tool._dispatch_to_plugin_provider( + "merge these", + "square", + references=["/path/a.png"], + ) + payload = json.loads(dispatched) + + assert payload["success"] is False + assert payload["error_type"] == "references_unsupported" + + def test_no_references_still_falls_through_to_fal(self, monkeypatch, tmp_path): + from tools import image_generation_tool + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(image_generation_tool, "_read_configured_image_provider", lambda: "fal") + + # No references → original fall-through behaviour preserved. + dispatched = image_generation_tool._dispatch_to_plugin_provider( + "just a cat", "square" + ) + assert dispatched is None diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index ac374497833b..db56107958de 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -26,7 +26,7 @@ import datetime import threading import uuid -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union from urllib.parse import urlencode import fal_client @@ -854,18 +854,22 @@ def check_image_generation_requirements() -> bool: IMAGE_GENERATE_SCHEMA = { "name": "image_generate", "description": ( - "Generate high-quality images from text prompts. The underlying " + "Generate high-quality images from text prompts, optionally " + "conditioned on one or more reference images. The underlying " "backend (FAL, OpenAI, etc.) and model are user-configured and not " "selectable by the agent. Returns either a URL or an absolute file " "path in the `image` field; display it with markdown " - "![description](url-or-path) and the gateway will deliver it." + "![description](url-or-path) and the gateway will deliver it. " + "Reference images only work when the active provider advertises " + "support for them — otherwise the call fails early with " + "error_type='references_unsupported'." ), "parameters": { "type": "object", "properties": { "prompt": { "type": "string", - "description": "The text prompt describing the desired image. Be detailed and descriptive.", + "description": "The text prompt describing the desired image. Be detailed and descriptive. When passing references, refer to them by index (\"like reference 1\", \"combine references 2 and 3\").", }, "aspect_ratio": { "type": "string", @@ -873,6 +877,11 @@ def check_image_generation_requirements() -> bool: "description": "The aspect ratio of the generated image. 'landscape' is 16:9 wide, 'portrait' is 16:9 tall, 'square' is 1:1.", "default": DEFAULT_ASPECT_RATIO, }, + "references": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional list of local image file paths to condition generation on (style transfer, image editing, multi-image composition). Currently honoured only by the openai-codex provider; other providers reject calls that include this field.", + }, }, "required": ["prompt"], }, @@ -900,7 +909,11 @@ def _read_configured_image_provider(): return None -def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str): +def _dispatch_to_plugin_provider( + prompt: str, + aspect_ratio: str, + references: Optional[List[str]] = None, +): """Route the call to a plugin-registered provider when one is selected. Returns a JSON string on dispatch, or ``None`` to fall through to the @@ -910,9 +923,27 @@ def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str): it does not point to ``fal`` (FAL still lives in-tree in this PR; a later PR ports it into ``plugins/image_gen/fal/``). Any other value that matches a registered plugin provider wins. + + When ``references`` is non-empty, the active provider must advertise + :attr:`~agent.image_gen_provider.ImageGenProvider.supports_references` — + otherwise the call is rejected early with a clear error rather than + silently dropping the reference images. """ configured = _read_configured_image_provider() if not configured or configured == "fal": + # The in-tree FAL path is prompt-only; reject references here so the + # user gets an actionable error instead of a silently ignored kwarg. + if references: + return json.dumps({ + "success": False, + "image": None, + "error": ( + "The FAL backend does not accept reference images. " + "Switch to a provider that supports them (e.g. " + "openai-codex) or remove the references field." + ), + "error_type": "references_unsupported", + }) return None try: @@ -949,8 +980,24 @@ def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str): "error_type": "provider_not_registered", }) + call_kwargs: Dict[str, Any] = {"prompt": prompt, "aspect_ratio": aspect_ratio} + if references: + if not getattr(provider, "supports_references", False): + return json.dumps({ + "success": False, + "image": None, + "error": ( + f"Provider '{getattr(provider, 'name', '?')}' does not " + f"accept reference images. Switch to a provider that " + f"supports them (e.g. openai-codex) or remove the " + f"references field." + ), + "error_type": "references_unsupported", + }) + call_kwargs["references"] = list(references) + try: - result = provider.generate(prompt=prompt, aspect_ratio=aspect_ratio) + result = provider.generate(**call_kwargs) except Exception as exc: logger.warning( "Image gen provider '%s' raised: %s", @@ -978,9 +1025,16 @@ def _handle_image_generate(args, **kw): return tool_error("prompt is required for image generation") aspect_ratio = args.get("aspect_ratio", DEFAULT_ASPECT_RATIO) + raw_references = args.get("references") or [] + if not isinstance(raw_references, list): + return tool_error( + "references must be a list of image file paths" + ) + references = [r for r in raw_references if isinstance(r, str) and r.strip()] + # Route to a plugin-registered provider if one is active (and it's # not the in-tree FAL path). - dispatched = _dispatch_to_plugin_provider(prompt, aspect_ratio) + dispatched = _dispatch_to_plugin_provider(prompt, aspect_ratio, references) if dispatched is not None: return dispatched