openai/openai-python

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
3121db4b81db3706cc47b4e19f2fe9d27de18dd0

Branches

Tags

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

Clone

HTTPS

Download ZIP

tests/test_client.py

1619lines · 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 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._types import Omit
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 async def test_invalid_http_client(self) -> None:
296 with pytest.raises(TypeError, match="Invalid `http_client` arg"):
297 async with httpx.AsyncClient() as http_client:
298 OpenAI(
299 base_url=base_url,
300 api_key=api_key,
301 _strict_response_validation=True,
302 http_client=cast(Any, http_client),
303 )
304
305 def test_default_headers_option(self) -> None:
306 client = OpenAI(
307 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
308 )
309 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
310 assert request.headers.get("x-foo") == "bar"
311 assert request.headers.get("x-stainless-lang") == "python"
312
313 client2 = OpenAI(
314 base_url=base_url,
315 api_key=api_key,
316 _strict_response_validation=True,
317 default_headers={
318 "X-Foo": "stainless",
319 "X-Stainless-Lang": "my-overriding-header",
320 },
321 )
322 request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
323 assert request.headers.get("x-foo") == "stainless"
324 assert request.headers.get("x-stainless-lang") == "my-overriding-header"
325
326 def test_validate_headers(self) -> None:
327 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
328 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
329 assert request.headers.get("Authorization") == f"Bearer {api_key}"
330
331 with pytest.raises(OpenAIError):
332 with update_env(**{"OPENAI_API_KEY": Omit()}):
333 client2 = OpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
334 _ = client2
335
336 def test_default_query_option(self) -> None:
337 client = OpenAI(
338 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
339 )
340 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
341 url = httpx.URL(request.url)
342 assert dict(url.params) == {"query_param": "bar"}
343
344 request = client._build_request(
345 FinalRequestOptions(
346 method="get",
347 url="/foo",
348 params={"foo": "baz", "query_param": "overriden"},
349 )
350 )
351 url = httpx.URL(request.url)
352 assert dict(url.params) == {"foo": "baz", "query_param": "overriden"}
353
354 def test_request_extra_json(self) -> None:
355 request = self.client._build_request(
356 FinalRequestOptions(
357 method="post",
358 url="/foo",
359 json_data={"foo": "bar"},
360 extra_json={"baz": False},
361 ),
362 )
363 data = json.loads(request.content.decode("utf-8"))
364 assert data == {"foo": "bar", "baz": False}
365
366 request = self.client._build_request(
367 FinalRequestOptions(
368 method="post",
369 url="/foo",
370 extra_json={"baz": False},
371 ),
372 )
373 data = json.loads(request.content.decode("utf-8"))
374 assert data == {"baz": False}
375
376 # `extra_json` takes priority over `json_data` when keys clash
377 request = self.client._build_request(
378 FinalRequestOptions(
379 method="post",
380 url="/foo",
381 json_data={"foo": "bar", "baz": True},
382 extra_json={"baz": None},
383 ),
384 )
385 data = json.loads(request.content.decode("utf-8"))
386 assert data == {"foo": "bar", "baz": None}
387
388 def test_request_extra_headers(self) -> None:
389 request = self.client._build_request(
390 FinalRequestOptions(
391 method="post",
392 url="/foo",
393 **make_request_options(extra_headers={"X-Foo": "Foo"}),
394 ),
395 )
396 assert request.headers.get("X-Foo") == "Foo"
397
398 # `extra_headers` takes priority over `default_headers` when keys clash
399 request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
400 FinalRequestOptions(
401 method="post",
402 url="/foo",
403 **make_request_options(
404 extra_headers={"X-Bar": "false"},
405 ),
406 ),
407 )
408 assert request.headers.get("X-Bar") == "false"
409
410 def test_request_extra_query(self) -> None:
411 request = self.client._build_request(
412 FinalRequestOptions(
413 method="post",
414 url="/foo",
415 **make_request_options(
416 extra_query={"my_query_param": "Foo"},
417 ),
418 ),
419 )
420 params = dict(request.url.params)
421 assert params == {"my_query_param": "Foo"}
422
423 # if both `query` and `extra_query` are given, they are merged
424 request = self.client._build_request(
425 FinalRequestOptions(
426 method="post",
427 url="/foo",
428 **make_request_options(
429 query={"bar": "1"},
430 extra_query={"foo": "2"},
431 ),
432 ),
433 )
434 params = dict(request.url.params)
435 assert params == {"bar": "1", "foo": "2"}
436
437 # `extra_query` takes priority over `query` when keys clash
438 request = self.client._build_request(
439 FinalRequestOptions(
440 method="post",
441 url="/foo",
442 **make_request_options(
443 query={"foo": "1"},
444 extra_query={"foo": "2"},
445 ),
446 ),
447 )
448 params = dict(request.url.params)
449 assert params == {"foo": "2"}
450
451 def test_multipart_repeating_array(self, client: OpenAI) -> None:
452 request = client._build_request(
453 FinalRequestOptions.construct(
454 method="get",
455 url="/foo",
456 headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
457 json_data={"array": ["foo", "bar"]},
458 files=[("foo.txt", b"hello world")],
459 )
460 )
461
462 assert request.read().split(b"\r\n") == [
463 b"--6b7ba517decee4a450543ea6ae821c82",
464 b'Content-Disposition: form-data; name="array[]"',
465 b"",
466 b"foo",
467 b"--6b7ba517decee4a450543ea6ae821c82",
468 b'Content-Disposition: form-data; name="array[]"',
469 b"",
470 b"bar",
471 b"--6b7ba517decee4a450543ea6ae821c82",
472 b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
473 b"Content-Type: application/octet-stream",
474 b"",
475 b"hello world",
476 b"--6b7ba517decee4a450543ea6ae821c82--",
477 b"",
478 ]
479
480 @pytest.mark.respx(base_url=base_url)
481 def test_basic_union_response(self, respx_mock: MockRouter) -> None:
482 class Model1(BaseModel):
483 name: str
484
485 class Model2(BaseModel):
486 foo: str
487
488 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
489
490 response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
491 assert isinstance(response, Model2)
492 assert response.foo == "bar"
493
494 @pytest.mark.respx(base_url=base_url)
495 def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
496 """Union of objects with the same field name using a different type"""
497
498 class Model1(BaseModel):
499 foo: int
500
501 class Model2(BaseModel):
502 foo: str
503
504 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
505
506 response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
507 assert isinstance(response, Model2)
508 assert response.foo == "bar"
509
510 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
511
512 response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
513 assert isinstance(response, Model1)
514 assert response.foo == 1
515
516 @pytest.mark.respx(base_url=base_url)
517 def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
518 """
519 Response that sets Content-Type to something other than application/json but returns json data
520 """
521
522 class Model(BaseModel):
523 foo: int
524
525 respx_mock.get("/foo").mock(
526 return_value=httpx.Response(
527 200,
528 content=json.dumps({"foo": 2}),
529 headers={"Content-Type": "application/text"},
530 )
531 )
532
533 response = self.client.get("/foo", cast_to=Model)
534 assert isinstance(response, Model)
535 assert response.foo == 2
536
537 def test_base_url_setter(self) -> None:
538 client = OpenAI(base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True)
539 assert client.base_url == "https://example.com/from_init/"
540
541 client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
542
543 assert client.base_url == "https://example.com/from_setter/"
544
545 def test_base_url_env(self) -> None:
546 with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
547 client = OpenAI(api_key=api_key, _strict_response_validation=True)
548 assert client.base_url == "http://localhost:5000/from/env/"
549
550 @pytest.mark.parametrize(
551 "client",
552 [
553 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
554 OpenAI(
555 base_url="http://localhost:5000/custom/path/",
556 api_key=api_key,
557 _strict_response_validation=True,
558 http_client=httpx.Client(),
559 ),
560 ],
561 ids=["standard", "custom http client"],
562 )
563 def test_base_url_trailing_slash(self, client: OpenAI) -> None:
564 request = client._build_request(
565 FinalRequestOptions(
566 method="post",
567 url="/foo",
568 json_data={"foo": "bar"},
569 ),
570 )
571 assert request.url == "http://localhost:5000/custom/path/foo"
572
573 @pytest.mark.parametrize(
574 "client",
575 [
576 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
577 OpenAI(
578 base_url="http://localhost:5000/custom/path/",
579 api_key=api_key,
580 _strict_response_validation=True,
581 http_client=httpx.Client(),
582 ),
583 ],
584 ids=["standard", "custom http client"],
585 )
586 def test_base_url_no_trailing_slash(self, client: OpenAI) -> None:
587 request = client._build_request(
588 FinalRequestOptions(
589 method="post",
590 url="/foo",
591 json_data={"foo": "bar"},
592 ),
593 )
594 assert request.url == "http://localhost:5000/custom/path/foo"
595
596 @pytest.mark.parametrize(
597 "client",
598 [
599 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
600 OpenAI(
601 base_url="http://localhost:5000/custom/path/",
602 api_key=api_key,
603 _strict_response_validation=True,
604 http_client=httpx.Client(),
605 ),
606 ],
607 ids=["standard", "custom http client"],
608 )
609 def test_absolute_request_url(self, client: OpenAI) -> None:
610 request = client._build_request(
611 FinalRequestOptions(
612 method="post",
613 url="https://myapi.com/foo",
614 json_data={"foo": "bar"},
615 ),
616 )
617 assert request.url == "https://myapi.com/foo"
618
619 def test_copied_client_does_not_close_http(self) -> None:
620 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
621 assert not client.is_closed()
622
623 copied = client.copy()
624 assert copied is not client
625
626 del copied
627
628 assert not client.is_closed()
629
630 def test_client_context_manager(self) -> None:
631 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
632 with client as c2:
633 assert c2 is client
634 assert not c2.is_closed()
635 assert not client.is_closed()
636 assert client.is_closed()
637
638 @pytest.mark.respx(base_url=base_url)
639 def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
640 class Model(BaseModel):
641 foo: str
642
643 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
644
645 with pytest.raises(APIResponseValidationError) as exc:
646 self.client.get("/foo", cast_to=Model)
647
648 assert isinstance(exc.value.__cause__, ValidationError)
649
650 def test_client_max_retries_validation(self) -> None:
651 with pytest.raises(TypeError, match=r"max_retries cannot be None"):
652 OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None))
653
654 @pytest.mark.respx(base_url=base_url)
655 def test_default_stream_cls(self, respx_mock: MockRouter) -> None:
656 class Model(BaseModel):
657 name: str
658
659 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
660
661 stream = self.client.post("/foo", cast_to=Model, stream=True, stream_cls=Stream[Model])
662 assert isinstance(stream, Stream)
663 stream.response.close()
664
665 @pytest.mark.respx(base_url=base_url)
666 def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
667 class Model(BaseModel):
668 name: str
669
670 respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
671
672 strict_client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
673
674 with pytest.raises(APIResponseValidationError):
675 strict_client.get("/foo", cast_to=Model)
676
677 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
678
679 response = client.get("/foo", cast_to=Model)
680 assert isinstance(response, str) # type: ignore[unreachable]
681
682 @pytest.mark.parametrize(
683 "remaining_retries,retry_after,timeout",
684 [
685 [3, "20", 20],
686 [3, "0", 0.5],
687 [3, "-10", 0.5],
688 [3, "60", 60],
689 [3, "61", 0.5],
690 [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
691 [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
692 [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
693 [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
694 [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
695 [3, "99999999999999999999999999999999999", 0.5],
696 [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
697 [3, "", 0.5],
698 [2, "", 0.5 * 2.0],
699 [1, "", 0.5 * 4.0],
700 ],
701 )
702 @mock.patch("time.time", mock.MagicMock(return_value=1696004797))
703 def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
704 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
705
706 headers = httpx.Headers({"retry-after": retry_after})
707 options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
708 calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
709 assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
710
711 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
712 @pytest.mark.respx(base_url=base_url)
713 def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
714 respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
715
716 with pytest.raises(APITimeoutError):
717 self.client.post(
718 "/chat/completions",
719 body=cast(
720 object,
721 dict(
722 messages=[
723 {
724 "role": "user",
725 "content": "Say this is a test",
726 }
727 ],
728 model="gpt-3.5-turbo",
729 ),
730 ),
731 cast_to=httpx.Response,
732 options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
733 )
734
735 assert _get_open_connections(self.client) == 0
736
737 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
738 @pytest.mark.respx(base_url=base_url)
739 def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
740 respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
741
742 with pytest.raises(APIStatusError):
743 self.client.post(
744 "/chat/completions",
745 body=cast(
746 object,
747 dict(
748 messages=[
749 {
750 "role": "user",
751 "content": "Say this is a test",
752 }
753 ],
754 model="gpt-3.5-turbo",
755 ),
756 ),
757 cast_to=httpx.Response,
758 options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
759 )
760
761 assert _get_open_connections(self.client) == 0
762
763 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
764 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
765 @pytest.mark.respx(base_url=base_url)
766 def test_retries_taken(self, client: OpenAI, failures_before_success: int, respx_mock: MockRouter) -> None:
767 client = client.with_options(max_retries=4)
768
769 nb_retries = 0
770
771 def retry_handler(_request: httpx.Request) -> httpx.Response:
772 nonlocal nb_retries
773 if nb_retries < failures_before_success:
774 nb_retries += 1
775 return httpx.Response(500)
776 return httpx.Response(200)
777
778 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
779
780 response = client.chat.completions.with_raw_response.create(
781 messages=[
782 {
783 "content": "string",
784 "role": "system",
785 }
786 ],
787 model="gpt-4o",
788 )
789
790 assert response.retries_taken == failures_before_success
791 assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
792
793 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
794 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
795 @pytest.mark.respx(base_url=base_url)
796 def test_retries_taken_new_response_class(
797 self, client: OpenAI, failures_before_success: int, respx_mock: MockRouter
798 ) -> None:
799 client = client.with_options(max_retries=4)
800
801 nb_retries = 0
802
803 def retry_handler(_request: httpx.Request) -> httpx.Response:
804 nonlocal nb_retries
805 if nb_retries < failures_before_success:
806 nb_retries += 1
807 return httpx.Response(500)
808 return httpx.Response(200)
809
810 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
811
812 with client.chat.completions.with_streaming_response.create(
813 messages=[
814 {
815 "content": "string",
816 "role": "system",
817 }
818 ],
819 model="gpt-4o",
820 ) as response:
821 assert response.retries_taken == failures_before_success
822 assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
823
824
825class TestAsyncOpenAI:
826 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
827
828 @pytest.mark.respx(base_url=base_url)
829 @pytest.mark.asyncio
830 async def test_raw_response(self, respx_mock: MockRouter) -> None:
831 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
832
833 response = await self.client.post("/foo", cast_to=httpx.Response)
834 assert response.status_code == 200
835 assert isinstance(response, httpx.Response)
836 assert response.json() == {"foo": "bar"}
837
838 @pytest.mark.respx(base_url=base_url)
839 @pytest.mark.asyncio
840 async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
841 respx_mock.post("/foo").mock(
842 return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
843 )
844
845 response = await self.client.post("/foo", cast_to=httpx.Response)
846 assert response.status_code == 200
847 assert isinstance(response, httpx.Response)
848 assert response.json() == {"foo": "bar"}
849
850 def test_copy(self) -> None:
851 copied = self.client.copy()
852 assert id(copied) != id(self.client)
853
854 copied = self.client.copy(api_key="another My API Key")
855 assert copied.api_key == "another My API Key"
856 assert self.client.api_key == "My API Key"
857
858 def test_copy_default_options(self) -> None:
859 # options that have a default are overridden correctly
860 copied = self.client.copy(max_retries=7)
861 assert copied.max_retries == 7
862 assert self.client.max_retries == 2
863
864 copied2 = copied.copy(max_retries=6)
865 assert copied2.max_retries == 6
866 assert copied.max_retries == 7
867
868 # timeout
869 assert isinstance(self.client.timeout, httpx.Timeout)
870 copied = self.client.copy(timeout=None)
871 assert copied.timeout is None
872 assert isinstance(self.client.timeout, httpx.Timeout)
873
874 def test_copy_default_headers(self) -> None:
875 client = AsyncOpenAI(
876 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
877 )
878 assert client.default_headers["X-Foo"] == "bar"
879
880 # does not override the already given value when not specified
881 copied = client.copy()
882 assert copied.default_headers["X-Foo"] == "bar"
883
884 # merges already given headers
885 copied = client.copy(default_headers={"X-Bar": "stainless"})
886 assert copied.default_headers["X-Foo"] == "bar"
887 assert copied.default_headers["X-Bar"] == "stainless"
888
889 # uses new values for any already given headers
890 copied = client.copy(default_headers={"X-Foo": "stainless"})
891 assert copied.default_headers["X-Foo"] == "stainless"
892
893 # set_default_headers
894
895 # completely overrides already set values
896 copied = client.copy(set_default_headers={})
897 assert copied.default_headers.get("X-Foo") is None
898
899 copied = client.copy(set_default_headers={"X-Bar": "Robert"})
900 assert copied.default_headers["X-Bar"] == "Robert"
901
902 with pytest.raises(
903 ValueError,
904 match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
905 ):
906 client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
907
908 def test_copy_default_query(self) -> None:
909 client = AsyncOpenAI(
910 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
911 )
912 assert _get_params(client)["foo"] == "bar"
913
914 # does not override the already given value when not specified
915 copied = client.copy()
916 assert _get_params(copied)["foo"] == "bar"
917
918 # merges already given params
919 copied = client.copy(default_query={"bar": "stainless"})
920 params = _get_params(copied)
921 assert params["foo"] == "bar"
922 assert params["bar"] == "stainless"
923
924 # uses new values for any already given headers
925 copied = client.copy(default_query={"foo": "stainless"})
926 assert _get_params(copied)["foo"] == "stainless"
927
928 # set_default_query
929
930 # completely overrides already set values
931 copied = client.copy(set_default_query={})
932 assert _get_params(copied) == {}
933
934 copied = client.copy(set_default_query={"bar": "Robert"})
935 assert _get_params(copied)["bar"] == "Robert"
936
937 with pytest.raises(
938 ValueError,
939 # TODO: update
940 match="`default_query` and `set_default_query` arguments are mutually exclusive",
941 ):
942 client.copy(set_default_query={}, default_query={"foo": "Bar"})
943
944 def test_copy_signature(self) -> None:
945 # ensure the same parameters that can be passed to the client are defined in the `.copy()` method
946 init_signature = inspect.signature(
947 # mypy doesn't like that we access the `__init__` property.
948 self.client.__init__, # type: ignore[misc]
949 )
950 copy_signature = inspect.signature(self.client.copy)
951 exclude_params = {"transport", "proxies", "_strict_response_validation"}
952
953 for name in init_signature.parameters.keys():
954 if name in exclude_params:
955 continue
956
957 copy_param = copy_signature.parameters.get(name)
958 assert copy_param is not None, f"copy() signature is missing the {name} param"
959
960 def test_copy_build_request(self) -> None:
961 options = FinalRequestOptions(method="get", url="/foo")
962
963 def build_request(options: FinalRequestOptions) -> None:
964 client = self.client.copy()
965 client._build_request(options)
966
967 # ensure that the machinery is warmed up before tracing starts.
968 build_request(options)
969 gc.collect()
970
971 tracemalloc.start(1000)
972
973 snapshot_before = tracemalloc.take_snapshot()
974
975 ITERATIONS = 10
976 for _ in range(ITERATIONS):
977 build_request(options)
978
979 gc.collect()
980 snapshot_after = tracemalloc.take_snapshot()
981
982 tracemalloc.stop()
983
984 def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None:
985 if diff.count == 0:
986 # Avoid false positives by considering only leaks (i.e. allocations that persist).
987 return
988
989 if diff.count % ITERATIONS != 0:
990 # Avoid false positives by considering only leaks that appear per iteration.
991 return
992
993 for frame in diff.traceback:
994 if any(
995 frame.filename.endswith(fragment)
996 for fragment in [
997 # to_raw_response_wrapper leaks through the @functools.wraps() decorator.
998 #
999 # removing the decorator fixes the leak for reasons we don't understand.
1000 "openai/_legacy_response.py",
1001 "openai/_response.py",
1002 # pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason.
1003 "openai/_compat.py",
1004 # Standard library leaks we don't care about.
1005 "/logging/__init__.py",
1006 ]
1007 ):
1008 return
1009
1010 leaks.append(diff)
1011
1012 leaks: list[tracemalloc.StatisticDiff] = []
1013 for diff in snapshot_after.compare_to(snapshot_before, "traceback"):
1014 add_leak(leaks, diff)
1015 if leaks:
1016 for leak in leaks:
1017 print("MEMORY LEAK:", leak)
1018 for frame in leak.traceback:
1019 print(frame)
1020 raise AssertionError()
1021
1022 async def test_request_timeout(self) -> None:
1023 request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
1024 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1025 assert timeout == DEFAULT_TIMEOUT
1026
1027 request = self.client._build_request(
1028 FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
1029 )
1030 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1031 assert timeout == httpx.Timeout(100.0)
1032
1033 async def test_client_timeout_option(self) -> None:
1034 client = AsyncOpenAI(
1035 base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0)
1036 )
1037
1038 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1039 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1040 assert timeout == httpx.Timeout(0)
1041
1042 async def test_http_client_timeout_option(self) -> None:
1043 # custom timeout given to the httpx client should be used
1044 async with httpx.AsyncClient(timeout=None) as http_client:
1045 client = AsyncOpenAI(
1046 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1047 )
1048
1049 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1050 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1051 assert timeout == httpx.Timeout(None)
1052
1053 # no timeout given to the httpx client should not use the httpx default
1054 async with httpx.AsyncClient() as http_client:
1055 client = AsyncOpenAI(
1056 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1057 )
1058
1059 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1060 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1061 assert timeout == DEFAULT_TIMEOUT
1062
1063 # explicitly passing the default timeout currently results in it being ignored
1064 async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
1065 client = AsyncOpenAI(
1066 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1067 )
1068
1069 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1070 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1071 assert timeout == DEFAULT_TIMEOUT # our default
1072
1073 def test_invalid_http_client(self) -> None:
1074 with pytest.raises(TypeError, match="Invalid `http_client` arg"):
1075 with httpx.Client() as http_client:
1076 AsyncOpenAI(
1077 base_url=base_url,
1078 api_key=api_key,
1079 _strict_response_validation=True,
1080 http_client=cast(Any, http_client),
1081 )
1082
1083 def test_default_headers_option(self) -> None:
1084 client = AsyncOpenAI(
1085 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
1086 )
1087 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1088 assert request.headers.get("x-foo") == "bar"
1089 assert request.headers.get("x-stainless-lang") == "python"
1090
1091 client2 = AsyncOpenAI(
1092 base_url=base_url,
1093 api_key=api_key,
1094 _strict_response_validation=True,
1095 default_headers={
1096 "X-Foo": "stainless",
1097 "X-Stainless-Lang": "my-overriding-header",
1098 },
1099 )
1100 request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
1101 assert request.headers.get("x-foo") == "stainless"
1102 assert request.headers.get("x-stainless-lang") == "my-overriding-header"
1103
1104 def test_validate_headers(self) -> None:
1105 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1106 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1107 assert request.headers.get("Authorization") == f"Bearer {api_key}"
1108
1109 with pytest.raises(OpenAIError):
1110 with update_env(**{"OPENAI_API_KEY": Omit()}):
1111 client2 = AsyncOpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
1112 _ = client2
1113
1114 def test_default_query_option(self) -> None:
1115 client = AsyncOpenAI(
1116 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
1117 )
1118 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1119 url = httpx.URL(request.url)
1120 assert dict(url.params) == {"query_param": "bar"}
1121
1122 request = client._build_request(
1123 FinalRequestOptions(
1124 method="get",
1125 url="/foo",
1126 params={"foo": "baz", "query_param": "overriden"},
1127 )
1128 )
1129 url = httpx.URL(request.url)
1130 assert dict(url.params) == {"foo": "baz", "query_param": "overriden"}
1131
1132 def test_request_extra_json(self) -> None:
1133 request = self.client._build_request(
1134 FinalRequestOptions(
1135 method="post",
1136 url="/foo",
1137 json_data={"foo": "bar"},
1138 extra_json={"baz": False},
1139 ),
1140 )
1141 data = json.loads(request.content.decode("utf-8"))
1142 assert data == {"foo": "bar", "baz": False}
1143
1144 request = self.client._build_request(
1145 FinalRequestOptions(
1146 method="post",
1147 url="/foo",
1148 extra_json={"baz": False},
1149 ),
1150 )
1151 data = json.loads(request.content.decode("utf-8"))
1152 assert data == {"baz": False}
1153
1154 # `extra_json` takes priority over `json_data` when keys clash
1155 request = self.client._build_request(
1156 FinalRequestOptions(
1157 method="post",
1158 url="/foo",
1159 json_data={"foo": "bar", "baz": True},
1160 extra_json={"baz": None},
1161 ),
1162 )
1163 data = json.loads(request.content.decode("utf-8"))
1164 assert data == {"foo": "bar", "baz": None}
1165
1166 def test_request_extra_headers(self) -> None:
1167 request = self.client._build_request(
1168 FinalRequestOptions(
1169 method="post",
1170 url="/foo",
1171 **make_request_options(extra_headers={"X-Foo": "Foo"}),
1172 ),
1173 )
1174 assert request.headers.get("X-Foo") == "Foo"
1175
1176 # `extra_headers` takes priority over `default_headers` when keys clash
1177 request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
1178 FinalRequestOptions(
1179 method="post",
1180 url="/foo",
1181 **make_request_options(
1182 extra_headers={"X-Bar": "false"},
1183 ),
1184 ),
1185 )
1186 assert request.headers.get("X-Bar") == "false"
1187
1188 def test_request_extra_query(self) -> None:
1189 request = self.client._build_request(
1190 FinalRequestOptions(
1191 method="post",
1192 url="/foo",
1193 **make_request_options(
1194 extra_query={"my_query_param": "Foo"},
1195 ),
1196 ),
1197 )
1198 params = dict(request.url.params)
1199 assert params == {"my_query_param": "Foo"}
1200
1201 # if both `query` and `extra_query` are given, they are merged
1202 request = self.client._build_request(
1203 FinalRequestOptions(
1204 method="post",
1205 url="/foo",
1206 **make_request_options(
1207 query={"bar": "1"},
1208 extra_query={"foo": "2"},
1209 ),
1210 ),
1211 )
1212 params = dict(request.url.params)
1213 assert params == {"bar": "1", "foo": "2"}
1214
1215 # `extra_query` takes priority over `query` when keys clash
1216 request = self.client._build_request(
1217 FinalRequestOptions(
1218 method="post",
1219 url="/foo",
1220 **make_request_options(
1221 query={"foo": "1"},
1222 extra_query={"foo": "2"},
1223 ),
1224 ),
1225 )
1226 params = dict(request.url.params)
1227 assert params == {"foo": "2"}
1228
1229 def test_multipart_repeating_array(self, async_client: AsyncOpenAI) -> None:
1230 request = async_client._build_request(
1231 FinalRequestOptions.construct(
1232 method="get",
1233 url="/foo",
1234 headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
1235 json_data={"array": ["foo", "bar"]},
1236 files=[("foo.txt", b"hello world")],
1237 )
1238 )
1239
1240 assert request.read().split(b"\r\n") == [
1241 b"--6b7ba517decee4a450543ea6ae821c82",
1242 b'Content-Disposition: form-data; name="array[]"',
1243 b"",
1244 b"foo",
1245 b"--6b7ba517decee4a450543ea6ae821c82",
1246 b'Content-Disposition: form-data; name="array[]"',
1247 b"",
1248 b"bar",
1249 b"--6b7ba517decee4a450543ea6ae821c82",
1250 b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
1251 b"Content-Type: application/octet-stream",
1252 b"",
1253 b"hello world",
1254 b"--6b7ba517decee4a450543ea6ae821c82--",
1255 b"",
1256 ]
1257
1258 @pytest.mark.respx(base_url=base_url)
1259 async def test_basic_union_response(self, respx_mock: MockRouter) -> None:
1260 class Model1(BaseModel):
1261 name: str
1262
1263 class Model2(BaseModel):
1264 foo: str
1265
1266 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1267
1268 response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1269 assert isinstance(response, Model2)
1270 assert response.foo == "bar"
1271
1272 @pytest.mark.respx(base_url=base_url)
1273 async def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
1274 """Union of objects with the same field name using a different type"""
1275
1276 class Model1(BaseModel):
1277 foo: int
1278
1279 class Model2(BaseModel):
1280 foo: str
1281
1282 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1283
1284 response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1285 assert isinstance(response, Model2)
1286 assert response.foo == "bar"
1287
1288 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
1289
1290 response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1291 assert isinstance(response, Model1)
1292 assert response.foo == 1
1293
1294 @pytest.mark.respx(base_url=base_url)
1295 async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
1296 """
1297 Response that sets Content-Type to something other than application/json but returns json data
1298 """
1299
1300 class Model(BaseModel):
1301 foo: int
1302
1303 respx_mock.get("/foo").mock(
1304 return_value=httpx.Response(
1305 200,
1306 content=json.dumps({"foo": 2}),
1307 headers={"Content-Type": "application/text"},
1308 )
1309 )
1310
1311 response = await self.client.get("/foo", cast_to=Model)
1312 assert isinstance(response, Model)
1313 assert response.foo == 2
1314
1315 def test_base_url_setter(self) -> None:
1316 client = AsyncOpenAI(
1317 base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True
1318 )
1319 assert client.base_url == "https://example.com/from_init/"
1320
1321 client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
1322
1323 assert client.base_url == "https://example.com/from_setter/"
1324
1325 def test_base_url_env(self) -> None:
1326 with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
1327 client = AsyncOpenAI(api_key=api_key, _strict_response_validation=True)
1328 assert client.base_url == "http://localhost:5000/from/env/"
1329
1330 @pytest.mark.parametrize(
1331 "client",
1332 [
1333 AsyncOpenAI(
1334 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1335 ),
1336 AsyncOpenAI(
1337 base_url="http://localhost:5000/custom/path/",
1338 api_key=api_key,
1339 _strict_response_validation=True,
1340 http_client=httpx.AsyncClient(),
1341 ),
1342 ],
1343 ids=["standard", "custom http client"],
1344 )
1345 def test_base_url_trailing_slash(self, client: AsyncOpenAI) -> None:
1346 request = client._build_request(
1347 FinalRequestOptions(
1348 method="post",
1349 url="/foo",
1350 json_data={"foo": "bar"},
1351 ),
1352 )
1353 assert request.url == "http://localhost:5000/custom/path/foo"
1354
1355 @pytest.mark.parametrize(
1356 "client",
1357 [
1358 AsyncOpenAI(
1359 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1360 ),
1361 AsyncOpenAI(
1362 base_url="http://localhost:5000/custom/path/",
1363 api_key=api_key,
1364 _strict_response_validation=True,
1365 http_client=httpx.AsyncClient(),
1366 ),
1367 ],
1368 ids=["standard", "custom http client"],
1369 )
1370 def test_base_url_no_trailing_slash(self, client: AsyncOpenAI) -> None:
1371 request = client._build_request(
1372 FinalRequestOptions(
1373 method="post",
1374 url="/foo",
1375 json_data={"foo": "bar"},
1376 ),
1377 )
1378 assert request.url == "http://localhost:5000/custom/path/foo"
1379
1380 @pytest.mark.parametrize(
1381 "client",
1382 [
1383 AsyncOpenAI(
1384 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1385 ),
1386 AsyncOpenAI(
1387 base_url="http://localhost:5000/custom/path/",
1388 api_key=api_key,
1389 _strict_response_validation=True,
1390 http_client=httpx.AsyncClient(),
1391 ),
1392 ],
1393 ids=["standard", "custom http client"],
1394 )
1395 def test_absolute_request_url(self, client: AsyncOpenAI) -> None:
1396 request = client._build_request(
1397 FinalRequestOptions(
1398 method="post",
1399 url="https://myapi.com/foo",
1400 json_data={"foo": "bar"},
1401 ),
1402 )
1403 assert request.url == "https://myapi.com/foo"
1404
1405 async def test_copied_client_does_not_close_http(self) -> None:
1406 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1407 assert not client.is_closed()
1408
1409 copied = client.copy()
1410 assert copied is not client
1411
1412 del copied
1413
1414 await asyncio.sleep(0.2)
1415 assert not client.is_closed()
1416
1417 async def test_client_context_manager(self) -> None:
1418 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1419 async with client as c2:
1420 assert c2 is client
1421 assert not c2.is_closed()
1422 assert not client.is_closed()
1423 assert client.is_closed()
1424
1425 @pytest.mark.respx(base_url=base_url)
1426 @pytest.mark.asyncio
1427 async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
1428 class Model(BaseModel):
1429 foo: str
1430
1431 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
1432
1433 with pytest.raises(APIResponseValidationError) as exc:
1434 await self.client.get("/foo", cast_to=Model)
1435
1436 assert isinstance(exc.value.__cause__, ValidationError)
1437
1438 async def test_client_max_retries_validation(self) -> None:
1439 with pytest.raises(TypeError, match=r"max_retries cannot be None"):
1440 AsyncOpenAI(
1441 base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None)
1442 )
1443
1444 @pytest.mark.respx(base_url=base_url)
1445 @pytest.mark.asyncio
1446 async def test_default_stream_cls(self, respx_mock: MockRouter) -> None:
1447 class Model(BaseModel):
1448 name: str
1449
1450 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1451
1452 stream = await self.client.post("/foo", cast_to=Model, stream=True, stream_cls=AsyncStream[Model])
1453 assert isinstance(stream, AsyncStream)
1454 await stream.response.aclose()
1455
1456 @pytest.mark.respx(base_url=base_url)
1457 @pytest.mark.asyncio
1458 async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
1459 class Model(BaseModel):
1460 name: str
1461
1462 respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
1463
1464 strict_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1465
1466 with pytest.raises(APIResponseValidationError):
1467 await strict_client.get("/foo", cast_to=Model)
1468
1469 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
1470
1471 response = await client.get("/foo", cast_to=Model)
1472 assert isinstance(response, str) # type: ignore[unreachable]
1473
1474 @pytest.mark.parametrize(
1475 "remaining_retries,retry_after,timeout",
1476 [
1477 [3, "20", 20],
1478 [3, "0", 0.5],
1479 [3, "-10", 0.5],
1480 [3, "60", 60],
1481 [3, "61", 0.5],
1482 [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
1483 [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
1484 [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
1485 [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
1486 [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
1487 [3, "99999999999999999999999999999999999", 0.5],
1488 [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
1489 [3, "", 0.5],
1490 [2, "", 0.5 * 2.0],
1491 [1, "", 0.5 * 4.0],
1492 ],
1493 )
1494 @mock.patch("time.time", mock.MagicMock(return_value=1696004797))
1495 @pytest.mark.asyncio
1496 async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
1497 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1498
1499 headers = httpx.Headers({"retry-after": retry_after})
1500 options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
1501 calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
1502 assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
1503
1504 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1505 @pytest.mark.respx(base_url=base_url)
1506 async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
1507 respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
1508
1509 with pytest.raises(APITimeoutError):
1510 await self.client.post(
1511 "/chat/completions",
1512 body=cast(
1513 object,
1514 dict(
1515 messages=[
1516 {
1517 "role": "user",
1518 "content": "Say this is a test",
1519 }
1520 ],
1521 model="gpt-3.5-turbo",
1522 ),
1523 ),
1524 cast_to=httpx.Response,
1525 options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
1526 )
1527
1528 assert _get_open_connections(self.client) == 0
1529
1530 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1531 @pytest.mark.respx(base_url=base_url)
1532 async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
1533 respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
1534
1535 with pytest.raises(APIStatusError):
1536 await self.client.post(
1537 "/chat/completions",
1538 body=cast(
1539 object,
1540 dict(
1541 messages=[
1542 {
1543 "role": "user",
1544 "content": "Say this is a test",
1545 }
1546 ],
1547 model="gpt-3.5-turbo",
1548 ),
1549 ),
1550 cast_to=httpx.Response,
1551 options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
1552 )
1553
1554 assert _get_open_connections(self.client) == 0
1555
1556 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1557 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1558 @pytest.mark.respx(base_url=base_url)
1559 @pytest.mark.asyncio
1560 async def test_retries_taken(
1561 self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1562 ) -> None:
1563 client = async_client.with_options(max_retries=4)
1564
1565 nb_retries = 0
1566
1567 def retry_handler(_request: httpx.Request) -> httpx.Response:
1568 nonlocal nb_retries
1569 if nb_retries < failures_before_success:
1570 nb_retries += 1
1571 return httpx.Response(500)
1572 return httpx.Response(200)
1573
1574 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1575
1576 response = await client.chat.completions.with_raw_response.create(
1577 messages=[
1578 {
1579 "content": "string",
1580 "role": "system",
1581 }
1582 ],
1583 model="gpt-4o",
1584 )
1585
1586 assert response.retries_taken == failures_before_success
1587 assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
1588
1589 @pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1590 @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1591 @pytest.mark.respx(base_url=base_url)
1592 @pytest.mark.asyncio
1593 async def test_retries_taken_new_response_class(
1594 self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1595 ) -> None:
1596 client = async_client.with_options(max_retries=4)
1597
1598 nb_retries = 0
1599
1600 def retry_handler(_request: httpx.Request) -> httpx.Response:
1601 nonlocal nb_retries
1602 if nb_retries < failures_before_success:
1603 nb_retries += 1
1604 return httpx.Response(500)
1605 return httpx.Response(200)
1606
1607 respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1608
1609 async with client.chat.completions.with_streaming_response.create(
1610 messages=[
1611 {
1612 "content": "string",
1613 "role": "system",
1614 }
1615 ],
1616 model="gpt-4o",
1617 ) as response:
1618 assert response.retries_taken == failures_before_success
1619 assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
1620