openai/openai-python

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
c4f76b2ebbe98ea42857feab795346348c2ec6fb

Branches

Tags

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

Clone

HTTPS

Download ZIP

tests/test_client.py

2063lines · 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-4o",
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-4o",
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-4o",
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-4o",
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-4o",
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-4o",
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
1034 client = DefaultHttpxClient()
1035
1036 mounts = tuple(client._mounts.items())
1037 assert len(mounts) == 1
1038 assert mounts[0][0].pattern == "https://"
1039
1040 @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning")
1041 def test_default_client_creation(self) -> None:
1042 # Ensure that the client can be initialized without any exceptions
1043 DefaultHttpxClient(
1044 verify=True,
1045 cert=None,
1046 trust_env=True,
1047 http1=True,
1048 http2=False,
1049 limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
1050 )
1051
1052 @pytest.mark.respx(base_url=base_url)
1053 def test_follow_redirects(self, respx_mock: MockRouter, client: OpenAI) -> None:
1054 # Test that the default follow_redirects=True allows following redirects
1055 respx_mock.post("/redirect").mock(
1056 return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
1057 )
1058 respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"}))
1059
1060 response = client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
1061 assert response.status_code == 200
1062 assert response.json() == {"status": "ok"}
1063
1064 @pytest.mark.respx(base_url=base_url)
1065 def test_follow_redirects_disabled(self, respx_mock: MockRouter, client: OpenAI) -> None:
1066 # Test that follow_redirects=False prevents following redirects
1067 respx_mock.post("/redirect").mock(
1068 return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
1069 )
1070
1071 with pytest.raises(APIStatusError) as exc_info:
1072 client.post("/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response)
1073
1074 assert exc_info.value.response.status_code == 302
1075 assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected"
1076
1077
1078class TestAsyncOpenAI:
1079 @pytest.mark.respx(base_url=base_url)
1080 async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1081 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1082
1083 response = await async_client.post("/foo", cast_to=httpx.Response)
1084 assert response.status_code == 200
1085 assert isinstance(response, httpx.Response)
1086 assert response.json() == {"foo": "bar"}
1087
1088 @pytest.mark.respx(base_url=base_url)
1089 async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1090 respx_mock.post("/foo").mock(
1091 return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
1092 )
1093
1094 response = await async_client.post("/foo", cast_to=httpx.Response)
1095 assert response.status_code == 200
1096 assert isinstance(response, httpx.Response)
1097 assert response.json() == {"foo": "bar"}
1098
1099 def test_copy(self, async_client: AsyncOpenAI) -> None:
1100 copied = async_client.copy()
1101 assert id(copied) != id(async_client)
1102
1103 copied = async_client.copy(api_key="another My API Key")
1104 assert copied.api_key == "another My API Key"
1105 assert async_client.api_key == "My API Key"
1106
1107 def test_copy_default_options(self, async_client: AsyncOpenAI) -> None:
1108 # options that have a default are overridden correctly
1109 copied = async_client.copy(max_retries=7)
1110 assert copied.max_retries == 7
1111 assert async_client.max_retries == 2
1112
1113 copied2 = copied.copy(max_retries=6)
1114 assert copied2.max_retries == 6
1115 assert copied.max_retries == 7
1116
1117 # timeout
1118 assert isinstance(async_client.timeout, httpx.Timeout)
1119 copied = async_client.copy(timeout=None)
1120 assert copied.timeout is None
1121 assert isinstance(async_client.timeout, httpx.Timeout)
1122
1123 async def test_copy_default_headers(self) -> None:
1124 client = AsyncOpenAI(
1125 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
1126 )
1127 assert client.default_headers["X-Foo"] == "bar"
1128
1129 # does not override the already given value when not specified
1130 copied = client.copy()
1131 assert copied.default_headers["X-Foo"] == "bar"
1132
1133 # merges already given headers
1134 copied = client.copy(default_headers={"X-Bar": "stainless"})
1135 assert copied.default_headers["X-Foo"] == "bar"
1136 assert copied.default_headers["X-Bar"] == "stainless"
1137
1138 # uses new values for any already given headers
1139 copied = client.copy(default_headers={"X-Foo": "stainless"})
1140 assert copied.default_headers["X-Foo"] == "stainless"
1141
1142 # set_default_headers
1143
1144 # completely overrides already set values
1145 copied = client.copy(set_default_headers={})
1146 assert copied.default_headers.get("X-Foo") is None
1147
1148 copied = client.copy(set_default_headers={"X-Bar": "Robert"})
1149 assert copied.default_headers["X-Bar"] == "Robert"
1150
1151 with pytest.raises(
1152 ValueError,
1153 match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
1154 ):
1155 client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
1156 await client.close()
1157
1158 async def test_copy_default_query(self) -> None:
1159 client = AsyncOpenAI(
1160 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
1161 )
1162 assert _get_params(client)["foo"] == "bar"
1163
1164 # does not override the already given value when not specified
1165 copied = client.copy()
1166 assert _get_params(copied)["foo"] == "bar"
1167
1168 # merges already given params
1169 copied = client.copy(default_query={"bar": "stainless"})
1170 params = _get_params(copied)
1171 assert params["foo"] == "bar"
1172 assert params["bar"] == "stainless"
1173
1174 # uses new values for any already given headers
1175 copied = client.copy(default_query={"foo": "stainless"})
1176 assert _get_params(copied)["foo"] == "stainless"
1177
1178 # set_default_query
1179
1180 # completely overrides already set values
1181 copied = client.copy(set_default_query={})
1182 assert _get_params(copied) == {}
1183
1184 copied = client.copy(set_default_query={"bar": "Robert"})
1185 assert _get_params(copied)["bar"] == "Robert"
1186
1187 with pytest.raises(
1188 ValueError,
1189 # TODO: update
1190 match="`default_query` and `set_default_query` arguments are mutually exclusive",
1191 ):
1192 client.copy(set_default_query={}, default_query={"foo": "Bar"})
1193
1194 await client.close()
1195
1196 def test_copy_signature(self, async_client: AsyncOpenAI) -> None:
1197 # ensure the same parameters that can be passed to the client are defined in the `.copy()` method
1198 init_signature = inspect.signature(
1199 # mypy doesn't like that we access the `__init__` property.
1200 async_client.__init__, # type: ignore[misc]
1201 )
1202 copy_signature = inspect.signature(async_client.copy)
1203 exclude_params = {"transport", "proxies", "_strict_response_validation"}
1204
1205 for name in init_signature.parameters.keys():
1206 if name in exclude_params:
1207 continue
1208
1209 copy_param = copy_signature.parameters.get(name)
1210 assert copy_param is not None, f"copy() signature is missing the {name} param"
1211
1212 @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12")
1213 def test_copy_build_request(self, async_client: AsyncOpenAI) -> None:
1214 options = FinalRequestOptions(method="get", url="/foo")
1215
1216 def build_request(options: FinalRequestOptions) -> None:
1217 client_copy = async_client.copy()
1218 client_copy._build_request(options)
1219
1220 # ensure that the machinery is warmed up before tracing starts.
1221 build_request(options)
1222 gc.collect()
1223
1224 tracemalloc.start(1000)
1225
1226 snapshot_before = tracemalloc.take_snapshot()
1227
1228 ITERATIONS = 10
1229 for _ in range(ITERATIONS):
1230 build_request(options)
1231
1232 gc.collect()
1233 snapshot_after = tracemalloc.take_snapshot()
1234
1235 tracemalloc.stop()
1236
1237 def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None:
1238 if diff.count == 0:
1239 # Avoid false positives by considering only leaks (i.e. allocations that persist).
1240 return
1241
1242 if diff.count % ITERATIONS != 0:
1243 # Avoid false positives by considering only leaks that appear per iteration.
1244 return
1245
1246 for frame in diff.traceback:
1247 if any(
1248 frame.filename.endswith(fragment)
1249 for fragment in [
1250 # to_raw_response_wrapper leaks through the @functools.wraps() decorator.
1251 #
1252 # removing the decorator fixes the leak for reasons we don't understand.
1253 "openai/_legacy_response.py",
1254 "openai/_response.py",
1255 # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason.
1256 "openai/_compat.py",
1257 # Standard library leaks we don't care about.
1258 "/logging/__init__.py",
1259 ]
1260 ):
1261 return
1262
1263 leaks.append(diff)
1264
1265 leaks: list[tracemalloc.StatisticDiff] = []
1266 for diff in snapshot_after.compare_to(snapshot_before, "traceback"):
1267 add_leak(leaks, diff)
1268 if leaks:
1269 for leak in leaks:
1270 print("MEMORY LEAK:", leak)
1271 for frame in leak.traceback:
1272 print(frame)
1273 raise AssertionError()
1274
1275 async def test_request_timeout(self, async_client: AsyncOpenAI) -> None:
1276 request = async_client._build_request(FinalRequestOptions(method="get", url="/foo"))
1277 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1278 assert timeout == DEFAULT_TIMEOUT
1279
1280 request = async_client._build_request(
1281 FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
1282 )
1283 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1284 assert timeout == httpx.Timeout(100.0)
1285
1286 async def test_client_timeout_option(self) -> None:
1287 client = AsyncOpenAI(
1288 base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0)
1289 )
1290
1291 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1292 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1293 assert timeout == httpx.Timeout(0)
1294
1295 await client.close()
1296
1297 async def test_http_client_timeout_option(self) -> None:
1298 # custom timeout given to the httpx client should be used
1299 async with httpx.AsyncClient(timeout=None) as http_client:
1300 client = AsyncOpenAI(
1301 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1302 )
1303
1304 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1305 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1306 assert timeout == httpx.Timeout(None)
1307
1308 await client.close()
1309
1310 # no timeout given to the httpx client should not use the httpx default
1311 async with httpx.AsyncClient() as http_client:
1312 client = AsyncOpenAI(
1313 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1314 )
1315
1316 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1317 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1318 assert timeout == DEFAULT_TIMEOUT
1319
1320 await client.close()
1321
1322 # explicitly passing the default timeout currently results in it being ignored
1323 async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
1324 client = AsyncOpenAI(
1325 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1326 )
1327
1328 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1329 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1330 assert timeout == DEFAULT_TIMEOUT # our default
1331
1332 await client.close()
1333
1334 def test_invalid_http_client(self) -> None:
1335 with pytest.raises(TypeError, match="Invalid `http_client` arg"):
1336 with httpx.Client() as http_client:
1337 AsyncOpenAI(
1338 base_url=base_url,
1339 api_key=api_key,
1340 _strict_response_validation=True,
1341 http_client=cast(Any, http_client),
1342 )
1343
1344 async def test_default_headers_option(self) -> None:
1345 test_client = AsyncOpenAI(
1346 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
1347 )
1348 request = test_client._build_request(FinalRequestOptions(method="get", url="/foo"))
1349 assert request.headers.get("x-foo") == "bar"
1350 assert request.headers.get("x-stainless-lang") == "python"
1351
1352 test_client2 = AsyncOpenAI(
1353 base_url=base_url,
1354 api_key=api_key,
1355 _strict_response_validation=True,
1356 default_headers={
1357 "X-Foo": "stainless",
1358 "X-Stainless-Lang": "my-overriding-header",
1359 },
1360 )
1361 request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo"))
1362 assert request.headers.get("x-foo") == "stainless"
1363 assert request.headers.get("x-stainless-lang") == "my-overriding-header"
1364
1365 await test_client.close()
1366 await test_client2.close()
1367
1368 def test_validate_headers(self) -> None:
1369 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1370 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1371 assert request.headers.get("Authorization") == f"Bearer {api_key}"
1372
1373 with pytest.raises(OpenAIError):
1374 with update_env(**{"OPENAI_API_KEY": Omit()}):
1375 client2 = AsyncOpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
1376 _ = client2
1377
1378 async def test_default_query_option(self) -> None:
1379 client = AsyncOpenAI(
1380 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
1381 )
1382 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1383 url = httpx.URL(request.url)
1384 assert dict(url.params) == {"query_param": "bar"}
1385
1386 request = client._build_request(
1387 FinalRequestOptions(
1388 method="get",
1389 url="/foo",
1390 params={"foo": "baz", "query_param": "overridden"},
1391 )
1392 )
1393 url = httpx.URL(request.url)
1394 assert dict(url.params) == {"foo": "baz", "query_param": "overridden"}
1395
1396 await client.close()
1397
1398 def test_request_extra_json(self, client: OpenAI) -> None:
1399 request = client._build_request(
1400 FinalRequestOptions(
1401 method="post",
1402 url="/foo",
1403 json_data={"foo": "bar"},
1404 extra_json={"baz": False},
1405 ),
1406 )
1407 data = json.loads(request.content.decode("utf-8"))
1408 assert data == {"foo": "bar", "baz": False}
1409
1410 request = client._build_request(
1411 FinalRequestOptions(
1412 method="post",
1413 url="/foo",
1414 extra_json={"baz": False},
1415 ),
1416 )
1417 data = json.loads(request.content.decode("utf-8"))
1418 assert data == {"baz": False}
1419
1420 # `extra_json` takes priority over `json_data` when keys clash
1421 request = client._build_request(
1422 FinalRequestOptions(
1423 method="post",
1424 url="/foo",
1425 json_data={"foo": "bar", "baz": True},
1426 extra_json={"baz": None},
1427 ),
1428 )
1429 data = json.loads(request.content.decode("utf-8"))
1430 assert data == {"foo": "bar", "baz": None}
1431
1432 def test_request_extra_headers(self, client: OpenAI) -> None:
1433 request = client._build_request(
1434 FinalRequestOptions(
1435 method="post",
1436 url="/foo",
1437 **make_request_options(extra_headers={"X-Foo": "Foo"}),
1438 ),
1439 )
1440 assert request.headers.get("X-Foo") == "Foo"
1441
1442 # `extra_headers` takes priority over `default_headers` when keys clash
1443 request = client.with_options(default_headers={"X-Bar": "true"})._build_request(
1444 FinalRequestOptions(
1445 method="post",
1446 url="/foo",
1447 **make_request_options(
1448 extra_headers={"X-Bar": "false"},
1449 ),
1450 ),
1451 )
1452 assert request.headers.get("X-Bar") == "false"
1453
1454 def test_request_extra_query(self, client: OpenAI) -> None:
1455 request = client._build_request(
1456 FinalRequestOptions(
1457 method="post",
1458 url="/foo",
1459 **make_request_options(
1460 extra_query={"my_query_param": "Foo"},
1461 ),
1462 ),
1463 )
1464 params = dict(request.url.params)
1465 assert params == {"my_query_param": "Foo"}
1466
1467 # if both `query` and `extra_query` are given, they are merged
1468 request = client._build_request(
1469 FinalRequestOptions(
1470 method="post",
1471 url="/foo",
1472 **make_request_options(
1473 query={"bar": "1"},
1474 extra_query={"foo": "2"},
1475 ),
1476 ),
1477 )
1478 params = dict(request.url.params)
1479 assert params == {"bar": "1", "foo": "2"}
1480
1481 # `extra_query` takes priority over `query` when keys clash
1482 request = client._build_request(
1483 FinalRequestOptions(
1484 method="post",
1485 url="/foo",
1486 **make_request_options(
1487 query={"foo": "1"},
1488 extra_query={"foo": "2"},
1489 ),
1490 ),
1491 )
1492 params = dict(request.url.params)
1493 assert params == {"foo": "2"}
1494
1495 def test_multipart_repeating_array(self, async_client: AsyncOpenAI) -> None:
1496 request = async_client._build_request(
1497 FinalRequestOptions.construct(
1498 method="post",
1499 url="/foo",
1500 headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
1501 json_data={"array": ["foo", "bar"]},
1502 files=[("foo.txt", b"hello world")],
1503 )
1504 )
1505
1506 assert request.read().split(b"\r\n") == [
1507 b"--6b7ba517decee4a450543ea6ae821c82",
1508 b'Content-Disposition: form-data; name="array[]"',
1509 b"",
1510 b"foo",
1511 b"--6b7ba517decee4a450543ea6ae821c82",
1512 b'Content-Disposition: form-data; name="array[]"',
1513 b"",
1514 b"bar",
1515 b"--6b7ba517decee4a450543ea6ae821c82",
1516 b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
1517 b"Content-Type: application/octet-stream",
1518 b"",
1519 b"hello world",
1520 b"--6b7ba517decee4a450543ea6ae821c82--",
1521 b"",
1522 ]
1523
1524 @pytest.mark.respx(base_url=base_url)
1525 async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1526 respx_mock.post("/upload").mock(side_effect=mirror_request_content)
1527
1528 file_content = b"Hello, this is a test file."
1529
1530 response = await async_client.post(
1531 "/upload",
1532 content=file_content,
1533 cast_to=httpx.Response,
1534 options={"headers": {"Content-Type": "application/octet-stream"}},
1535 )
1536
1537 assert response.status_code == 200
1538 assert response.request.headers["Content-Type"] == "application/octet-stream"
1539 assert response.content == file_content
1540
1541 async def test_binary_content_upload_with_asynciterator(self) -> None:
1542 file_content = b"Hello, this is a test file."
1543 counter = Counter()
1544 iterator = _make_async_iterator([file_content], counter=counter)
1545
1546 async def mock_handler(request: httpx.Request) -> httpx.Response:
1547 assert counter.value == 0, "the request body should not have been read"
1548 return httpx.Response(200, content=await request.aread())
1549
1550 async with AsyncOpenAI(
1551 base_url=base_url,
1552 api_key=api_key,
1553 _strict_response_validation=True,
1554 http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)),
1555 ) as client:
1556 response = await client.post(
1557 "/upload",
1558 content=iterator,
1559 cast_to=httpx.Response,
1560 options={"headers": {"Content-Type": "application/octet-stream"}},
1561 )
1562
1563 assert response.status_code == 200
1564 assert response.request.headers["Content-Type"] == "application/octet-stream"
1565 assert response.content == file_content
1566 assert counter.value == 1
1567
1568 @pytest.mark.respx(base_url=base_url)
1569 async def test_binary_content_upload_with_body_is_deprecated(
1570 self, respx_mock: MockRouter, async_client: AsyncOpenAI
1571 ) -> None:
1572 respx_mock.post("/upload").mock(side_effect=mirror_request_content)
1573
1574 file_content = b"Hello, this is a test file."
1575
1576 with pytest.deprecated_call(
1577 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."
1578 ):
1579 response = await async_client.post(
1580 "/upload",
1581 body=file_content,
1582 cast_to=httpx.Response,
1583 options={"headers": {"Content-Type": "application/octet-stream"}},
1584 )
1585
1586 assert response.status_code == 200
1587 assert response.request.headers["Content-Type"] == "application/octet-stream"
1588 assert response.content == file_content
1589
1590 @pytest.mark.respx(base_url=base_url)
1591 async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1592 class Model1(BaseModel):
1593 name: str
1594
1595 class Model2(BaseModel):
1596 foo: str
1597
1598 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1599
1600 response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1601 assert isinstance(response, Model2)
1602 assert response.foo == "bar"
1603
1604 @pytest.mark.respx(base_url=base_url)
1605 async def test_union_response_different_types(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1606 """Union of objects with the same field name using a different type"""
1607
1608 class Model1(BaseModel):
1609 foo: int
1610
1611 class Model2(BaseModel):
1612 foo: str
1613
1614 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1615
1616 response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1617 assert isinstance(response, Model2)
1618 assert response.foo == "bar"
1619
1620 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
1621
1622 response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1623 assert isinstance(response, Model1)
1624 assert response.foo == 1
1625
1626 @pytest.mark.respx(base_url=base_url)
1627 async def test_non_application_json_content_type_for_json_data(
1628 self, respx_mock: MockRouter, async_client: AsyncOpenAI
1629 ) -> None:
1630 """
1631 Response that sets Content-Type to something other than application/json but returns json data
1632 """
1633
1634 class Model(BaseModel):
1635 foo: int
1636
1637 respx_mock.get("/foo").mock(
1638 return_value=httpx.Response(
1639 200,
1640 content=json.dumps({"foo": 2}),
1641 headers={"Content-Type": "application/text"},
1642 )
1643 )
1644
1645 response = await async_client.get("/foo", cast_to=Model)
1646 assert isinstance(response, Model)
1647 assert response.foo == 2
1648
1649 async def test_base_url_setter(self) -> None:
1650 client = AsyncOpenAI(
1651 base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True
1652 )
1653 assert client.base_url == "https://example.com/from_init/"
1654
1655 client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
1656
1657 assert client.base_url == "https://example.com/from_setter/"
1658
1659 await client.close()
1660
1661 async def test_base_url_env(self) -> None:
1662 with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
1663 client = AsyncOpenAI(api_key=api_key, _strict_response_validation=True)
1664 assert client.base_url == "http://localhost:5000/from/env/"
1665
1666 @pytest.mark.parametrize(
1667 "client",
1668 [
1669 AsyncOpenAI(
1670 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1671 ),
1672 AsyncOpenAI(
1673 base_url="http://localhost:5000/custom/path/",
1674 api_key=api_key,
1675 _strict_response_validation=True,
1676 http_client=httpx.AsyncClient(),
1677 ),
1678 ],
1679 ids=["standard", "custom http client"],
1680 )
1681 async def test_base_url_trailing_slash(self, client: AsyncOpenAI) -> None:
1682 request = client._build_request(
1683 FinalRequestOptions(
1684 method="post",
1685 url="/foo",
1686 json_data={"foo": "bar"},
1687 ),
1688 )
1689 assert request.url == "http://localhost:5000/custom/path/foo"
1690 await client.close()
1691
1692 @pytest.mark.parametrize(
1693 "client",
1694 [
1695 AsyncOpenAI(
1696 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1697 ),
1698 AsyncOpenAI(
1699 base_url="http://localhost:5000/custom/path/",
1700 api_key=api_key,
1701 _strict_response_validation=True,
1702 http_client=httpx.AsyncClient(),
1703 ),
1704 ],
1705 ids=["standard", "custom http client"],
1706 )
1707 async def test_base_url_no_trailing_slash(self, client: AsyncOpenAI) -> None:
1708 request = client._build_request(
1709 FinalRequestOptions(
1710 method="post",
1711 url="/foo",
1712 json_data={"foo": "bar"},
1713 ),
1714 )
1715 assert request.url == "http://localhost:5000/custom/path/foo"
1716 await client.close()
1717
1718 @pytest.mark.parametrize(
1719 "client",
1720 [
1721 AsyncOpenAI(
1722 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1723 ),
1724 AsyncOpenAI(
1725 base_url="http://localhost:5000/custom/path/",
1726 api_key=api_key,
1727 _strict_response_validation=True,
1728 http_client=httpx.AsyncClient(),
1729 ),
1730 ],
1731 ids=["standard", "custom http client"],
1732 )
1733 async def test_absolute_request_url(self, client: AsyncOpenAI) -> None:
1734 request = client._build_request(
1735 FinalRequestOptions(
1736 method="post",
1737 url="https://myapi.com/foo",
1738 json_data={"foo": "bar"},
1739 ),
1740 )
1741 assert request.url == "https://myapi.com/foo"
1742 await client.close()
1743
1744 async def test_copied_client_does_not_close_http(self) -> None:
1745 test_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1746 assert not test_client.is_closed()
1747
1748 copied = test_client.copy()
1749 assert copied is not test_client
1750
1751 del copied
1752
1753 await asyncio.sleep(0.2)
1754 assert not test_client.is_closed()
1755
1756 async def test_client_context_manager(self) -> None:
1757 test_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1758 async with test_client as c2:
1759 assert c2 is test_client
1760 assert not c2.is_closed()
1761 assert not test_client.is_closed()
1762 assert test_client.is_closed()
1763
1764 @pytest.mark.respx(base_url=base_url)
1765 async def test_client_response_validation_error(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1766 class Model(BaseModel):
1767 foo: str
1768
1769 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
1770
1771 with pytest.raises(APIResponseValidationError) as exc:
1772 await async_client.get("/foo", cast_to=Model)
1773
1774 assert isinstance(exc.value.__cause__, ValidationError)
1775
1776 async def test_client_max_retries_validation(self) -> None:
1777 with pytest.raises(TypeError, match=r"max_retries cannot be None"):
1778 AsyncOpenAI(
1779 base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None)
1780 )
1781
1782 @pytest.mark.respx(base_url=base_url)
1783 async def test_default_stream_cls(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1784 class Model(BaseModel):
1785 name: str
1786
1787 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1788
1789 stream = await async_client.post("/foo", cast_to=Model, stream=True, stream_cls=AsyncStream[Model])
1790 assert isinstance(stream, AsyncStream)
1791 await stream.response.aclose()
1792
1793 @pytest.mark.respx(base_url=base_url)
1794 async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
1795 class Model(BaseModel):
1796 name: str
1797
1798 respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
1799
1800 strict_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1801
1802 with pytest.raises(APIResponseValidationError):
1803 await strict_client.get("/foo", cast_to=Model)
1804
1805 non_strict_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
1806
1807 response = await non_strict_client.get("/foo", cast_to=Model)
1808 assert isinstance(response, str) # type: ignore[unreachable]
1809
1810 await strict_client.close()
1811 await non_strict_client.close()
1812
1813 @pytest.mark.parametrize(
1814 "remaining_retries,retry_after,timeout",
1815 [
1816 [3, "20", 20],
1817 [3, "0", 0.5],
1818 [3, "-10", 0.5],
1819 [3, "60", 60],
1820 [3, "61", 0.5],
1821 [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
1822 [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
1823 [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
1824 [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
1825 [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
1826 [3, "99999999999999999999999999999999999", 0.5],
1827 [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
1828 [3, "", 0.5],
1829 [2, "", 0.5 * 2.0],
1830 [1, "", 0.5 * 4.0],
1831 [-1100, "", 8], # test large number potentially overflowing
1832 ],
1833 )
1834 @mock.patch("time.time", mock.MagicMock(return_value=1696004797))
1835 async def test_parse_retry_after_header(
1836 self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncOpenAI
1837 ) -> None:
1838 headers = httpx.Headers({"retry-after": retry_after})
1839 options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
1840 calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers)
1841 assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
1842
1843 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1844 @pytest.mark.respx(base_url=base_url)
1845 async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1846 respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
1847
1848 with pytest.raises(APITimeoutError):
1849 await async_client.chat.completions.with_streaming_response.create(
1850 messages=[
1851 {
1852 "content": "string",
1853 "role": "developer",
1854 }
1855 ],
1856 model="gpt-4o",
1857 ).__aenter__()
1858
1859 assert _get_open_connections(async_client) == 0
1860
1861 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1862 @pytest.mark.respx(base_url=base_url)
1863 async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
1864 respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
1865
1866 with pytest.raises(APIStatusError):
1867 await async_client.chat.completions.with_streaming_response.create(
1868 messages=[
1869 {
1870 "content": "string",
1871 "role": "developer",
1872 }
1873 ],
1874 model="gpt-4o",
1875 ).__aenter__()
1876 assert _get_open_connections(async_client) == 0
1877
1878 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1879 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1880 @pytest.mark.respx(base_url=base_url)
1881 @pytest.mark.parametrize("failure_mode", ["status", "exception"])
1882 async def test_retries_taken(
1883 self,
1884 async_client: AsyncOpenAI,
1885 failures_before_success: int,
1886 failure_mode: Literal["status", "exception"],
1887 respx_mock: MockRouter,
1888 ) -> None:
1889 client = async_client.with_options(max_retries=4)
1890
1891 nb_retries = 0
1892
1893 def retry_handler(_request: httpx.Request) -> httpx.Response:
1894 nonlocal nb_retries
1895 if nb_retries < failures_before_success:
1896 nb_retries += 1
1897 if failure_mode == "exception":
1898 raise RuntimeError("oops")
1899 return httpx.Response(500)
1900 return httpx.Response(200)
1901
1902 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1903
1904 response = await client.chat.completions.with_raw_response.create(
1905 messages=[
1906 {
1907 "content": "string",
1908 "role": "developer",
1909 }
1910 ],
1911 model="gpt-4o",
1912 )
1913
1914 assert response.retries_taken == failures_before_success
1915 assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
1916
1917 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1918 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1919 @pytest.mark.respx(base_url=base_url)
1920 async def test_omit_retry_count_header(
1921 self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1922 ) -> None:
1923 client = async_client.with_options(max_retries=4)
1924
1925 nb_retries = 0
1926
1927 def retry_handler(_request: httpx.Request) -> httpx.Response:
1928 nonlocal nb_retries
1929 if nb_retries < failures_before_success:
1930 nb_retries += 1
1931 return httpx.Response(500)
1932 return httpx.Response(200)
1933
1934 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1935
1936 response = await client.chat.completions.with_raw_response.create(
1937 messages=[
1938 {
1939 "content": "string",
1940 "role": "developer",
1941 }
1942 ],
1943 model="gpt-4o",
1944 extra_headers={"x-stainless-retry-count": Omit()},
1945 )
1946
1947 assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 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 async def test_overwrite_retry_count_header(
1953 self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1954 ) -> None:
1955 client = async_client.with_options(max_retries=4)
1956
1957 nb_retries = 0
1958
1959 def retry_handler(_request: httpx.Request) -> httpx.Response:
1960 nonlocal nb_retries
1961 if nb_retries < failures_before_success:
1962 nb_retries += 1
1963 return httpx.Response(500)
1964 return httpx.Response(200)
1965
1966 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1967
1968 response = await client.chat.completions.with_raw_response.create(
1969 messages=[
1970 {
1971 "content": "string",
1972 "role": "developer",
1973 }
1974 ],
1975 model="gpt-4o",
1976 extra_headers={"x-stainless-retry-count": "42"},
1977 )
1978
1979 assert response.http_request.headers.get("x-stainless-retry-count") == "42"
1980
1981 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1982 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1983 @pytest.mark.respx(base_url=base_url)
1984 async def test_retries_taken_new_response_class(
1985 self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1986 ) -> None:
1987 client = async_client.with_options(max_retries=4)
1988
1989 nb_retries = 0
1990
1991 def retry_handler(_request: httpx.Request) -> httpx.Response:
1992 nonlocal nb_retries
1993 if nb_retries < failures_before_success:
1994 nb_retries += 1
1995 return httpx.Response(500)
1996 return httpx.Response(200)
1997
1998 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1999
2000 async with client.chat.completions.with_streaming_response.create(
2001 messages=[
2002 {
2003 "content": "string",
2004 "role": "developer",
2005 }
2006 ],
2007 model="gpt-4o",
2008 ) as response:
2009 assert response.retries_taken == failures_before_success
2010 assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
2011
2012 async def test_get_platform(self) -> None:
2013 platform = await asyncify(get_platform)()
2014 assert isinstance(platform, (str, OtherPlatform))
2015
2016 async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None:
2017 # Test that the proxy environment variables are set correctly
2018 monkeypatch.setenv("HTTPS_PROXY", "https://example.org")
2019
2020 client = DefaultAsyncHttpxClient()
2021
2022 mounts = tuple(client._mounts.items())
2023 assert len(mounts) == 1
2024 assert mounts[0][0].pattern == "https://"
2025
2026 @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning")
2027 async def test_default_client_creation(self) -> None:
2028 # Ensure that the client can be initialized without any exceptions
2029 DefaultAsyncHttpxClient(
2030 verify=True,
2031 cert=None,
2032 trust_env=True,
2033 http1=True,
2034 http2=False,
2035 limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
2036 )
2037
2038 @pytest.mark.respx(base_url=base_url)
2039 async def test_follow_redirects(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
2040 # Test that the default follow_redirects=True allows following redirects
2041 respx_mock.post("/redirect").mock(
2042 return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
2043 )
2044 respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"}))
2045
2046 response = await async_client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response)
2047 assert response.status_code == 200
2048 assert response.json() == {"status": "ok"}
2049
2050 @pytest.mark.respx(base_url=base_url)
2051 async def test_follow_redirects_disabled(self, respx_mock: MockRouter, async_client: AsyncOpenAI) -> None:
2052 # Test that follow_redirects=False prevents following redirects
2053 respx_mock.post("/redirect").mock(
2054 return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"})
2055 )
2056
2057 with pytest.raises(APIStatusError) as exc_info:
2058 await async_client.post(
2059 "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response
2060 )
2061
2062 assert exc_info.value.response.status_code == 302
2063 assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected"