openai/openai-python

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v1.14.3

Branches

Tags

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

Clone

HTTPS

Download ZIP

tests/test_client.py

1481lines · modeblame

5cfb125aStainless Bot2 years ago1# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
08b8179aDavid Schnurr2 years ago2
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
dae0ec80Stainless Bot2 years ago295async def test_invalid_http_client(self) -> None:
296with pytest.raises(TypeError, match="Invalid `http_client` arg"):
297async with httpx.AsyncClient() as http_client:
298OpenAI(
299base_url=base_url,
300api_key=api_key,
301_strict_response_validation=True,
302http_client=cast(Any, http_client),
303)
304
08b8179aDavid Schnurr2 years ago305def test_default_headers_option(self) -> None:
306client = OpenAI(
307base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
308)
309request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
310assert request.headers.get("x-foo") == "bar"
311assert request.headers.get("x-stainless-lang") == "python"
312
313client2 = OpenAI(
314base_url=base_url,
315api_key=api_key,
316_strict_response_validation=True,
317default_headers={
318"X-Foo": "stainless",
319"X-Stainless-Lang": "my-overriding-header",
320},
321)
322request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
323assert request.headers.get("x-foo") == "stainless"
324assert request.headers.get("x-stainless-lang") == "my-overriding-header"
325
326def test_validate_headers(self) -> None:
327client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
328request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
329assert request.headers.get("Authorization") == f"Bearer {api_key}"
330
e967f5a5Stainless Bot2 years ago331with pytest.raises(OpenAIError):
08b8179aDavid Schnurr2 years ago332client2 = OpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
333_ = client2
334
335def test_default_query_option(self) -> None:
336client = OpenAI(
337base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
338)
339request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
340url = httpx.URL(request.url)
341assert dict(url.params) == {"query_param": "bar"}
342
343request = client._build_request(
344FinalRequestOptions(
345method="get",
346url="/foo",
347params={"foo": "baz", "query_param": "overriden"},
348)
349)
350url = httpx.URL(request.url)
351assert dict(url.params) == {"foo": "baz", "query_param": "overriden"}
352
353def test_request_extra_json(self) -> None:
354request = self.client._build_request(
355FinalRequestOptions(
356method="post",
357url="/foo",
358json_data={"foo": "bar"},
359extra_json={"baz": False},
360),
361)
362data = json.loads(request.content.decode("utf-8"))
363assert data == {"foo": "bar", "baz": False}
364
365request = self.client._build_request(
366FinalRequestOptions(
367method="post",
368url="/foo",
369extra_json={"baz": False},
370),
371)
372data = json.loads(request.content.decode("utf-8"))
373assert data == {"baz": False}
374
375# `extra_json` takes priority over `json_data` when keys clash
376request = self.client._build_request(
377FinalRequestOptions(
378method="post",
379url="/foo",
380json_data={"foo": "bar", "baz": True},
381extra_json={"baz": None},
382),
383)
384data = json.loads(request.content.decode("utf-8"))
385assert data == {"foo": "bar", "baz": None}
386
387def test_request_extra_headers(self) -> None:
388request = self.client._build_request(
389FinalRequestOptions(
390method="post",
391url="/foo",
392**make_request_options(extra_headers={"X-Foo": "Foo"}),
393),
394)
395assert request.headers.get("X-Foo") == "Foo"
396
397# `extra_headers` takes priority over `default_headers` when keys clash
398request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
399FinalRequestOptions(
400method="post",
401url="/foo",
402**make_request_options(
403extra_headers={"X-Bar": "false"},
404),
405),
406)
407assert request.headers.get("X-Bar") == "false"
408
409def test_request_extra_query(self) -> None:
410request = self.client._build_request(
411FinalRequestOptions(
412method="post",
413url="/foo",
414**make_request_options(
415extra_query={"my_query_param": "Foo"},
416),
417),
418)
31573844Stainless Bot2 years ago419params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago420assert params == {"my_query_param": "Foo"}
421
422# if both `query` and `extra_query` are given, they are merged
423request = self.client._build_request(
424FinalRequestOptions(
425method="post",
426url="/foo",
427**make_request_options(
428query={"bar": "1"},
429extra_query={"foo": "2"},
430),
431),
432)
31573844Stainless Bot2 years ago433params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago434assert params == {"bar": "1", "foo": "2"}
435
436# `extra_query` takes priority over `query` when keys clash
437request = self.client._build_request(
438FinalRequestOptions(
439method="post",
440url="/foo",
441**make_request_options(
442query={"foo": "1"},
443extra_query={"foo": "2"},
444),
445),
446)
31573844Stainless Bot2 years ago447params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago448assert params == {"foo": "2"}
449
22713fd0Stainless Bot2 years ago450def test_multipart_repeating_array(self, client: OpenAI) -> None:
451request = client._build_request(
452FinalRequestOptions.construct(
453method="get",
454url="/foo",
455headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
456json_data={"array": ["foo", "bar"]},
457files=[("foo.txt", b"hello world")],
458)
459)
460
461assert request.read().split(b"\r\n") == [
462b"--6b7ba517decee4a450543ea6ae821c82",
463b'Content-Disposition: form-data; name="array[]"',
464b"",
465b"foo",
466b"--6b7ba517decee4a450543ea6ae821c82",
467b'Content-Disposition: form-data; name="array[]"',
468b"",
469b"bar",
470b"--6b7ba517decee4a450543ea6ae821c82",
471b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
472b"Content-Type: application/octet-stream",
473b"",
474b"hello world",
475b"--6b7ba517decee4a450543ea6ae821c82--",
476b"",
477]
478
08b8179aDavid Schnurr2 years ago479@pytest.mark.respx(base_url=base_url)
480def test_basic_union_response(self, respx_mock: MockRouter) -> None:
481class Model1(BaseModel):
482name: str
483
484class Model2(BaseModel):
485foo: str
486
487respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
488
489response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
490assert isinstance(response, Model2)
491assert response.foo == "bar"
492
493@pytest.mark.respx(base_url=base_url)
494def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
495"""Union of objects with the same field name using a different type"""
496
497class Model1(BaseModel):
498foo: int
499
500class Model2(BaseModel):
501foo: str
502
503respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
504
505response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
506assert isinstance(response, Model2)
507assert response.foo == "bar"
508
509respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
510
511response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
512assert isinstance(response, Model1)
513assert response.foo == 1
514
c26014e2Stainless Bot2 years ago515@pytest.mark.respx(base_url=base_url)
516def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
517"""
518Response that sets Content-Type to something other than application/json but returns json data
519"""
520
521class Model(BaseModel):
522foo: int
523
524respx_mock.get("/foo").mock(
525return_value=httpx.Response(
526200,
527content=json.dumps({"foo": 2}),
528headers={"Content-Type": "application/text"},
529)
530)
531
532response = self.client.get("/foo", cast_to=Model)
533assert isinstance(response, Model)
534assert response.foo == 2
535
f6f38a9bStainless Bot2 years ago536def test_base_url_setter(self) -> None:
537client = OpenAI(base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True)
538assert client.base_url == "https://example.com/from_init/"
539
540client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
541
542assert client.base_url == "https://example.com/from_setter/"
543
0733934fStainless Bot2 years ago544def test_base_url_env(self) -> None:
545with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
546client = OpenAI(api_key=api_key, _strict_response_validation=True)
547assert client.base_url == "http://localhost:5000/from/env/"
548
08b8179aDavid Schnurr2 years ago549@pytest.mark.parametrize(
550"client",
551[
552OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
553OpenAI(
554base_url="http://localhost:5000/custom/path/",
555api_key=api_key,
556_strict_response_validation=True,
557http_client=httpx.Client(),
558),
559],
560ids=["standard", "custom http client"],
561)
562def test_base_url_trailing_slash(self, client: OpenAI) -> None:
563request = client._build_request(
564FinalRequestOptions(
565method="post",
566url="/foo",
567json_data={"foo": "bar"},
568),
569)
570assert request.url == "http://localhost:5000/custom/path/foo"
571
572@pytest.mark.parametrize(
573"client",
574[
575OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
576OpenAI(
577base_url="http://localhost:5000/custom/path/",
578api_key=api_key,
579_strict_response_validation=True,
580http_client=httpx.Client(),
581),
582],
583ids=["standard", "custom http client"],
584)
585def test_base_url_no_trailing_slash(self, client: OpenAI) -> None:
586request = client._build_request(
587FinalRequestOptions(
588method="post",
589url="/foo",
590json_data={"foo": "bar"},
591),
592)
593assert request.url == "http://localhost:5000/custom/path/foo"
594
595@pytest.mark.parametrize(
596"client",
597[
598OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
599OpenAI(
600base_url="http://localhost:5000/custom/path/",
601api_key=api_key,
602_strict_response_validation=True,
603http_client=httpx.Client(),
604),
605],
606ids=["standard", "custom http client"],
607)
608def test_absolute_request_url(self, client: OpenAI) -> None:
609request = client._build_request(
610FinalRequestOptions(
611method="post",
612url="https://myapi.com/foo",
613json_data={"foo": "bar"},
614),
615)
616assert request.url == "https://myapi.com/foo"
617
618def test_copied_client_does_not_close_http(self) -> None:
619client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
620assert not client.is_closed()
621
622copied = client.copy()
623assert copied is not client
624
a7ebc260Stainless Bot2 years ago625del copied
08b8179aDavid Schnurr2 years ago626
627assert not client.is_closed()
628
629def test_client_context_manager(self) -> None:
630client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
631with client as c2:
632assert c2 is client
633assert not c2.is_closed()
634assert not client.is_closed()
635assert client.is_closed()
636
637@pytest.mark.respx(base_url=base_url)
638def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
639class Model(BaseModel):
640foo: str
641
642respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
643
644with pytest.raises(APIResponseValidationError) as exc:
645self.client.get("/foo", cast_to=Model)
646
647assert isinstance(exc.value.__cause__, ValidationError)
648
649@pytest.mark.respx(base_url=base_url)
650def test_default_stream_cls(self, respx_mock: MockRouter) -> None:
651class Model(BaseModel):
652name: str
653
654respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
655
86379b44Stainless Bot2 years ago656stream = self.client.post("/foo", cast_to=Model, stream=True, stream_cls=Stream[Model])
657assert isinstance(stream, Stream)
658stream.response.close()
08b8179aDavid Schnurr2 years ago659
660@pytest.mark.respx(base_url=base_url)
661def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
662class Model(BaseModel):
663name: str
664
665respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
666
667strict_client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
668
669with pytest.raises(APIResponseValidationError):
670strict_client.get("/foo", cast_to=Model)
671
672client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
673
674response = client.get("/foo", cast_to=Model)
675assert isinstance(response, str) # type: ignore[unreachable]
676
677@pytest.mark.parametrize(
678"remaining_retries,retry_after,timeout",
679[
680[3, "20", 20],
681[3, "0", 0.5],
682[3, "-10", 0.5],
683[3, "60", 60],
684[3, "61", 0.5],
685[3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
686[3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
687[3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
688[3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
689[3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
690[3, "99999999999999999999999999999999999", 0.5],
691[3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
692[3, "", 0.5],
693[2, "", 0.5 * 2.0],
694[1, "", 0.5 * 4.0],
695],
696)
697@mock.patch("time.time", mock.MagicMock(return_value=1696004797))
698def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
699client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
700
701headers = httpx.Headers({"retry-after": retry_after})
702options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
703calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
704assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
705
ba4f7a97Stainless Bot2 years ago706@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
707@pytest.mark.respx(base_url=base_url)
708def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
709respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
710
711with pytest.raises(APITimeoutError):
712self.client.post(
713"/chat/completions",
76382e3cStainless Bot2 years ago714body=cast(
715object,
716dict(
717messages=[
718{
719"role": "user",
720"content": "Say this is a test",
721}
722],
723model="gpt-3.5-turbo",
724),
ba4f7a97Stainless Bot2 years ago725),
726cast_to=httpx.Response,
86379b44Stainless Bot2 years ago727options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
ba4f7a97Stainless Bot2 years ago728)
7aad3405Stainless Bot2 years ago729
730assert _get_open_connections(self.client) == 0
731
ba4f7a97Stainless Bot2 years ago732@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
7aad3405Stainless Bot2 years ago733@pytest.mark.respx(base_url=base_url)
ba4f7a97Stainless Bot2 years ago734def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
735respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
7aad3405Stainless Bot2 years ago736
ba4f7a97Stainless Bot2 years ago737with pytest.raises(APIStatusError):
738self.client.post(
739"/chat/completions",
76382e3cStainless Bot2 years ago740body=cast(
741object,
742dict(
743messages=[
744{
745"role": "user",
746"content": "Say this is a test",
747}
748],
749model="gpt-3.5-turbo",
750),
ba4f7a97Stainless Bot2 years ago751),
752cast_to=httpx.Response,
86379b44Stainless Bot2 years ago753options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
7aad3405Stainless Bot2 years ago754)
755
ba4f7a97Stainless Bot2 years ago756assert _get_open_connections(self.client) == 0
7aad3405Stainless Bot2 years ago757
08b8179aDavid Schnurr2 years ago758
759class TestAsyncOpenAI:
760client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
761
762@pytest.mark.respx(base_url=base_url)
763@pytest.mark.asyncio
764async def test_raw_response(self, respx_mock: MockRouter) -> None:
c5975bd0Stainless Bot2 years ago765respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
08b8179aDavid Schnurr2 years ago766
767response = await self.client.post("/foo", cast_to=httpx.Response)
768assert response.status_code == 200
769assert isinstance(response, httpx.Response)
c5975bd0Stainless Bot2 years ago770assert response.json() == {"foo": "bar"}
08b8179aDavid Schnurr2 years ago771
772@pytest.mark.respx(base_url=base_url)
773@pytest.mark.asyncio
774async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
775respx_mock.post("/foo").mock(
776return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
777)
778
779response = await self.client.post("/foo", cast_to=httpx.Response)
780assert response.status_code == 200
781assert isinstance(response, httpx.Response)
c5975bd0Stainless Bot2 years ago782assert response.json() == {"foo": "bar"}
08b8179aDavid Schnurr2 years ago783
784def test_copy(self) -> None:
785copied = self.client.copy()
786assert id(copied) != id(self.client)
787
788copied = self.client.copy(api_key="another My API Key")
789assert copied.api_key == "another My API Key"
790assert self.client.api_key == "My API Key"
791
792def test_copy_default_options(self) -> None:
793# options that have a default are overridden correctly
794copied = self.client.copy(max_retries=7)
795assert copied.max_retries == 7
796assert self.client.max_retries == 2
797
798copied2 = copied.copy(max_retries=6)
799assert copied2.max_retries == 6
800assert copied.max_retries == 7
801
802# timeout
803assert isinstance(self.client.timeout, httpx.Timeout)
804copied = self.client.copy(timeout=None)
805assert copied.timeout is None
806assert isinstance(self.client.timeout, httpx.Timeout)
807
808def test_copy_default_headers(self) -> None:
809client = AsyncOpenAI(
810base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
811)
812assert client.default_headers["X-Foo"] == "bar"
813
814# does not override the already given value when not specified
815copied = client.copy()
816assert copied.default_headers["X-Foo"] == "bar"
817
818# merges already given headers
819copied = client.copy(default_headers={"X-Bar": "stainless"})
820assert copied.default_headers["X-Foo"] == "bar"
821assert copied.default_headers["X-Bar"] == "stainless"
822
823# uses new values for any already given headers
824copied = client.copy(default_headers={"X-Foo": "stainless"})
825assert copied.default_headers["X-Foo"] == "stainless"
826
827# set_default_headers
828
829# completely overrides already set values
830copied = client.copy(set_default_headers={})
831assert copied.default_headers.get("X-Foo") is None
832
833copied = client.copy(set_default_headers={"X-Bar": "Robert"})
834assert copied.default_headers["X-Bar"] == "Robert"
835
836with pytest.raises(
837ValueError,
838match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
839):
840client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
841
842def test_copy_default_query(self) -> None:
843client = AsyncOpenAI(
844base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
845)
846assert _get_params(client)["foo"] == "bar"
847
848# does not override the already given value when not specified
849copied = client.copy()
850assert _get_params(copied)["foo"] == "bar"
851
852# merges already given params
853copied = client.copy(default_query={"bar": "stainless"})
854params = _get_params(copied)
855assert params["foo"] == "bar"
856assert params["bar"] == "stainless"
857
858# uses new values for any already given headers
859copied = client.copy(default_query={"foo": "stainless"})
860assert _get_params(copied)["foo"] == "stainless"
861
862# set_default_query
863
864# completely overrides already set values
865copied = client.copy(set_default_query={})
866assert _get_params(copied) == {}
867
868copied = client.copy(set_default_query={"bar": "Robert"})
869assert _get_params(copied)["bar"] == "Robert"
870
871with pytest.raises(
872ValueError,
873# TODO: update
874match="`default_query` and `set_default_query` arguments are mutually exclusive",
875):
876client.copy(set_default_query={}, default_query={"foo": "Bar"})
877
878def test_copy_signature(self) -> None:
879# ensure the same parameters that can be passed to the client are defined in the `.copy()` method
880init_signature = inspect.signature(
881# mypy doesn't like that we access the `__init__` property.
882self.client.__init__, # type: ignore[misc]
883)
884copy_signature = inspect.signature(self.client.copy)
885exclude_params = {"transport", "proxies", "_strict_response_validation"}
886
887for name in init_signature.parameters.keys():
888if name in exclude_params:
889continue
890
891copy_param = copy_signature.parameters.get(name)
892assert copy_param is not None, f"copy() signature is missing the {name} param"
893
d052708aStainless Bot2 years ago894def test_copy_build_request(self) -> None:
895options = FinalRequestOptions(method="get", url="/foo")
896
897def build_request(options: FinalRequestOptions) -> None:
898client = self.client.copy()
899client._build_request(options)
900
901# ensure that the machinery is warmed up before tracing starts.
902build_request(options)
903gc.collect()
904
905tracemalloc.start(1000)
906
907snapshot_before = tracemalloc.take_snapshot()
908
909ITERATIONS = 10
910for _ in range(ITERATIONS):
911build_request(options)
912
ce04ec28Stainless Bot2 years ago913gc.collect()
d052708aStainless Bot2 years ago914snapshot_after = tracemalloc.take_snapshot()
915
916tracemalloc.stop()
917
918def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None:
919if diff.count == 0:
920# Avoid false positives by considering only leaks (i.e. allocations that persist).
921return
922
923if diff.count % ITERATIONS != 0:
924# Avoid false positives by considering only leaks that appear per iteration.
925return
926
927for frame in diff.traceback:
928if any(
929frame.filename.endswith(fragment)
930for fragment in [
931# to_raw_response_wrapper leaks through the @functools.wraps() decorator.
932#
933# removing the decorator fixes the leak for reasons we don't understand.
86379b44Stainless Bot2 years ago934"openai/_legacy_response.py",
d052708aStainless Bot2 years ago935"openai/_response.py",
936# pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason.
937"openai/_compat.py",
938# Standard library leaks we don't care about.
939"/logging/__init__.py",
940]
941):
942return
943
944leaks.append(diff)
945
946leaks: list[tracemalloc.StatisticDiff] = []
947for diff in snapshot_after.compare_to(snapshot_before, "traceback"):
948add_leak(leaks, diff)
949if leaks:
950for leak in leaks:
951print("MEMORY LEAK:", leak)
952for frame in leak.traceback:
953print(frame)
954raise AssertionError()
955
08b8179aDavid Schnurr2 years ago956async def test_request_timeout(self) -> None:
957request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
958timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
959assert timeout == DEFAULT_TIMEOUT
960
961request = self.client._build_request(
962FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
963)
964timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
965assert timeout == httpx.Timeout(100.0)
966
967async def test_client_timeout_option(self) -> None:
968client = AsyncOpenAI(
969base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0)
970)
971
972request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
973timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
974assert timeout == httpx.Timeout(0)
975
976async def test_http_client_timeout_option(self) -> None:
977# custom timeout given to the httpx client should be used
978async with httpx.AsyncClient(timeout=None) 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 == httpx.Timeout(None)
986
987# no timeout given to the httpx client should not use the httpx default
988async with httpx.AsyncClient() 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
996
997# explicitly passing the default timeout currently results in it being ignored
998async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
999client = AsyncOpenAI(
1000base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1001)
1002
1003request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1004timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1005assert timeout == DEFAULT_TIMEOUT # our default
1006
dae0ec80Stainless Bot2 years ago1007def test_invalid_http_client(self) -> None:
1008with pytest.raises(TypeError, match="Invalid `http_client` arg"):
1009with httpx.Client() as http_client:
1010AsyncOpenAI(
1011base_url=base_url,
1012api_key=api_key,
1013_strict_response_validation=True,
1014http_client=cast(Any, http_client),
1015)
1016
08b8179aDavid Schnurr2 years ago1017def test_default_headers_option(self) -> None:
1018client = AsyncOpenAI(
1019base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
1020)
1021request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1022assert request.headers.get("x-foo") == "bar"
1023assert request.headers.get("x-stainless-lang") == "python"
1024
1025client2 = AsyncOpenAI(
1026base_url=base_url,
1027api_key=api_key,
1028_strict_response_validation=True,
1029default_headers={
1030"X-Foo": "stainless",
1031"X-Stainless-Lang": "my-overriding-header",
1032},
1033)
1034request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
1035assert request.headers.get("x-foo") == "stainless"
1036assert request.headers.get("x-stainless-lang") == "my-overriding-header"
1037
1038def test_validate_headers(self) -> None:
1039client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1040request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1041assert request.headers.get("Authorization") == f"Bearer {api_key}"
1042
e967f5a5Stainless Bot2 years ago1043with pytest.raises(OpenAIError):
08b8179aDavid Schnurr2 years ago1044client2 = AsyncOpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
1045_ = client2
1046
1047def test_default_query_option(self) -> None:
1048client = AsyncOpenAI(
1049base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
1050)
1051request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1052url = httpx.URL(request.url)
1053assert dict(url.params) == {"query_param": "bar"}
1054
1055request = client._build_request(
1056FinalRequestOptions(
1057method="get",
1058url="/foo",
1059params={"foo": "baz", "query_param": "overriden"},
1060)
1061)
1062url = httpx.URL(request.url)
1063assert dict(url.params) == {"foo": "baz", "query_param": "overriden"}
1064
1065def test_request_extra_json(self) -> None:
1066request = self.client._build_request(
1067FinalRequestOptions(
1068method="post",
1069url="/foo",
1070json_data={"foo": "bar"},
1071extra_json={"baz": False},
1072),
1073)
1074data = json.loads(request.content.decode("utf-8"))
1075assert data == {"foo": "bar", "baz": False}
1076
1077request = self.client._build_request(
1078FinalRequestOptions(
1079method="post",
1080url="/foo",
1081extra_json={"baz": False},
1082),
1083)
1084data = json.loads(request.content.decode("utf-8"))
1085assert data == {"baz": False}
1086
1087# `extra_json` takes priority over `json_data` when keys clash
1088request = self.client._build_request(
1089FinalRequestOptions(
1090method="post",
1091url="/foo",
1092json_data={"foo": "bar", "baz": True},
1093extra_json={"baz": None},
1094),
1095)
1096data = json.loads(request.content.decode("utf-8"))
1097assert data == {"foo": "bar", "baz": None}
1098
1099def test_request_extra_headers(self) -> None:
1100request = self.client._build_request(
1101FinalRequestOptions(
1102method="post",
1103url="/foo",
1104**make_request_options(extra_headers={"X-Foo": "Foo"}),
1105),
1106)
1107assert request.headers.get("X-Foo") == "Foo"
1108
1109# `extra_headers` takes priority over `default_headers` when keys clash
1110request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
1111FinalRequestOptions(
1112method="post",
1113url="/foo",
1114**make_request_options(
1115extra_headers={"X-Bar": "false"},
1116),
1117),
1118)
1119assert request.headers.get("X-Bar") == "false"
1120
1121def test_request_extra_query(self) -> None:
1122request = self.client._build_request(
1123FinalRequestOptions(
1124method="post",
1125url="/foo",
1126**make_request_options(
1127extra_query={"my_query_param": "Foo"},
1128),
1129),
1130)
31573844Stainless Bot2 years ago1131params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago1132assert params == {"my_query_param": "Foo"}
1133
1134# if both `query` and `extra_query` are given, they are merged
1135request = self.client._build_request(
1136FinalRequestOptions(
1137method="post",
1138url="/foo",
1139**make_request_options(
1140query={"bar": "1"},
1141extra_query={"foo": "2"},
1142),
1143),
1144)
31573844Stainless Bot2 years ago1145params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago1146assert params == {"bar": "1", "foo": "2"}
1147
1148# `extra_query` takes priority over `query` when keys clash
1149request = self.client._build_request(
1150FinalRequestOptions(
1151method="post",
1152url="/foo",
1153**make_request_options(
1154query={"foo": "1"},
1155extra_query={"foo": "2"},
1156),
1157),
1158)
31573844Stainless Bot2 years ago1159params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago1160assert params == {"foo": "2"}
1161
22713fd0Stainless Bot2 years ago1162def test_multipart_repeating_array(self, async_client: AsyncOpenAI) -> None:
1163request = async_client._build_request(
1164FinalRequestOptions.construct(
1165method="get",
1166url="/foo",
1167headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
1168json_data={"array": ["foo", "bar"]},
1169files=[("foo.txt", b"hello world")],
1170)
1171)
1172
1173assert request.read().split(b"\r\n") == [
1174b"--6b7ba517decee4a450543ea6ae821c82",
1175b'Content-Disposition: form-data; name="array[]"',
1176b"",
1177b"foo",
1178b"--6b7ba517decee4a450543ea6ae821c82",
1179b'Content-Disposition: form-data; name="array[]"',
1180b"",
1181b"bar",
1182b"--6b7ba517decee4a450543ea6ae821c82",
1183b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
1184b"Content-Type: application/octet-stream",
1185b"",
1186b"hello world",
1187b"--6b7ba517decee4a450543ea6ae821c82--",
1188b"",
1189]
1190
08b8179aDavid Schnurr2 years ago1191@pytest.mark.respx(base_url=base_url)
1192async def test_basic_union_response(self, respx_mock: MockRouter) -> None:
1193class Model1(BaseModel):
1194name: str
1195
1196class Model2(BaseModel):
1197foo: str
1198
1199respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1200
1201response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1202assert isinstance(response, Model2)
1203assert response.foo == "bar"
1204
1205@pytest.mark.respx(base_url=base_url)
1206async def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
1207"""Union of objects with the same field name using a different type"""
1208
1209class Model1(BaseModel):
1210foo: int
1211
1212class Model2(BaseModel):
1213foo: str
1214
1215respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1216
1217response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1218assert isinstance(response, Model2)
1219assert response.foo == "bar"
1220
1221respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
1222
1223response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1224assert isinstance(response, Model1)
1225assert response.foo == 1
1226
c26014e2Stainless Bot2 years ago1227@pytest.mark.respx(base_url=base_url)
1228async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
1229"""
1230Response that sets Content-Type to something other than application/json but returns json data
1231"""
1232
1233class Model(BaseModel):
1234foo: int
1235
1236respx_mock.get("/foo").mock(
1237return_value=httpx.Response(
1238200,
1239content=json.dumps({"foo": 2}),
1240headers={"Content-Type": "application/text"},
1241)
1242)
1243
1244response = await self.client.get("/foo", cast_to=Model)
1245assert isinstance(response, Model)
1246assert response.foo == 2
1247
f6f38a9bStainless Bot2 years ago1248def test_base_url_setter(self) -> None:
1249client = AsyncOpenAI(
1250base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True
1251)
1252assert client.base_url == "https://example.com/from_init/"
1253
1254client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
1255
1256assert client.base_url == "https://example.com/from_setter/"
1257
0733934fStainless Bot2 years ago1258def test_base_url_env(self) -> None:
1259with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
1260client = AsyncOpenAI(api_key=api_key, _strict_response_validation=True)
1261assert client.base_url == "http://localhost:5000/from/env/"
1262
08b8179aDavid Schnurr2 years ago1263@pytest.mark.parametrize(
1264"client",
1265[
1266AsyncOpenAI(
1267base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1268),
1269AsyncOpenAI(
1270base_url="http://localhost:5000/custom/path/",
1271api_key=api_key,
1272_strict_response_validation=True,
1273http_client=httpx.AsyncClient(),
1274),
1275],
1276ids=["standard", "custom http client"],
1277)
1278def test_base_url_trailing_slash(self, client: AsyncOpenAI) -> None:
1279request = client._build_request(
1280FinalRequestOptions(
1281method="post",
1282url="/foo",
1283json_data={"foo": "bar"},
1284),
1285)
1286assert request.url == "http://localhost:5000/custom/path/foo"
1287
1288@pytest.mark.parametrize(
1289"client",
1290[
1291AsyncOpenAI(
1292base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1293),
1294AsyncOpenAI(
1295base_url="http://localhost:5000/custom/path/",
1296api_key=api_key,
1297_strict_response_validation=True,
1298http_client=httpx.AsyncClient(),
1299),
1300],
1301ids=["standard", "custom http client"],
1302)
1303def test_base_url_no_trailing_slash(self, client: AsyncOpenAI) -> None:
1304request = client._build_request(
1305FinalRequestOptions(
1306method="post",
1307url="/foo",
1308json_data={"foo": "bar"},
1309),
1310)
1311assert request.url == "http://localhost:5000/custom/path/foo"
1312
1313@pytest.mark.parametrize(
1314"client",
1315[
1316AsyncOpenAI(
1317base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1318),
1319AsyncOpenAI(
1320base_url="http://localhost:5000/custom/path/",
1321api_key=api_key,
1322_strict_response_validation=True,
1323http_client=httpx.AsyncClient(),
1324),
1325],
1326ids=["standard", "custom http client"],
1327)
1328def test_absolute_request_url(self, client: AsyncOpenAI) -> None:
1329request = client._build_request(
1330FinalRequestOptions(
1331method="post",
1332url="https://myapi.com/foo",
1333json_data={"foo": "bar"},
1334),
1335)
1336assert request.url == "https://myapi.com/foo"
1337
1338async def test_copied_client_does_not_close_http(self) -> None:
1339client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1340assert not client.is_closed()
1341
1342copied = client.copy()
1343assert copied is not client
1344
a7ebc260Stainless Bot2 years ago1345del copied
08b8179aDavid Schnurr2 years ago1346
1347await asyncio.sleep(0.2)
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
86379b44Stainless Bot2 years ago1379stream = await self.client.post("/foo", cast_to=Model, stream=True, stream_cls=AsyncStream[Model])
1380assert isinstance(stream, AsyncStream)
1381await stream.response.aclose()
08b8179aDavid Schnurr2 years ago1382
1383@pytest.mark.respx(base_url=base_url)
1384@pytest.mark.asyncio
1385async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
1386class Model(BaseModel):
1387name: str
1388
1389respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
1390
1391strict_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1392
1393with pytest.raises(APIResponseValidationError):
1394await strict_client.get("/foo", cast_to=Model)
1395
1396client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
1397
1398response = await client.get("/foo", cast_to=Model)
1399assert isinstance(response, str) # type: ignore[unreachable]
1400
1401@pytest.mark.parametrize(
1402"remaining_retries,retry_after,timeout",
1403[
1404[3, "20", 20],
1405[3, "0", 0.5],
1406[3, "-10", 0.5],
1407[3, "60", 60],
1408[3, "61", 0.5],
1409[3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
1410[3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
1411[3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
1412[3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
1413[3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
1414[3, "99999999999999999999999999999999999", 0.5],
1415[3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
1416[3, "", 0.5],
1417[2, "", 0.5 * 2.0],
1418[1, "", 0.5 * 4.0],
1419],
1420)
1421@mock.patch("time.time", mock.MagicMock(return_value=1696004797))
1422@pytest.mark.asyncio
1423async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
1424client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1425
1426headers = httpx.Headers({"retry-after": retry_after})
1427options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
1428calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
1429assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
7aad3405Stainless Bot2 years ago1430
ba4f7a97Stainless Bot2 years ago1431@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1432@pytest.mark.respx(base_url=base_url)
1433async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
1434respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
1435
1436with pytest.raises(APITimeoutError):
1437await self.client.post(
1438"/chat/completions",
76382e3cStainless Bot2 years ago1439body=cast(
1440object,
1441dict(
1442messages=[
1443{
1444"role": "user",
1445"content": "Say this is a test",
1446}
1447],
1448model="gpt-3.5-turbo",
1449),
ba4f7a97Stainless Bot2 years ago1450),
1451cast_to=httpx.Response,
86379b44Stainless Bot2 years ago1452options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
ba4f7a97Stainless Bot2 years ago1453)
7aad3405Stainless Bot2 years ago1454
1455assert _get_open_connections(self.client) == 0
1456
ba4f7a97Stainless Bot2 years ago1457@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
7aad3405Stainless Bot2 years ago1458@pytest.mark.respx(base_url=base_url)
ba4f7a97Stainless Bot2 years ago1459async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
1460respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
7aad3405Stainless Bot2 years ago1461
ba4f7a97Stainless Bot2 years ago1462with pytest.raises(APIStatusError):
1463await self.client.post(
1464"/chat/completions",
76382e3cStainless Bot2 years ago1465body=cast(
1466object,
1467dict(
1468messages=[
1469{
1470"role": "user",
1471"content": "Say this is a test",
1472}
1473],
1474model="gpt-3.5-turbo",
1475),
ba4f7a97Stainless Bot2 years ago1476),
1477cast_to=httpx.Response,
86379b44Stainless Bot2 years ago1478options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
7aad3405Stainless Bot2 years ago1479)
1480
ba4f7a97Stainless Bot2 years ago1481assert _get_open_connections(self.client) == 0