openai/openai-python

Public

mirrored fromhttps://github.com/openai/openai-pythonAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hintz/static

Branches

Tags

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

Clone

HTTPS

Download ZIP

tests/test_client.py

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