openai/openai-python

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
f1fd4fae0329ee3df2f1bb25d93f51311782ad1a

Branches

Tags

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

Clone

HTTPS

Download ZIP

tests/test_client.py

2257lines · modecode

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