From e386c286745b4496b2a9ff9a8bae30f1248df69c Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Thu, 29 Oct 2026 16:54:27 +0000 Subject: [PATCH] feat(api): add image edit markup overlays Castiron-Internal-PR: https://github.com/openai/openai-python-internal/pull/382 Castiron-Source-SHA: 1d6715c1337133d53127ad3465d881c9e1b9e64b Castiron-Public-Base-SHA: f552fcffcf774520e946121704b5fbbf7ba986e8 --- .castiron.stats.yml | 10 +- api_reference/openapi.transformed.yml | 9 ++ examples/realtime/realtime.py | 34 ++++- scripts/castiron/generated-files.json | 6 +- src/openai/resources/images.py | 46 ++++++- src/openai/types/image_edit_params.py | 7 + tests/api_resources/test_images.py | 4 + tests/lib/test_realtime_example_keepalive.py | 112 ++++++++++++++-- tests/test_image_markup_upload.py | 128 +++++++++++++++++++ 9 files changed, 331 insertions(+), 25 deletions(-) create mode 100644 tests/test_image_markup_upload.py diff --git a/.castiron.stats.yml b/.castiron.stats.yml index eec219ed44..11ea6dbf4d 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,7 +1,7 @@ schema_version: 1 -generation_id: 093e3a9d-ca04-4b14-b524-cdefc6807fe1 -openapi_spec_hash: df8d45213a6894a955338882c36a1d86 -openapi_transformed_spec_hash: 050a1c6e01247b5af767e740b8494686 +generation_id: ca0448a4-c5bb-4dae-b9a8-ce0695616efc +openapi_spec_hash: 8ca9f63bc016670f3fd9c8265f106c33 +openapi_transformed_spec_hash: 2cb5f40bd6fd564475d5b6ef285c9858 config_hash: 8a684aec06907ff8ced7809753e16225 -codegen_sha: 514ca4482332258ac041a8d62a18c51ad31627e3 -codegen_hash: 319a11170cf75d4fb01e168ed07a00b7eecc51b23d5912ff6d9d8552968d127d +codegen_sha: 52a6c414b9565e2a8a3f89f87a4df1a5b20deaea +codegen_hash: 6171d1d87772171c445edcacc04ee274f4d10497fe9da70810d5c778ab57c394 diff --git a/api_reference/openapi.transformed.yml b/api_reference/openapi.transformed.yml index ad43c0fe5f..5d87fa3a9b 100644 --- a/api_reference/openapi.transformed.yml +++ b/api_reference/openapi.transformed.yml @@ -44419,6 +44419,8 @@ components: example: high description: | The quality of the image that will be generated for GPT image models. The GPT image models support `low`, `medium`, and `high`. `gpt-image-2.5-sunburst` and `gpt-image-2.5-flare`, including their `2026-09-08` snapshots, also support `xhigh` and `max`. Defaults to `auto`. + markup: + $ref: '#/components/schemas/markup' required: - prompt - image @@ -46985,6 +46987,9 @@ components: description: Stream partial image results as events. partial_images: $ref: '#/components/schemas/PartialImages' + markup: + $ref: '#/components/schemas/ImageRefParam' + description: A transparent PNG overlaid on the first image, referenced by file ID, HTTPS URL, or base64 data URL. Must match that image's dimensions after orientation and contain transparent pixels. Supports GPT Image 2.5 and 2.6. Cannot be combined with mask. required: - images - prompt @@ -111105,6 +111110,10 @@ components: - async additionalProperties: false description: The effective settings for compacting the agent's context. + markup: + type: string + format: binary + description: A transparent PNG overlaid on the first image to guide the edit. Must match that image's dimensions after orientation and contain transparent pixels. Supports GPT Image 2.5 and 2.6. Cannot be combined with mask. SessionSpendControlResource: type: object properties: diff --git a/examples/realtime/realtime.py b/examples/realtime/realtime.py index 3f4ad1b975..8f81e42ab5 100755 --- a/examples/realtime/realtime.py +++ b/examples/realtime/realtime.py @@ -1,4 +1,9 @@ #!/usr/bin/env -S uv run python +import os +import sys +import codecs +import select + from openai import OpenAI # Azure OpenAI Realtime Docs @@ -8,6 +13,33 @@ # Entra ID auth: https://learn.microsoft.com/azure/ai-services/openai/how-to/managed-identity +def read_input(prompt: str) -> str: + if sys.platform == "win32": + # Windows console input does not support select(). + return input(prompt) + + # SIGINT may reach a WebSocket thread. Poll on the main thread so Python + # can run its signal handler, without leaving a stdin reader at shutdown. + print(prompt, end="", flush=True) + decoder = codecs.getincrementaldecoder(sys.stdin.encoding)(errors=sys.stdin.errors or "strict") + characters: list[str] = [] + while True: + readable, _, _ = select.select([sys.stdin], [], [], 0.1) + if not readable: + continue + # Do not read ahead into the next prompt or block on a partial line. + data = os.read(sys.stdin.fileno(), 1) + character = decoder.decode(data, final=not data) + if character == "\n": + return "".join(characters).removesuffix("\r") + characters.append(character) + if not data: + text = "".join(characters) + if not text: + raise EOFError + return text + + def main() -> None: """The following example demonstrates how to configure OpenAI to use the Realtime API. For an audio example, see push_to_talk_app.py and update the client and model parameter accordingly. @@ -28,7 +60,7 @@ def main() -> None: } ) while True: - user_input = input("Enter a message: ") + user_input = read_input("Enter a message: ") if user_input == "q": break diff --git a/scripts/castiron/generated-files.json b/scripts/castiron/generated-files.json index 774f643726..ab14eec853 100644 --- a/scripts/castiron/generated-files.json +++ b/scripts/castiron/generated-files.json @@ -547,7 +547,7 @@ }, "src/openai/resources/images.py": { "mode": "100644", - "sha256": "f8f5376eeda7b45a90179eb287066447fce19ed761d313de5ab4c59df620b659" + "sha256": "211377871db5f2d21633f93122a131023174ea093828c7a957cd0ece49a03b76" }, "src/openai/resources/live/__init__.py": { "mode": "100644", @@ -4543,7 +4543,7 @@ }, "src/openai/types/image_edit_params.py": { "mode": "100644", - "sha256": "e45d0c075029142ad45cbb557962a6e065d299687449b97405390a9534d5b7bf" + "sha256": "a08fc3c5d22c14b3eb969fa91891e3d3dd7088f9e46749aa5bd59c4e5c558df3" }, "src/openai/types/image_edit_partial_image_event.py": { "mode": "100644", @@ -7999,7 +7999,7 @@ }, "tests/api_resources/test_images.py": { "mode": "100644", - "sha256": "ebd279e1c7d8c8bd3c8148d1aadbeb7dc1ba43a6e2ee7396653e37563e743f3b" + "sha256": "ca9dae5fdc1d35f7a7d1dd2356736628b24d3f11c28e4750f7cea920a6c9804c" }, "tests/api_resources/test_live.py": { "mode": "100644", diff --git a/src/openai/resources/images.py b/src/openai/resources/images.py index e9442df1ff..7d07e59ed0 100644 --- a/src/openai/resources/images.py +++ b/src/openai/resources/images.py @@ -144,6 +144,7 @@ def edit( prompt: str, background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, + markup: FileTypes | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, n: Optional[int] | Omit = omit, @@ -197,6 +198,10 @@ def edit( `gpt-image-1.5`; `gpt-image-1-mini` supports only `low`. For `gpt-image-2`, omit this parameter. Defaults to `low` on supported models. + markup: A transparent PNG overlaid on the first image to guide the edit. Must match that + image's dimensions after orientation and contain transparent pixels. Supports + GPT Image 2.5 and 2.6. Cannot be combined with mask. + mask: An additional image whose fully transparent areas (e.g. where alpha is zero) indicate where `image` should be edited. If there are multiple images provided, the mask will be applied on the first image. Must be a valid PNG file, less than @@ -300,6 +305,7 @@ def edit( stream: Literal[True], background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, + markup: FileTypes | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, n: Optional[int] | Omit = omit, @@ -356,6 +362,10 @@ def edit( `gpt-image-1.5`; `gpt-image-1-mini` supports only `low`. For `gpt-image-2`, omit this parameter. Defaults to `low` on supported models. + markup: A transparent PNG overlaid on the first image to guide the edit. Must match that + image's dimensions after orientation and contain transparent pixels. Supports + GPT Image 2.5 and 2.6. Cannot be combined with mask. + mask: An additional image whose fully transparent areas (e.g. where alpha is zero) indicate where `image` should be edited. If there are multiple images provided, the mask will be applied on the first image. Must be a valid PNG file, less than @@ -455,6 +465,7 @@ def edit( stream: bool, background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, + markup: FileTypes | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, n: Optional[int] | Omit = omit, @@ -511,6 +522,10 @@ def edit( `gpt-image-1.5`; `gpt-image-1-mini` supports only `low`. For `gpt-image-2`, omit this parameter. Defaults to `low` on supported models. + markup: A transparent PNG overlaid on the first image to guide the edit. Must match that + image's dimensions after orientation and contain transparent pixels. Supports + GPT Image 2.5 and 2.6. Cannot be combined with mask. + mask: An additional image whose fully transparent areas (e.g. where alpha is zero) indicate where `image` should be edited. If there are multiple images provided, the mask will be applied on the first image. Must be a valid PNG file, less than @@ -609,6 +624,7 @@ def edit( prompt: str, background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, + markup: FileTypes | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, n: Optional[int] | Omit = omit, @@ -635,6 +651,7 @@ def edit( "prompt": prompt, "background": background, "input_fidelity": input_fidelity, + "markup": markup, "mask": mask, "model": model, "n": n, @@ -648,9 +665,11 @@ def edit( "stream": stream, "user": user, }, - [["image"], ["image", ""], ["mask"]], + [["image"], ["image", ""], ["markup"], ["mask"]], + ) + files = extract_files( + cast(Mapping[str, object], body), paths=[["image"], ["image", ""], ["markup"], ["mask"]] ) - files = extract_files(cast(Mapping[str, object], body), paths=[["image"], ["image", ""], ["mask"]]) # It should be noted that the actual Content-Type header that will be # sent to the server will contain a `boundary` parameter, e.g. # multipart/form-data; boundary=---abc-- @@ -1301,6 +1320,7 @@ async def edit( prompt: str, background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, + markup: FileTypes | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, n: Optional[int] | Omit = omit, @@ -1354,6 +1374,10 @@ async def edit( `gpt-image-1.5`; `gpt-image-1-mini` supports only `low`. For `gpt-image-2`, omit this parameter. Defaults to `low` on supported models. + markup: A transparent PNG overlaid on the first image to guide the edit. Must match that + image's dimensions after orientation and contain transparent pixels. Supports + GPT Image 2.5 and 2.6. Cannot be combined with mask. + mask: An additional image whose fully transparent areas (e.g. where alpha is zero) indicate where `image` should be edited. If there are multiple images provided, the mask will be applied on the first image. Must be a valid PNG file, less than @@ -1457,6 +1481,7 @@ async def edit( stream: Literal[True], background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, + markup: FileTypes | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, n: Optional[int] | Omit = omit, @@ -1513,6 +1538,10 @@ async def edit( `gpt-image-1.5`; `gpt-image-1-mini` supports only `low`. For `gpt-image-2`, omit this parameter. Defaults to `low` on supported models. + markup: A transparent PNG overlaid on the first image to guide the edit. Must match that + image's dimensions after orientation and contain transparent pixels. Supports + GPT Image 2.5 and 2.6. Cannot be combined with mask. + mask: An additional image whose fully transparent areas (e.g. where alpha is zero) indicate where `image` should be edited. If there are multiple images provided, the mask will be applied on the first image. Must be a valid PNG file, less than @@ -1612,6 +1641,7 @@ async def edit( stream: bool, background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, + markup: FileTypes | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, n: Optional[int] | Omit = omit, @@ -1668,6 +1698,10 @@ async def edit( `gpt-image-1.5`; `gpt-image-1-mini` supports only `low`. For `gpt-image-2`, omit this parameter. Defaults to `low` on supported models. + markup: A transparent PNG overlaid on the first image to guide the edit. Must match that + image's dimensions after orientation and contain transparent pixels. Supports + GPT Image 2.5 and 2.6. Cannot be combined with mask. + mask: An additional image whose fully transparent areas (e.g. where alpha is zero) indicate where `image` should be edited. If there are multiple images provided, the mask will be applied on the first image. Must be a valid PNG file, less than @@ -1766,6 +1800,7 @@ async def edit( prompt: str, background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, + markup: FileTypes | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, n: Optional[int] | Omit = omit, @@ -1792,6 +1827,7 @@ async def edit( "prompt": prompt, "background": background, "input_fidelity": input_fidelity, + "markup": markup, "mask": mask, "model": model, "n": n, @@ -1805,9 +1841,11 @@ async def edit( "stream": stream, "user": user, }, - [["image"], ["image", ""], ["mask"]], + [["image"], ["image", ""], ["markup"], ["mask"]], + ) + files = extract_files( + cast(Mapping[str, object], body), paths=[["image"], ["image", ""], ["markup"], ["mask"]] ) - files = extract_files(cast(Mapping[str, object], body), paths=[["image"], ["image", ""], ["mask"]]) # It should be noted that the actual Content-Type header that will be # sent to the server will contain a `boundary` parameter, e.g. # multipart/form-data; boundary=---abc-- diff --git a/src/openai/types/image_edit_params.py b/src/openai/types/image_edit_params.py index 148962015b..3bf624cbe2 100644 --- a/src/openai/types/image_edit_params.py +++ b/src/openai/types/image_edit_params.py @@ -51,6 +51,13 @@ class ImageEditParamsBase(TypedDict, total=False): this parameter. Defaults to `low` on supported models. """ + markup: FileTypes + """A transparent PNG overlaid on the first image to guide the edit. + + Must match that image's dimensions after orientation and contain transparent + pixels. Supports GPT Image 2.5 and 2.6. Cannot be combined with mask. + """ + mask: FileTypes """An additional image whose fully transparent areas (e.g. diff --git a/tests/api_resources/test_images.py b/tests/api_resources/test_images.py index 8c7d4001de..592939044d 100644 --- a/tests/api_resources/test_images.py +++ b/tests/api_resources/test_images.py @@ -81,6 +81,7 @@ def test_method_edit_with_all_params_overload_1(self, client: OpenAI) -> None: prompt="A cute baby sea otter wearing a beret", background="transparent", input_fidelity="high", + markup=b"Example data", mask=b"Example data", model="gpt-image-1.5", n=1, @@ -139,6 +140,7 @@ def test_method_edit_with_all_params_overload_2(self, client: OpenAI) -> None: stream=True, background="transparent", input_fidelity="high", + markup=b"Example data", mask=b"Example data", model="gpt-image-1.5", n=1, @@ -360,6 +362,7 @@ async def test_method_edit_with_all_params_overload_1(self, async_client: AsyncO prompt="A cute baby sea otter wearing a beret", background="transparent", input_fidelity="high", + markup=b"Example data", mask=b"Example data", model="gpt-image-1.5", n=1, @@ -418,6 +421,7 @@ async def test_method_edit_with_all_params_overload_2(self, async_client: AsyncO stream=True, background="transparent", input_fidelity="high", + markup=b"Example data", mask=b"Example data", model="gpt-image-1.5", n=1, diff --git a/tests/lib/test_realtime_example_keepalive.py b/tests/lib/test_realtime_example_keepalive.py index daca485716..913282e270 100644 --- a/tests/lib/test_realtime_example_keepalive.py +++ b/tests/lib/test_realtime_example_keepalive.py @@ -3,27 +3,53 @@ import json import signal import asyncio +from typing import Iterator from pathlib import Path import pytest from websockets.asyncio.server import ServerConnection, serve +@pytest.fixture +def signal_pipe() -> Iterator[tuple[int, int]]: + read_fd, write_fd = os.pipe() + try: + yield read_fd, write_fd + finally: + os.close(read_fd) + os.close(write_fd) + + @pytest.mark.asyncio @pytest.mark.parametrize( "exit_action", [ "conversation_then_q", "eof", + "q_eof", + "unicode_then_q", + "preloaded_then_q", + pytest.param( + "partial_input_sigint", + marks=pytest.mark.skipif(sys.platform == "win32", reason="Requires POSIX SIGINT"), + ), pytest.param( "sigint", marks=pytest.mark.skipif( sys.platform == "win32", reason="Windows uses console events instead of POSIX SIGINT" ), ), + pytest.param( + "sigint_worker", + marks=pytest.mark.skipif( + not hasattr(signal, "pthread_kill"), reason="Requires POSIX thread-directed signals" + ), + ), ], ) -async def test_interactive_realtime_example_remains_responsive_at_input(exit_action: str) -> None: +async def test_interactive_realtime_example_remains_responsive_at_input( + exit_action: str, signal_pipe: tuple[int, int] +) -> None: root = Path(__file__).resolve().parents[2] connected: asyncio.Future[ServerConnection] = asyncio.get_running_loop().create_future() @@ -44,13 +70,27 @@ async def accept(socket: ServerConnection) -> None: for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"): env.pop(name, None) + arguments = [sys.executable, str(root / "examples" / "realtime" / "realtime.py")] + if exit_action == "sigint_worker": + # Reproduce SIGINT reaching a worker while the example awaits stdin. + # Python still has to run its handler on the main thread. + bootstrap = """ +import os, runpy, signal, sys, threading +def interrupt(): + if os.read(int(sys.argv[2]), 1): + signal.pthread_kill(threading.get_ident(), signal.SIGINT) +threading.Thread(target=interrupt, daemon=True).start() +runpy.run_path(sys.argv[1], run_name="__main__") +""" + arguments = [sys.executable, "-c", bootstrap, arguments[1], str(signal_pipe[0])] + process = await asyncio.create_subprocess_exec( - sys.executable, - str(root / "examples" / "realtime" / "realtime.py"), + *arguments, env=env, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + pass_fds=(signal_pipe[0],) if exit_action == "sigint_worker" else (), ) assert process.stdin is not None assert process.stdout is not None @@ -63,12 +103,24 @@ async def accept(socket: ServerConnection) -> None: pong = await socket.ping() await asyncio.wait_for(pong, timeout=2) - if exit_action == "conversation_then_q": - process.stdin.write(b"Hello from the example test\n") + if exit_action in {"conversation_then_q", "unicode_then_q", "preloaded_then_q"}: + message = "Hello café 🌍" if exit_action == "unicode_then_q" else "Hello from the example test" + if exit_action == "unicode_then_q": + # Split inside a UTF-8 character, with no complete line yet. + encoded = (message + "\r\n").encode() + split = encoded.index(b"\xc3") + 1 + process.stdin.write(encoded[:split]) + await process.stdin.drain() + pong = await socket.ping() + await asyncio.wait_for(pong, timeout=2) + process.stdin.write(encoded[split:]) + else: + suffix = b"q\n" if exit_action == "preloaded_then_q" else b"" + process.stdin.write(message.encode() + b"\n" + suffix) await process.stdin.drain() item_event = json.loads(await asyncio.wait_for(socket.recv(), timeout=10)) assert item_event["type"] == "conversation.item.create" - assert item_event["item"]["content"] == [{"type": "input_text", "text": "Hello from the example test"}] + assert item_event["item"]["content"] == [{"type": "input_text", "text": message}] response_event = json.loads(await asyncio.wait_for(socket.recv(), timeout=10)) assert response_event["type"] == "response.create" await socket.send( @@ -123,24 +175,60 @@ async def accept(socket: ServerConnection) -> None: ) output = await asyncio.wait_for(process.stdout.readuntil(prompt), timeout=10) assert output == b"Hello back" + os.linesep.encode() + prompt - pong = await socket.ping() - await asyncio.wait_for(pong, timeout=2) - process.stdin.write(b"q\n") + if exit_action != "preloaded_then_q": + pong = await socket.ping() + await asyncio.wait_for(pong, timeout=2) + process.stdin.write(b"q\n") + await process.stdin.drain() + elif exit_action == "q_eof": + process.stdin.write(b"q") await process.stdin.drain() + process.stdin.close() elif exit_action == "eof": process.stdin.close() + elif exit_action == "sigint_worker": + os.write(signal_pipe[1], b"interrupt") else: + if exit_action == "partial_input_sigint": + process.stdin.write(b"unfinished line") + await process.stdin.drain() + pong = await socket.ping() + await asyncio.wait_for(pong, timeout=2) process.send_signal(signal.SIGINT) - await asyncio.wait_for(process.wait(), timeout=2) + try: + await asyncio.wait_for(process.wait(), timeout=2) + except asyncio.TimeoutError: + diagnostics = [] + if sys.platform == "linux": + proc = Path("/proc") / str(process.pid) + try: + diagnostics.extend( + line + for line in (proc / "status").read_text().splitlines() + if line.split(":", 1)[0] + in {"State", "Threads", "SigPnd", "ShdPnd", "SigBlk", "SigIgn", "SigCgt"} + ) + diagnostics.append(f"wait channel: {(proc / 'wchan').read_text().strip()}") + except OSError as exc: + diagnostics.append(f"Process state unavailable: {exc}") + if process.returncode is None: + process.kill() + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=2) + pytest.fail( + "Example did not exit within two seconds.\n" + + "\n".join(diagnostics) + + f"\nstdout: {stdout.decode(errors='replace')}\nstderr: {stderr.decode(errors='replace')}" + ) _, stderr = await process.communicate() - if exit_action == "conversation_then_q": + assert b"Fatal Python error" not in stderr, stderr.decode() + if exit_action in {"conversation_then_q", "unicode_then_q", "preloaded_then_q", "q_eof"}: assert process.returncode == 0, stderr.decode() elif exit_action == "eof": assert process.returncode != 0 assert b"EOFError" in stderr else: - assert process.returncode != 0 + assert process.returncode in {1, -signal.SIGINT, 128 + signal.SIGINT}, stderr.decode() assert b"KeyboardInterrupt" in stderr finally: if process.returncode is None: diff --git a/tests/test_image_markup_upload.py b/tests/test_image_markup_upload.py new file mode 100644 index 0000000000..6d093f1856 --- /dev/null +++ b/tests/test_image_markup_upload.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import io +from email import policy +from email.parser import BytesParser +from email.message import EmailMessage + +import httpx2 +import pytest + +from openai import OpenAI, Stream, AsyncOpenAI, AsyncStream, BadRequestError, omit +from openai.types import ImagesResponse + +API_KEY = "sk-synthetic-image-upload-test" +IMAGE = b"\x89PNG\r\nsynthetic-private-original-image" +OVERLAY = b"\x89PNG\r\nsynthetic-private-overlay-image" +PROMPT = "synthetic-private-edit-prompt" + + +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("field", [None, "markup", "mask"]) +@pytest.mark.parametrize("status", [200, 400]) +async def test_image_edit_upload( + is_async: bool, + stream: bool, + field: str | None, + status: int, + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level("DEBUG", logger="openai") + requests: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + requests.append(request) + assert request.method == "POST" + assert str(request.url) == "https://images.example.test/v1/images/edits" + assert request.headers["authorization"] == f"Bearer {API_KEY}" + content_type = request.headers["content-type"] + assert content_type.startswith("multipart/form-data; boundary=") + message = BytesParser(_class=EmailMessage, policy=policy.default).parsebytes( + f"Content-Type: {content_type}\r\n\r\n".encode() + request.read() + ) + parts = list(message.iter_parts()) + named = {part.get_param("name", header="content-disposition"): part for part in parts} + expected = {"image", "prompt", "stream"} | ({field} if field else set()) + assert set(named) == expected + assert len(parts) == len(expected) + assert named["image"].get_payload(decode=True) == IMAGE + assert named["prompt"].get_payload(decode=True) == PROMPT.encode() + assert named["stream"].get_payload(decode=True) == (b"true" if stream else b"false") + if field: + assert named[field].get_payload(decode=True) == OVERLAY + assert named[field].get_filename() == "overlay.png" + assert named[field].get_content_type() == "image/png" + if status == 400: + return httpx2.Response(400, json={"error": {"message": "Invalid image", "type": "invalid_request_error"}}) + if stream: + return httpx2.Response(200, headers={"content-type": "text/event-stream"}, content=b"data: [DONE]\n\n") + return httpx2.Response(200, json={"created": 1, "data": []}) + + transport = httpx2.MockTransport(handler) + client = ( + AsyncOpenAI( + api_key=API_KEY, + base_url="https://images.example.test/v1", + max_retries=0, + http_client=httpx2.AsyncClient(transport=transport), + ) + if is_async + else OpenAI( + api_key=API_KEY, + base_url="https://images.example.test/v1", + max_retries=0, + http_client=httpx2.Client(transport=transport), + ) + ) + try: + with io.BytesIO(IMAGE) as image, io.BytesIO(OVERLAY) as overlay: + try: + if isinstance(client, AsyncOpenAI): + result = await client.images.edit( + image=("original.png", image, "image/png"), + prompt=PROMPT, + stream=stream, + markup=("overlay.png", overlay, "image/png") if field == "markup" else omit, + mask=("overlay.png", overlay, "image/png") if field == "mask" else omit, + ) + if stream: + assert isinstance(result, AsyncStream) + assert [event async for event in result] == [] + else: + assert isinstance(result, ImagesResponse) + assert result.data == [] + else: + sync_result = client.images.edit( + image=("original.png", image, "image/png"), + prompt=PROMPT, + stream=stream, + markup=("overlay.png", overlay, "image/png") if field == "markup" else omit, + mask=("overlay.png", overlay, "image/png") if field == "mask" else omit, + ) + if stream: + assert isinstance(sync_result, Stream) + assert list(sync_result) == [] + else: + assert isinstance(sync_result, ImagesResponse) + assert sync_result.data == [] + except BadRequestError as error: + assert status == 400 + assert all(value not in str(error) for value in (API_KEY, PROMPT, "synthetic-private")) + else: + assert status == 200 + assert not image.closed and not overlay.closed + finally: + if isinstance(client, AsyncOpenAI): + await client.close() + else: + client.close() + + assert len(requests) == 1 + assert any("Sending HTTP Request" in record.getMessage() for record in caplog.records) + for record in caplog.records: + assert all( + value not in record.getMessage() and value not in repr(record.args) + for value in (API_KEY, PROMPT, "synthetic-private") + ) + assert record.exc_info is None