diff --git a/hokusai/builder.py b/hokusai/builder.py index 322632f..087e1f9 100644 --- a/hokusai/builder.py +++ b/hokusai/builder.py @@ -91,9 +91,9 @@ def _collect_extra_params(target_name: str, config: ProjectConfig) -> dict[str, if target_cfg.height is not None: params["height"] = target_cfg.height if target_cfg.reference_images: - params["reference_images"] = list(target_cfg.reference_images) + params["reference_images"] = tuple(target_cfg.reference_images) if target_cfg.control_images: - params["control_images"] = list(target_cfg.control_images) + params["control_images"] = tuple(target_cfg.control_images) return params diff --git a/hokusai/providers/openai_image.py b/hokusai/providers/openai_image.py index 2bbb2d9..3cedb0b 100644 --- a/hokusai/providers/openai_image.py +++ b/hokusai/providers/openai_image.py @@ -3,7 +3,6 @@ from __future__ import annotations import base64 -import io from pathlib import Path from typing import Literal, override @@ -129,6 +128,7 @@ class OpenAIImageProvider(Provider): target_config.reference_images, project_dir, size, + output_format, ) else: response = await _generate_new( @@ -183,30 +183,17 @@ async def _generate_edit( reference_images: list[str], project_dir: Path, size: _SIZE | None, + output_format: str | None = None, ) -> ImagesResponse: """Generate an image using reference images via the edits endpoint. gpt-image-* models accept up to 16 images and return b64 by default - (they reject ``response_format`` and ``output_format``). - DALL-E 2 accepts only one image. + (they reject ``response_format``). DALL-E 2 accepts only one image. """ - raw_images = [(project_dir / name).read_bytes() for name in reference_images] + images = [(project_dir / name).read_bytes() for name in reference_images] + image: bytes | list[bytes] = images[0] if len(images) == 1 else images if model.startswith("gpt-image-"): - # gpt-image-* models require file-like objects with a name attribute; - # raw bytes trigger the legacy multipart path that only accepts dall-e-2. - def _to_named_buf(data: bytes, name: str) -> io.BytesIO: - buf = io.BytesIO(data) - buf.name = name - return buf - - file_images = [ - _to_named_buf(data, name) - for data, name in zip(raw_images, reference_images, strict=True) - ] - image: io.BytesIO | list[io.BytesIO] = ( - file_images[0] if len(file_images) == 1 else file_images - ) kwargs: dict[str, object] = { "image": image, "prompt": prompt, @@ -215,13 +202,12 @@ async def _generate_edit( } if size is not None: kwargs["size"] = size + if output_format is not None: + kwargs["output_format"] = output_format return await client.images.edit(**kwargs) # pyright: ignore[reportCallIssue,reportArgumentType,reportUnknownVariableType] - dalle_image: bytes | list[bytes] = ( - raw_images[0] if len(raw_images) == 1 else raw_images - ) kwargs = { - "image": dalle_image, + "image": image, "prompt": prompt, "model": model, "n": 1, diff --git a/pyproject.toml b/pyproject.toml index 0424d07..8045ffa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "httpx>=0.27.0", "mistralai>=1.0.0", "networkx>=3.6.1", - "openai>=2.25.0", + "openai>=2.21.0", "pillow>=11.0.0", "pydantic>=2.12.5", "pyyaml>=6.0", diff --git a/tests/test_builder.py b/tests/test_builder.py index 2ef9c80..3d19d8e 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -152,7 +152,7 @@ class TestCollectHelpers: params = _collect_extra_params("out.png", config) assert params["width"] == 512 assert params["height"] == 256 - assert params["reference_images"] == ["ref.png"] + assert params["reference_images"] == ("ref.png",) def test_collect_extra_params_empty(self, write_config: WriteConfig) -> None: config = write_config({"targets": {"out.txt": {"prompt": "x"}}}) diff --git a/tests/test_providers.py b/tests/test_providers.py index 0e0659f..1d63e01 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -7,7 +7,6 @@ Mock-heavy tests produce many Any-typed expressions from MagicMock. from __future__ import annotations import base64 -import io from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -481,10 +480,8 @@ class TestOpenAIImageProvider: ) call_args = mock_client.images.edit.call_args - # gpt-image-* models pass a BytesIO with a name attribute - img_arg = call_args.kwargs["image"] - assert img_arg.read() == b"reference data" - assert hasattr(img_arg, "name") + # Single reference image should be passed as raw bytes + assert call_args.kwargs["image"] == b"reference data" output = project_dir / "out.png" assert output.exists() @@ -517,9 +514,9 @@ class TestOpenAIImageProvider: ) call_args = mock_client.images.edit.call_args - # gpt-image-* models pass a list of BytesIO with name attributes - image_arg: list[io.BytesIO] = call_args.kwargs["image"] + # Multiple reference images should be passed as a list of bytes + image_arg: list[bytes] = call_args.kwargs["image"] assert isinstance(image_arg, list) assert len(image_arg) == 2 - assert image_arg[0].read() == b"ref1 data" - assert image_arg[1].read() == b"ref2 data" + assert image_arg[0] == b"ref1 data" + assert image_arg[1] == b"ref2 data" diff --git a/uv.lock b/uv.lock index 8ea6e5f..fb50a73 100644 --- a/uv.lock +++ b/uv.lock @@ -184,7 +184,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "mistralai", specifier = ">=1.0.0" }, { name = "networkx", specifier = ">=3.6.1" }, - { name = "openai", specifier = ">=2.25.0" }, + { name = "openai", specifier = ">=2.21.0" }, { name = "pillow", specifier = ">=11.0.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pyyaml", specifier = ">=6.0" }, @@ -386,7 +386,7 @@ wheels = [ [[package]] name = "openai" -version = "2.25.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -398,9 +398,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/2b/a442b206ed74908dd79e2ad1ef3ffaeae66422b1fb506af981f0ef671ba0/openai-2.25.0.tar.gz", hash = "sha256:c3e1965d83c333dbd341eb2c7c8aceb783c272cd57fc57353404d9a443634e29", size = 666577, upload-time = "2026-03-05T18:35:38.292Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/e5/3d197a0947a166649f566706d7a4c8f7fe38f1fa7b24c9bcffe4c7591d44/openai-2.21.0.tar.gz", hash = "sha256:81b48ce4b8bbb2cc3af02047ceb19561f7b1dc0d4e52d1de7f02abfd15aa59b7", size = 644374, upload-time = "2026-02-14T00:12:01.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/e6/ca496976bacdd7f86d4fa69359c2d9b5a547d7acbf0ae5cac7bff107ff50/openai-2.25.0-py3-none-any.whl", hash = "sha256:7c7c01a0a4b69771a29913e28d06bc1e9cee8781b4d206bb56cb946d8d2fcb23", size = 1136347, upload-time = "2026-03-05T18:35:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/0a89092a453bb2c676d66abee44f863e742b2110d4dbb1dbcca3f7e5fc33/openai-2.21.0-py3-none-any.whl", hash = "sha256:0bc1c775e5b1536c294eded39ee08f8407656537ccc71b1004104fe1602e267c", size = 1103065, upload-time = "2026-02-14T00:11:59.603Z" }, ] [[package]]