openai/openai-python

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v1.3.8

Branches

Tags

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

Clone

HTTPS

Download ZIP

tests/test_client.py

1527lines · modeblame

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