diff --git a/src/openai/resources/images.py b/src/openai/resources/images.py index 8140b5863f..4fbdf4a4e4 100644 --- a/src/openai/resources/images.py +++ b/src/openai/resources/images.py @@ -129,9 +129,10 @@ def create_variation( def edit( self, *, - image: Union[FileTypes, SequenceNotStr[FileTypes]], + image: Union[FileTypes, SequenceNotStr[FileTypes]] | Omit = omit, prompt: str, background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, + image_url: str | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, @@ -185,6 +186,10 @@ def edit( If `transparent`, the output format needs to support transparency, so it should be set to either `png` (default value) or `webp`. + image_url: A fully qualified URL or base64-encoded data URL for the image to edit. + This parameter can be used as an alternative to `image` when you want to + reference an image by URL instead of uploading a file. + input_fidelity: Control how much effort the model will exert to match the style and features, especially facial features, of input images. This parameter is only supported for `gpt-image-1` and `gpt-image-1.5` and later models, unsupported for @@ -259,10 +264,11 @@ def edit( def edit( self, *, - image: Union[FileTypes, SequenceNotStr[FileTypes]], + image: Union[FileTypes, SequenceNotStr[FileTypes]] | Omit = omit, prompt: str, stream: Literal[True], background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, + image_url: str | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, @@ -389,10 +395,11 @@ def edit( def edit( self, *, - image: Union[FileTypes, SequenceNotStr[FileTypes]], + image: Union[FileTypes, SequenceNotStr[FileTypes]] | Omit = omit, prompt: str, stream: bool, background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, + image_url: str | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, @@ -515,11 +522,12 @@ def edit( """ ... - @required_args(["image", "prompt"], ["image", "prompt", "stream"]) + @required_args(["image", "prompt"], ["image", "prompt", "stream"], ["image_url", "prompt"], ["image_url", "prompt", "stream"]) def edit( self, *, - image: Union[FileTypes, SequenceNotStr[FileTypes]], + image: Union[FileTypes, SequenceNotStr[FileTypes]] | Omit = omit, + image_url: str | Omit = omit, prompt: str, background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, @@ -542,6 +550,48 @@ def edit( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ImagesResponse | Stream[ImageEditStreamEvent]: + if image_url is not omit: + if image is not omit: + raise ValueError("Cannot specify both `image` and `image_url`.") + if mask is not omit: + raise ValueError("`mask` file uploads are not supported when using `image_url`.") + + image_url_value = cast(str, image_url) + return self._post( + "/images/edits", + body=maybe_transform( + { + "images": [{"image_url": image_url_value}], + "prompt": prompt, + "background": background, + "input_fidelity": input_fidelity, + "model": model, + "n": n, + "output_compression": output_compression, + "output_format": output_format, + "partial_images": partial_images, + "quality": quality, + "response_format": response_format, + "size": size, + "stream": stream, + "user": user, + }, + image_edit_params.ImageEditParamsStreaming + if stream + else image_edit_params.ImageEditParamsNonStreaming, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + security={"bearer_auth": True}, + ), + cast_to=ImagesResponse, + stream=stream or False, + stream_cls=Stream[ImageEditStreamEvent], + ) + body = deepcopy_with_paths( { "image": image, @@ -1134,9 +1184,10 @@ async def create_variation( async def edit( self, *, - image: Union[FileTypes, SequenceNotStr[FileTypes]], + image: Union[FileTypes, SequenceNotStr[FileTypes]] | Omit = omit, prompt: str, background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, + image_url: str | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, @@ -1264,10 +1315,11 @@ async def edit( async def edit( self, *, - image: Union[FileTypes, SequenceNotStr[FileTypes]], + image: Union[FileTypes, SequenceNotStr[FileTypes]] | Omit = omit, prompt: str, stream: Literal[True], background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, + image_url: str | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, @@ -1394,10 +1446,11 @@ async def edit( async def edit( self, *, - image: Union[FileTypes, SequenceNotStr[FileTypes]], + image: Union[FileTypes, SequenceNotStr[FileTypes]] | Omit = omit, prompt: str, stream: bool, background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, + image_url: str | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, mask: FileTypes | Omit = omit, model: Union[str, ImageModel, None] | Omit = omit, @@ -1520,11 +1573,12 @@ async def edit( """ ... - @required_args(["image", "prompt"], ["image", "prompt", "stream"]) + @required_args(["image", "prompt"], ["image", "prompt", "stream"], ["image_url", "prompt"], ["image_url", "prompt", "stream"]) async def edit( self, *, - image: Union[FileTypes, SequenceNotStr[FileTypes]], + image: Union[FileTypes, SequenceNotStr[FileTypes]] | Omit = omit, + image_url: str | Omit = omit, prompt: str, background: Optional[Literal["transparent", "opaque", "auto"]] | Omit = omit, input_fidelity: Optional[Literal["high", "low"]] | Omit = omit, @@ -1547,6 +1601,48 @@ async def edit( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ImagesResponse | AsyncStream[ImageEditStreamEvent]: + if image_url is not omit: + if image is not omit: + raise ValueError("Cannot specify both `image` and `image_url`.") + if mask is not omit: + raise ValueError("`mask` file uploads are not supported when using `image_url`.") + + image_url_value = cast(str, image_url) + return await self._post( + "/images/edits", + body=await async_maybe_transform( + { + "images": [{"image_url": image_url_value}], + "prompt": prompt, + "background": background, + "input_fidelity": input_fidelity, + "model": model, + "n": n, + "output_compression": output_compression, + "output_format": output_format, + "partial_images": partial_images, + "quality": quality, + "response_format": response_format, + "size": size, + "stream": stream, + "user": user, + }, + image_edit_params.ImageEditParamsStreaming + if stream + else image_edit_params.ImageEditParamsNonStreaming, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + security={"bearer_auth": True}, + ), + cast_to=ImagesResponse, + stream=stream or False, + stream_cls=AsyncStream[ImageEditStreamEvent], + ) + body = deepcopy_with_paths( { "image": image, diff --git a/src/openai/types/image_edit_params.py b/src/openai/types/image_edit_params.py index 1bd6dfd320..524e9532c8 100644 --- a/src/openai/types/image_edit_params.py +++ b/src/openai/types/image_edit_params.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Union, Optional +from typing import Union, Iterable, Optional from typing_extensions import Literal, Required, TypedDict from .._types import FileTypes, SequenceNotStr @@ -11,8 +11,16 @@ __all__ = ["ImageEditParamsBase", "ImageEditParamsNonStreaming", "ImageEditParamsStreaming"] +class Image(TypedDict, total=False): + file_id: str + """The ID of an uploaded image file to edit.""" + + image_url: str + """A fully qualified URL or base64-encoded data URL for the image to edit.""" + + class ImageEditParamsBase(TypedDict, total=False): - image: Required[Union[FileTypes, SequenceNotStr[FileTypes]]] + image: Union[FileTypes, SequenceNotStr[FileTypes]] """The image(s) to edit. Must be a supported image file or an array of images. For the GPT image models (`gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5`, @@ -24,6 +32,12 @@ class ImageEditParamsBase(TypedDict, total=False): file less than 4MB. """ + images: Iterable[Image] + """The image references to edit. + + Each item can reference an image by URL or file ID. + """ + prompt: Required[str] """A text description of the desired image(s). diff --git a/tests/api_resources/test_images.py b/tests/api_resources/test_images.py index fa8054c01d..370c48b556 100644 --- a/tests/api_resources/test_images.py +++ b/tests/api_resources/test_images.py @@ -2,10 +2,13 @@ from __future__ import annotations +import json import os from typing import Any, cast +import httpx import pytest +from respx import MockRouter from openai import OpenAI, AsyncOpenAI from tests.utils import assert_matches_type @@ -101,6 +104,26 @@ def test_raw_response_edit_overload_1(self, client: OpenAI) -> None: image = response.parse() assert_matches_type(ImagesResponse, image, path=["response"]) + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_raw_response_edit_with_image_url(self, client: OpenAI, respx_mock: MockRouter) -> None: + respx_mock.post("/images/edits").mock(return_value=httpx.Response(200, json={"created": 0, "data": []})) + + response = client.images.with_raw_response.edit( + image_url="https://example.com/image.png", + prompt="A cute baby sea otter wearing a beret", + ) + + content_type = response.http_request.headers.get("content-type") + assert content_type is None or not content_type.startswith("multipart/form-data") + assert json.loads(response.http_request.content) == { + "images": [{"image_url": "https://example.com/image.png"}], + "prompt": "A cute baby sea otter wearing a beret", + } + + image = response.parse() + assert_matches_type(ImagesResponse, image, path=["response"]) + @parametrize def test_streaming_response_edit_overload_1(self, client: OpenAI) -> None: with client.images.with_streaming_response.edit( @@ -366,6 +389,28 @@ async def test_raw_response_edit_overload_1(self, async_client: AsyncOpenAI) -> image = response.parse() assert_matches_type(ImagesResponse, image, path=["response"]) + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_edit_with_image_url( + self, async_client: AsyncOpenAI, respx_mock: MockRouter + ) -> None: + respx_mock.post("/images/edits").mock(return_value=httpx.Response(200, json={"created": 0, "data": []})) + + response = await async_client.images.with_raw_response.edit( + image_url="https://example.com/image.png", + prompt="A cute baby sea otter wearing a beret", + ) + + content_type = response.http_request.headers.get("content-type") + assert content_type is None or not content_type.startswith("multipart/form-data") + assert json.loads(response.http_request.content) == { + "images": [{"image_url": "https://example.com/image.png"}], + "prompt": "A cute baby sea otter wearing a beret", + } + + image = response.parse() + assert_matches_type(ImagesResponse, image, path=["response"]) + @parametrize async def test_streaming_response_edit_overload_1(self, async_client: AsyncOpenAI) -> None: async with async_client.images.with_streaming_response.edit(