diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b258565371..c1a7e63f69 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.16.0" + ".": "2.17.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index a43021242b..d9e272c17f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 137 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-506f44f37cccac43899267dd64cc5615e96f6e15f2736aa37e5e4eed2eccc567.yml -openapi_spec_hash: d242c25afd700d928787a46e7901fa45 -config_hash: ad7136f7366fddec432ec378939e58a7 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-64c3a646eb5dcad2b7ff7bd976c0e312b886676a542f6ffcd9a6c8503ae24c58.yml +openapi_spec_hash: 91b1b7bf3c1a6b6c9c7507d4cac8fe2a +config_hash: f8e6baff429cf000b8e4ba1da08dff47 diff --git a/CHANGELOG.md b/CHANGELOG.md index 28902cb2b2..ea480c424e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 2.17.0 (2026-02-05) + +Full Changelog: [v2.16.0...v2.17.0](https://github.com/openai/openai-python/compare/v2.16.0...v2.17.0) + +### Features + +* **api:** add shell_call_output status field ([1bbaf88](https://github.com/openai/openai-python/commit/1bbaf8865000b338c24c9fdd5e985183feaca10f)) +* **api:** image generation actions for responses; ResponseFunctionCallArgumentsDoneEvent.name ([7d96513](https://github.com/openai/openai-python/commit/7d965135f93f41b0c3dbf3dc9f01796bd9645b6c)) +* **client:** add custom JSON encoder for extended type support ([9f43c8b](https://github.com/openai/openai-python/commit/9f43c8b1a1641db2336cc6d0ec0c6dc470a89103)) + + +### Bug Fixes + +* **client:** undo change to web search Find action ([8f14eb0](https://github.com/openai/openai-python/commit/8f14eb0a74363fdfc648c5cd5c6d34a85b938d3c)) +* **client:** update type for `find_in_page` action ([ec54dde](https://github.com/openai/openai-python/commit/ec54ddeb357e49edd81cc3fe53d549c297e59a07)) + ## 2.16.0 (2026-01-27) Full Changelog: [v2.15.0...v2.16.0](https://github.com/openai/openai-python/compare/v2.15.0...v2.16.0) diff --git a/pyproject.toml b/pyproject.toml index bd75d0096d..e3843e1f56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai" -version = "2.16.0" +version = "2.17.0" description = "The official Python library for the openai API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index d34208abef..17863bc067 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -86,6 +86,7 @@ APIConnectionError, APIResponseValidationError, ) +from ._utils._json import openapi_dumps from ._legacy_response import LegacyAPIResponse log: logging.Logger = logging.getLogger(__name__) @@ -556,8 +557,10 @@ def _build_request( kwargs["content"] = options.content elif isinstance(json_data, bytes): kwargs["content"] = json_data - else: - kwargs["json"] = json_data if is_given(json_data) else None + elif not files: + # Don't set content when JSON is sent as multipart/form-data, + # since httpx's content param overrides other body arguments + kwargs["content"] = openapi_dumps(json_data) if is_given(json_data) and json_data is not None else None kwargs["files"] = files else: headers.pop("Content-Type", None) diff --git a/src/openai/_compat.py b/src/openai/_compat.py index 73a1f3ea93..020ffeb2ca 100644 --- a/src/openai/_compat.py +++ b/src/openai/_compat.py @@ -139,6 +139,7 @@ def model_dump( exclude_defaults: bool = False, warnings: bool = True, mode: Literal["json", "python"] = "python", + by_alias: bool | None = None, ) -> dict[str, Any]: if (not PYDANTIC_V1) or hasattr(model, "model_dump"): return model.model_dump( @@ -148,13 +149,12 @@ def model_dump( exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 warnings=True if PYDANTIC_V1 else warnings, + by_alias=by_alias, ) return cast( "dict[str, Any]", model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - exclude=exclude, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, + exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, by_alias=bool(by_alias) ), ) diff --git a/src/openai/_utils/_json.py b/src/openai/_utils/_json.py new file mode 100644 index 0000000000..60584214af --- /dev/null +++ b/src/openai/_utils/_json.py @@ -0,0 +1,35 @@ +import json +from typing import Any +from datetime import datetime +from typing_extensions import override + +import pydantic + +from .._compat import model_dump + + +def openapi_dumps(obj: Any) -> bytes: + """ + Serialize an object to UTF-8 encoded JSON bytes. + + Extends the standard json.dumps with support for additional types + commonly used in the SDK, such as `datetime`, `pydantic.BaseModel`, etc. + """ + return json.dumps( + obj, + cls=_CustomEncoder, + # Uses the same defaults as httpx's JSON serialization + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +class _CustomEncoder(json.JSONEncoder): + @override + def default(self, o: Any) -> Any: + if isinstance(o, datetime): + return o.isoformat() + if isinstance(o, pydantic.BaseModel): + return model_dump(o, exclude_unset=True, mode="json", by_alias=True) + return super().default(o) diff --git a/src/openai/_version.py b/src/openai/_version.py index eb61bdd2c6..5f7901120a 100644 --- a/src/openai/_version.py +++ b/src/openai/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "openai" -__version__ = "2.16.0" # x-release-please-version +__version__ = "2.17.0" # x-release-please-version diff --git a/src/openai/types/realtime/response_function_call_arguments_done_event.py b/src/openai/types/realtime/response_function_call_arguments_done_event.py index 504f91d558..01bae80562 100644 --- a/src/openai/types/realtime/response_function_call_arguments_done_event.py +++ b/src/openai/types/realtime/response_function_call_arguments_done_event.py @@ -25,6 +25,9 @@ class ResponseFunctionCallArgumentsDoneEvent(BaseModel): item_id: str """The ID of the function call item.""" + name: str + """The name of the function that was called.""" + output_index: int """The index of the output item in the response.""" diff --git a/src/openai/types/responses/response_function_web_search.py b/src/openai/types/responses/response_function_web_search.py index 0cb7e0b0d1..de6001e146 100644 --- a/src/openai/types/responses/response_function_web_search.py +++ b/src/openai/types/responses/response_function_web_search.py @@ -6,7 +6,14 @@ from ..._utils import PropertyInfo from ..._models import BaseModel -__all__ = ["ResponseFunctionWebSearch", "Action", "ActionSearch", "ActionSearchSource", "ActionOpenPage", "ActionFind"] +__all__ = [ + "ResponseFunctionWebSearch", + "Action", + "ActionSearch", + "ActionSearchSource", + "ActionOpenPage", + "ActionFind", +] class ActionSearchSource(BaseModel): @@ -41,17 +48,17 @@ class ActionOpenPage(BaseModel): type: Literal["open_page"] """The action type.""" - url: str + url: Optional[str] = None """The URL opened by the model.""" class ActionFind(BaseModel): - """Action type "find": Searches for a pattern within a loaded page.""" + """Action type "find_in_page": Searches for a pattern within a loaded page.""" pattern: str """The pattern or text to search for within the page.""" - type: Literal["find"] + type: Literal["find_in_page"] """The action type.""" url: str @@ -74,7 +81,7 @@ class ResponseFunctionWebSearch(BaseModel): action: Action """ An object describing the specific action taken in this web search call. Includes - details on how the model used the web (search, open_page, find). + details on how the model used the web (search, open_page, find_in_page). """ status: Literal["in_progress", "searching", "completed", "failed"] diff --git a/src/openai/types/responses/response_function_web_search_param.py b/src/openai/types/responses/response_function_web_search_param.py index 7db3e3c833..15e313b0d3 100644 --- a/src/openai/types/responses/response_function_web_search_param.py +++ b/src/openai/types/responses/response_function_web_search_param.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Union, Iterable +from typing import Union, Iterable, Optional from typing_extensions import Literal, Required, TypeAlias, TypedDict from ..._types import SequenceNotStr @@ -49,17 +49,17 @@ class ActionOpenPage(TypedDict, total=False): type: Required[Literal["open_page"]] """The action type.""" - url: Required[str] + url: Optional[str] """The URL opened by the model.""" class ActionFind(TypedDict, total=False): - """Action type "find": Searches for a pattern within a loaded page.""" + """Action type "find_in_page": Searches for a pattern within a loaded page.""" pattern: Required[str] """The pattern or text to search for within the page.""" - type: Required[Literal["find"]] + type: Required[Literal["find_in_page"]] """The action type.""" url: Required[str] @@ -82,7 +82,7 @@ class ResponseFunctionWebSearchParam(TypedDict, total=False): action: Required[Action] """ An object describing the specific action taken in this web search call. Includes - details on how the model used the web (search, open_page, find). + details on how the model used the web (search, open_page, find_in_page). """ status: Required[Literal["in_progress", "searching", "completed", "failed"]] diff --git a/src/openai/types/responses/response_input_item.py b/src/openai/types/responses/response_input_item.py index 23eb2c8950..e1f521f957 100644 --- a/src/openai/types/responses/response_input_item.py +++ b/src/openai/types/responses/response_input_item.py @@ -285,6 +285,9 @@ class ShellCallOutput(BaseModel): output. """ + status: Optional[Literal["in_progress", "completed", "incomplete"]] = None + """The status of the shell call output.""" + class ApplyPatchCallOperationCreateFile(BaseModel): """Instruction for creating a new file via the apply_patch tool.""" diff --git a/src/openai/types/responses/response_input_item_param.py b/src/openai/types/responses/response_input_item_param.py index 2c42b93021..03fe86c42c 100644 --- a/src/openai/types/responses/response_input_item_param.py +++ b/src/openai/types/responses/response_input_item_param.py @@ -286,6 +286,9 @@ class ShellCallOutput(TypedDict, total=False): output. """ + status: Optional[Literal["in_progress", "completed", "incomplete"]] + """The status of the shell call output.""" + class ApplyPatchCallOperationCreateFile(TypedDict, total=False): """Instruction for creating a new file via the apply_patch tool.""" diff --git a/src/openai/types/responses/response_input_param.py b/src/openai/types/responses/response_input_param.py index c2d12c0ab4..5be65b3e14 100644 --- a/src/openai/types/responses/response_input_param.py +++ b/src/openai/types/responses/response_input_param.py @@ -287,6 +287,9 @@ class ShellCallOutput(TypedDict, total=False): output. """ + status: Optional[Literal["in_progress", "completed", "incomplete"]] + """The status of the shell call output.""" + class ApplyPatchCallOperationCreateFile(TypedDict, total=False): """Instruction for creating a new file via the apply_patch tool.""" diff --git a/src/openai/types/responses/tool.py b/src/openai/types/responses/tool.py index 019962a0ba..435f046768 100644 --- a/src/openai/types/responses/tool.py +++ b/src/openai/types/responses/tool.py @@ -227,6 +227,9 @@ class ImageGeneration(BaseModel): type: Literal["image_generation"] """The type of the image generation tool. Always `image_generation`.""" + action: Optional[Literal["generate", "edit", "auto"]] = None + """Whether to generate a new image or edit an existing image. Default: `auto`.""" + background: Optional[Literal["transparent", "opaque", "auto"]] = None """Background type for the generated image. @@ -247,7 +250,7 @@ class ImageGeneration(BaseModel): Contains `image_url` (string, optional) and `file_id` (string, optional). """ - model: Union[str, Literal["gpt-image-1", "gpt-image-1-mini"], None] = None + model: Union[str, Literal["gpt-image-1", "gpt-image-1-mini", "gpt-image-1.5"], None] = None """The image generation model to use. Default: `gpt-image-1`.""" moderation: Optional[Literal["auto", "low"]] = None diff --git a/src/openai/types/responses/tool_param.py b/src/openai/types/responses/tool_param.py index 37d3dde024..3afff87892 100644 --- a/src/openai/types/responses/tool_param.py +++ b/src/openai/types/responses/tool_param.py @@ -227,6 +227,9 @@ class ImageGeneration(TypedDict, total=False): type: Required[Literal["image_generation"]] """The type of the image generation tool. Always `image_generation`.""" + action: Literal["generate", "edit", "auto"] + """Whether to generate a new image or edit an existing image. Default: `auto`.""" + background: Literal["transparent", "opaque", "auto"] """Background type for the generated image. @@ -247,7 +250,7 @@ class ImageGeneration(TypedDict, total=False): Contains `image_url` (string, optional) and `file_id` (string, optional). """ - model: Union[str, Literal["gpt-image-1", "gpt-image-1-mini"]] + model: Union[str, Literal["gpt-image-1", "gpt-image-1-mini", "gpt-image-1.5"]] """The image generation model to use. Default: `gpt-image-1`.""" moderation: Literal["auto", "low"] diff --git a/tests/test_utils/test_json.py b/tests/test_utils/test_json.py new file mode 100644 index 0000000000..240c64562f --- /dev/null +++ b/tests/test_utils/test_json.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import datetime +from typing import Union + +import pydantic + +from openai import _compat +from openai._utils._json import openapi_dumps + + +class TestOpenapiDumps: + def test_basic(self) -> None: + data = {"key": "value", "number": 42} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"key":"value","number":42}' + + def test_datetime_serialization(self) -> None: + dt = datetime.datetime(2023, 1, 1, 12, 0, 0) + data = {"datetime": dt} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"datetime":"2023-01-01T12:00:00"}' + + def test_pydantic_model_serialization(self) -> None: + class User(pydantic.BaseModel): + first_name: str + last_name: str + age: int + + model_instance = User(first_name="John", last_name="Kramer", age=83) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"first_name":"John","last_name":"Kramer","age":83}}' + + def test_pydantic_model_with_default_values(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + score: int = 0 + + model_instance = User(name="Alice") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Alice"}}' + + def test_pydantic_model_with_default_values_overridden(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + + model_instance = User(name="Bob", role="admin", active=False) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Bob","role":"admin","active":false}}' + + def test_pydantic_model_with_alias(self) -> None: + class User(pydantic.BaseModel): + first_name: str = pydantic.Field(alias="firstName") + last_name: str = pydantic.Field(alias="lastName") + + model_instance = User(firstName="John", lastName="Doe") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"firstName":"John","lastName":"Doe"}}' + + def test_pydantic_model_with_alias_and_default(self) -> None: + class User(pydantic.BaseModel): + user_name: str = pydantic.Field(alias="userName") + user_role: str = pydantic.Field(default="member", alias="userRole") + is_active: bool = pydantic.Field(default=True, alias="isActive") + + model_instance = User(userName="charlie") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"charlie"}}' + + model_with_overrides = User(userName="diana", userRole="admin", isActive=False) + data = {"model": model_with_overrides} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"diana","userRole":"admin","isActive":false}}' + + def test_pydantic_model_with_nested_models_and_defaults(self) -> None: + class Address(pydantic.BaseModel): + street: str + city: str = "Unknown" + + class User(pydantic.BaseModel): + name: str + address: Address + verified: bool = False + + if _compat.PYDANTIC_V1: + # to handle forward references in Pydantic v1 + User.update_forward_refs(**locals()) # type: ignore[reportDeprecated] + + address = Address(street="123 Main St") + user = User(name="Diana", address=address) + data = {"user": user} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"user":{"name":"Diana","address":{"street":"123 Main St"}}}' + + address_with_city = Address(street="456 Oak Ave", city="Boston") + user_verified = User(name="Eve", address=address_with_city, verified=True) + data = {"user": user_verified} + json_bytes = openapi_dumps(data) + assert ( + json_bytes == b'{"user":{"name":"Eve","address":{"street":"456 Oak Ave","city":"Boston"},"verified":true}}' + ) + + def test_pydantic_model_with_optional_fields(self) -> None: + class User(pydantic.BaseModel): + name: str + email: Union[str, None] + phone: Union[str, None] + + model_with_none = User(name="Eve", email=None, phone=None) + data = {"model": model_with_none} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Eve","email":null,"phone":null}}' + + model_with_values = User(name="Frank", email="frank@example.com", phone=None) + data = {"model": model_with_values} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Frank","email":"frank@example.com","phone":null}}'