openai/openai-python

Public

mirrored from https://github.com/openai/openai-pythonAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
647d50abd97cc132d145eee079d158c495b52635

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

tests/test_client.py

2079lines · modecode

1# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
2
3from __future__ import annotations
4
5import gc
6import os
7import sys
8import json
9import asyncio
10import inspect
11import dataclasses
12import tracemalloc
13from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast
14from unittest import mock
15from typing_extensions import Literal, AsyncIterator, override
16
17import httpx
18import pytest
19from respx import MockRouter
20from pydantic import ValidationError
21
22from openai import OpenAI, AsyncOpenAI, APIResponseValidationError
23from openai._types import Omit
24from openai._utils import asyncify
25from openai._models import BaseModel, FinalRequestOptions
26from openai._streaming import Stream, AsyncStream
27from openai._exceptions import OpenAIError, APIStatusError, APITimeoutError, APIResponseValidationError
28from openai._base_client import (
29 DEFAULT_TIMEOUT,
30 HTTPX_DEFAULT_TIMEOUT,
31 BaseClient,
32 OtherPlatform,
33 DefaultHttpxClient,
34 DefaultAsyncHttpxClient,
35 get_platform,
36 make_request_options,
37)
38
39from .utils import update_env
40
41T = TypeVar("T")
42base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
43api_key = "My API Key"
44
45
46def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]:
47 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
48 url = httpx.URL(request.url)
49 return dict(url.params)
50
51
52def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float:
53 return 0.1
54
55
56def mirror_request_content(request: httpx.Request) -> httpx.Response:
57 return httpx.Response(200, content=request.content)
58
59
60# note: we can't use the httpx.MockTransport class as it consumes the request
61# body itself, which means we can't test that the body is read lazily
62class MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport):
63 def __init__(
64 self,
65 handler: Callable[[httpx.Request], httpx.Response]
66 | Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]],
67 ) -> None:
68 self.handler = handler
69
70 @override
71 def handle_request(
72 self,
73 request: httpx.Request,
74 ) -> httpx.Response:
75 assert not inspect.iscoroutinefunction(self.handler), "handler must not be a coroutine function"
76 assert inspect.isfunction(self.handler), "handler must be a function"
77 return self.handler(request)
78
79 @override
80 async def handle_async_request(
81 self,
82 request: httpx.Request,
83 ) -> httpx.Response:
84 assert inspect.iscoroutinefunction(self.handler), "handler must be a coroutine function"
85 return await self.handler(request)
86
87
88@dataclasses.dataclass
89class Counter:
90 value: int = 0
91
92
93def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]:
94 for item in iterable:
95 if counter:
96 counter.value += 1
97 yield item
98
99
100async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> AsyncIterator[T]:
101 for item in iterable:
102 if counter:
103 counter.value += 1
104 yield item
105
106
107def _get_open_connections(client: OpenAI | AsyncOpenAI) -> int:
108 transport = client._client._transport
109 assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport)
110
111 pool = transport._pool
112 return len(pool._requests)
113
114
115class TestOpenAI:
116 @pytest.mark.respx(base_url=base_url)
117 def test_raw_response(self, respx_mock: MockRouter, client: OpenAI) -> None:
118 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
119
120 response = client.post("/foo", cast_to=httpx.Response)
121 assert response.status_code == 200
122 assert isinstance(response, httpx.Response)
123 assert response.json() == {"foo": "bar"}
124
125 @pytest.mark.respx(base_url=base_url)
126 def test_raw_response_for_binary(self, respx_mock: MockRouter, client: OpenAI) -> None:
127 respx_mock.post("/foo").mock(
128 return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
129 )
130
131 response = client.post("/foo", cast_to=httpx.Response)
132 assert response.status_code == 200
133 assert isinstance(response, httpx.Response)
134 assert response.json() == {"foo": "bar"}
135
136 def test_copy(self, client: OpenAI) -> None:
137 copied = client.copy()
138 assert id(copied) != id(client)
139
140 copied = client.copy(api_key="another My API Key")
141 assert copied.api_key == "another My API Key"
142 assert client.api_key == "My API Key"
143
144 def test_copy_default_options(self, client: OpenAI) -> None:
145 # options that have a default are overridden correctly
146 copied = client.copy(max_retries=7)
147 assert copied.max_retries == 7
148 assert client.max_retries == 2
149
150 copied2 = copied.copy(max_retries=6)
151 assert copied2.max_retries == 6
152 assert copied.max_retries == 7
153
154 # timeout
155 assert isinstance(client.timeout, httpx.Timeout)
156 copied = client.copy(timeout=None)
157 assert copied.timeout is None
158 assert isinstance(client.timeout, httpx.Timeout)
159
160 def test_copy_default_headers(self) -> None:
161 client = OpenAI(
162 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
163 )
164 assert client.default_headers["X-Foo"] == "bar"
165
166 # does not override the already given value when not specified
167 copied = client.copy()
168 assert copied.default_headers["X-Foo"] == "bar"
169
170 # merges already given headers
171 copied = client.copy(default_headers={"X-Bar": "stainless"})
172 assert copied.default_headers["X-Foo"] == "bar"
173 assert copied.default_headers["X-Bar"] == "stainless"
174
175 # uses new values for any already given headers
176 copied = client.copy(default_headers={"X-Foo": "stainless"})
177 assert copied.default_headers["X-Foo"] == "stainless"
178
179 # set_default_headers
180
181 # completely overrides already set values
182 copied = client.copy(set_default_headers={})
183 assert copied.default_headers.get("X-Foo") is None
184
185 copied = client.copy(set_default_headers={"X-Bar": "Robert"})
186 assert copied.default_headers["X-Bar"] == "Robert"
187
188 with pytest.raises(
189 ValueError,
190 match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
191 ):
192 client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
193 client.close()
194
195 def test_copy_default_query(self) -> None:
196 client = OpenAI(
197 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
198 )
199 assert _get_params(client)["foo"] == "bar"
200
201 # does not override the already given value when not specified
202 copied = client.copy()
203 assert _get_params(copied)["foo"] == "bar"
204
205 # merges already given params
206 copied = client.copy(default_query={"bar": "stainless"})
207 params = _get_params(copied)
208 assert params["foo"] == "bar"
209 assert params["bar"] == "stainless"
210
211 # uses new values for any already given headers
212 copied = client.copy(default_query={"foo": "stainless"})
213 assert _get_params(copied)["foo"] == "stainless"
214
215 # set_default_query
216
217 # completely overrides already set values
218 copied = client.copy(set_default_query={})
219 assert _get_params(copied) == {}
220
221 copied = client.copy(set_default_query={"bar": "Robert"})
222 assert _get_params(copied)["bar"] == "Robert"
223
224 with pytest.raises(
225 ValueError,
226 # TODO: update
227 match="`default_query` and `set_default_query` arguments are mutually exclusive",
228 ):
229 client.copy(set_default_query={}, default_query={"foo": "Bar"})
230
231 client.close()
232
233 def test_copy_signature(self, client: OpenAI) -> None:
234 # ensure the same parameters that can be passed to the client are defined in the `.copy()` method
235 init_signature = inspect.signature(
236 # mypy doesn't like that we access the `__init__` property.
237 client.__init__, # type: ignore[misc]
238 )
239 copy_signature = inspect.signature(client.copy)
240 exclude_params = {"transport", "proxies", "_strict_response_validation"}
241
242 for name in init_signature.parameters.keys():
243 if name in exclude_params:
244 continue
245
246 copy_param = copy_signature.parameters.get(name)
247 assert copy_param is not None, f"copy() signature is missing the {name} param"
248
249 @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12")
250 def test_copy_build_request(self, client: OpenAI) -> None:
251 options = FinalRequestOptions(method="get", url="/foo")
252
253 def build_request(options: FinalRequestOptions) -> None:
254 client_copy = client.copy()
255 client_copy._build_request(options)
256
257 # ensure that the machinery is warmed up before tracing starts.
258 build_request(options)
259 gc.collect()
260
261 tracemalloc.start(1000)
262
263 snapshot_before = tracemalloc.take_snapshot()
264
265 ITERATIONS = 10
266 for _ in range(ITERATIONS):
267 build_request(options)
268
269 gc.collect()
270 snapshot_after = tracemalloc.take_snapshot()
271
272 tracemalloc.stop()
273
274 def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None:
275 if diff.count == 0:
276 # Avoid false positives by considering only leaks (i.e. allocations that persist).
277 return
278
279 if diff.count % ITERATIONS != 0:
280 # Avoid false positives by considering only leaks that appear per iteration.
281 return
282
283 for frame in diff.traceback:
284 if any(
285 frame.filename.endswith(fragment)
286 for fragment in [
287 # to_raw_response_wrapper leaks through the @functools.wraps() decorator.
288 #
289 # removing the decorator fixes the leak for reasons we don't understand.
290 "openai/_legacy_response.py",
291 "openai/_response.py",
292 # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason.
293 "openai/_compat.py",
294 # Standard library leaks we don't care about.
295 "/logging/__init__.py",
296 ]
297 ):
298 return
299
300 leaks.append(diff)
301
302 leaks: list[tracemalloc.StatisticDiff] = []
303 for diff in snapshot_after.compare_to(snapshot_before, "traceback"):
304 add_leak(leaks, diff)
305 if leaks:
306 for leak in leaks:
307 print("MEMORY LEAK:", leak)
308 for frame in leak.traceback:
309 print(frame)
310 raise AssertionError()
311
312 def test_request_timeout(self, client: OpenAI) -> None:
313 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
314 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
315 assert timeout == DEFAULT_TIMEOUT
316
317 request = client._build_request(FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)))
318 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
319 assert timeout == httpx.Timeout(100.0)
320
321 def test_client_timeout_option(self) -> None:
322 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0))
323
324 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
325 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
326 assert timeout == httpx.Timeout(0)
327
328 client.close()
329
330 def test_http_client_timeout_option(self) -> None:
331 # custom timeout given to the httpx client should be used
332 with httpx.Client(timeout=None) as http_client:
333 client = OpenAI(
334 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
335 )
336
337 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
338 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
339 assert timeout == httpx.Timeout(None)
340
341 client.close()
342
343 # no timeout given to the httpx client should not use the httpx default
344 with httpx.Client() as http_client:
345 client = OpenAI(
346 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
347 )
348
349 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
350 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
351 assert timeout == DEFAULT_TIMEOUT
352
353 client.close()
354
355 # explicitly passing the default timeout currently results in it being ignored
356 with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
357 client = OpenAI(
358 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
359 )
360
361 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
362 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
363 assert timeout == DEFAULT_TIMEOUT # our default
364
365 client.close()
366
367 async def test_invalid_http_client(self) -> None:
368 with pytest.raises(TypeError, match="Invalid `http_client` arg"):
369 async with httpx.AsyncClient() as http_client:
370 OpenAI(
371 base_url=base_url,
372 api_key=api_key,
373 _strict_response_validation=True,
374 http_client=cast(Any, http_client),
375 )
376
377 def test_default_headers_option(self) -> None:
378 test_client = OpenAI(
379 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
380 )
381 request = test_client._build_request(FinalRequestOptions(method="get", url="/foo"))
382 assert request.headers.get("x-foo") == "bar"
383 assert request.headers.get("x-stainless-lang") == "python"
384
385 test_client2 = OpenAI(
386 base_url=base_url,
387 api_key=api_key,
388 _strict_response_validation=True,
389 default_headers={
390 "X-Foo": "stainless",
391 "X-Stainless-Lang": "my-overriding-header",
392 },
393 )
394 request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo"))
395 assert request.headers.get("x-foo") == "stainless"
396 assert request.headers.get("x-stainless-lang") == "my-overriding-header"
397
398 test_client.close()
399 test_client2.close()
400
401 def test_validate_headers(self) -> None:
402 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
403 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
404 assert request.headers.get("Authorization") == f"Bearer {api_key}"
405
406 with pytest.raises(OpenAIError):
407 with update_env(**{"OPENAI_API_KEY": Omit()}):
408 client2 = OpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
409 _ = client2
410
411 def test_default_query_option(self) -> None:
412 client = OpenAI(
413 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
414 )
415 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
416 url = httpx.URL(request.url)
417 assert dict(url.params) == {"query_param": "bar"}
418
419 request = client._build_request(
420 FinalRequestOptions(
421 method="get",
422 url="/foo",
423 params={"foo": "baz", "query_param": "overridden"},
424 )
425 )
426 url = httpx.URL(request.url)
427 assert dict(url.params) == {"foo": "baz", "query_param": "overridden"}
428
429 client.close()
430
431 def test_request_extra_json(self, client: OpenAI) -> None:
432 request = client._build_request(
433 FinalRequestOptions(
434 method="post",
435 url="/foo",
436 json_data={"foo": "bar"},
437 extra_json={"baz": False},
438 ),
439 )
440 data = json.loads(request.content.decode("utf-8"))
441 assert data == {"foo": "bar", "baz": False}
442
443 request = client._build_request(
444 FinalRequestOptions(
445 method="post",
446 url="/foo",
447 extra_json={"baz": False},
448 ),
449 )
450 data = json.loads(request.content.decode("utf-8"))
451 assert data == {"baz": False}
452
453 # `extra_json` takes priority over `json_data` when keys clash
454 request = client._build_request(
455 FinalRequestOptions(
456 method="post",
457 url="/foo",
458 json_data={"foo": "bar", "baz": True},
459 extra_json={"baz": None},
460 ),
461 )
462 data = json.loads(request.content.decode("utf-8"))
463 assert data == {"foo": "bar", "baz": None}
464
465 def test_request_extra_headers(self, client: OpenAI) -> None:
466 request = client._build_request(
467 FinalRequestOptions(
468 method="post",
469 url="/foo",
470 **make_request_options(extra_headers={"X-Foo": "Foo"}),
471 ),
472 )
473 assert request.headers.get("X-Foo") == "Foo"
474
475 # `extra_headers` takes priority over `default_headers` when keys clash
476 request = client.with_options(default_headers={"X-Bar": "true"})._build_request(
477 FinalRequestOptions(
478 method="post",
479 url="/foo",
480 **make_request_options(
481 extra_headers={"X-Bar": "false"},
482 ),
483 ),
484 )
485 assert request.headers.get("X-Bar") == "false"
486
487 def test_request_extra_query(self, client: OpenAI) -> None:
488 request = client._build_request(
489 FinalRequestOptions(
490 method="post",
491 url="/foo",
492 **make_request_options(
493 extra_query={"my_query_param": "Foo"},
494 ),
495 ),
496 )
497 params = dict(request.url.params)
498 assert params == {"my_query_param": "Foo"}
499
500 # if both `query` and `extra_query` are given, they are merged
501 request = client._build_request(
502 FinalRequestOptions(
503 method="post",
504 url="/foo",
505 **make_request_options(
506 query={"bar": "1"},
507 extra_query={"foo": "2"},
508 ),
509 ),
510 )
511 params = dict(request.url.params)
512 assert params == {"bar": "1", "foo": "2"}
513
514 # `extra_query` takes priority over `query` when keys clash
515 request = client._build_request(
516 FinalRequestOptions(
517 method="post",
518 url="/foo",
519 **make_request_options(
520 query={"foo": "1"},
521 extra_query={"foo": "2"},
522 ),
523 ),
524 )
525 params = dict(request.url.params)
526 assert params == {"foo": "2"}
527
528 def test_multipart_repeating_array(self, client: OpenAI) -> None:
529 request = client._build_request(
530 FinalRequestOptions.construct(
531 method="post",
532 url="/foo",
533 headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
534 json_data={"array": ["foo", "bar"]},
535 files=[("foo.txt", b"hello world")],
536 )
537 )
538
539 assert request.read().split(b"\r\n") == [
540 b"--6b7ba517decee4a450543ea6ae821c82",
541 b'Content-Disposition: form-data; name="array[]"',
542 b"",
543 b"foo",
544 b"--6b7ba517decee4a450543ea6ae821c82",
545 b'Content-Disposition: form-data; name="array[]"',
546 b"",
547 b"bar",
548 b"--6b7ba517decee4a450543ea6ae821c82",
549 b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
550 b"Content-Type: application/octet-stream",
551 b"",
552 b"hello world",
553 b"--6b7ba517decee4a450543ea6ae821c82--",
554 b"",
555 ]
556
557 @pytest.mark.respx(base_url=base_url)
558 def test_binary_content_upload(self, respx_mock: MockRouter, client: OpenAI) -> None:
559 respx_mock.post("/upload").mock(side_effect=mirror_request_content)
560
561 file_content = b"Hello, this is a test file."
562
563 response = client.post(
564 "/upload",
565 content=file_content,
566 cast_to=httpx.Response,
567 options={"headers": {"Content-Type": "application/octet-stream"}},
568 )
569
570 assert response.status_code == 200
571 assert response.request.headers["Content-Type"] == "application/octet-stream"
572 assert response.content == file_content
573
574 def test_binary_content_upload_with_iterator(self) -> None:
575 file_content = b"Hello, this is a test file."
576 counter = Counter()
577 iterator = _make_sync_iterator([file_content], counter=counter)
578
579 def mock_handler(request: httpx.Request) -> httpx.Response:
580 assert counter.value == 0, "the request body should not have been read"
581 return httpx.Response(200, content=request.read())
582
583 with OpenAI(
584 base_url=base_url,
585 api_key=api_key,
586 _strict_response_validation=True,
587 http_client=httpx.Client(transport=MockTransport(handler=mock_handler)),
588 ) as client:
589 response = client.post(
590 "/upload",
591 content=iterator,
592 cast_to=httpx.Response,
593 options={"headers": {"Content-Type": "application/octet-stream"}},
594 )
595
596 assert response.status_code == 200
597 assert response.request.headers["Content-Type"] == "application/octet-stream"
598 assert response.content == file_content
599 assert counter.value == 1
600
601 @pytest.mark.respx(base_url=base_url)
602 def test_binary_content_upload_with_body_is_deprecated(self, respx_mock: MockRouter, client: OpenAI) -> None:
603 respx_mock.post("/upload").mock(side_effect=mirror_request_content)
604
605 file_content = b"Hello, this is a test file."
606
607 with pytest.deprecated_call(
608 match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead."
609 ):
610 response = client.post(
611 "/upload",
612 body=file_content,
613 cast_to=httpx.Response,
614 options={"headers": {"Content-Type": "application/octet-stream"}},
615 )
616
617 assert response.status_code == 200
618 assert response.request.headers["Content-Type"] == "application/octet-stream"
619 assert response.content == file_content
620
621 @pytest.mark.respx(base_url=base_url)
622 def test_basic_union_response(self, respx_mock: MockRouter, client: OpenAI) -> None:
623 class Model1(BaseModel):
624 name: str
625
626 class Model2(BaseModel):
627 foo: str
628
629 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
630
631 response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
632 assert isinstance(response, Model2)
633 assert response.foo == "bar"
634
635 @pytest.mark.respx(base_url=base_url)
636 def test_union_response_different_types(self, respx_mock: MockRouter, client: OpenAI) -> None:
637 """Union of objects with the same field name using a different type"""
638
639 class Model1(BaseModel):
640 foo: int
641
642 class Model2(BaseModel):
643 foo: str
644
645 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
646
647 response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
648 assert isinstance(response, Model2)
649 assert response.foo == "bar"
650
651 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
652
653 response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
654 assert isinstance(response, Model1)
655 assert response.foo == 1
656
657 @pytest.mark.respx(base_url=base_url)
658 def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter, client: OpenAI) -> None:
659 """
660 Response that sets Content-Type to something other than application/json but returns json data
661 """
662
663 class Model(BaseModel):
664 foo: int
665
666 respx_mock.get("/foo").mock(
667 return_value=httpx.Response(
668 200,
669 content=json.dumps({"foo": 2}),
670 headers={"Content-Type": "application/text"},
671 )
672 )
673
674 response = client.get("/foo", cast_to=Model)
675 assert isinstance(response, Model)
676 assert response.foo == 2
677
678 def test_base_url_setter(self) -> None:
679 client = OpenAI(base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True)
680 assert client.base_url == "https://example.com/from_init/"
681
682 client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
683
684 assert client.base_url == "https://example.com/from_setter/"
685
686 client.close()
687
688 def test_base_url_env(self) -> None:
689 with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
690 client = OpenAI(api_key=api_key, _strict_response_validation=True)
691 assert client.base_url == "http://localhost:5000/from/env/"
692
693 @pytest.mark.parametrize(
694 "client",
695 [
696 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
697 OpenAI(
698 base_url="http://localhost:5000/custom/path/",
699 api_key=api_key,
700 _strict_response_validation=True,
701 http_client=httpx.Client(),
702 ),
703 ],
704 ids=["standard", "custom http client"],
705 )
706 def test_base_url_trailing_slash(self, client: OpenAI) -> None:
707 request = client._build_request(
708 FinalRequestOptions(
709 method="post",
710 url="/foo",
711 json_data={"foo": "bar"},
712 ),
713 )
714 assert request.url == "http://localhost:5000/custom/path/foo"
715 client.close()
716
717 @pytest.mark.parametrize(
718 "client",
719 [
720 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
721 OpenAI(
722 base_url="http://localhost:5000/custom/path/",
723 api_key=api_key,
724 _strict_response_validation=True,
725 http_client=httpx.Client(),
726 ),
727 ],
728 ids=["standard", "custom http client"],
729 )
730 def test_base_url_no_trailing_slash(self, client: OpenAI) -> None:
731 request = client._build_request(
732 FinalRequestOptions(
733 method="post",
734 url="/foo",
735 json_data={"foo": "bar"},
736 ),
737 )
738 assert request.url == "http://localhost:5000/custom/path/foo"
739 client.close()
740
741 @pytest.mark.parametrize(
742 "client",
743 [
744 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
745 OpenAI(
746 base_url="http://localhost:5000/custom/path/",
747 api_key=api_key,
748 _strict_response_validation=True,
749 http_client=httpx.Client(),
750 ),
751 ],
752 ids=["standard", "custom http client"],
753 )
754 def test_absolute_request_url(self, client: OpenAI) -> None:
755 request = client._build_request(
756 FinalRequestOptions(
757 method="post",
758 url="https://myapi.com/foo",
759 json_data={"foo": "bar"},
760 ),
761 )
762 assert request.url == "https://myapi.com/foo"
763 client.close()
764
765 def test_copied_client_does_not_close_http(self) -> None:
766 test_client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
767 assert not test_client.is_closed()
768
769 copied = test_client.copy()
770 assert copied is not test_client
771
772 del copied
773
774 assert not test_client.is_closed()
775
776 def test_client_context_manager(self) -> None:
777 test_client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
778 with test_client as c2:
779 assert c2 is test_client
780 assert not c2.is_closed()
781 assert not test_client.is_closed()
782 assert test_client.is_closed()
783
784 @pytest.mark.respx(base_url=base_url)
785 def test_client_response_validation_error(self, respx_mock: MockRouter, client: OpenAI) -> None:
786 class Model(BaseModel):
787 foo: str
788
789 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
790
791 with pytest.raises(APIResponseValidationError) as exc:
792 client.get("/foo", cast_to=Model)
793
794 assert isinstance(exc.value.__cause__, ValidationError)
795
796 def test_client_max_retries_validation(self) -> None:
797 with pytest.raises(TypeError, match=r"max_retries cannot be None"):
798 OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None))
799
800 @pytest.mark.respx(base_url=base_url)
801 def test_default_stream_cls(self, respx_mock: MockRouter, client: OpenAI) -> None:
802 class Model(BaseModel):
803 name: str
804
805 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
806
807 stream = client.post("/foo", cast_to=Model, stream=True, stream_cls=Stream[Model])
808 assert isinstance(stream, Stream)
809 stream.response.close()
810
811 @pytest.mark.respx(base_url=base_url)
812 def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
813 class Model(BaseModel):
814 name: str
815
816 respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
817
818 strict_client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
819
820 with pytest.raises(APIResponseValidationError):
821 strict_client.get("/foo", cast_to=Model)
822
823 non_strict_client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
824
825 response = non_strict_client.get("/foo", cast_to=Model)
826 assert isinstance(response, str) # type: ignore[unreachable]
827
828 strict_client.close()
829 non_strict_client.close()
830
831 @pytest.mark.parametrize(
832 "remaining_retries,retry_after,timeout",
833 [
834 [3, "20", 20],
835 [3, "0", 0.5],
836 [3, "-10", 0.5],
837 [3, "60", 60],
838 [3, "61", 0.5],
839 [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
840 [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
841 [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
842 [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
843 [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
844 [3, "99999999999999999999999999999999999", 0.5],
845 [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
846 [3, "", 0.5],
847 [2, "", 0.5 * 2.0],
848 [1, "", 0.5 * 4.0],
849 [-1100, "", 8], # test large number potentially overflowing
850 ],
851 )
852 @mock.patch("time.time", mock.MagicMock(return_value=1696004797))
853 def test_parse_retry_after_header(
854 self, remaining_retries: int, retry_after: str, timeout: float, client: OpenAI
855 ) -> None:
856 headers = httpx.Headers({"retry-after": retry_after})
857 options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
858 calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
859 assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
860
861 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
862 @pytest.mark.respx(base_url=base_url)
863 def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: OpenAI) -> None:
864 respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
865
866 with pytest.raises(APITimeoutError):
867 client.chat.completions.with_streaming_response.create(
868 messages=[
869 {
870 "content": "string",
871 "role": "developer",
872 }
873 ],
874 model="gpt-5.4",
875 ).__enter__()
876
877 assert _get_open_connections(client) == 0
878
879 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
880 @pytest.mark.respx(base_url=base_url)
881 def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: OpenAI) -> None:
882 respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
883
884 with pytest.raises(APIStatusError):
885 client.chat.completions.with_streaming_response.create(
886 messages=[
887 {
888 "content": "string",
889 "role": "developer",
890 }
891 ],
892 model="gpt-5.4",
893 ).__enter__()
894 assert _get_open_connections(client) == 0
895
896 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
897 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
898 @pytest.mark.respx(base_url=base_url)
899 @pytest.mark.parametrize("failure_mode", ["status", "exception"])
900 def test_retries_taken(
901 self,
902 client: OpenAI,
903 failures_before_success: int,
904 failure_mode: Literal["status", "exception"],
905 respx_mock: MockRouter,
906 ) -> None:
907 client = client.with_options(max_retries=4)
908
909 nb_retries = 0
910
911 def retry_handler(_request: httpx.Request) -> httpx.Response:
912 nonlocal nb_retries
913 if nb_retries < failures_before_success:
914 nb_retries += 1
915 if failure_mode == "exception":
916 raise RuntimeError("oops")
917 return httpx.Response(500)
918 return httpx.Response(200)
919
920 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
921
922 response = client.chat.completions.with_raw_response.create(
923 messages=[
924 {
925 "content": "string",
926 "role": "developer",
927 }
928 ],
929 model="gpt-5.4",
930 )
931
932 assert response.retries_taken == failures_before_success
933 assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
934
935 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
936 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
937 @pytest.mark.respx(base_url=base_url)
938 def test_omit_retry_count_header(
939 self, client: OpenAI, failures_before_success: int, respx_mock: MockRouter
940 ) -> None:
941 client = client.with_options(max_retries=4)
942
943 nb_retries = 0
944
945 def retry_handler(_request: httpx.Request) -> httpx.Response:
946 nonlocal nb_retries
947 if nb_retries < failures_before_success:
948 nb_retries += 1
949 return httpx.Response(500)
950 return httpx.Response(200)
951
952 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
953
954 response = client.chat.completions.with_raw_response.create(
955 messages=[
956 {
957 "content": "string",
958 "role": "developer",
959 }
960 ],
961 model="gpt-5.4",
962 extra_headers={"x-stainless-retry-count": Omit()},
963 )
964
965 assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0
966
967 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
968 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
969 @pytest.mark.respx(base_url=base_url)
970 def test_overwrite_retry_count_header(
971 self, client: OpenAI, failures_before_success: int, respx_mock: MockRouter
972 ) -> None:
973 client = client.with_options(max_retries=4)
974
975 nb_retries = 0
976
977 def retry_handler(_request: httpx.Request) -> httpx.Response:
978 nonlocal nb_retries
979 if nb_retries < failures_before_success:
980 nb_retries += 1
981 return httpx.Response(500)
982 return httpx.Response(200)
983
984 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
985
986 response = client.chat.completions.with_raw_response.create(
987 messages=[
988 {
989 "content": "string",
990 "role": "developer",
991 }
992 ],
993 model="gpt-5.4",
994 extra_headers={"x-stainless-retry-count": "42"},
995 )
996
997 assert response.http_request.headers.get("x-stainless-retry-count") == "42"
998
999 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1000 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1001 @pytest.mark.respx(base_url=base_url)
1002 def test_retries_taken_new_response_class(
1003 self, client: OpenAI, failures_before_success: int, respx_mock: MockRouter
1004 ) -> None:
1005 client = client.with_options(max_retries=4)
1006
1007 nb_retries = 0
1008
1009 def retry_handler(_request: httpx.Request) -> httpx.Response:
1010 nonlocal nb_retries
1011 if nb_retries < failures_before_success:
1012 nb_retries += 1
1013 return httpx.Response(500)
1014 return httpx.Response(200)
1015
1016 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1017
1018 with client.chat.completions.with_streaming_response.create(
1019 messages=[
1020 {
1021 "content": "string",
1022 "role": "developer",
1023 }
1024 ],
1025 model="gpt-5.4",
1026 ) as response:
1027 assert response.retries_taken == failures_before_success
1028 assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
1029
1030 def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None:
1031 # Test that the proxy environment variables are set correctly
1032 monkeypatch.setenv("HTTPS_PROXY", "https://example.org")
1033 # Delete in case our environment has any proxy env vars set
1034 monkeypatch.delenv("HTTP_PROXY", raising=False)
1035 monkeypatch.delenv("ALL_PROXY", raising=False)
1036 monkeypatch.delenv("NO_PROXY", raising=False)
1037 monkeypatch.delenv("http_proxy", raising=False)
1038 monkeypatch.delenv("https_proxy", raising=False)
1039 monkeypatch.delenv("all_proxy", raising=False)
1040 monkeypatch.delenv("no_proxy", raising=False)
1041
1042 client = DefaultHttpxClient()
1043
1044 mounts = tuple(client._mounts.items())
1045 assert len(mounts) == 1
1046 assert mounts[0][0].pattern == "https://"
1047
1048 @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning")
1049 def test_default_client_creation(self) -> None:
1050 # Ensure that the client can be initialized without any exceptions
1051 DefaultHttpxClient(
1052 verify=True,
1053 cert=None,
1054 trust_env=True,
1055 http1=True,
1056 http2=False,
1057 limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
1058 )
1059
1060 @pytest.mark.respx(base_url=base_url)
1061 def test_follow_redirects(self, respx_mock: MockRouter, client: OpenAI) -> None:
1062 # Test that the default follow_redirects=True allows following redirects
1063 respx_mock.post("/redirect").mock(
1064 return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
1065 )
1066 respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"}))
1067
1068 response = client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
1069 assert response.status_code == 200
1070 assert response.json() == {"status": "ok"}
1071
1072 @pytest.mark.respx(base_url=base_url)
1073 def test_follow_redirects_disabled(self, respx_mock: MockRouter, client: OpenAI) -> None:
1074 # Test that follow_redirects=False prevents following redirects
1075 respx_mock.post("/redirect").mock(
1076 return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
1077 )
1078
1079 with pytest.raises(APIStatusError) as exc_info:
1080 client.post("/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response)
1081
1082 assert exc_info.value.response.status_code == 302
1083 assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected"
1084
1085
1086class TestAsyncOpenAI:
1087 @pytest.mark.respx(base_url=base_url)
1088 async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1089 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1090
1091 response = await async_client.post("/foo", cast_to=httpx.Response)
1092 assert response.status_code == 200
1093 assert isinstance(response, httpx.Response)
1094 assert response.json() == {"foo": "bar"}
1095
1096 @pytest.mark.respx(base_url=base_url)
1097 async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1098 respx_mock.post("/foo").mock(
1099 return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
1100 )
1101
1102 response = await async_client.post("/foo", cast_to=httpx.Response)
1103 assert response.status_code == 200
1104 assert isinstance(response, httpx.Response)
1105 assert response.json() == {"foo": "bar"}
1106
1107 def test_copy(self, async_client: AsyncOpenAI) -> None:
1108 copied = async_client.copy()
1109 assert id(copied) != id(async_client)
1110
1111 copied = async_client.copy(api_key="another My API Key")
1112 assert copied.api_key == "another My API Key"
1113 assert async_client.api_key == "My API Key"
1114
1115 def test_copy_default_options(self, async_client: AsyncOpenAI) -> None:
1116 # options that have a default are overridden correctly
1117 copied = async_client.copy(max_retries=7)
1118 assert copied.max_retries == 7
1119 assert async_client.max_retries == 2
1120
1121 copied2 = copied.copy(max_retries=6)
1122 assert copied2.max_retries == 6
1123 assert copied.max_retries == 7
1124
1125 # timeout
1126 assert isinstance(async_client.timeout, httpx.Timeout)
1127 copied = async_client.copy(timeout=None)
1128 assert copied.timeout is None
1129 assert isinstance(async_client.timeout, httpx.Timeout)
1130
1131 async def test_copy_default_headers(self) -> None:
1132 client = AsyncOpenAI(
1133 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
1134 )
1135 assert client.default_headers["X-Foo"] == "bar"
1136
1137 # does not override the already given value when not specified
1138 copied = client.copy()
1139 assert copied.default_headers["X-Foo"] == "bar"
1140
1141 # merges already given headers
1142 copied = client.copy(default_headers={"X-Bar": "stainless"})
1143 assert copied.default_headers["X-Foo"] == "bar"
1144 assert copied.default_headers["X-Bar"] == "stainless"
1145
1146 # uses new values for any already given headers
1147 copied = client.copy(default_headers={"X-Foo": "stainless"})
1148 assert copied.default_headers["X-Foo"] == "stainless"
1149
1150 # set_default_headers
1151
1152 # completely overrides already set values
1153 copied = client.copy(set_default_headers={})
1154 assert copied.default_headers.get("X-Foo") is None
1155
1156 copied = client.copy(set_default_headers={"X-Bar": "Robert"})
1157 assert copied.default_headers["X-Bar"] == "Robert"
1158
1159 with pytest.raises(
1160 ValueError,
1161 match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
1162 ):
1163 client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
1164 await client.close()
1165
1166 async def test_copy_default_query(self) -> None:
1167 client = AsyncOpenAI(
1168 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
1169 )
1170 assert _get_params(client)["foo"] == "bar"
1171
1172 # does not override the already given value when not specified
1173 copied = client.copy()
1174 assert _get_params(copied)["foo"] == "bar"
1175
1176 # merges already given params
1177 copied = client.copy(default_query={"bar": "stainless"})
1178 params = _get_params(copied)
1179 assert params["foo"] == "bar"
1180 assert params["bar"] == "stainless"
1181
1182 # uses new values for any already given headers
1183 copied = client.copy(default_query={"foo": "stainless"})
1184 assert _get_params(copied)["foo"] == "stainless"
1185
1186 # set_default_query
1187
1188 # completely overrides already set values
1189 copied = client.copy(set_default_query={})
1190 assert _get_params(copied) == {}
1191
1192 copied = client.copy(set_default_query={"bar": "Robert"})
1193 assert _get_params(copied)["bar"] == "Robert"
1194
1195 with pytest.raises(
1196 ValueError,
1197 # TODO: update
1198 match="`default_query` and `set_default_query` arguments are mutually exclusive",
1199 ):
1200 client.copy(set_default_query={}, default_query={"foo": "Bar"})
1201
1202 await client.close()
1203
1204 def test_copy_signature(self, async_client: AsyncOpenAI) -> None:
1205 # ensure the same parameters that can be passed to the client are defined in the `.copy()` method
1206 init_signature = inspect.signature(
1207 # mypy doesn't like that we access the `__init__` property.
1208 async_client.__init__, # type: ignore[misc]
1209 )
1210 copy_signature = inspect.signature(async_client.copy)
1211 exclude_params = {"transport", "proxies", "_strict_response_validation"}
1212
1213 for name in init_signature.parameters.keys():
1214 if name in exclude_params:
1215 continue
1216
1217 copy_param = copy_signature.parameters.get(name)
1218 assert copy_param is not None, f"copy() signature is missing the {name} param"
1219
1220 @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12")
1221 def test_copy_build_request(self, async_client: AsyncOpenAI) -> None:
1222 options = FinalRequestOptions(method="get", url="/foo")
1223
1224 def build_request(options: FinalRequestOptions) -> None:
1225 client_copy = async_client.copy()
1226 client_copy._build_request(options)
1227
1228 # ensure that the machinery is warmed up before tracing starts.
1229 build_request(options)
1230 gc.collect()
1231
1232 tracemalloc.start(1000)
1233
1234 snapshot_before = tracemalloc.take_snapshot()
1235
1236 ITERATIONS = 10
1237 for _ in range(ITERATIONS):
1238 build_request(options)
1239
1240 gc.collect()
1241 snapshot_after = tracemalloc.take_snapshot()
1242
1243 tracemalloc.stop()
1244
1245 def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None:
1246 if diff.count == 0:
1247 # Avoid false positives by considering only leaks (i.e. allocations that persist).
1248 return
1249
1250 if diff.count % ITERATIONS != 0:
1251 # Avoid false positives by considering only leaks that appear per iteration.
1252 return
1253
1254 for frame in diff.traceback:
1255 if any(
1256 frame.filename.endswith(fragment)
1257 for fragment in [
1258 # to_raw_response_wrapper leaks through the @functools.wraps() decorator.
1259 #
1260 # removing the decorator fixes the leak for reasons we don't understand.
1261 "openai/_legacy_response.py",
1262 "openai/_response.py",
1263 # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason.
1264 "openai/_compat.py",
1265 # Standard library leaks we don't care about.
1266 "/logging/__init__.py",
1267 ]
1268 ):
1269 return
1270
1271 leaks.append(diff)
1272
1273 leaks: list[tracemalloc.StatisticDiff] = []
1274 for diff in snapshot_after.compare_to(snapshot_before, "traceback"):
1275 add_leak(leaks, diff)
1276 if leaks:
1277 for leak in leaks:
1278 print("MEMORY LEAK:", leak)
1279 for frame in leak.traceback:
1280 print(frame)
1281 raise AssertionError()
1282
1283 async def test_request_timeout(self, async_client: AsyncOpenAI) -> None:
1284 request = async_client._build_request(FinalRequestOptions(method="get", url="/foo"))
1285 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1286 assert timeout == DEFAULT_TIMEOUT
1287
1288 request = async_client._build_request(
1289 FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
1290 )
1291 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1292 assert timeout == httpx.Timeout(100.0)
1293
1294 async def test_client_timeout_option(self) -> None:
1295 client = AsyncOpenAI(
1296 base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0)
1297 )
1298
1299 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1300 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1301 assert timeout == httpx.Timeout(0)
1302
1303 await client.close()
1304
1305 async def test_http_client_timeout_option(self) -> None:
1306 # custom timeout given to the httpx client should be used
1307 async with httpx.AsyncClient(timeout=None) as http_client:
1308 client = AsyncOpenAI(
1309 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1310 )
1311
1312 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1313 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1314 assert timeout == httpx.Timeout(None)
1315
1316 await client.close()
1317
1318 # no timeout given to the httpx client should not use the httpx default
1319 async with httpx.AsyncClient() as http_client:
1320 client = AsyncOpenAI(
1321 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1322 )
1323
1324 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1325 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1326 assert timeout == DEFAULT_TIMEOUT
1327
1328 await client.close()
1329
1330 # explicitly passing the default timeout currently results in it being ignored
1331 async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
1332 client = AsyncOpenAI(
1333 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1334 )
1335
1336 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1337 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1338 assert timeout == DEFAULT_TIMEOUT # our default
1339
1340 await client.close()
1341
1342 def test_invalid_http_client(self) -> None:
1343 with pytest.raises(TypeError, match="Invalid `http_client` arg"):
1344 with httpx.Client() as http_client:
1345 AsyncOpenAI(
1346 base_url=base_url,
1347 api_key=api_key,
1348 _strict_response_validation=True,
1349 http_client=cast(Any, http_client),
1350 )
1351
1352 async def test_default_headers_option(self) -> None:
1353 test_client = AsyncOpenAI(
1354 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
1355 )
1356 request = test_client._build_request(FinalRequestOptions(method="get", url="/foo"))
1357 assert request.headers.get("x-foo") == "bar"
1358 assert request.headers.get("x-stainless-lang") == "python"
1359
1360 test_client2 = AsyncOpenAI(
1361 base_url=base_url,
1362 api_key=api_key,
1363 _strict_response_validation=True,
1364 default_headers={
1365 "X-Foo": "stainless",
1366 "X-Stainless-Lang": "my-overriding-header",
1367 },
1368 )
1369 request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo"))
1370 assert request.headers.get("x-foo") == "stainless"
1371 assert request.headers.get("x-stainless-lang") == "my-overriding-header"
1372
1373 await test_client.close()
1374 await test_client2.close()
1375
1376 def test_validate_headers(self) -> None:
1377 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1378 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1379 assert request.headers.get("Authorization") == f"Bearer {api_key}"
1380
1381 with pytest.raises(OpenAIError):
1382 with update_env(**{"OPENAI_API_KEY": Omit()}):
1383 client2 = AsyncOpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
1384 _ = client2
1385
1386 async def test_default_query_option(self) -> None:
1387 client = AsyncOpenAI(
1388 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
1389 )
1390 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1391 url = httpx.URL(request.url)
1392 assert dict(url.params) == {"query_param": "bar"}
1393
1394 request = client._build_request(
1395 FinalRequestOptions(
1396 method="get",
1397 url="/foo",
1398 params={"foo": "baz", "query_param": "overridden"},
1399 )
1400 )
1401 url = httpx.URL(request.url)
1402 assert dict(url.params) == {"foo": "baz", "query_param": "overridden"}
1403
1404 await client.close()
1405
1406 def test_request_extra_json(self, client: OpenAI) -> None:
1407 request = client._build_request(
1408 FinalRequestOptions(
1409 method="post",
1410 url="/foo",
1411 json_data={"foo": "bar"},
1412 extra_json={"baz": False},
1413 ),
1414 )
1415 data = json.loads(request.content.decode("utf-8"))
1416 assert data == {"foo": "bar", "baz": False}
1417
1418 request = client._build_request(
1419 FinalRequestOptions(
1420 method="post",
1421 url="/foo",
1422 extra_json={"baz": False},
1423 ),
1424 )
1425 data = json.loads(request.content.decode("utf-8"))
1426 assert data == {"baz": False}
1427
1428 # `extra_json` takes priority over `json_data` when keys clash
1429 request = client._build_request(
1430 FinalRequestOptions(
1431 method="post",
1432 url="/foo",
1433 json_data={"foo": "bar", "baz": True},
1434 extra_json={"baz": None},
1435 ),
1436 )
1437 data = json.loads(request.content.decode("utf-8"))
1438 assert data == {"foo": "bar", "baz": None}
1439
1440 def test_request_extra_headers(self, client: OpenAI) -> None:
1441 request = client._build_request(
1442 FinalRequestOptions(
1443 method="post",
1444 url="/foo",
1445 **make_request_options(extra_headers={"X-Foo": "Foo"}),
1446 ),
1447 )
1448 assert request.headers.get("X-Foo") == "Foo"
1449
1450 # `extra_headers` takes priority over `default_headers` when keys clash
1451 request = client.with_options(default_headers={"X-Bar": "true"})._build_request(
1452 FinalRequestOptions(
1453 method="post",
1454 url="/foo",
1455 **make_request_options(
1456 extra_headers={"X-Bar": "false"},
1457 ),
1458 ),
1459 )
1460 assert request.headers.get("X-Bar") == "false"
1461
1462 def test_request_extra_query(self, client: OpenAI) -> None:
1463 request = client._build_request(
1464 FinalRequestOptions(
1465 method="post",
1466 url="/foo",
1467 **make_request_options(
1468 extra_query={"my_query_param": "Foo"},
1469 ),
1470 ),
1471 )
1472 params = dict(request.url.params)
1473 assert params == {"my_query_param": "Foo"}
1474
1475 # if both `query` and `extra_query` are given, they are merged
1476 request = client._build_request(
1477 FinalRequestOptions(
1478 method="post",
1479 url="/foo",
1480 **make_request_options(
1481 query={"bar": "1"},
1482 extra_query={"foo": "2"},
1483 ),
1484 ),
1485 )
1486 params = dict(request.url.params)
1487 assert params == {"bar": "1", "foo": "2"}
1488
1489 # `extra_query` takes priority over `query` when keys clash
1490 request = client._build_request(
1491 FinalRequestOptions(
1492 method="post",
1493 url="/foo",
1494 **make_request_options(
1495 query={"foo": "1"},
1496 extra_query={"foo": "2"},
1497 ),
1498 ),
1499 )
1500 params = dict(request.url.params)
1501 assert params == {"foo": "2"}
1502
1503 def test_multipart_repeating_array(self, async_client: AsyncOpenAI) -> None:
1504 request = async_client._build_request(
1505 FinalRequestOptions.construct(
1506 method="post",
1507 url="/foo",
1508 headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
1509 json_data={"array": ["foo", "bar"]},
1510 files=[("foo.txt", b"hello world")],
1511 )
1512 )
1513
1514 assert request.read().split(b"\r\n") == [
1515 b"--6b7ba517decee4a450543ea6ae821c82",
1516 b'Content-Disposition: form-data; name="array[]"',
1517 b"",
1518 b"foo",
1519 b"--6b7ba517decee4a450543ea6ae821c82",
1520 b'Content-Disposition: form-data; name="array[]"',
1521 b"",
1522 b"bar",
1523 b"--6b7ba517decee4a450543ea6ae821c82",
1524 b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
1525 b"Content-Type: application/octet-stream",
1526 b"",
1527 b"hello world",
1528 b"--6b7ba517decee4a450543ea6ae821c82--",
1529 b"",
1530 ]
1531
1532 @pytest.mark.respx(base_url=base_url)
1533 async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1534 respx_mock.post("/upload").mock(side_effect=mirror_request_content)
1535
1536 file_content = b"Hello, this is a test file."
1537
1538 response = await async_client.post(
1539 "/upload",
1540 content=file_content,
1541 cast_to=httpx.Response,
1542 options={"headers": {"Content-Type": "application/octet-stream"}},
1543 )
1544
1545 assert response.status_code == 200
1546 assert response.request.headers["Content-Type"] == "application/octet-stream"
1547 assert response.content == file_content
1548
1549 async def test_binary_content_upload_with_asynciterator(self) -> None:
1550 file_content = b"Hello, this is a test file."
1551 counter = Counter()
1552 iterator = _make_async_iterator([file_content], counter=counter)
1553
1554 async def mock_handler(request: httpx.Request) -> httpx.Response:
1555 assert counter.value == 0, "the request body should not have been read"
1556 return httpx.Response(200, content=await request.aread())
1557
1558 async with AsyncOpenAI(
1559 base_url=base_url,
1560 api_key=api_key,
1561 _strict_response_validation=True,
1562 http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)),
1563 ) as client:
1564 response = await client.post(
1565 "/upload",
1566 content=iterator,
1567 cast_to=httpx.Response,
1568 options={"headers": {"Content-Type": "application/octet-stream"}},
1569 )
1570
1571 assert response.status_code == 200
1572 assert response.request.headers["Content-Type"] == "application/octet-stream"
1573 assert response.content == file_content
1574 assert counter.value == 1
1575
1576 @pytest.mark.respx(base_url=base_url)
1577 async def test_binary_content_upload_with_body_is_deprecated(
1578 self, respx_mock: MockRouter, async_client: AsyncOpenAI
1579 ) -> None:
1580 respx_mock.post("/upload").mock(side_effect=mirror_request_content)
1581
1582 file_content = b"Hello, this is a test file."
1583
1584 with pytest.deprecated_call(
1585 match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead."
1586 ):
1587 response = await async_client.post(
1588 "/upload",
1589 body=file_content,
1590 cast_to=httpx.Response,
1591 options={"headers": {"Content-Type": "application/octet-stream"}},
1592 )
1593
1594 assert response.status_code == 200
1595 assert response.request.headers["Content-Type"] == "application/octet-stream"
1596 assert response.content == file_content
1597
1598 @pytest.mark.respx(base_url=base_url)
1599 async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1600 class Model1(BaseModel):
1601 name: str
1602
1603 class Model2(BaseModel):
1604 foo: str
1605
1606 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1607
1608 response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1609 assert isinstance(response, Model2)
1610 assert response.foo == "bar"
1611
1612 @pytest.mark.respx(base_url=base_url)
1613 async def test_union_response_different_types(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1614 """Union of objects with the same field name using a different type"""
1615
1616 class Model1(BaseModel):
1617 foo: int
1618
1619 class Model2(BaseModel):
1620 foo: str
1621
1622 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1623
1624 response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1625 assert isinstance(response, Model2)
1626 assert response.foo == "bar"
1627
1628 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
1629
1630 response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1631 assert isinstance(response, Model1)
1632 assert response.foo == 1
1633
1634 @pytest.mark.respx(base_url=base_url)
1635 async def test_non_application_json_content_type_for_json_data(
1636 self, respx_mock: MockRouter, async_client: AsyncOpenAI
1637 ) -> None:
1638 """
1639 Response that sets Content-Type to something other than application/json but returns json data
1640 """
1641
1642 class Model(BaseModel):
1643 foo: int
1644
1645 respx_mock.get("/foo").mock(
1646 return_value=httpx.Response(
1647 200,
1648 content=json.dumps({"foo": 2}),
1649 headers={"Content-Type": "application/text"},
1650 )
1651 )
1652
1653 response = await async_client.get("/foo", cast_to=Model)
1654 assert isinstance(response, Model)
1655 assert response.foo == 2
1656
1657 async def test_base_url_setter(self) -> None:
1658 client = AsyncOpenAI(
1659 base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True
1660 )
1661 assert client.base_url == "https://example.com/from_init/"
1662
1663 client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
1664
1665 assert client.base_url == "https://example.com/from_setter/"
1666
1667 await client.close()
1668
1669 async def test_base_url_env(self) -> None:
1670 with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
1671 client = AsyncOpenAI(api_key=api_key, _strict_response_validation=True)
1672 assert client.base_url == "http://localhost:5000/from/env/"
1673
1674 @pytest.mark.parametrize(
1675 "client",
1676 [
1677 AsyncOpenAI(
1678 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1679 ),
1680 AsyncOpenAI(
1681 base_url="http://localhost:5000/custom/path/",
1682 api_key=api_key,
1683 _strict_response_validation=True,
1684 http_client=httpx.AsyncClient(),
1685 ),
1686 ],
1687 ids=["standard", "custom http client"],
1688 )
1689 async def test_base_url_trailing_slash(self, client: AsyncOpenAI) -> None:
1690 request = client._build_request(
1691 FinalRequestOptions(
1692 method="post",
1693 url="/foo",
1694 json_data={"foo": "bar"},
1695 ),
1696 )
1697 assert request.url == "http://localhost:5000/custom/path/foo"
1698 await client.close()
1699
1700 @pytest.mark.parametrize(
1701 "client",
1702 [
1703 AsyncOpenAI(
1704 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1705 ),
1706 AsyncOpenAI(
1707 base_url="http://localhost:5000/custom/path/",
1708 api_key=api_key,
1709 _strict_response_validation=True,
1710 http_client=httpx.AsyncClient(),
1711 ),
1712 ],
1713 ids=["standard", "custom http client"],
1714 )
1715 async def test_base_url_no_trailing_slash(self, client: AsyncOpenAI) -> None:
1716 request = client._build_request(
1717 FinalRequestOptions(
1718 method="post",
1719 url="/foo",
1720 json_data={"foo": "bar"},
1721 ),
1722 )
1723 assert request.url == "http://localhost:5000/custom/path/foo"
1724 await client.close()
1725
1726 @pytest.mark.parametrize(
1727 "client",
1728 [
1729 AsyncOpenAI(
1730 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1731 ),
1732 AsyncOpenAI(
1733 base_url="http://localhost:5000/custom/path/",
1734 api_key=api_key,
1735 _strict_response_validation=True,
1736 http_client=httpx.AsyncClient(),
1737 ),
1738 ],
1739 ids=["standard", "custom http client"],
1740 )
1741 async def test_absolute_request_url(self, client: AsyncOpenAI) -> None:
1742 request = client._build_request(
1743 FinalRequestOptions(
1744 method="post",
1745 url="https://myapi.com/foo",
1746 json_data={"foo": "bar"},
1747 ),
1748 )
1749 assert request.url == "https://myapi.com/foo"
1750 await client.close()
1751
1752 async def test_copied_client_does_not_close_http(self) -> None:
1753 test_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1754 assert not test_client.is_closed()
1755
1756 copied = test_client.copy()
1757 assert copied is not test_client
1758
1759 del copied
1760
1761 await asyncio.sleep(0.2)
1762 assert not test_client.is_closed()
1763
1764 async def test_client_context_manager(self) -> None:
1765 test_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1766 async with test_client as c2:
1767 assert c2 is test_client
1768 assert not c2.is_closed()
1769 assert not test_client.is_closed()
1770 assert test_client.is_closed()
1771
1772 @pytest.mark.respx(base_url=base_url)
1773 async def test_client_response_validation_error(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1774 class Model(BaseModel):
1775 foo: str
1776
1777 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
1778
1779 with pytest.raises(APIResponseValidationError) as exc:
1780 await async_client.get("/foo", cast_to=Model)
1781
1782 assert isinstance(exc.value.__cause__, ValidationError)
1783
1784 async def test_client_max_retries_validation(self) -> None:
1785 with pytest.raises(TypeError, match=r"max_retries cannot be None"):
1786 AsyncOpenAI(
1787 base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None)
1788 )
1789
1790 @pytest.mark.respx(base_url=base_url)
1791 async def test_default_stream_cls(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1792 class Model(BaseModel):
1793 name: str
1794
1795 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1796
1797 stream = await async_client.post("/foo", cast_to=Model, stream=True, stream_cls=AsyncStream[Model])
1798 assert isinstance(stream, AsyncStream)
1799 await stream.response.aclose()
1800
1801 @pytest.mark.respx(base_url=base_url)
1802 async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
1803 class Model(BaseModel):
1804 name: str
1805
1806 respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
1807
1808 strict_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1809
1810 with pytest.raises(APIResponseValidationError):
1811 await strict_client.get("/foo", cast_to=Model)
1812
1813 non_strict_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
1814
1815 response = await non_strict_client.get("/foo", cast_to=Model)
1816 assert isinstance(response, str) # type: ignore[unreachable]
1817
1818 await strict_client.close()
1819 await non_strict_client.close()
1820
1821 @pytest.mark.parametrize(
1822 "remaining_retries,retry_after,timeout",
1823 [
1824 [3, "20", 20],
1825 [3, "0", 0.5],
1826 [3, "-10", 0.5],
1827 [3, "60", 60],
1828 [3, "61", 0.5],
1829 [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
1830 [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
1831 [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
1832 [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
1833 [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
1834 [3, "99999999999999999999999999999999999", 0.5],
1835 [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
1836 [3, "", 0.5],
1837 [2, "", 0.5 * 2.0],
1838 [1, "", 0.5 * 4.0],
1839 [-1100, "", 8], # test large number potentially overflowing
1840 ],
1841 )
1842 @mock.patch("time.time", mock.MagicMock(return_value=1696004797))
1843 async def test_parse_retry_after_header(
1844 self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncOpenAI
1845 ) -> None:
1846 headers = httpx.Headers({"retry-after": retry_after})
1847 options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
1848 calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers)
1849 assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
1850
1851 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1852 @pytest.mark.respx(base_url=base_url)
1853 async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1854 respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
1855
1856 with pytest.raises(APITimeoutError):
1857 await async_client.chat.completions.with_streaming_response.create(
1858 messages=[
1859 {
1860 "content": "string",
1861 "role": "developer",
1862 }
1863 ],
1864 model="gpt-5.4",
1865 ).__aenter__()
1866
1867 assert _get_open_connections(async_client) == 0
1868
1869 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1870 @pytest.mark.respx(base_url=base_url)
1871 async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1872 respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
1873
1874 with pytest.raises(APIStatusError):
1875 await async_client.chat.completions.with_streaming_response.create(
1876 messages=[
1877 {
1878 "content": "string",
1879 "role": "developer",
1880 }
1881 ],
1882 model="gpt-5.4",
1883 ).__aenter__()
1884 assert _get_open_connections(async_client) == 0
1885
1886 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1887 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1888 @pytest.mark.respx(base_url=base_url)
1889 @pytest.mark.parametrize("failure_mode", ["status", "exception"])
1890 async def test_retries_taken(
1891 self,
1892 async_client: AsyncOpenAI,
1893 failures_before_success: int,
1894 failure_mode: Literal["status", "exception"],
1895 respx_mock: MockRouter,
1896 ) -> None:
1897 client = async_client.with_options(max_retries=4)
1898
1899 nb_retries = 0
1900
1901 def retry_handler(_request: httpx.Request) -> httpx.Response:
1902 nonlocal nb_retries
1903 if nb_retries < failures_before_success:
1904 nb_retries += 1
1905 if failure_mode == "exception":
1906 raise RuntimeError("oops")
1907 return httpx.Response(500)
1908 return httpx.Response(200)
1909
1910 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1911
1912 response = await client.chat.completions.with_raw_response.create(
1913 messages=[
1914 {
1915 "content": "string",
1916 "role": "developer",
1917 }
1918 ],
1919 model="gpt-5.4",
1920 )
1921
1922 assert response.retries_taken == failures_before_success
1923 assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
1924
1925 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1926 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1927 @pytest.mark.respx(base_url=base_url)
1928 async def test_omit_retry_count_header(
1929 self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1930 ) -> None:
1931 client = async_client.with_options(max_retries=4)
1932
1933 nb_retries = 0
1934
1935 def retry_handler(_request: httpx.Request) -> httpx.Response:
1936 nonlocal nb_retries
1937 if nb_retries < failures_before_success:
1938 nb_retries += 1
1939 return httpx.Response(500)
1940 return httpx.Response(200)
1941
1942 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1943
1944 response = await client.chat.completions.with_raw_response.create(
1945 messages=[
1946 {
1947 "content": "string",
1948 "role": "developer",
1949 }
1950 ],
1951 model="gpt-5.4",
1952 extra_headers={"x-stainless-retry-count": Omit()},
1953 )
1954
1955 assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0
1956
1957 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1958 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1959 @pytest.mark.respx(base_url=base_url)
1960 async def test_overwrite_retry_count_header(
1961 self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1962 ) -> None:
1963 client = async_client.with_options(max_retries=4)
1964
1965 nb_retries = 0
1966
1967 def retry_handler(_request: httpx.Request) -> httpx.Response:
1968 nonlocal nb_retries
1969 if nb_retries < failures_before_success:
1970 nb_retries += 1
1971 return httpx.Response(500)
1972 return httpx.Response(200)
1973
1974 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1975
1976 response = await client.chat.completions.with_raw_response.create(
1977 messages=[
1978 {
1979 "content": "string",
1980 "role": "developer",
1981 }
1982 ],
1983 model="gpt-5.4",
1984 extra_headers={"x-stainless-retry-count": "42"},
1985 )
1986
1987 assert response.http_request.headers.get("x-stainless-retry-count") == "42"
1988
1989 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1990 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1991 @pytest.mark.respx(base_url=base_url)
1992 async def test_retries_taken_new_response_class(
1993 self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1994 ) -> None:
1995 client = async_client.with_options(max_retries=4)
1996
1997 nb_retries = 0
1998
1999 def retry_handler(_request: httpx.Request) -> httpx.Response:
2000 nonlocal nb_retries
2001 if nb_retries < failures_before_success:
2002 nb_retries += 1
2003 return httpx.Response(500)
2004 return httpx.Response(200)
2005
2006 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
2007
2008 async with client.chat.completions.with_streaming_response.create(
2009 messages=[
2010 {
2011 "content": "string",
2012 "role": "developer",
2013 }
2014 ],
2015 model="gpt-5.4",
2016 ) as response:
2017 assert response.retries_taken == failures_before_success
2018 assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
2019
2020 async def test_get_platform(self) -> None:
2021 platform = await asyncify(get_platform)()
2022 assert isinstance(platform, (str, OtherPlatform))
2023
2024 async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None:
2025 # Test that the proxy environment variables are set correctly
2026 monkeypatch.setenv("HTTPS_PROXY", "https://example.org")
2027 # Delete in case our environment has any proxy env vars set
2028 monkeypatch.delenv("HTTP_PROXY", raising=False)
2029 monkeypatch.delenv("ALL_PROXY", raising=False)
2030 monkeypatch.delenv("NO_PROXY", raising=False)
2031 monkeypatch.delenv("http_proxy", raising=False)
2032 monkeypatch.delenv("https_proxy", raising=False)
2033 monkeypatch.delenv("all_proxy", raising=False)
2034 monkeypatch.delenv("no_proxy", raising=False)
2035
2036 client = DefaultAsyncHttpxClient()
2037
2038 mounts = tuple(client._mounts.items())
2039 assert len(mounts) == 1
2040 assert mounts[0][0].pattern == "https://"
2041
2042 @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning")
2043 async def test_default_client_creation(self) -> None:
2044 # Ensure that the client can be initialized without any exceptions
2045 DefaultAsyncHttpxClient(
2046 verify=True,
2047 cert=None,
2048 trust_env=True,
2049 http1=True,
2050 http2=False,
2051 limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
2052 )
2053
2054 @pytest.mark.respx(base_url=base_url)
2055 async def test_follow_redirects(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
2056 # Test that the default follow_redirects=True allows following redirects
2057 respx_mock.post("/redirect").mock(
2058 return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
2059 )
2060 respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"}))
2061
2062 response = await async_client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
2063 assert response.status_code == 200
2064 assert response.json() == {"status": "ok"}
2065
2066 @pytest.mark.respx(base_url=base_url)
2067 async def test_follow_redirects_disabled(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
2068 # Test that follow_redirects=False prevents following redirects
2069 respx_mock.post("/redirect").mock(
2070 return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
2071 )
2072
2073 with pytest.raises(APIStatusError) as exc_info:
2074 await async_client.post(
2075 "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response
2076 )
2077
2078 assert exc_info.value.response.status_code == 302
2079 assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected"
2080