openai/openai-python

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
2d85e1e78fc866310767298354e91e2437151eb9

Branches

Tags

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

Clone

HTTPS

Download ZIP

tests/test_client.py

1164lines · modecode

1# File generated from our OpenAPI spec by Stainless.
2
3from __future__ import annotations
4
5import os
6import json
7import asyncio
8import inspect
9from typing import Any, Dict, Union, cast
10from unittest import mock
11
12import httpx
13import pytest
14from respx import MockRouter
15from pydantic import ValidationError
16
17from openai import OpenAI, AsyncOpenAI, APIResponseValidationError
18from openai._client import OpenAI, AsyncOpenAI
19from openai._models import BaseModel, FinalRequestOptions
20from openai._streaming import Stream, AsyncStream
21from openai._exceptions import APIResponseValidationError
22from openai._base_client import (
23 DEFAULT_TIMEOUT,
24 HTTPX_DEFAULT_TIMEOUT,
25 BaseClient,
26 make_request_options,
27)
28
29from .utils import update_env
30
31base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
32api_key = "My API Key"
33
34
35def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]:
36 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
37 url = httpx.URL(request.url)
38 return dict(url.params)
39
40
41class TestOpenAI:
42 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
43
44 @pytest.mark.respx(base_url=base_url)
45 def test_raw_response(self, respx_mock: MockRouter) -> None:
46 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
47
48 response = self.client.post("/foo", cast_to=httpx.Response)
49 assert response.status_code == 200
50 assert isinstance(response, httpx.Response)
51 assert response.json() == {"foo": "bar"}
52
53 @pytest.mark.respx(base_url=base_url)
54 def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
55 respx_mock.post("/foo").mock(
56 return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
57 )
58
59 response = self.client.post("/foo", cast_to=httpx.Response)
60 assert response.status_code == 200
61 assert isinstance(response, httpx.Response)
62 assert response.json() == {"foo": "bar"}
63
64 def test_copy(self) -> None:
65 copied = self.client.copy()
66 assert id(copied) != id(self.client)
67
68 copied = self.client.copy(api_key="another My API Key")
69 assert copied.api_key == "another My API Key"
70 assert self.client.api_key == "My API Key"
71
72 def test_copy_default_options(self) -> None:
73 # options that have a default are overridden correctly
74 copied = self.client.copy(max_retries=7)
75 assert copied.max_retries == 7
76 assert self.client.max_retries == 2
77
78 copied2 = copied.copy(max_retries=6)
79 assert copied2.max_retries == 6
80 assert copied.max_retries == 7
81
82 # timeout
83 assert isinstance(self.client.timeout, httpx.Timeout)
84 copied = self.client.copy(timeout=None)
85 assert copied.timeout is None
86 assert isinstance(self.client.timeout, httpx.Timeout)
87
88 def test_copy_default_headers(self) -> None:
89 client = OpenAI(
90 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
91 )
92 assert client.default_headers["X-Foo"] == "bar"
93
94 # does not override the already given value when not specified
95 copied = client.copy()
96 assert copied.default_headers["X-Foo"] == "bar"
97
98 # merges already given headers
99 copied = client.copy(default_headers={"X-Bar": "stainless"})
100 assert copied.default_headers["X-Foo"] == "bar"
101 assert copied.default_headers["X-Bar"] == "stainless"
102
103 # uses new values for any already given headers
104 copied = client.copy(default_headers={"X-Foo": "stainless"})
105 assert copied.default_headers["X-Foo"] == "stainless"
106
107 # set_default_headers
108
109 # completely overrides already set values
110 copied = client.copy(set_default_headers={})
111 assert copied.default_headers.get("X-Foo") is None
112
113 copied = client.copy(set_default_headers={"X-Bar": "Robert"})
114 assert copied.default_headers["X-Bar"] == "Robert"
115
116 with pytest.raises(
117 ValueError,
118 match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
119 ):
120 client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
121
122 def test_copy_default_query(self) -> None:
123 client = OpenAI(
124 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
125 )
126 assert _get_params(client)["foo"] == "bar"
127
128 # does not override the already given value when not specified
129 copied = client.copy()
130 assert _get_params(copied)["foo"] == "bar"
131
132 # merges already given params
133 copied = client.copy(default_query={"bar": "stainless"})
134 params = _get_params(copied)
135 assert params["foo"] == "bar"
136 assert params["bar"] == "stainless"
137
138 # uses new values for any already given headers
139 copied = client.copy(default_query={"foo": "stainless"})
140 assert _get_params(copied)["foo"] == "stainless"
141
142 # set_default_query
143
144 # completely overrides already set values
145 copied = client.copy(set_default_query={})
146 assert _get_params(copied) == {}
147
148 copied = client.copy(set_default_query={"bar": "Robert"})
149 assert _get_params(copied)["bar"] == "Robert"
150
151 with pytest.raises(
152 ValueError,
153 # TODO: update
154 match="`default_query` and `set_default_query` arguments are mutually exclusive",
155 ):
156 client.copy(set_default_query={}, default_query={"foo": "Bar"})
157
158 def test_copy_signature(self) -> None:
159 # ensure the same parameters that can be passed to the client are defined in the `.copy()` method
160 init_signature = inspect.signature(
161 # mypy doesn't like that we access the `__init__` property.
162 self.client.__init__, # type: ignore[misc]
163 )
164 copy_signature = inspect.signature(self.client.copy)
165 exclude_params = {"transport", "proxies", "_strict_response_validation"}
166
167 for name in init_signature.parameters.keys():
168 if name in exclude_params:
169 continue
170
171 copy_param = copy_signature.parameters.get(name)
172 assert copy_param is not None, f"copy() signature is missing the {name} param"
173
174 def test_request_timeout(self) -> None:
175 request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
176 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
177 assert timeout == DEFAULT_TIMEOUT
178
179 request = self.client._build_request(
180 FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
181 )
182 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
183 assert timeout == httpx.Timeout(100.0)
184
185 def test_client_timeout_option(self) -> None:
186 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0))
187
188 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
189 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
190 assert timeout == httpx.Timeout(0)
191
192 def test_http_client_timeout_option(self) -> None:
193 # custom timeout given to the httpx client should be used
194 with httpx.Client(timeout=None) as http_client:
195 client = OpenAI(
196 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
197 )
198
199 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
200 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
201 assert timeout == httpx.Timeout(None)
202
203 # no timeout given to the httpx client should not use the httpx default
204 with httpx.Client() as http_client:
205 client = OpenAI(
206 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
207 )
208
209 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
210 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
211 assert timeout == DEFAULT_TIMEOUT
212
213 # explicitly passing the default timeout currently results in it being ignored
214 with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
215 client = OpenAI(
216 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
217 )
218
219 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
220 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
221 assert timeout == DEFAULT_TIMEOUT # our default
222
223 def test_default_headers_option(self) -> None:
224 client = OpenAI(
225 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
226 )
227 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
228 assert request.headers.get("x-foo") == "bar"
229 assert request.headers.get("x-stainless-lang") == "python"
230
231 client2 = OpenAI(
232 base_url=base_url,
233 api_key=api_key,
234 _strict_response_validation=True,
235 default_headers={
236 "X-Foo": "stainless",
237 "X-Stainless-Lang": "my-overriding-header",
238 },
239 )
240 request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
241 assert request.headers.get("x-foo") == "stainless"
242 assert request.headers.get("x-stainless-lang") == "my-overriding-header"
243
244 def test_validate_headers(self) -> None:
245 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
246 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
247 assert request.headers.get("Authorization") == f"Bearer {api_key}"
248
249 with pytest.raises(Exception):
250 client2 = OpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
251 _ = client2
252
253 def test_default_query_option(self) -> None:
254 client = OpenAI(
255 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
256 )
257 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
258 url = httpx.URL(request.url)
259 assert dict(url.params) == {"query_param": "bar"}
260
261 request = client._build_request(
262 FinalRequestOptions(
263 method="get",
264 url="/foo",
265 params={"foo": "baz", "query_param": "overriden"},
266 )
267 )
268 url = httpx.URL(request.url)
269 assert dict(url.params) == {"foo": "baz", "query_param": "overriden"}
270
271 def test_request_extra_json(self) -> None:
272 request = self.client._build_request(
273 FinalRequestOptions(
274 method="post",
275 url="/foo",
276 json_data={"foo": "bar"},
277 extra_json={"baz": False},
278 ),
279 )
280 data = json.loads(request.content.decode("utf-8"))
281 assert data == {"foo": "bar", "baz": False}
282
283 request = self.client._build_request(
284 FinalRequestOptions(
285 method="post",
286 url="/foo",
287 extra_json={"baz": False},
288 ),
289 )
290 data = json.loads(request.content.decode("utf-8"))
291 assert data == {"baz": False}
292
293 # `extra_json` takes priority over `json_data` when keys clash
294 request = self.client._build_request(
295 FinalRequestOptions(
296 method="post",
297 url="/foo",
298 json_data={"foo": "bar", "baz": True},
299 extra_json={"baz": None},
300 ),
301 )
302 data = json.loads(request.content.decode("utf-8"))
303 assert data == {"foo": "bar", "baz": None}
304
305 def test_request_extra_headers(self) -> None:
306 request = self.client._build_request(
307 FinalRequestOptions(
308 method="post",
309 url="/foo",
310 **make_request_options(extra_headers={"X-Foo": "Foo"}),
311 ),
312 )
313 assert request.headers.get("X-Foo") == "Foo"
314
315 # `extra_headers` takes priority over `default_headers` when keys clash
316 request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
317 FinalRequestOptions(
318 method="post",
319 url="/foo",
320 **make_request_options(
321 extra_headers={"X-Bar": "false"},
322 ),
323 ),
324 )
325 assert request.headers.get("X-Bar") == "false"
326
327 def test_request_extra_query(self) -> None:
328 request = self.client._build_request(
329 FinalRequestOptions(
330 method="post",
331 url="/foo",
332 **make_request_options(
333 extra_query={"my_query_param": "Foo"},
334 ),
335 ),
336 )
337 params = cast(Dict[str, str], dict(request.url.params))
338 assert params == {"my_query_param": "Foo"}
339
340 # if both `query` and `extra_query` are given, they are merged
341 request = self.client._build_request(
342 FinalRequestOptions(
343 method="post",
344 url="/foo",
345 **make_request_options(
346 query={"bar": "1"},
347 extra_query={"foo": "2"},
348 ),
349 ),
350 )
351 params = cast(Dict[str, str], dict(request.url.params))
352 assert params == {"bar": "1", "foo": "2"}
353
354 # `extra_query` takes priority over `query` when keys clash
355 request = self.client._build_request(
356 FinalRequestOptions(
357 method="post",
358 url="/foo",
359 **make_request_options(
360 query={"foo": "1"},
361 extra_query={"foo": "2"},
362 ),
363 ),
364 )
365 params = cast(Dict[str, str], dict(request.url.params))
366 assert params == {"foo": "2"}
367
368 @pytest.mark.respx(base_url=base_url)
369 def test_basic_union_response(self, respx_mock: MockRouter) -> None:
370 class Model1(BaseModel):
371 name: str
372
373 class Model2(BaseModel):
374 foo: str
375
376 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
377
378 response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
379 assert isinstance(response, Model2)
380 assert response.foo == "bar"
381
382 @pytest.mark.respx(base_url=base_url)
383 def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
384 """Union of objects with the same field name using a different type"""
385
386 class Model1(BaseModel):
387 foo: int
388
389 class Model2(BaseModel):
390 foo: str
391
392 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
393
394 response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
395 assert isinstance(response, Model2)
396 assert response.foo == "bar"
397
398 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
399
400 response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
401 assert isinstance(response, Model1)
402 assert response.foo == 1
403
404 @pytest.mark.respx(base_url=base_url)
405 def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
406 """
407 Response that sets Content-Type to something other than application/json but returns json data
408 """
409
410 class Model(BaseModel):
411 foo: int
412
413 respx_mock.get("/foo").mock(
414 return_value=httpx.Response(
415 200,
416 content=json.dumps({"foo": 2}),
417 headers={"Content-Type": "application/text"},
418 )
419 )
420
421 response = self.client.get("/foo", cast_to=Model)
422 assert isinstance(response, Model)
423 assert response.foo == 2
424
425 def test_base_url_env(self) -> None:
426 with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
427 client = OpenAI(api_key=api_key, _strict_response_validation=True)
428 assert client.base_url == "http://localhost:5000/from/env/"
429
430 @pytest.mark.parametrize(
431 "client",
432 [
433 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
434 OpenAI(
435 base_url="http://localhost:5000/custom/path/",
436 api_key=api_key,
437 _strict_response_validation=True,
438 http_client=httpx.Client(),
439 ),
440 ],
441 ids=["standard", "custom http client"],
442 )
443 def test_base_url_trailing_slash(self, client: OpenAI) -> None:
444 request = client._build_request(
445 FinalRequestOptions(
446 method="post",
447 url="/foo",
448 json_data={"foo": "bar"},
449 ),
450 )
451 assert request.url == "http://localhost:5000/custom/path/foo"
452
453 @pytest.mark.parametrize(
454 "client",
455 [
456 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
457 OpenAI(
458 base_url="http://localhost:5000/custom/path/",
459 api_key=api_key,
460 _strict_response_validation=True,
461 http_client=httpx.Client(),
462 ),
463 ],
464 ids=["standard", "custom http client"],
465 )
466 def test_base_url_no_trailing_slash(self, client: OpenAI) -> None:
467 request = client._build_request(
468 FinalRequestOptions(
469 method="post",
470 url="/foo",
471 json_data={"foo": "bar"},
472 ),
473 )
474 assert request.url == "http://localhost:5000/custom/path/foo"
475
476 @pytest.mark.parametrize(
477 "client",
478 [
479 OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
480 OpenAI(
481 base_url="http://localhost:5000/custom/path/",
482 api_key=api_key,
483 _strict_response_validation=True,
484 http_client=httpx.Client(),
485 ),
486 ],
487 ids=["standard", "custom http client"],
488 )
489 def test_absolute_request_url(self, client: OpenAI) -> None:
490 request = client._build_request(
491 FinalRequestOptions(
492 method="post",
493 url="https://myapi.com/foo",
494 json_data={"foo": "bar"},
495 ),
496 )
497 assert request.url == "https://myapi.com/foo"
498
499 def test_client_del(self) -> None:
500 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
501 assert not client.is_closed()
502
503 client.__del__()
504
505 assert client.is_closed()
506
507 def test_copied_client_does_not_close_http(self) -> None:
508 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
509 assert not client.is_closed()
510
511 copied = client.copy()
512 assert copied is not client
513
514 copied.__del__()
515
516 assert not copied.is_closed()
517 assert not client.is_closed()
518
519 def test_client_context_manager(self) -> None:
520 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
521 with client as c2:
522 assert c2 is client
523 assert not c2.is_closed()
524 assert not client.is_closed()
525 assert client.is_closed()
526
527 @pytest.mark.respx(base_url=base_url)
528 def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
529 class Model(BaseModel):
530 foo: str
531
532 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
533
534 with pytest.raises(APIResponseValidationError) as exc:
535 self.client.get("/foo", cast_to=Model)
536
537 assert isinstance(exc.value.__cause__, ValidationError)
538
539 @pytest.mark.respx(base_url=base_url)
540 def test_default_stream_cls(self, respx_mock: MockRouter) -> None:
541 class Model(BaseModel):
542 name: str
543
544 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
545
546 response = self.client.post("/foo", cast_to=Model, stream=True)
547 assert isinstance(response, Stream)
548
549 @pytest.mark.respx(base_url=base_url)
550 def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
551 class Model(BaseModel):
552 name: str
553
554 respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
555
556 strict_client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
557
558 with pytest.raises(APIResponseValidationError):
559 strict_client.get("/foo", cast_to=Model)
560
561 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
562
563 response = client.get("/foo", cast_to=Model)
564 assert isinstance(response, str) # type: ignore[unreachable]
565
566 @pytest.mark.parametrize(
567 "remaining_retries,retry_after,timeout",
568 [
569 [3, "20", 20],
570 [3, "0", 0.5],
571 [3, "-10", 0.5],
572 [3, "60", 60],
573 [3, "61", 0.5],
574 [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
575 [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
576 [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
577 [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
578 [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
579 [3, "99999999999999999999999999999999999", 0.5],
580 [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
581 [3, "", 0.5],
582 [2, "", 0.5 * 2.0],
583 [1, "", 0.5 * 4.0],
584 ],
585 )
586 @mock.patch("time.time", mock.MagicMock(return_value=1696004797))
587 def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
588 client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
589
590 headers = httpx.Headers({"retry-after": retry_after})
591 options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
592 calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
593 assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
594
595
596class TestAsyncOpenAI:
597 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
598
599 @pytest.mark.respx(base_url=base_url)
600 @pytest.mark.asyncio
601 async def test_raw_response(self, respx_mock: MockRouter) -> None:
602 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
603
604 response = await self.client.post("/foo", cast_to=httpx.Response)
605 assert response.status_code == 200
606 assert isinstance(response, httpx.Response)
607 assert response.json() == {"foo": "bar"}
608
609 @pytest.mark.respx(base_url=base_url)
610 @pytest.mark.asyncio
611 async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
612 respx_mock.post("/foo").mock(
613 return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
614 )
615
616 response = await self.client.post("/foo", cast_to=httpx.Response)
617 assert response.status_code == 200
618 assert isinstance(response, httpx.Response)
619 assert response.json() == {"foo": "bar"}
620
621 def test_copy(self) -> None:
622 copied = self.client.copy()
623 assert id(copied) != id(self.client)
624
625 copied = self.client.copy(api_key="another My API Key")
626 assert copied.api_key == "another My API Key"
627 assert self.client.api_key == "My API Key"
628
629 def test_copy_default_options(self) -> None:
630 # options that have a default are overridden correctly
631 copied = self.client.copy(max_retries=7)
632 assert copied.max_retries == 7
633 assert self.client.max_retries == 2
634
635 copied2 = copied.copy(max_retries=6)
636 assert copied2.max_retries == 6
637 assert copied.max_retries == 7
638
639 # timeout
640 assert isinstance(self.client.timeout, httpx.Timeout)
641 copied = self.client.copy(timeout=None)
642 assert copied.timeout is None
643 assert isinstance(self.client.timeout, httpx.Timeout)
644
645 def test_copy_default_headers(self) -> None:
646 client = AsyncOpenAI(
647 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
648 )
649 assert client.default_headers["X-Foo"] == "bar"
650
651 # does not override the already given value when not specified
652 copied = client.copy()
653 assert copied.default_headers["X-Foo"] == "bar"
654
655 # merges already given headers
656 copied = client.copy(default_headers={"X-Bar": "stainless"})
657 assert copied.default_headers["X-Foo"] == "bar"
658 assert copied.default_headers["X-Bar"] == "stainless"
659
660 # uses new values for any already given headers
661 copied = client.copy(default_headers={"X-Foo": "stainless"})
662 assert copied.default_headers["X-Foo"] == "stainless"
663
664 # set_default_headers
665
666 # completely overrides already set values
667 copied = client.copy(set_default_headers={})
668 assert copied.default_headers.get("X-Foo") is None
669
670 copied = client.copy(set_default_headers={"X-Bar": "Robert"})
671 assert copied.default_headers["X-Bar"] == "Robert"
672
673 with pytest.raises(
674 ValueError,
675 match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
676 ):
677 client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
678
679 def test_copy_default_query(self) -> None:
680 client = AsyncOpenAI(
681 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
682 )
683 assert _get_params(client)["foo"] == "bar"
684
685 # does not override the already given value when not specified
686 copied = client.copy()
687 assert _get_params(copied)["foo"] == "bar"
688
689 # merges already given params
690 copied = client.copy(default_query={"bar": "stainless"})
691 params = _get_params(copied)
692 assert params["foo"] == "bar"
693 assert params["bar"] == "stainless"
694
695 # uses new values for any already given headers
696 copied = client.copy(default_query={"foo": "stainless"})
697 assert _get_params(copied)["foo"] == "stainless"
698
699 # set_default_query
700
701 # completely overrides already set values
702 copied = client.copy(set_default_query={})
703 assert _get_params(copied) == {}
704
705 copied = client.copy(set_default_query={"bar": "Robert"})
706 assert _get_params(copied)["bar"] == "Robert"
707
708 with pytest.raises(
709 ValueError,
710 # TODO: update
711 match="`default_query` and `set_default_query` arguments are mutually exclusive",
712 ):
713 client.copy(set_default_query={}, default_query={"foo": "Bar"})
714
715 def test_copy_signature(self) -> None:
716 # ensure the same parameters that can be passed to the client are defined in the `.copy()` method
717 init_signature = inspect.signature(
718 # mypy doesn't like that we access the `__init__` property.
719 self.client.__init__, # type: ignore[misc]
720 )
721 copy_signature = inspect.signature(self.client.copy)
722 exclude_params = {"transport", "proxies", "_strict_response_validation"}
723
724 for name in init_signature.parameters.keys():
725 if name in exclude_params:
726 continue
727
728 copy_param = copy_signature.parameters.get(name)
729 assert copy_param is not None, f"copy() signature is missing the {name} param"
730
731 async def test_request_timeout(self) -> None:
732 request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
733 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
734 assert timeout == DEFAULT_TIMEOUT
735
736 request = self.client._build_request(
737 FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
738 )
739 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
740 assert timeout == httpx.Timeout(100.0)
741
742 async def test_client_timeout_option(self) -> None:
743 client = AsyncOpenAI(
744 base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0)
745 )
746
747 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
748 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
749 assert timeout == httpx.Timeout(0)
750
751 async def test_http_client_timeout_option(self) -> None:
752 # custom timeout given to the httpx client should be used
753 async with httpx.AsyncClient(timeout=None) as http_client:
754 client = AsyncOpenAI(
755 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
756 )
757
758 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
759 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
760 assert timeout == httpx.Timeout(None)
761
762 # no timeout given to the httpx client should not use the httpx default
763 async with httpx.AsyncClient() as http_client:
764 client = AsyncOpenAI(
765 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
766 )
767
768 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
769 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
770 assert timeout == DEFAULT_TIMEOUT
771
772 # explicitly passing the default timeout currently results in it being ignored
773 async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
774 client = AsyncOpenAI(
775 base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
776 )
777
778 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
779 timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
780 assert timeout == DEFAULT_TIMEOUT # our default
781
782 def test_default_headers_option(self) -> None:
783 client = AsyncOpenAI(
784 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
785 )
786 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
787 assert request.headers.get("x-foo") == "bar"
788 assert request.headers.get("x-stainless-lang") == "python"
789
790 client2 = AsyncOpenAI(
791 base_url=base_url,
792 api_key=api_key,
793 _strict_response_validation=True,
794 default_headers={
795 "X-Foo": "stainless",
796 "X-Stainless-Lang": "my-overriding-header",
797 },
798 )
799 request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
800 assert request.headers.get("x-foo") == "stainless"
801 assert request.headers.get("x-stainless-lang") == "my-overriding-header"
802
803 def test_validate_headers(self) -> None:
804 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
805 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
806 assert request.headers.get("Authorization") == f"Bearer {api_key}"
807
808 with pytest.raises(Exception):
809 client2 = AsyncOpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
810 _ = client2
811
812 def test_default_query_option(self) -> None:
813 client = AsyncOpenAI(
814 base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
815 )
816 request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
817 url = httpx.URL(request.url)
818 assert dict(url.params) == {"query_param": "bar"}
819
820 request = client._build_request(
821 FinalRequestOptions(
822 method="get",
823 url="/foo",
824 params={"foo": "baz", "query_param": "overriden"},
825 )
826 )
827 url = httpx.URL(request.url)
828 assert dict(url.params) == {"foo": "baz", "query_param": "overriden"}
829
830 def test_request_extra_json(self) -> None:
831 request = self.client._build_request(
832 FinalRequestOptions(
833 method="post",
834 url="/foo",
835 json_data={"foo": "bar"},
836 extra_json={"baz": False},
837 ),
838 )
839 data = json.loads(request.content.decode("utf-8"))
840 assert data == {"foo": "bar", "baz": False}
841
842 request = self.client._build_request(
843 FinalRequestOptions(
844 method="post",
845 url="/foo",
846 extra_json={"baz": False},
847 ),
848 )
849 data = json.loads(request.content.decode("utf-8"))
850 assert data == {"baz": False}
851
852 # `extra_json` takes priority over `json_data` when keys clash
853 request = self.client._build_request(
854 FinalRequestOptions(
855 method="post",
856 url="/foo",
857 json_data={"foo": "bar", "baz": True},
858 extra_json={"baz": None},
859 ),
860 )
861 data = json.loads(request.content.decode("utf-8"))
862 assert data == {"foo": "bar", "baz": None}
863
864 def test_request_extra_headers(self) -> None:
865 request = self.client._build_request(
866 FinalRequestOptions(
867 method="post",
868 url="/foo",
869 **make_request_options(extra_headers={"X-Foo": "Foo"}),
870 ),
871 )
872 assert request.headers.get("X-Foo") == "Foo"
873
874 # `extra_headers` takes priority over `default_headers` when keys clash
875 request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
876 FinalRequestOptions(
877 method="post",
878 url="/foo",
879 **make_request_options(
880 extra_headers={"X-Bar": "false"},
881 ),
882 ),
883 )
884 assert request.headers.get("X-Bar") == "false"
885
886 def test_request_extra_query(self) -> None:
887 request = self.client._build_request(
888 FinalRequestOptions(
889 method="post",
890 url="/foo",
891 **make_request_options(
892 extra_query={"my_query_param": "Foo"},
893 ),
894 ),
895 )
896 params = cast(Dict[str, str], dict(request.url.params))
897 assert params == {"my_query_param": "Foo"}
898
899 # if both `query` and `extra_query` are given, they are merged
900 request = self.client._build_request(
901 FinalRequestOptions(
902 method="post",
903 url="/foo",
904 **make_request_options(
905 query={"bar": "1"},
906 extra_query={"foo": "2"},
907 ),
908 ),
909 )
910 params = cast(Dict[str, str], dict(request.url.params))
911 assert params == {"bar": "1", "foo": "2"}
912
913 # `extra_query` takes priority over `query` when keys clash
914 request = self.client._build_request(
915 FinalRequestOptions(
916 method="post",
917 url="/foo",
918 **make_request_options(
919 query={"foo": "1"},
920 extra_query={"foo": "2"},
921 ),
922 ),
923 )
924 params = cast(Dict[str, str], dict(request.url.params))
925 assert params == {"foo": "2"}
926
927 @pytest.mark.respx(base_url=base_url)
928 async def test_basic_union_response(self, respx_mock: MockRouter) -> None:
929 class Model1(BaseModel):
930 name: str
931
932 class Model2(BaseModel):
933 foo: str
934
935 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
936
937 response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
938 assert isinstance(response, Model2)
939 assert response.foo == "bar"
940
941 @pytest.mark.respx(base_url=base_url)
942 async def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
943 """Union of objects with the same field name using a different type"""
944
945 class Model1(BaseModel):
946 foo: int
947
948 class Model2(BaseModel):
949 foo: str
950
951 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
952
953 response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
954 assert isinstance(response, Model2)
955 assert response.foo == "bar"
956
957 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
958
959 response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
960 assert isinstance(response, Model1)
961 assert response.foo == 1
962
963 @pytest.mark.respx(base_url=base_url)
964 async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
965 """
966 Response that sets Content-Type to something other than application/json but returns json data
967 """
968
969 class Model(BaseModel):
970 foo: int
971
972 respx_mock.get("/foo").mock(
973 return_value=httpx.Response(
974 200,
975 content=json.dumps({"foo": 2}),
976 headers={"Content-Type": "application/text"},
977 )
978 )
979
980 response = await self.client.get("/foo", cast_to=Model)
981 assert isinstance(response, Model)
982 assert response.foo == 2
983
984 def test_base_url_env(self) -> None:
985 with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
986 client = AsyncOpenAI(api_key=api_key, _strict_response_validation=True)
987 assert client.base_url == "http://localhost:5000/from/env/"
988
989 @pytest.mark.parametrize(
990 "client",
991 [
992 AsyncOpenAI(
993 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
994 ),
995 AsyncOpenAI(
996 base_url="http://localhost:5000/custom/path/",
997 api_key=api_key,
998 _strict_response_validation=True,
999 http_client=httpx.AsyncClient(),
1000 ),
1001 ],
1002 ids=["standard", "custom http client"],
1003 )
1004 def test_base_url_trailing_slash(self, client: AsyncOpenAI) -> None:
1005 request = client._build_request(
1006 FinalRequestOptions(
1007 method="post",
1008 url="/foo",
1009 json_data={"foo": "bar"},
1010 ),
1011 )
1012 assert request.url == "http://localhost:5000/custom/path/foo"
1013
1014 @pytest.mark.parametrize(
1015 "client",
1016 [
1017 AsyncOpenAI(
1018 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1019 ),
1020 AsyncOpenAI(
1021 base_url="http://localhost:5000/custom/path/",
1022 api_key=api_key,
1023 _strict_response_validation=True,
1024 http_client=httpx.AsyncClient(),
1025 ),
1026 ],
1027 ids=["standard", "custom http client"],
1028 )
1029 def test_base_url_no_trailing_slash(self, client: AsyncOpenAI) -> None:
1030 request = client._build_request(
1031 FinalRequestOptions(
1032 method="post",
1033 url="/foo",
1034 json_data={"foo": "bar"},
1035 ),
1036 )
1037 assert request.url == "http://localhost:5000/custom/path/foo"
1038
1039 @pytest.mark.parametrize(
1040 "client",
1041 [
1042 AsyncOpenAI(
1043 base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1044 ),
1045 AsyncOpenAI(
1046 base_url="http://localhost:5000/custom/path/",
1047 api_key=api_key,
1048 _strict_response_validation=True,
1049 http_client=httpx.AsyncClient(),
1050 ),
1051 ],
1052 ids=["standard", "custom http client"],
1053 )
1054 def test_absolute_request_url(self, client: AsyncOpenAI) -> None:
1055 request = client._build_request(
1056 FinalRequestOptions(
1057 method="post",
1058 url="https://myapi.com/foo",
1059 json_data={"foo": "bar"},
1060 ),
1061 )
1062 assert request.url == "https://myapi.com/foo"
1063
1064 async def test_client_del(self) -> None:
1065 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1066 assert not client.is_closed()
1067
1068 client.__del__()
1069
1070 await asyncio.sleep(0.2)
1071 assert client.is_closed()
1072
1073 async def test_copied_client_does_not_close_http(self) -> None:
1074 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1075 assert not client.is_closed()
1076
1077 copied = client.copy()
1078 assert copied is not client
1079
1080 copied.__del__()
1081
1082 await asyncio.sleep(0.2)
1083 assert not copied.is_closed()
1084 assert not client.is_closed()
1085
1086 async def test_client_context_manager(self) -> None:
1087 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1088 async with client as c2:
1089 assert c2 is client
1090 assert not c2.is_closed()
1091 assert not client.is_closed()
1092 assert client.is_closed()
1093
1094 @pytest.mark.respx(base_url=base_url)
1095 @pytest.mark.asyncio
1096 async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
1097 class Model(BaseModel):
1098 foo: str
1099
1100 respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
1101
1102 with pytest.raises(APIResponseValidationError) as exc:
1103 await self.client.get("/foo", cast_to=Model)
1104
1105 assert isinstance(exc.value.__cause__, ValidationError)
1106
1107 @pytest.mark.respx(base_url=base_url)
1108 @pytest.mark.asyncio
1109 async def test_default_stream_cls(self, respx_mock: MockRouter) -> None:
1110 class Model(BaseModel):
1111 name: str
1112
1113 respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1114
1115 response = await self.client.post("/foo", cast_to=Model, stream=True)
1116 assert isinstance(response, AsyncStream)
1117
1118 @pytest.mark.respx(base_url=base_url)
1119 @pytest.mark.asyncio
1120 async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
1121 class Model(BaseModel):
1122 name: str
1123
1124 respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
1125
1126 strict_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1127
1128 with pytest.raises(APIResponseValidationError):
1129 await strict_client.get("/foo", cast_to=Model)
1130
1131 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
1132
1133 response = await client.get("/foo", cast_to=Model)
1134 assert isinstance(response, str) # type: ignore[unreachable]
1135
1136 @pytest.mark.parametrize(
1137 "remaining_retries,retry_after,timeout",
1138 [
1139 [3, "20", 20],
1140 [3, "0", 0.5],
1141 [3, "-10", 0.5],
1142 [3, "60", 60],
1143 [3, "61", 0.5],
1144 [3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
1145 [3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
1146 [3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
1147 [3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
1148 [3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
1149 [3, "99999999999999999999999999999999999", 0.5],
1150 [3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
1151 [3, "", 0.5],
1152 [2, "", 0.5 * 2.0],
1153 [1, "", 0.5 * 4.0],
1154 ],
1155 )
1156 @mock.patch("time.time", mock.MagicMock(return_value=1696004797))
1157 @pytest.mark.asyncio
1158 async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
1159 client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1160
1161 headers = httpx.Headers({"retry-after": retry_after})
1162 options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
1163 calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
1164 assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
1165