Skip to content

Commit 455bc9f

Browse files
chore(internal): support multipart data with overlapping keys (#1104)
1 parent 0c1e58d commit 455bc9f

File tree

2 files changed

+84
-6
lines changed

2 files changed

+84
-6
lines changed

src/openai/_base_client.py

+26-6
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
RequestOptions,
6262
ModelBuilderProtocol,
6363
)
64-
from ._utils import is_dict, is_given, is_mapping
64+
from ._utils import is_dict, is_list, is_given, is_mapping
6565
from ._compat import model_copy, model_dump
6666
from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type
6767
from ._response import (
@@ -451,14 +451,18 @@ def _build_request(
451451

452452
headers = self._build_headers(options)
453453
params = _merge_mappings(self._custom_query, options.params)
454+
content_type = headers.get("Content-Type")
454455

455456
# If the given Content-Type header is multipart/form-data then it
456457
# has to be removed so that httpx can generate the header with
457458
# additional information for us as it has to be in this form
458459
# for the server to be able to correctly parse the request:
459460
# multipart/form-data; boundary=---abc--
460-
if headers.get("Content-Type") == "multipart/form-data":
461-
headers.pop("Content-Type")
461+
if content_type is not None and content_type.startswith("multipart/form-data"):
462+
if "boundary" not in content_type:
463+
# only remove the header if the boundary hasn't been explicitly set
464+
# as the caller doesn't want httpx to come up with their own boundary
465+
headers.pop("Content-Type")
462466

463467
# As we are now sending multipart/form-data instead of application/json
464468
# we need to tell httpx to use it, https://www.python-httpx.org/advanced/#multipart-file-encoding
@@ -494,9 +498,25 @@ def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, o
494498
)
495499
serialized: dict[str, object] = {}
496500
for key, value in items:
497-
if key in serialized:
498-
raise ValueError(f"Duplicate key encountered: {key}; This behaviour is not supported")
499-
serialized[key] = value
501+
existing = serialized.get(key)
502+
503+
if not existing:
504+
serialized[key] = value
505+
continue
506+
507+
# If a value has already been set for this key then that
508+
# means we're sending data like `array[]=[1, 2, 3]` and we
509+
# need to tell httpx that we want to send multiple values with
510+
# the same key which is done by using a list or a tuple.
511+
#
512+
# Note: 2d arrays should never result in the same key at both
513+
# levels so it's safe to assume that if the value is a list,
514+
# it was because we changed it to be a list.
515+
if is_list(existing):
516+
existing.append(value)
517+
else:
518+
serialized[key] = [existing, value]
519+
500520
return serialized
501521

502522
def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalRequestOptions) -> type[ResponseT]:

tests/test_client.py

+58
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,35 @@ def test_request_extra_query(self) -> None:
437437
params = dict(request.url.params)
438438
assert params == {"foo": "2"}
439439

440+
def test_multipart_repeating_array(self, client: OpenAI) -> None:
441+
request = client._build_request(
442+
FinalRequestOptions.construct(
443+
method="get",
444+
url="/foo",
445+
headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
446+
json_data={"array": ["foo", "bar"]},
447+
files=[("foo.txt", b"hello world")],
448+
)
449+
)
450+
451+
assert request.read().split(b"\r\n") == [
452+
b"--6b7ba517decee4a450543ea6ae821c82",
453+
b'Content-Disposition: form-data; name="array[]"',
454+
b"",
455+
b"foo",
456+
b"--6b7ba517decee4a450543ea6ae821c82",
457+
b'Content-Disposition: form-data; name="array[]"',
458+
b"",
459+
b"bar",
460+
b"--6b7ba517decee4a450543ea6ae821c82",
461+
b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
462+
b"Content-Type: application/octet-stream",
463+
b"",
464+
b"hello world",
465+
b"--6b7ba517decee4a450543ea6ae821c82--",
466+
b"",
467+
]
468+
440469
@pytest.mark.respx(base_url=base_url)
441470
def test_basic_union_response(self, respx_mock: MockRouter) -> None:
442471
class Model1(BaseModel):
@@ -1104,6 +1133,35 @@ def test_request_extra_query(self) -> None:
11041133
params = dict(request.url.params)
11051134
assert params == {"foo": "2"}
11061135

1136+
def test_multipart_repeating_array(self, async_client: AsyncOpenAI) -> None:
1137+
request = async_client._build_request(
1138+
FinalRequestOptions.construct(
1139+
method="get",
1140+
url="/foo",
1141+
headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
1142+
json_data={"array": ["foo", "bar"]},
1143+
files=[("foo.txt", b"hello world")],
1144+
)
1145+
)
1146+
1147+
assert request.read().split(b"\r\n") == [
1148+
b"--6b7ba517decee4a450543ea6ae821c82",
1149+
b'Content-Disposition: form-data; name="array[]"',
1150+
b"",
1151+
b"foo",
1152+
b"--6b7ba517decee4a450543ea6ae821c82",
1153+
b'Content-Disposition: form-data; name="array[]"',
1154+
b"",
1155+
b"bar",
1156+
b"--6b7ba517decee4a450543ea6ae821c82",
1157+
b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
1158+
b"Content-Type: application/octet-stream",
1159+
b"",
1160+
b"hello world",
1161+
b"--6b7ba517decee4a450543ea6ae821c82--",
1162+
b"",
1163+
]
1164+
11071165
@pytest.mark.respx(base_url=base_url)
11081166
async def test_basic_union_response(self, respx_mock: MockRouter) -> None:
11091167
class Model1(BaseModel):

0 commit comments

Comments
 (0)