Skip to content

Commit a532f6e

Browse files
feat(client): add support for binary request streaming
1 parent 722d3ff commit a532f6e

4 files changed

Lines changed: 344 additions & 14 deletions

File tree

src/openai/_base_client.py

Lines changed: 134 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import inspect
1010
import logging
1111
import platform
12+
import warnings
1213
import email.utils
1314
from types import TracebackType
1415
from random import random
@@ -51,9 +52,11 @@
5152
ResponseT,
5253
AnyMapping,
5354
PostParser,
55+
BinaryTypes,
5456
RequestFiles,
5557
HttpxSendArgs,
5658
RequestOptions,
59+
AsyncBinaryTypes,
5760
HttpxRequestFiles,
5861
ModelBuilderProtocol,
5962
not_given,
@@ -479,8 +482,19 @@ def _build_request(
479482
retries_taken: int = 0,
480483
) -> httpx.Request:
481484
if log.isEnabledFor(logging.DEBUG):
482-
log.debug("Request options: %s", model_dump(options, exclude_unset=True))
483-
485+
log.debug(
486+
"Request options: %s",
487+
model_dump(
488+
options,
489+
exclude_unset=True,
490+
# Pydantic v1 can't dump every type we support in content, so we exclude it for now.
491+
exclude={
492+
"content",
493+
}
494+
if PYDANTIC_V1
495+
else {},
496+
),
497+
)
484498
kwargs: dict[str, Any] = {}
485499

486500
json_data = options.json_data
@@ -534,7 +548,13 @@ def _build_request(
534548
is_body_allowed = options.method.lower() != "get"
535549

536550
if is_body_allowed:
537-
if isinstance(json_data, bytes):
551+
if options.content is not None and json_data is not None:
552+
raise TypeError("Passing both `content` and `json_data` is not supported")
553+
if options.content is not None and files is not None:
554+
raise TypeError("Passing both `content` and `files` is not supported")
555+
if options.content is not None:
556+
kwargs["content"] = options.content
557+
elif isinstance(json_data, bytes):
538558
kwargs["content"] = json_data
539559
else:
540560
kwargs["json"] = json_data if is_given(json_data) else None
@@ -1211,6 +1231,7 @@ def post(
12111231
*,
12121232
cast_to: Type[ResponseT],
12131233
body: Body | None = None,
1234+
content: BinaryTypes | None = None,
12141235
options: RequestOptions = {},
12151236
files: RequestFiles | None = None,
12161237
stream: Literal[False] = False,
@@ -1223,6 +1244,7 @@ def post(
12231244
*,
12241245
cast_to: Type[ResponseT],
12251246
body: Body | None = None,
1247+
content: BinaryTypes | None = None,
12261248
options: RequestOptions = {},
12271249
files: RequestFiles | None = None,
12281250
stream: Literal[True],
@@ -1236,6 +1258,7 @@ def post(
12361258
*,
12371259
cast_to: Type[ResponseT],
12381260
body: Body | None = None,
1261+
content: BinaryTypes | None = None,
12391262
options: RequestOptions = {},
12401263
files: RequestFiles | None = None,
12411264
stream: bool,
@@ -1248,13 +1271,25 @@ def post(
12481271
*,
12491272
cast_to: Type[ResponseT],
12501273
body: Body | None = None,
1274+
content: BinaryTypes | None = None,
12511275
options: RequestOptions = {},
12521276
files: RequestFiles | None = None,
12531277
stream: bool = False,
12541278
stream_cls: type[_StreamT] | None = None,
12551279
) -> ResponseT | _StreamT:
1280+
if body is not None and content is not None:
1281+
raise TypeError("Passing both `body` and `content` is not supported")
1282+
if files is not None and content is not None:
1283+
raise TypeError("Passing both `files` and `content` is not supported")
1284+
if isinstance(body, bytes):
1285+
warnings.warn(
1286+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1287+
"Please pass raw bytes via the `content` parameter instead.",
1288+
DeprecationWarning,
1289+
stacklevel=2,
1290+
)
12561291
opts = FinalRequestOptions.construct(
1257-
method="post", url=path, json_data=body, files=to_httpx_files(files), **options
1292+
method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
12581293
)
12591294
return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls))
12601295

@@ -1264,11 +1299,23 @@ def patch(
12641299
*,
12651300
cast_to: Type[ResponseT],
12661301
body: Body | None = None,
1302+
content: BinaryTypes | None = None,
12671303
files: RequestFiles | None = None,
12681304
options: RequestOptions = {},
12691305
) -> ResponseT:
1306+
if body is not None and content is not None:
1307+
raise TypeError("Passing both `body` and `content` is not supported")
1308+
if files is not None and content is not None:
1309+
raise TypeError("Passing both `files` and `content` is not supported")
1310+
if isinstance(body, bytes):
1311+
warnings.warn(
1312+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1313+
"Please pass raw bytes via the `content` parameter instead.",
1314+
DeprecationWarning,
1315+
stacklevel=2,
1316+
)
12701317
opts = FinalRequestOptions.construct(
1271-
method="patch", url=path, json_data=body, files=to_httpx_files(files), **options
1318+
method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
12721319
)
12731320
return self.request(cast_to, opts)
12741321

@@ -1278,11 +1325,23 @@ def put(
12781325
*,
12791326
cast_to: Type[ResponseT],
12801327
body: Body | None = None,
1328+
content: BinaryTypes | None = None,
12811329
files: RequestFiles | None = None,
12821330
options: RequestOptions = {},
12831331
) -> ResponseT:
1332+
if body is not None and content is not None:
1333+
raise TypeError("Passing both `body` and `content` is not supported")
1334+
if files is not None and content is not None:
1335+
raise TypeError("Passing both `files` and `content` is not supported")
1336+
if isinstance(body, bytes):
1337+
warnings.warn(
1338+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1339+
"Please pass raw bytes via the `content` parameter instead.",
1340+
DeprecationWarning,
1341+
stacklevel=2,
1342+
)
12841343
opts = FinalRequestOptions.construct(
1285-
method="put", url=path, json_data=body, files=to_httpx_files(files), **options
1344+
method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options
12861345
)
12871346
return self.request(cast_to, opts)
12881347

@@ -1292,9 +1351,19 @@ def delete(
12921351
*,
12931352
cast_to: Type[ResponseT],
12941353
body: Body | None = None,
1354+
content: BinaryTypes | None = None,
12951355
options: RequestOptions = {},
12961356
) -> ResponseT:
1297-
opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options)
1357+
if body is not None and content is not None:
1358+
raise TypeError("Passing both `body` and `content` is not supported")
1359+
if isinstance(body, bytes):
1360+
warnings.warn(
1361+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1362+
"Please pass raw bytes via the `content` parameter instead.",
1363+
DeprecationWarning,
1364+
stacklevel=2,
1365+
)
1366+
opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options)
12981367
return self.request(cast_to, opts)
12991368

