openai/openai-python

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
e41abf7b7dbc1e744d167f748e55d4dedfc0dca7

Branches

Tags

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

Clone

HTTPS

Download ZIP

tests/test_client.py

1461lines · modecode

1# File generated from our OpenAPI spec by Stainless.
2
3from __future__ import annotations
4
5import gc
6import os
7import json
8import asyncio
9import inspect
10import tracemalloc
11from typing import Any, Union, cast
12from unittest import mock
13
14import httpx
15import pytest
16from respx import MockRouter
17from pydantic import ValidationError
18
19from openai import OpenAI, AsyncOpenAI, APIResponseValidationError
20from openai._client import OpenAI, AsyncOpenAI
21from openai._models import BaseModel, FinalRequestOptions
22from openai._constants import RAW_RESPONSE_HEADER
23from openai._streaming import Stream, AsyncStream
24from openai._exceptions import OpenAIError, APIStatusError, APITimeoutError, APIResponseValidationError
25from openai._base_client import DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, make_request_options
26
27from .utils import update_env
28
29base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
30api_key = "My API Key"
31
32
33def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]:
34 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
35 url = httpx.URL(request.url)
36 return dict(url.params)
37
38
39def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float:
40 return 0.1
41
42
43def _get_open_connections(client: OpenAI | AsyncOpenAI) -> int:
44 transport = client._client._transport
45 assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport)
46
47 pool = transport._pool
48 return len(pool._requests)
49
50
51class TestOpenAI:
52 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
53
54 @pytest.mark.respx(base_url=base_url)
55 def test_raw_response(self, respx_mock: MockRouter) -> None:
56 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
57
58 response = self.client.post("/foo", cast_to=httpx.Response)
59 assert response.status_code == 200
60 assert isinstance(response, httpx.Response)
61 assert response.json() == {"foo": "bar"}
62
63 @pytest.mark.respx(base_url=base_url)
64 def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
65 respx_mock.post("/foo").mock(
66 return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
67 )
68
69 response = self.client.post("/foo", cast_to=httpx.Response)
70 assert response.status_code == 200
71 assert isinstance(response, httpx.Response)
72 assert response.json() == {"foo": "bar"}
73
74 def test_copy(self) -> None:
75 copied = self.client.copy()
76 assert id(copied) != id(self.client)
77
78 copied = self.client.copy(api_key="another My API Key")
79 assert copied.api_key == "another My API Key"
80 assert self.client.api_key == "My API Key"
81
82 def test_copy_default_options(self) -> None:
83 # options that have a default are overridden correctly
84 copied = self.client.copy(max_retries=7)
85 assert copied.max_retries == 7
86 assert self.client.max_retries == 2
87
88 copied2 = copied.copy(max_retries=6)
89 assert copied2.max_retries == 6
90 assert copied.max_retries == 7
91
92 # timeout
93 assert isinstance(self.client.timeout, httpx.Timeout)
94 copied = self.client.copy(timeout=None)
95 assert copied.timeout is None
96 assert isinstance(self.client.timeout, httpx.Timeout)
97
98 def test_copy_default_headers(self) -> None:
99 client = OpenAI(
100 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
101 )
102 assert client.default_headers["X-Foo"] == "bar"
103
104 # does not override the already given value when not specified
105 copied = client.copy()
106 assert copied.default_headers["X-Foo"] == "bar"
107
108 # merges already given headers
109 copied = client.copy(default_headers={"X-Bar": "stainless"})
110 assert copied.default_headers["X-Foo"] == "bar"
111 assert copied.default_headers["X-Bar"] == "stainless"
112
113 # uses new values for any already given headers
114 copied = client.copy(default_headers={"X-Foo": "stainless"})
115 assert copied.default_headers["X-Foo"] == "stainless"
116
117 # set_default_headers
118
119 # completely overrides already set values
120 copied = client.copy(set_default_headers={})
121 assert copied.default_headers.get("X-Foo") is None
122
123 copied = client.copy(set_default_headers={"X-Bar": "Robert"})
124 assert copied.default_headers["X-Bar"] == "Robert"
125
126 with pytest.raises(
127 ValueError,
128 match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
129 ):
130 client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
131
132 def test_copy_default_query(self) -> None:
133 client = OpenAI(
134 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
135 )
136 assert _get_params(client)["foo"] == "bar"
137
138 # does not override the already given value when not specified
139 copied = client.copy()
140 assert _get_params(copied)["foo"] == "bar"
141
142 # merges already given params
143 copied = client.copy(default_query={"bar": "stainless"})
144 params = _get_params(copied)
145 assert params["foo"] == "bar"
146 assert params["bar"] == "stainless"
147
148 # uses new values for any already given headers
149 copied = client.copy(default_query={"foo": "stainless"})
150 assert _get_params(copied)["foo"] == "stainless"
151
152 # set_default_query
153
154 # completely overrides already set values
155 copied = client.copy(set_default_query={})
156 assert _get_params(copied) == {}
157
158 copied = client.copy(set_default_query={"bar": "Robert"})
159 assert _get_params(copied)["bar"] == "Robert"
160
161 with pytest.raises(
162 ValueError,
163 # TODO: update
164 match="`default_query` and `set_default_query` arguments are mutually exclusive",
165 ):
166 client.copy(set_default_query={}, default_query={"foo": "Bar"})
167
168 def test_copy_signature(self) -> None:
169 # ensure the same parameters that can be passed to the client are defined in the `.copy()` method
170 init_signature = inspect.signature(
171 # mypy doesn't like that we access the `__init__` property.
172 self.client.__init__, # type: ignore[misc]
173 )
174 copy_signature = inspect.signature(self.client.copy)
175 exclude_params = {"transport", "proxies", "_strict_response_validation"}
176
177 for name in init_signature.parameters.keys():
178 if name in exclude_params:
179 continue
180
181 copy_param = copy_signature.parameters.get(name)
182 assert copy_param is not None, f"copy() signature is missing the {name} param"
183
184 def test_copy_build_request(self) -> None:
185 options = FinalRequestOptions(method="get", url="/foo")
186
187 def build_request(options: FinalRequestOptions) -> None:
188 client = self.client.copy()
189 client._build_request(options)
190
191 # ensure that the machinery is warmed up before tracing starts.
192 build_request(options)
193 gc.collect()
194
195 tracemalloc.start(1000)
196
197 snapshot_before = tracemalloc.take_snapshot()
198
199 ITERATIONS = 10
200 for _ in range(ITERATIONS):
201 build_request(options)
202
203 gc.collect()
204 snapshot_after = tracemalloc.take_snapshot()
205
206 tracemalloc.stop()
207
208 def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None:
209 if diff.count == 0:
210 # Avoid false positives by considering only leaks (i.e. allocations that persist).
211 return
212
213 if diff.count % ITERATIONS != 0:
214 # Avoid false positives by considering only leaks that appear per iteration.
215 return
216
217 for frame in diff.traceback:
218 if any(
219 frame.filename.endswith(fragment)
220 for fragment in [
221 # to_raw_response_wrapper leaks through the @functools.wraps() decorator.
222 #
223 # removing the decorator fixes the leak for reasons we don't understand.
224 "openai/_legacy_response.py",
225 "openai/_response.py",
226 # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason.
227 "openai/_compat.py",
228 # Standard library leaks we don't care about.
229 "/logging/__init__.py",
230 ]
231 ):
232 return
233
234 leaks.append(diff)
235
236 leaks: list[tracemalloc.StatisticDiff] = []
237 for diff in snapshot_after.compare_to(snapshot_before, "traceback"):
238 add_leak(leaks, diff)
239 if leaks:
240 for leak in leaks:
241 print("MEMORY LEAK:", leak)
242 for frame in leak.traceback:
243 print(frame)
244 raise AssertionError()
245
246 def test_request_timeout(self) -> None:
247 request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
248 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
249 assert timeout == DEFAULT_TIMEOUT
250
251 request = self.client._build_request(
252 FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
253 )
254 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
255 assert timeout == httpx.Timeout(100.0)
256
257 def test_client_timeout_option(self) -> None:
258 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0))
259
260 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
261 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
262 assert timeout == httpx.Timeout(0)
263
264 def test_http_client_timeout_option(self) -> None:
265 # custom timeout given to the httpx client should be used
266 with httpx.Client(timeout=None) as http_client:
267 client = OpenAI(
268 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
269 )
270
271 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
272 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
273 assert timeout == httpx.Timeout(None)
274
275 # no timeout given to the httpx client should not use the httpx default
276 with httpx.Client() as http_client:
277 client = OpenAI(
278 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
279 )
280
281 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
282 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
283 assert timeout == DEFAULT_TIMEOUT
284
285 # explicitly passing the default timeout currently results in it being ignored
286 with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
287 client = OpenAI(
288 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
289 )
290
291 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
292 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
293 assert timeout == DEFAULT_TIMEOUT # our default
294
295 def test_default_headers_option(self) -> None:
296 client = OpenAI(
297 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
298 )
299 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
300 assert request.headers.get("x-foo") == "bar"
301 assert request.headers.get("x-stainless-lang") == "python"
302
303 client2 = OpenAI(
304 base_url=base_url,
305 api_key=api_key,
306 _strict_response_validation=True,
307 default_headers={
308 "X-Foo": "stainless",
309 "X-Stainless-Lang": "my-overriding-header",
310 },
311 )
312 request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
313 assert request.headers.get("x-foo") == "stainless"
314 assert request.headers.get("x-stainless-lang") == "my-overriding-header"
315
316 def test_validate_headers(self) -> None:
317 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
318 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
319 assert request.headers.get("Authorization") == f"Bearer {api_key}"
320
321 with pytest.raises(OpenAIError):
322 client2 = OpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
323 _ = client2
324
325 def test_default_query_option(self) -> None:
326 client = OpenAI(
327 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
328 )
329 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
330 url = httpx.URL(request.url)
331 assert dict(url.params) == {"query_param": "bar"}
332
333 request = client._build_request(
334 FinalRequestOptions(
335 method="get",
336 url="/foo",
337 params={"foo": "baz", "query_param": "overriden"},
338 )
339 )
340 url = httpx.URL(request.url)
341 assert dict(url.params) == {"foo": "baz", "query_param": "overriden"}
342
343 def test_request_extra_json(self) -> None:
344 request = self.client._build_request(
345 FinalRequestOptions(
346 method="post",
347 url="/foo",
348 json_data={"foo": "bar"},
349 extra_json={"baz": False},
350 ),
351 )
352 data = json.loads(request.content.decode("utf-8"))
353 assert data == {"foo": "bar", "baz": False}
354
355 request = self.client._build_request(
356 FinalRequestOptions(
357 method="post",
358 url="/foo",
359 extra_json={"baz": False},
360 ),
361 )
362 data = json.loads(request.content.decode("utf-8"))
363 assert data == {"baz": False}
364
365 # `extra_json` takes priority over `json_data` when keys clash
366 request = self.client._build_request(
367 FinalRequestOptions(
368 method="post",
369 url="/foo",
370 json_data={"foo": "bar", "baz": True},
371 extra_json={"baz": None},
372 ),
373 )
374 data = json.loads(request.content.decode("utf-8"))
375 assert data == {"foo": "bar", "baz": None}
376
377 def test_request_extra_headers(self) -> None:
378 request = self.client._build_request(
379 FinalRequestOptions(
380 method="post",
381 url="/foo",
382 **make_request_options(extra_headers={"X-Foo": "Foo"}),
383 ),
384 )
385 assert request.headers.get("X-Foo") == "Foo"
386
387 # `extra_headers` takes priority over `default_headers` when keys clash
388 request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
389 FinalRequestOptions(
390 method="post",
391 url="/foo",
392 **make_request_options(
393 extra_headers={"X-Bar": "false"},
394 ),
395 ),
396 )
397 assert request.headers.get("X-Bar") == "false"
398
399 def test_request_extra_query(self) -> None:
400 request = self.client._build_request(
401 FinalRequestOptions(
402 method="post",
403 url="/foo",
404 **make_request_options(
405 extra_query={"my_query_param": "Foo"},
406 ),
407 ),
408 )
409 params = dict(request.url.params)
410 assert params == {"my_query_param": "Foo"}
411
412 # if both `query` and `extra_query` are given, they are merged
413 request = self.client._build_request(
414 FinalRequestOptions(
415 method="post",
416 url="/foo",
417 **make_request_options(
418 query={"bar": "1"},
419 extra_query={"foo": "2"},
420 ),
421 ),
422 )
423 params = dict(request.url.params)
424 assert params == {"bar": "1", "foo": "2"}
425
426 # `extra_query` takes priority over `query` when keys clash
427 request = self.client._build_request(
428 FinalRequestOptions(
429 method="post",
430 url="/foo",
431 **make_request_options(
432 query={"foo": "1"},
433 extra_query={"foo": "2"},
434 ),
435 ),
436 )
437 params = dict(request.url.params)
438 assert params == {"foo": "2"}
439
440 def test_multipart_repeating_array(self, client: OpenAI) -> None:
441 request = client._build_request(
442 FinalRequestOptions.construct(
443 method="get",
444 url="/foo",
445 headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
446 json_data={"array": ["foo", "bar"]},
447 files=[("foo.txt", b"hello world")],
448 )
449 )
450
451 assert request.read().split(b"\r\n") == [
452 b"--6b7ba517decee4a450543ea6ae821c82",
453 b'Content-Disposition: form-data; name="array[]"',
454 b"",
455 b"foo",
456 b"--6b7ba517decee4a450543ea6ae821c82",
457 b'Content-Disposition: form-data; name="array[]"',
458 b"",
459 b"bar",
460 b"--6b7ba517decee4a450543ea6ae821c82",
461 b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
462 b"Content-Type: application/octet-stream",
463 b"",
464 b"hello world",
465 b"--6b7ba517decee4a450543ea6ae821c82--",
466 b"",
467 ]
468
469 @pytest.mark.respx(base_url=base_url)
470 def test_basic_union_response(self, respx_mock: MockRouter) -> None:
471 class Model1(BaseModel):
472 name: str
473
474 class Model2(BaseModel):
475 foo: str
476
477 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
478
479 response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
480 assert isinstance(response, Model2)
481 assert response.foo == "bar"
482
483 @pytest.mark.respx(base_url=base_url)
484 def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
485 """Union of objects with the same field name using a different type"""
486
487 class Model1(BaseModel):
488 foo: int
489
490 class Model2(BaseModel):
491 foo: str
492
493 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
494
495 response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
496 assert isinstance(response, Model2)
497 assert response.foo == "bar"
498
499 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
500
501 response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
502 assert isinstance(response, Model1)
503 assert response.foo == 1
504
505 @pytest.mark.respx(base_url=base_url)
506 def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
507 """
508 Response that sets Content-Type to something other than application/json but returns json data
509 """
510
511 class Model(BaseModel):
512 foo: int
513
514 respx_mock.get("/foo").mock(
515 return_value=httpx.Response(
516 200,
517 content=json.dumps({"foo": 2}),
518 headers={"Content-Type": "application/text"},
519 )
520 )
521
522 response = self.client.get("/foo", cast_to=Model)
523 assert isinstance(response, Model)
524 assert response.foo == 2
525
526 def test_base_url_setter(self) -> None:
527 client = OpenAI(base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True)
528 assert client.base_url == "https://example.com/from_init/"
529
530 client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
531
532 assert client.base_url == "https://example.com/from_setter/"
533
534 def test_base_url_env(self) -> None:
535 with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
536 client = OpenAI(api_key=api_key, _strict_response_validation=True)
537 assert client.base_url == "http://localhost:5000/from/env/"
538
539 @pytest.mark.parametrize(
540 "client",
541 [
542 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
543 OpenAI(
544 base_url="http://localhost:5000/custom/path/",
545 api_key=api_key,
546 _strict_response_validation=True,
547 http_client=httpx.Client(),
548 ),
549 ],
550 ids=["standard", "custom http client"],
551 )
552 def test_base_url_trailing_slash(self, client: OpenAI) -> None:
553 request = client._build_request(
554 FinalRequestOptions(
555 method="post",
556 url="/foo",
557 json_data={"foo": "bar"},
558 ),
559 )
560 assert request.url == "http://localhost:5000/custom/path/foo"
561
562 @pytest.mark.parametrize(
563 "client",
564 [
565 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
566 OpenAI(
567 base_url="http://localhost:5000/custom/path/",
568 api_key=api_key,
569 _strict_response_validation=True,
570 http_client=httpx.Client(),
571 ),
572 ],
573 ids=["standard", "custom http client"],
574 )
575 def test_base_url_no_trailing_slash(self, client: OpenAI) -> None:
576 request = client._build_request(
577 FinalRequestOptions(
578 method="post",
579 url="/foo",
580 json_data={"foo": "bar"},
581 ),
582 )
583 assert request.url == "http://localhost:5000/custom/path/foo"
584
585 @pytest.mark.parametrize(
586 "client",
587 [
588 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
589 OpenAI(
590 base_url="http://localhost:5000/custom/path/",
591 api_key=api_key,
592 _strict_response_validation=True,
593 http_client=httpx.Client(),
594 ),
595 ],
596 ids=["standard", "custom http client"],
597 )
598 def test_absolute_request_url(self, client: OpenAI) -> None:
599 request = client._build_request(
600 FinalRequestOptions(
601 method="post",
602 url="https://myapi.com/foo",
603 json_data={"foo": "bar"},
604 ),
605 )
606 assert request.url == "https://myapi.com/foo"
607
608 def test_copied_client_does_not_close_http(self) -> None:
609 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
610 assert not client.is_closed()
611
612 copied = client.copy()
613 assert copied is not client
614
615 del copied
616
617 assert not client.is_closed()
618
619 def test_client_context_manager(self) -> None:
620 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
621 with client as c2:
622 assert c2 is client
623 assert not c2.is_closed()
624 assert not client.is_closed()
625 assert client.is_closed()
626
627 @pytest.mark.respx(base_url=base_url)
628 def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
629 class Model(BaseModel):
630 foo: str
631
632 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
633
634 with pytest.raises(APIResponseValidationError) as exc:
635 self.client.get("/foo", cast_to=Model)
636
637 assert isinstance(exc.value.__cause__, ValidationError)
638
639 @pytest.mark.respx(base_url=base_url)
640 def test_default_stream_cls(self, respx_mock: MockRouter) -> None:
641 class Model(BaseModel):
642 name: str
643
644 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
645
646 stream = self.client.post("/foo", cast_to=Model, stream=True, stream_cls=Stream[Model])
647 assert isinstance(stream, Stream)
648 stream.response.close()
649
650 @pytest.mark.respx(base_url=base_url)
651 def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
652 class Model(BaseModel):
653 name: str
654
655 respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
656
657 strict_client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
658
659 with pytest.raises(APIResponseValidationError):
660 strict_client.get("/foo", cast_to=Model)
661
662 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
663
664 response = client.get("/foo", cast_to=Model)
665 assert isinstance(response, str) # type: ignore[unreachable]
666
667 @pytest.mark.parametrize(
668 "remaining_retries,retry_after,timeout",
669 [
670 [3, "20", 20],
671 [3, "0", 0.5],
672 [3, "-10", 0.5],
673 [3, "60", 60],
674 [3, "61", 0.5],
675 [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
676 [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
677 [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
678 [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
679 [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
680 [3, "99999999999999999999999999999999999", 0.5],
681 [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
682 [3, "", 0.5],
683 [2, "", 0.5 * 2.0],
684 [1, "", 0.5 * 4.0],
685 ],
686 )
687 @mock.patch("time.time", mock.MagicMock(return_value=1696004797))
688 def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
689 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
690
691 headers = httpx.Headers({"retry-after": retry_after})
692 options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
693 calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
694 assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
695
696 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
697 @pytest.mark.respx(base_url=base_url)
698 def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
699 respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
700
701 with pytest.raises(APITimeoutError):
702 self.client.post(
703 "/chat/completions",
704 body=cast(
705 object,
706 dict(
707 messages=[
708 {
709 "role": "user",
710 "content": "Say this is a test",
711 }
712 ],
713 model="gpt-3.5-turbo",
714 ),
715 ),
716 cast_to=httpx.Response,
717 options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
718 )
719
720 assert _get_open_connections(self.client) == 0
721
722 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
723 @pytest.mark.respx(base_url=base_url)
724 def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
725 respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
726
727 with pytest.raises(APIStatusError):
728 self.client.post(
729 "/chat/completions",
730 body=cast(
731 object,
732 dict(
733 messages=[
734 {
735 "role": "user",
736 "content": "Say this is a test",
737 }
738 ],
739 model="gpt-3.5-turbo",
740 ),
741 ),
742 cast_to=httpx.Response,
743 options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
744 )
745
746 assert _get_open_connections(self.client) == 0
747
748
749class TestAsyncOpenAI:
750 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
751
752 @pytest.mark.respx(base_url=base_url)
753 @pytest.mark.asyncio
754 async def test_raw_response(self, respx_mock: MockRouter) -> None:
755 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
756
757 response = await self.client.post("/foo", cast_to=httpx.Response)
758 assert response.status_code == 200
759 assert isinstance(response, httpx.Response)
760 assert response.json() == {"foo": "bar"}
761
762 @pytest.mark.respx(base_url=base_url)
763 @pytest.mark.asyncio
764 async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
765 respx_mock.post("/foo").mock(
766 return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
767 )
768
769 response = await self.client.post("/foo", cast_to=httpx.Response)
770 assert response.status_code == 200
771 assert isinstance(response, httpx.Response)
772 assert response.json() == {"foo": "bar"}
773
774 def test_copy(self) -> None:
775 copied = self.client.copy()
776 assert id(copied) != id(self.client)
777
778 copied = self.client.copy(api_key="another My API Key")
779 assert copied.api_key == "another My API Key"
780 assert self.client.api_key == "My API Key"
781
782 def test_copy_default_options(self) -> None:
783 # options that have a default are overridden correctly
784 copied = self.client.copy(max_retries=7)
785 assert copied.max_retries == 7
786 assert self.client.max_retries == 2
787
788 copied2 = copied.copy(max_retries=6)
789 assert copied2.max_retries == 6
790 assert copied.max_retries == 7
791
792 # timeout
793 assert isinstance(self.client.timeout, httpx.Timeout)
794 copied = self.client.copy(timeout=None)
795 assert copied.timeout is None
796 assert isinstance(self.client.timeout, httpx.Timeout)
797
798 def test_copy_default_headers(self) -> None:
799 client = AsyncOpenAI(
800 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
801 )
802 assert client.default_headers["X-Foo"] == "bar"
803
804 # does not override the already given value when not specified
805 copied = client.copy()
806 assert copied.default_headers["X-Foo"] == "bar"
807
808 # merges already given headers
809 copied = client.copy(default_headers={"X-Bar": "stainless"})
810 assert copied.default_headers["X-Foo"] == "bar"
811 assert copied.default_headers["X-Bar"] == "stainless"
812
813 # uses new values for any already given headers
814 copied = client.copy(default_headers={"X-Foo": "stainless"})
815 assert copied.default_headers["X-Foo"] == "stainless"
816
817 # set_default_headers
818
819 # completely overrides already set values
820 copied = client.copy(set_default_headers={})
821 assert copied.default_headers.get("X-Foo") is None
822
823 copied = client.copy(set_default_headers={"X-Bar": "Robert"})
824 assert copied.default_headers["X-Bar"] == "Robert"
825
826 with pytest.raises(
827 ValueError,
828 match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
829 ):
830 client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
831
832 def test_copy_default_query(self) -> None:
833 client = AsyncOpenAI(
834 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
835 )
836 assert _get_params(client)["foo"] == "bar"
837
838 # does not override the already given value when not specified
839 copied = client.copy()
840 assert _get_params(copied)["foo"] == "bar"
841
842 # merges already given params
843 copied = client.copy(default_query={"bar": "stainless"})
844 params = _get_params(copied)
845 assert params["foo"] == "bar"
846 assert params["bar"] == "stainless"
847
848 # uses new values for any already given headers
849 copied = client.copy(default_query={"foo": "stainless"})
850 assert _get_params(copied)["foo"] == "stainless"
851
852 # set_default_query
853
854 # completely overrides already set values
855 copied = client.copy(set_default_query={})
856 assert _get_params(copied) == {}
857
858 copied = client.copy(set_default_query={"bar": "Robert"})
859 assert _get_params(copied)["bar"] == "Robert"
860
861 with pytest.raises(
862 ValueError,
863 # TODO: update
864 match="`default_query` and `set_default_query` arguments are mutually exclusive",
865 ):
866 client.copy(set_default_query={}, default_query={"foo": "Bar"})
867
868 def test_copy_signature(self) -> None:
869 # ensure the same parameters that can be passed to the client are defined in the `.copy()` method
870 init_signature = inspect.signature(
871 # mypy doesn't like that we access the `__init__` property.
872 self.client.__init__, # type: ignore[misc]
873 )
874 copy_signature = inspect.signature(self.client.copy)
875 exclude_params = {"transport", "proxies", "_strict_response_validation"}
876
877 for name in init_signature.parameters.keys():
878 if name in exclude_params:
879 continue
880
881 copy_param = copy_signature.parameters.get(name)
882 assert copy_param is not None, f"copy() signature is missing the {name} param"
883
884 def test_copy_build_request(self) -> None:
885 options = FinalRequestOptions(method="get", url="/foo")
886
887 def build_request(options: FinalRequestOptions) -> None:
888 client = self.client.copy()
889 client._build_request(options)
890
891 # ensure that the machinery is warmed up before tracing starts.
892 build_request(options)
893 gc.collect()
894
895 tracemalloc.start(1000)
896
897 snapshot_before = tracemalloc.take_snapshot()
898
899 ITERATIONS = 10
900 for _ in range(ITERATIONS):
901 build_request(options)
902
903 gc.collect()
904 snapshot_after = tracemalloc.take_snapshot()
905
906 tracemalloc.stop()
907
908 def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None:
909 if diff.count == 0:
910 # Avoid false positives by considering only leaks (i.e. allocations that persist).
911 return
912
913 if diff.count % ITERATIONS != 0:
914 # Avoid false positives by considering only leaks that appear per iteration.
915 return
916
917 for frame in diff.traceback:
918 if any(
919 frame.filename.endswith(fragment)
920 for fragment in [
921 # to_raw_response_wrapper leaks through the @functools.wraps() decorator.
922 #
923 # removing the decorator fixes the leak for reasons we don't understand.
924 "openai/_legacy_response.py",
925 "openai/_response.py",
926 # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason.
927 "openai/_compat.py",
928 # Standard library leaks we don't care about.
929 "/logging/__init__.py",
930 ]
931 ):
932 return
933
934 leaks.append(diff)
935
936 leaks: list[tracemalloc.StatisticDiff] = []
937 for diff in snapshot_after.compare_to(snapshot_before, "traceback"):
938 add_leak(leaks, diff)
939 if leaks:
940 for leak in leaks:
941 print("MEMORY LEAK:", leak)
942 for frame in leak.traceback:
943 print(frame)
944 raise AssertionError()
945
946 async def test_request_timeout(self) -> None:
947 request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
948 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
949 assert timeout == DEFAULT_TIMEOUT
950
951 request = self.client._build_request(
952 FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
953 )
954 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
955 assert timeout == httpx.Timeout(100.0)
956
957 async def test_client_timeout_option(self) -> None:
958 client = AsyncOpenAI(
959 base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0)
960 )
961
962 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
963 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
964 assert timeout == httpx.Timeout(0)
965
966 async def test_http_client_timeout_option(self) -> None:
967 # custom timeout given to the httpx client should be used
968 async with httpx.AsyncClient(timeout=None) as http_client:
969 client = AsyncOpenAI(
970 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
971 )
972
973 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
974 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
975 assert timeout == httpx.Timeout(None)
976
977 # no timeout given to the httpx client should not use the httpx default
978 async with httpx.AsyncClient() as http_client:
979 client = AsyncOpenAI(
980 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
981 )
982
983 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
984 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
985 assert timeout == DEFAULT_TIMEOUT
986
987 # explicitly passing the default timeout currently results in it being ignored
988 async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
989 client = AsyncOpenAI(
990 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
991 )
992
993 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
994 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
995 assert timeout == DEFAULT_TIMEOUT # our default
996
997 def test_default_headers_option(self) -> None:
998 client = AsyncOpenAI(
999 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
1000 )
1001 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1002 assert request.headers.get("x-foo") == "bar"
1003 assert request.headers.get("x-stainless-lang") == "python"
1004
1005 client2 = AsyncOpenAI(
1006 base_url=base_url,
1007 api_key=api_key,
1008 _strict_response_validation=True,
1009 default_headers={
1010 "X-Foo": "stainless",
1011 "X-Stainless-Lang": "my-overriding-header",
1012 },
1013 )
1014 request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
1015 assert request.headers.get("x-foo") == "stainless"
1016 assert request.headers.get("x-stainless-lang") == "my-overriding-header"
1017
1018 def test_validate_headers(self) -> None:
1019 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1020 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1021 assert request.headers.get("Authorization") == f"Bearer {api_key}"
1022
1023 with pytest.raises(OpenAIError):
1024 client2 = AsyncOpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
1025 _ = client2
1026
1027 def test_default_query_option(self) -> None:
1028 client = AsyncOpenAI(
1029 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
1030 )
1031 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1032 url = httpx.URL(request.url)
1033 assert dict(url.params) == {"query_param": "bar"}
1034
1035 request = client._build_request(
1036 FinalRequestOptions(
1037 method="get",
1038 url="/foo",
1039 params={"foo": "baz", "query_param": "overriden"},
1040 )
1041 )
1042 url = httpx.URL(request.url)
1043 assert dict(url.params) == {"foo": "baz", "query_param": "overriden"}
1044
1045 def test_request_extra_json(self) -> None:
1046 request = self.client._build_request(
1047 FinalRequestOptions(
1048 method="post",
1049 url="/foo",
1050 json_data={"foo": "bar"},
1051 extra_json={"baz": False},
1052 ),
1053 )
1054 data = json.loads(request.content.decode("utf-8"))
1055 assert data == {"foo": "bar", "baz": False}
1056
1057 request = self.client._build_request(
1058 FinalRequestOptions(
1059 method="post",
1060 url="/foo",
1061 extra_json={"baz": False},
1062 ),
1063 )
1064 data = json.loads(request.content.decode("utf-8"))
1065 assert data == {"baz": False}
1066
1067 # `extra_json` takes priority over `json_data` when keys clash
1068 request = self.client._build_request(
1069 FinalRequestOptions(
1070 method="post",
1071 url="/foo",
1072 json_data={"foo": "bar", "baz": True},
1073 extra_json={"baz": None},
1074 ),
1075 )
1076 data = json.loads(request.content.decode("utf-8"))
1077 assert data == {"foo": "bar", "baz": None}
1078
1079 def test_request_extra_headers(self) -> None:
1080 request = self.client._build_request(
1081 FinalRequestOptions(
1082 method="post",
1083 url="/foo",
1084 **make_request_options(extra_headers={"X-Foo": "Foo"}),
1085 ),
1086 )
1087 assert request.headers.get("X-Foo") == "Foo"
1088
1089 # `extra_headers` takes priority over `default_headers` when keys clash
1090 request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
1091 FinalRequestOptions(
1092 method="post",
1093 url="/foo",
1094 **make_request_options(
1095 extra_headers={"X-Bar": "false"},
1096 ),
1097 ),
1098 )
1099 assert request.headers.get("X-Bar") == "false"
1100
1101 def test_request_extra_query(self) -> None:
1102 request = self.client._build_request(
1103 FinalRequestOptions(
1104 method="post",
1105 url="/foo",
1106 **make_request_options(
1107 extra_query={"my_query_param": "Foo"},
1108 ),
1109 ),
1110 )
1111 params = dict(request.url.params)
1112 assert params == {"my_query_param": "Foo"}
1113
1114 # if both `query` and `extra_query` are given, they are merged
1115 request = self.client._build_request(
1116 FinalRequestOptions(
1117 method="post",
1118 url="/foo",
1119 **make_request_options(
1120 query={"bar": "1"},
1121 extra_query={"foo": "2"},
1122 ),
1123 ),
1124 )
1125 params = dict(request.url.params)
1126 assert params == {"bar": "1", "foo": "2"}
1127
1128 # `extra_query` takes priority over `query` when keys clash
1129 request = self.client._build_request(
1130 FinalRequestOptions(
1131 method="post",
1132 url="/foo",
1133 **make_request_options(
1134 query={"foo": "1"},
1135 extra_query={"foo": "2"},
1136 ),
1137 ),
1138 )
1139 params = dict(request.url.params)
1140 assert params == {"foo": "2"}
1141
1142 def test_multipart_repeating_array(self, async_client: AsyncOpenAI) -> None:
1143 request = async_client._build_request(
1144 FinalRequestOptions.construct(
1145 method="get",
1146 url="/foo",
1147 headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
1148 json_data={"array": ["foo", "bar"]},
1149 files=[("foo.txt", b"hello world")],
1150 )
1151 )
1152
1153 assert request.read().split(b"\r\n") == [
1154 b"--6b7ba517decee4a450543ea6ae821c82",
1155 b'Content-Disposition: form-data; name="array[]"',
1156 b"",
1157 b"foo",
1158 b"--6b7ba517decee4a450543ea6ae821c82",
1159 b'Content-Disposition: form-data; name="array[]"',
1160 b"",
1161 b"bar",
1162 b"--6b7ba517decee4a450543ea6ae821c82",
1163 b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
1164 b"Content-Type: application/octet-stream",
1165 b"",
1166 b"hello world",
1167 b"--6b7ba517decee4a450543ea6ae821c82--",
1168 b"",
1169 ]
1170
1171 @pytest.mark.respx(base_url=base_url)
1172 async def test_basic_union_response(self, respx_mock: MockRouter) -> None:
1173 class Model1(BaseModel):
1174 name: str
1175
1176 class Model2(BaseModel):
1177 foo: str
1178
1179 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1180
1181 response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1182 assert isinstance(response, Model2)
1183 assert response.foo == "bar"
1184
1185 @pytest.mark.respx(base_url=base_url)
1186 async def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
1187 """Union of objects with the same field name using a different type"""
1188
1189 class Model1(BaseModel):
1190 foo: int
1191
1192 class Model2(BaseModel):
1193 foo: str
1194
1195 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1196
1197 response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1198 assert isinstance(response, Model2)
1199 assert response.foo == "bar"
1200
1201 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
1202
1203 response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1204 assert isinstance(response, Model1)
1205 assert response.foo == 1
1206
1207 @pytest.mark.respx(base_url=base_url)
1208 async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
1209 """
1210 Response that sets Content-Type to something other than application/json but returns json data
1211 """
1212
1213 class Model(BaseModel):
1214 foo: int
1215
1216 respx_mock.get("/foo").mock(
1217 return_value=httpx.Response(
1218 200,
1219 content=json.dumps({"foo": 2}),
1220 headers={"Content-Type": "application/text"},
1221 )
1222 )
1223
1224 response = await self.client.get("/foo", cast_to=Model)
1225 assert isinstance(response, Model)
1226 assert response.foo == 2
1227
1228 def test_base_url_setter(self) -> None:
1229 client = AsyncOpenAI(
1230 base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True
1231 )
1232 assert client.base_url == "https://example.com/from_init/"
1233
1234 client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
1235
1236 assert client.base_url == "https://example.com/from_setter/"
1237
1238 def test_base_url_env(self) -> None:
1239 with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
1240 client = AsyncOpenAI(api_key=api_key, _strict_response_validation=True)
1241 assert client.base_url == "http://localhost:5000/from/env/"
1242
1243 @pytest.mark.parametrize(
1244 "client",
1245 [
1246 AsyncOpenAI(
1247 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1248 ),
1249 AsyncOpenAI(
1250 base_url="http://localhost:5000/custom/path/",
1251 api_key=api_key,
1252 _strict_response_validation=True,
1253 http_client=httpx.AsyncClient(),
1254 ),
1255 ],
1256 ids=["standard", "custom http client"],
1257 )
1258 def test_base_url_trailing_slash(self, client: AsyncOpenAI) -> None:
1259 request = client._build_request(
1260 FinalRequestOptions(
1261 method="post",
1262 url="/foo",
1263 json_data={"foo": "bar"},
1264 ),
1265 )
1266 assert request.url == "http://localhost:5000/custom/path/foo"
1267
1268 @pytest.mark.parametrize(
1269 "client",
1270 [
1271 AsyncOpenAI(
1272 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1273 ),
1274 AsyncOpenAI(
1275 base_url="http://localhost:5000/custom/path/",
1276 api_key=api_key,
1277 _strict_response_validation=True,
1278 http_client=httpx.AsyncClient(),
1279 ),
1280 ],
1281 ids=["standard", "custom http client"],
1282 )
1283 def test_base_url_no_trailing_slash(self, client: AsyncOpenAI) -> None:
1284 request = client._build_request(
1285 FinalRequestOptions(
1286 method="post",
1287 url="/foo",
1288 json_data={"foo": "bar"},
1289 ),
1290 )
1291 assert request.url == "http://localhost:5000/custom/path/foo"
1292
1293 @pytest.mark.parametrize(
1294 "client",
1295 [
1296 AsyncOpenAI(
1297 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1298 ),
1299 AsyncOpenAI(
1300 base_url="http://localhost:5000/custom/path/",
1301 api_key=api_key,
1302 _strict_response_validation=True,
1303 http_client=httpx.AsyncClient(),
1304 ),
1305 ],
1306 ids=["standard", "custom http client"],
1307 )
1308 def test_absolute_request_url(self, client: AsyncOpenAI) -> None:
1309 request = client._build_request(
1310 FinalRequestOptions(
1311 method="post",
1312 url="https://myapi.com/foo",
1313 json_data={"foo": "bar"},
1314 ),
1315 )
1316 assert request.url == "https://myapi.com/foo"
1317
1318 async def test_copied_client_does_not_close_http(self) -> None:
1319 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1320 assert not client.is_closed()
1321
1322 copied = client.copy()
1323 assert copied is not client
1324
1325 del copied
1326
1327 await asyncio.sleep(0.2)
1328 assert not client.is_closed()
1329
1330 async def test_client_context_manager(self) -> None:
1331 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1332 async with client as c2:
1333 assert c2 is client
1334 assert not c2.is_closed()
1335 assert not client.is_closed()
1336 assert client.is_closed()
1337
1338 @pytest.mark.respx(base_url=base_url)
1339 @pytest.mark.asyncio
1340 async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
1341 class Model(BaseModel):
1342 foo: str
1343
1344 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
1345
1346 with pytest.raises(APIResponseValidationError) as exc:
1347 await self.client.get("/foo", cast_to=Model)
1348
1349 assert isinstance(exc.value.__cause__, ValidationError)
1350
1351 @pytest.mark.respx(base_url=base_url)
1352 @pytest.mark.asyncio
1353 async def test_default_stream_cls(self, respx_mock: MockRouter) -> None:
1354 class Model(BaseModel):
1355 name: str
1356
1357 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1358
1359 stream = await self.client.post("/foo", cast_to=Model, stream=True, stream_cls=AsyncStream[Model])
1360 assert isinstance(stream, AsyncStream)
1361 await stream.response.aclose()
1362
1363 @pytest.mark.respx(base_url=base_url)
1364 @pytest.mark.asyncio
1365 async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
1366 class Model(BaseModel):
1367 name: str
1368
1369 respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
1370
1371 strict_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1372
1373 with pytest.raises(APIResponseValidationError):
1374 await strict_client.get("/foo", cast_to=Model)
1375
1376 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
1377
1378 response = await client.get("/foo", cast_to=Model)
1379 assert isinstance(response, str) # type: ignore[unreachable]
1380
1381 @pytest.mark.parametrize(
1382 "remaining_retries,retry_after,timeout",
1383 [
1384 [3, "20", 20],
1385 [3, "0", 0.5],
1386 [3, "-10", 0.5],
1387 [3, "60", 60],
1388 [3, "61", 0.5],
1389 [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
1390 [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
1391 [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
1392 [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
1393 [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
1394 [3, "99999999999999999999999999999999999", 0.5],
1395 [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
1396 [3, "", 0.5],
1397 [2, "", 0.5 * 2.0],
1398 [1, "", 0.5 * 4.0],
1399 ],
1400 )
1401 @mock.patch("time.time", mock.MagicMock(return_value=1696004797))
1402 @pytest.mark.asyncio
1403 async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
1404 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1405
1406 headers = httpx.Headers({"retry-after": retry_after})
1407 options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
1408 calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
1409 assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
1410
1411 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1412 @pytest.mark.respx(base_url=base_url)
1413 async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
1414 respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
1415
1416 with pytest.raises(APITimeoutError):
1417 await self.client.post(
1418 "/chat/completions",
1419 body=cast(
1420 object,
1421 dict(
1422 messages=[
1423 {
1424 "role": "user",
1425 "content": "Say this is a test",
1426 }
1427 ],
1428 model="gpt-3.5-turbo",
1429 ),
1430 ),
1431 cast_to=httpx.Response,
1432 options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
1433 )
1434
1435 assert _get_open_connections(self.client) == 0
1436
1437 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1438 @pytest.mark.respx(base_url=base_url)
1439 async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
1440 respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
1441
1442 with pytest.raises(APIStatusError):
1443 await self.client.post(
1444 "/chat/completions",
1445 body=cast(
1446 object,
1447 dict(
1448 messages=[
1449 {
1450 "role": "user",
1451 "content": "Say this is a test",
1452 }
1453 ],
1454 model="gpt-3.5-turbo",
1455 ),
1456 ),
1457 cast_to=httpx.Response,
1458 options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
1459 )
1460
1461 assert _get_open_connections(self.client) == 0
1462