openai/openai-python

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v1.13.1

Branches

Tags

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

Clone

HTTPS

Download ZIP

tests/test_client.py

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