openai/openai-python

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v1.35.1

Branches

Tags

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

Clone

HTTPS

Download ZIP

tests/test_client.py

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