openai/openai-python

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
49e84301eccc8cb1028bea4c9e69456e2ebf5c2e

Branches

Tags

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

Clone

HTTPS

Download ZIP

tests/test_client.py

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