13001369
def get_api_list(
@@ -1749,6 +1818,7 @@ async def post(
17491818
*,
17501819
cast_to: Type[ResponseT],
17511820
body: Body | None = None,
1821+
content: AsyncBinaryTypes | None = None,
17521822
files: RequestFiles | None = None,
17531823
options: RequestOptions = {},
17541824
stream: Literal[False] = False,
@@ -1761,6 +1831,7 @@ async def post(
17611831
*,
17621832
cast_to: Type[ResponseT],
17631833
body: Body | None = None,
1834+
content: AsyncBinaryTypes | None = None,
17641835
files: RequestFiles | None = None,
17651836
options: RequestOptions = {},
17661837
stream: Literal[True],
@@ -1774,6 +1845,7 @@ async def post(
17741845
*,
17751846
cast_to: Type[ResponseT],
17761847
body: Body | None = None,
1848+
content: AsyncBinaryTypes | None = None,
17771849
files: RequestFiles | None = None,
17781850
options: RequestOptions = {},
17791851
stream: bool,
@@ -1786,13 +1858,25 @@ async def post(
17861858
*,
17871859
cast_to: Type[ResponseT],
17881860
body: Body | None = None,
1861+
content: AsyncBinaryTypes | None = None,
17891862
files: RequestFiles | None = None,
17901863
options: RequestOptions = {},
17911864
stream: bool = False,
17921865
stream_cls: type[_AsyncStreamT] | None = None,
17931866
) -> ResponseT | _AsyncStreamT:
1867+
if body is not None and content is not None:
1868+
raise TypeError("Passing both `body` and `content` is not supported")
1869+
if files is not None and content is not None:
1870+
raise TypeError("Passing both `files` and `content` is not supported")
1871+
if isinstance(body, bytes):
1872+
warnings.warn(
1873+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1874+
"Please pass raw bytes via the `content` parameter instead.",
1875+
DeprecationWarning,
1876+
stacklevel=2,
1877+
)
17941878
opts = FinalRequestOptions.construct(
1795-
method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options
1879+
method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options
17961880
)
17971881
return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)
17981882

@@ -1802,11 +1886,28 @@ async def patch(
18021886
*,
18031887
cast_to: Type[ResponseT],
18041888
body: Body | None = None,
1889+
content: AsyncBinaryTypes | None = None,
18051890
files: RequestFiles | None = None,
18061891
options: RequestOptions = {},
18071892
) -> ResponseT:
1893+
if body is not None and content is not None:
1894+
raise TypeError("Passing both `body` and `content` is not supported")
1895+
if files is not None and content is not None:
1896+
raise TypeError("Passing both `files` and `content` is not supported")
1897+
if isinstance(body, bytes):
1898+
warnings.warn(
1899+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1900+
"Please pass raw bytes via the `content` parameter instead.",
1901+
DeprecationWarning,
1902+
stacklevel=2,
1903+
)
18081904
opts = FinalRequestOptions.construct(
1809-
method="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options
1905+
method="patch",
1906+
url=path,
1907+
json_data=body,
1908+
content=content,
1909+
files=await async_to_httpx_files(files),
1910+
**options,
18101911
)
18111912
return await self.request(cast_to, opts)
18121913

@@ -1816,11 +1917,23 @@ async def put(
18161917
*,
18171918
cast_to: Type[ResponseT],
18181919
body: Body | None = None,
1920+
content: AsyncBinaryTypes | None = None,
18191921
files: RequestFiles | None = None,
18201922
options: RequestOptions = {},
18211923
) -> ResponseT:
1924+
if body is not None and content is not None:
1925+
raise TypeError("Passing both `body` and `content` is not supported")
1926+
if files is not None and content is not None:
1927+
raise TypeError("Passing both `files` and `content` is not supported")
1928+
if isinstance(body, bytes):
1929+
warnings.warn(
1930+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1931+
"Please pass raw bytes via the `content` parameter instead.",
1932+
DeprecationWarning,
1933+
stacklevel=2,
1934+
)
18221935
opts = FinalRequestOptions.construct(
1823-
method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options
1936+
method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options
18241937
)
18251938
return await self.request(cast_to, opts)
18261939

@@ -1830,9 +1943,19 @@ async def delete(
18301943
*,
18311944
cast_to: Type[ResponseT],
18321945
body: Body | None = None,
1946+
content: AsyncBinaryTypes | None = None,
18331947
options: RequestOptions = {},
18341948
) -> ResponseT:
1835-
opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options)
1949+
if body is not None and content is not None:
1950+
raise TypeError("Passing both `body` and `content` is not supported")
1951+
if isinstance(body, bytes):
1952+
warnings.warn(
1953+
"Passing raw bytes as `body` is deprecated and will be removed in a future version. "
1954+
"Please pass raw bytes via the `content` parameter instead.",
1955+
DeprecationWarning,
1956+
stacklevel=2,
1957+
)
1958+
opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options)
18361959
return await self.request(cast_to, opts)
18371960

18381961
def get_api_list(

src/openai/_models.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,20 @@
33
import os
44
import inspect
55
import weakref
6-
from typing import TYPE_CHECKING, Any, Type, Tuple, Union, Generic, TypeVar, Callable, Optional, cast
6+
from typing import (
7+
IO,
8+
TYPE_CHECKING,
9+
Any,
10+
Type, Tuple,
11+
Union,
12+
Generic,
13+
TypeVar,
14+
Callable,
15+
Iterable,
16+
Optional,
17+
AsyncIterable,
18+
cast,
19+
)
720
from datetime import date, datetime
821
from typing_extensions import (
922
List,
@@ -827,6 +840,7 @@ class FinalRequestOptionsInput(TypedDict, total=False):
827840
timeout: float | Timeout | None
828841
files: HttpxRequestFiles | None
829842
idempotency_key: str
843+
content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None]
830844
json_data: Body
831845
extra_json: AnyMapping
832846
follow_redirects: bool
@@ -845,6 +859,7 @@ class FinalRequestOptions(pydantic.BaseModel):
845859
post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven()
846860
follow_redirects: Union[bool, None] = None
847861

862+
content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None
848863
# It should be noted that we cannot use `json` here as that would override
849864
# a BaseModel method in an incompatible fashion.
850865
json_data: Union[Body, None] = None

src/openai/_types.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
Mapping,
1414
TypeVar,
1515
Callable,
16+
Iterable,
1617
Iterator,
1718
Optional,
1819
Sequence,
20+
AsyncIterable,
1921
)
2022
from typing_extensions import (
2123
Set,
@@ -57,6 +59,13 @@
5759
else:
5860
Base64FileInput = Union[IO[bytes], PathLike]
5961
FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8.
62+
63+
64+
# Used for sending raw binary data / streaming data in request bodies
65+
# e.g. for file uploads without multipart encoding
66+
BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]]
67+
AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]]
68+
6069
FileTypes = Union[
6170
# file (or bytes)
6271
FileContent,

0 commit comments

Comments
 (0)