openai/openai-python

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v1.59.4

Branches

Tags

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

Clone

HTTPS

Download ZIP

tests/test_client.py

1806lines · 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
d8901d28Seth Gilchrist1 years ago7import sys
08b8179aDavid Schnurr2 years ago8import json
9import asyncio
10import inspect
d8901d28Seth Gilchrist1 years ago11import subprocess
d052708aStainless Bot2 years ago12import tracemalloc
31573844Stainless Bot2 years ago13from typing import Any, Union, cast
d8901d28Seth Gilchrist1 years ago14from textwrap import dedent
08b8179aDavid Schnurr2 years ago15from unittest import mock
6634f525stainless-app[bot]1 years ago16from typing_extensions import Literal
08b8179aDavid Schnurr2 years ago17
18import httpx
19import pytest
20from respx import MockRouter
21from pydantic import ValidationError
22
23from openai import OpenAI, AsyncOpenAI, APIResponseValidationError
30194f19stainless-app[bot]1 years ago24from openai._types import Omit
08b8179aDavid Schnurr2 years ago25from openai._models import BaseModel, FinalRequestOptions
86379b44Stainless Bot2 years ago26from openai._constants import RAW_RESPONSE_HEADER
08b8179aDavid Schnurr2 years ago27from openai._streaming import Stream, AsyncStream
a47375b7Stainless Bot2 years ago28from openai._exceptions import OpenAIError, APIStatusError, APITimeoutError, APIResponseValidationError
29from openai._base_client import DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, make_request_options
08b8179aDavid Schnurr2 years ago30
0733934fStainless Bot2 years ago31from .utils import update_env
32
08b8179aDavid Schnurr2 years ago33base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
34api_key = "My API Key"
35
36
37def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]:
38request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
39url = httpx.URL(request.url)
40return dict(url.params)
41
42
ba4f7a97Stainless Bot2 years ago43def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float:
44return 0.1
7aad3405Stainless Bot2 years ago45
46
47def _get_open_connections(client: OpenAI | AsyncOpenAI) -> int:
48transport = client._client._transport
49assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport)
50
51pool = transport._pool
52return len(pool._requests)
53
54
08b8179aDavid Schnurr2 years ago55class TestOpenAI:
56client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
57
58@pytest.mark.respx(base_url=base_url)
59def test_raw_response(self, respx_mock: MockRouter) -> None:
c5975bd0Stainless Bot2 years ago60respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
08b8179aDavid Schnurr2 years ago61
62response = self.client.post("/foo", cast_to=httpx.Response)
63assert response.status_code == 200
64assert isinstance(response, httpx.Response)
c5975bd0Stainless Bot2 years ago65assert response.json() == {"foo": "bar"}
08b8179aDavid Schnurr2 years ago66
67@pytest.mark.respx(base_url=base_url)
68def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
69respx_mock.post("/foo").mock(
70return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
71)
72
73response = self.client.post("/foo", cast_to=httpx.Response)
74assert response.status_code == 200
75assert isinstance(response, httpx.Response)
c5975bd0Stainless Bot2 years ago76assert response.json() == {"foo": "bar"}
08b8179aDavid Schnurr2 years ago77
78def test_copy(self) -> None:
79copied = self.client.copy()
80assert id(copied) != id(self.client)
81
82copied = self.client.copy(api_key="another My API Key")
83assert copied.api_key == "another My API Key"
84assert self.client.api_key == "My API Key"
85
86def test_copy_default_options(self) -> None:
87# options that have a default are overridden correctly
88copied = self.client.copy(max_retries=7)
89assert copied.max_retries == 7
90assert self.client.max_retries == 2
91
92copied2 = copied.copy(max_retries=6)
93assert copied2.max_retries == 6
94assert copied.max_retries == 7
95
96# timeout
97assert isinstance(self.client.timeout, httpx.Timeout)
98copied = self.client.copy(timeout=None)
99assert copied.timeout is None
100assert isinstance(self.client.timeout, httpx.Timeout)
101
102def test_copy_default_headers(self) -> None:
103client = OpenAI(
104base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
105)
106assert client.default_headers["X-Foo"] == "bar"
107
108# does not override the already given value when not specified
109copied = client.copy()
110assert copied.default_headers["X-Foo"] == "bar"
111
112# merges already given headers
113copied = client.copy(default_headers={"X-Bar": "stainless"})
114assert copied.default_headers["X-Foo"] == "bar"
115assert copied.default_headers["X-Bar"] == "stainless"
116
117# uses new values for any already given headers
118copied = client.copy(default_headers={"X-Foo": "stainless"})
119assert copied.default_headers["X-Foo"] == "stainless"
120
121# set_default_headers
122
123# completely overrides already set values
124copied = client.copy(set_default_headers={})
125assert copied.default_headers.get("X-Foo") is None
126
127copied = client.copy(set_default_headers={"X-Bar": "Robert"})
128assert copied.default_headers["X-Bar"] == "Robert"
129
130with pytest.raises(
131ValueError,
132match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
133):
134client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
135
136def test_copy_default_query(self) -> None:
137client = OpenAI(
138base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
139)
140assert _get_params(client)["foo"] == "bar"
141
142# does not override the already given value when not specified
143copied = client.copy()
144assert _get_params(copied)["foo"] == "bar"
145
146# merges already given params
147copied = client.copy(default_query={"bar": "stainless"})
148params = _get_params(copied)
149assert params["foo"] == "bar"
150assert params["bar"] == "stainless"
151
152# uses new values for any already given headers
153copied = client.copy(default_query={"foo": "stainless"})
154assert _get_params(copied)["foo"] == "stainless"
155
156# set_default_query
157
158# completely overrides already set values
159copied = client.copy(set_default_query={})
160assert _get_params(copied) == {}
161
162copied = client.copy(set_default_query={"bar": "Robert"})
163assert _get_params(copied)["bar"] == "Robert"
164
165with pytest.raises(
166ValueError,
167# TODO: update
168match="`default_query` and `set_default_query` arguments are mutually exclusive",
169):
170client.copy(set_default_query={}, default_query={"foo": "Bar"})
171
172def test_copy_signature(self) -> None:
173# ensure the same parameters that can be passed to the client are defined in the `.copy()` method
174init_signature = inspect.signature(
175# mypy doesn't like that we access the `__init__` property.
176self.client.__init__, # type: ignore[misc]
177)
178copy_signature = inspect.signature(self.client.copy)
179exclude_params = {"transport", "proxies", "_strict_response_validation"}
180
181for name in init_signature.parameters.keys():
182if name in exclude_params:
183continue
184
185copy_param = copy_signature.parameters.get(name)
186assert copy_param is not None, f"copy() signature is missing the {name} param"
187
d052708aStainless Bot2 years ago188def test_copy_build_request(self) -> None:
189options = FinalRequestOptions(method="get", url="/foo")
190
191def build_request(options: FinalRequestOptions) -> None:
192client = self.client.copy()
193client._build_request(options)
194
195# ensure that the machinery is warmed up before tracing starts.
196build_request(options)
197gc.collect()
198
199tracemalloc.start(1000)
200
201snapshot_before = tracemalloc.take_snapshot()
202
203ITERATIONS = 10
204for _ in range(ITERATIONS):
205build_request(options)
206
ce04ec28Stainless Bot2 years ago207gc.collect()
d052708aStainless Bot2 years ago208snapshot_after = tracemalloc.take_snapshot()
209
210tracemalloc.stop()
211
212def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None:
213if diff.count == 0:
214# Avoid false positives by considering only leaks (i.e. allocations that persist).
215return
216
217if diff.count % ITERATIONS != 0:
218# Avoid false positives by considering only leaks that appear per iteration.
219return
220
221for frame in diff.traceback:
222if any(
223frame.filename.endswith(fragment)
224for fragment in [
225# to_raw_response_wrapper leaks through the @functools.wraps() decorator.
226#
227# removing the decorator fixes the leak for reasons we don't understand.
86379b44Stainless Bot2 years ago228"openai/_legacy_response.py",
d052708aStainless Bot2 years ago229"openai/_response.py",
230# pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason.
231"openai/_compat.py",
232# Standard library leaks we don't care about.
233"/logging/__init__.py",
234]
235):
236return
237
238leaks.append(diff)
239
240leaks: list[tracemalloc.StatisticDiff] = []
241for diff in snapshot_after.compare_to(snapshot_before, "traceback"):
242add_leak(leaks, diff)
243if leaks:
244for leak in leaks:
245print("MEMORY LEAK:", leak)
246for frame in leak.traceback:
247print(frame)
248raise AssertionError()
249
08b8179aDavid Schnurr2 years ago250def test_request_timeout(self) -> None:
251request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
252timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
253assert timeout == DEFAULT_TIMEOUT
254
255request = self.client._build_request(
256FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
257)
258timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
259assert timeout == httpx.Timeout(100.0)
260
261def test_client_timeout_option(self) -> None:
262client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0))
263
264request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
265timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
266assert timeout == httpx.Timeout(0)
267
268def test_http_client_timeout_option(self) -> None:
269# custom timeout given to the httpx client should be used
270with httpx.Client(timeout=None) as http_client:
271client = OpenAI(
272base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
273)
274
275request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
276timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
277assert timeout == httpx.Timeout(None)
278
279# no timeout given to the httpx client should not use the httpx default
280with httpx.Client() as http_client:
281client = OpenAI(
282base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
283)
284
285request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
286timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
287assert timeout == DEFAULT_TIMEOUT
288
289# explicitly passing the default timeout currently results in it being ignored
290with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
291client = OpenAI(
292base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
293)
294
295request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
296timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
297assert timeout == DEFAULT_TIMEOUT # our default
298
dae0ec80Stainless Bot2 years ago299async def test_invalid_http_client(self) -> None:
300with pytest.raises(TypeError, match="Invalid `http_client` arg"):
301async with httpx.AsyncClient() as http_client:
302OpenAI(
303base_url=base_url,
304api_key=api_key,
305_strict_response_validation=True,
306http_client=cast(Any, http_client),
307)
308
08b8179aDavid Schnurr2 years ago309def test_default_headers_option(self) -> None:
310client = OpenAI(
311base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
312)
313request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
314assert request.headers.get("x-foo") == "bar"
315assert request.headers.get("x-stainless-lang") == "python"
316
317client2 = OpenAI(
318base_url=base_url,
319api_key=api_key,
320_strict_response_validation=True,
321default_headers={
322"X-Foo": "stainless",
323"X-Stainless-Lang": "my-overriding-header",
324},
325)
326request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
327assert request.headers.get("x-foo") == "stainless"
328assert request.headers.get("x-stainless-lang") == "my-overriding-header"
329
330def test_validate_headers(self) -> None:
331client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
332request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
333assert request.headers.get("Authorization") == f"Bearer {api_key}"
334
e967f5a5Stainless Bot2 years ago335with pytest.raises(OpenAIError):
30194f19stainless-app[bot]1 years ago336with update_env(**{"OPENAI_API_KEY": Omit()}):
337client2 = OpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
08b8179aDavid Schnurr2 years ago338_ = client2
339
340def test_default_query_option(self) -> None:
341client = OpenAI(
342base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
343)
344request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
345url = httpx.URL(request.url)
346assert dict(url.params) == {"query_param": "bar"}
347
348request = client._build_request(
349FinalRequestOptions(
350method="get",
351url="/foo",
eba67815stainless-app[bot]1 years ago352params={"foo": "baz", "query_param": "overridden"},
08b8179aDavid Schnurr2 years ago353)
354)
355url = httpx.URL(request.url)
eba67815stainless-app[bot]1 years ago356assert dict(url.params) == {"foo": "baz", "query_param": "overridden"}
08b8179aDavid Schnurr2 years ago357
358def test_request_extra_json(self) -> None:
359request = self.client._build_request(
360FinalRequestOptions(
361method="post",
362url="/foo",
363json_data={"foo": "bar"},
364extra_json={"baz": False},
365),
366)
367data = json.loads(request.content.decode("utf-8"))
368assert data == {"foo": "bar", "baz": False}
369
370request = self.client._build_request(
371FinalRequestOptions(
372method="post",
373url="/foo",
374extra_json={"baz": False},
375),
376)
377data = json.loads(request.content.decode("utf-8"))
378assert data == {"baz": False}
379
380# `extra_json` takes priority over `json_data` when keys clash
381request = self.client._build_request(
382FinalRequestOptions(
383method="post",
384url="/foo",
385json_data={"foo": "bar", "baz": True},
386extra_json={"baz": None},
387),
388)
389data = json.loads(request.content.decode("utf-8"))
390assert data == {"foo": "bar", "baz": None}
391
392def test_request_extra_headers(self) -> None:
393request = self.client._build_request(
394FinalRequestOptions(
395method="post",
396url="/foo",
397**make_request_options(extra_headers={"X-Foo": "Foo"}),
398),
399)
400assert request.headers.get("X-Foo") == "Foo"
401
402# `extra_headers` takes priority over `default_headers` when keys clash
403request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
404FinalRequestOptions(
405method="post",
406url="/foo",
407**make_request_options(
408extra_headers={"X-Bar": "false"},
409),
410),
411)
412assert request.headers.get("X-Bar") == "false"
413
414def test_request_extra_query(self) -> None:
415request = self.client._build_request(
416FinalRequestOptions(
417method="post",
418url="/foo",
419**make_request_options(
420extra_query={"my_query_param": "Foo"},
421),
422),
423)
31573844Stainless Bot2 years ago424params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago425assert params == {"my_query_param": "Foo"}
426
427# if both `query` and `extra_query` are given, they are merged
428request = self.client._build_request(
429FinalRequestOptions(
430method="post",
431url="/foo",
432**make_request_options(
433query={"bar": "1"},
434extra_query={"foo": "2"},
435),
436),
437)
31573844Stainless Bot2 years ago438params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago439assert params == {"bar": "1", "foo": "2"}
440
441# `extra_query` takes priority over `query` when keys clash
442request = self.client._build_request(
443FinalRequestOptions(
444method="post",
445url="/foo",
446**make_request_options(
447query={"foo": "1"},
448extra_query={"foo": "2"},
449),
450),
451)
31573844Stainless Bot2 years ago452params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago453assert params == {"foo": "2"}
454
22713fd0Stainless Bot2 years ago455def test_multipart_repeating_array(self, client: OpenAI) -> None:
456request = client._build_request(
457FinalRequestOptions.construct(
458method="get",
459url="/foo",
460headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
461json_data={"array": ["foo", "bar"]},
462files=[("foo.txt", b"hello world")],
463)
464)
465
466assert request.read().split(b"\r\n") == [
467b"--6b7ba517decee4a450543ea6ae821c82",
468b'Content-Disposition: form-data; name="array[]"',
469b"",
470b"foo",
471b"--6b7ba517decee4a450543ea6ae821c82",
472b'Content-Disposition: form-data; name="array[]"',
473b"",
474b"bar",
475b"--6b7ba517decee4a450543ea6ae821c82",
476b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
477b"Content-Type: application/octet-stream",
478b"",
479b"hello world",
480b"--6b7ba517decee4a450543ea6ae821c82--",
481b"",
482]
483
08b8179aDavid Schnurr2 years ago484@pytest.mark.respx(base_url=base_url)
485def test_basic_union_response(self, respx_mock: MockRouter) -> None:
486class Model1(BaseModel):
487name: str
488
489class Model2(BaseModel):
490foo: str
491
492respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
493
494response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
495assert isinstance(response, Model2)
496assert response.foo == "bar"
497
498@pytest.mark.respx(base_url=base_url)
499def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
500"""Union of objects with the same field name using a different type"""
501
502class Model1(BaseModel):
503foo: int
504
505class Model2(BaseModel):
506foo: str
507
508respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
509
510response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
511assert isinstance(response, Model2)
512assert response.foo == "bar"
513
514respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
515
516response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
517assert isinstance(response, Model1)
518assert response.foo == 1
519
c26014e2Stainless Bot2 years ago520@pytest.mark.respx(base_url=base_url)
521def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
522"""
523Response that sets Content-Type to something other than application/json but returns json data
524"""
525
526class Model(BaseModel):
527foo: int
528
529respx_mock.get("/foo").mock(
530return_value=httpx.Response(
531200,
532content=json.dumps({"foo": 2}),
533headers={"Content-Type": "application/text"},
534)
535)
536
537response = self.client.get("/foo", cast_to=Model)
538assert isinstance(response, Model)
539assert response.foo == 2
540
f6f38a9bStainless Bot2 years ago541def test_base_url_setter(self) -> None:
542client = OpenAI(base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True)
543assert client.base_url == "https://example.com/from_init/"
544
545client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
546
547assert client.base_url == "https://example.com/from_setter/"
548
0733934fStainless Bot2 years ago549def test_base_url_env(self) -> None:
550with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
551client = OpenAI(api_key=api_key, _strict_response_validation=True)
552assert client.base_url == "http://localhost:5000/from/env/"
553
08b8179aDavid Schnurr2 years ago554@pytest.mark.parametrize(
555"client",
556[
557OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
558OpenAI(
559base_url="http://localhost:5000/custom/path/",
560api_key=api_key,
561_strict_response_validation=True,
562http_client=httpx.Client(),
563),
564],
565ids=["standard", "custom http client"],
566)
567def test_base_url_trailing_slash(self, client: OpenAI) -> None:
568request = client._build_request(
569FinalRequestOptions(
570method="post",
571url="/foo",
572json_data={"foo": "bar"},
573),
574)
575assert request.url == "http://localhost:5000/custom/path/foo"
576
577@pytest.mark.parametrize(
578"client",
579[
580OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
581OpenAI(
582base_url="http://localhost:5000/custom/path/",
583api_key=api_key,
584_strict_response_validation=True,
585http_client=httpx.Client(),
586),
587],
588ids=["standard", "custom http client"],
589)
590def test_base_url_no_trailing_slash(self, client: OpenAI) -> None:
591request = client._build_request(
592FinalRequestOptions(
593method="post",
594url="/foo",
595json_data={"foo": "bar"},
596),
597)
598assert request.url == "http://localhost:5000/custom/path/foo"
599
600@pytest.mark.parametrize(
601"client",
602[
603OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
604OpenAI(
605base_url="http://localhost:5000/custom/path/",
606api_key=api_key,
607_strict_response_validation=True,
608http_client=httpx.Client(),
609),
610],
611ids=["standard", "custom http client"],
612)
613def test_absolute_request_url(self, client: OpenAI) -> None:
614request = client._build_request(
615FinalRequestOptions(
616method="post",
617url="https://myapi.com/foo",
618json_data={"foo": "bar"},
619),
620)
621assert request.url == "https://myapi.com/foo"
622
623def test_copied_client_does_not_close_http(self) -> None:
624client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
625assert not client.is_closed()
626
627copied = client.copy()
628assert copied is not client
629
a7ebc260Stainless Bot2 years ago630del copied
08b8179aDavid Schnurr2 years ago631
632assert not client.is_closed()
633
634def test_client_context_manager(self) -> None:
635client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
636with client as c2:
637assert c2 is client
638assert not c2.is_closed()
639assert not client.is_closed()
640assert client.is_closed()
641
642@pytest.mark.respx(base_url=base_url)
643def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
644class Model(BaseModel):
645foo: str
646
647respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
648
649with pytest.raises(APIResponseValidationError) as exc:
650self.client.get("/foo", cast_to=Model)
651
652assert isinstance(exc.value.__cause__, ValidationError)
653
07079085Stainless Bot2 years ago654def test_client_max_retries_validation(self) -> None:
655with pytest.raises(TypeError, match=r"max_retries cannot be None"):
656OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None))
657
08b8179aDavid Schnurr2 years ago658@pytest.mark.respx(base_url=base_url)
659def test_default_stream_cls(self, respx_mock: MockRouter) -> None:
660class Model(BaseModel):
661name: str
662
663respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
664
86379b44Stainless Bot2 years ago665stream = self.client.post("/foo", cast_to=Model, stream=True, stream_cls=Stream[Model])
666assert isinstance(stream, Stream)
667stream.response.close()
08b8179aDavid Schnurr2 years ago668
669@pytest.mark.respx(base_url=base_url)
670def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
671class Model(BaseModel):
672name: str
673
674respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
675
676strict_client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
677
678with pytest.raises(APIResponseValidationError):
679strict_client.get("/foo", cast_to=Model)
680
681client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
682
683response = client.get("/foo", cast_to=Model)
684assert isinstance(response, str) # type: ignore[unreachable]
685
686@pytest.mark.parametrize(
687"remaining_retries,retry_after,timeout",
688[
689[3, "20", 20],
690[3, "0", 0.5],
691[3, "-10", 0.5],
692[3, "60", 60],
693[3, "61", 0.5],
694[3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
695[3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
696[3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
697[3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
698[3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
699[3, "99999999999999999999999999999999999", 0.5],
700[3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
701[3, "", 0.5],
702[2, "", 0.5 * 2.0],
703[1, "", 0.5 * 4.0],
7f6a921cstainless-app[bot]1 years ago704[-1100, "", 8], # test large number potentially overflowing
08b8179aDavid Schnurr2 years ago705],
706)
707@mock.patch("time.time", mock.MagicMock(return_value=1696004797))
708def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
709client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
710
711headers = httpx.Headers({"retry-after": retry_after})
712options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
713calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
714assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
715
ba4f7a97Stainless Bot2 years ago716@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
717@pytest.mark.respx(base_url=base_url)
718def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
719respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
720
721with pytest.raises(APITimeoutError):
722self.client.post(
723"/chat/completions",
76382e3cStainless Bot2 years ago724body=cast(
725object,
726dict(
727messages=[
728{
729"role": "user",
730"content": "Say this is a test",
731}
732],
23444ed9Stainless Bot1 years ago733model="gpt-4o",
76382e3cStainless Bot2 years ago734),
ba4f7a97Stainless Bot2 years ago735),
736cast_to=httpx.Response,
86379b44Stainless Bot2 years ago737options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
ba4f7a97Stainless Bot2 years ago738)
7aad3405Stainless Bot2 years ago739
740assert _get_open_connections(self.client) == 0
741
ba4f7a97Stainless Bot2 years ago742@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
7aad3405Stainless Bot2 years ago743@pytest.mark.respx(base_url=base_url)
ba4f7a97Stainless Bot2 years ago744def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
745respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
7aad3405Stainless Bot2 years ago746
ba4f7a97Stainless Bot2 years ago747with pytest.raises(APIStatusError):
748self.client.post(
749"/chat/completions",
76382e3cStainless Bot2 years ago750body=cast(
751object,
752dict(
753messages=[
754{
755"role": "user",
756"content": "Say this is a test",
757}
758],
23444ed9Stainless Bot1 years ago759model="gpt-4o",
76382e3cStainless Bot2 years ago760),
ba4f7a97Stainless Bot2 years ago761),
762cast_to=httpx.Response,
86379b44Stainless Bot2 years ago763options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
7aad3405Stainless Bot2 years ago764)
765
ba4f7a97Stainless Bot2 years ago766assert _get_open_connections(self.client) == 0
7aad3405Stainless Bot2 years ago767
98d8b2acstainless-app[bot]1 years ago768@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
769@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
770@pytest.mark.respx(base_url=base_url)
6634f525stainless-app[bot]1 years ago771@pytest.mark.parametrize("failure_mode", ["status", "exception"])
772def test_retries_taken(
773self,
774client: OpenAI,
775failures_before_success: int,
776failure_mode: Literal["status", "exception"],
777respx_mock: MockRouter,
778) -> None:
98d8b2acstainless-app[bot]1 years ago779client = client.with_options(max_retries=4)
780
781nb_retries = 0
782
783def retry_handler(_request: httpx.Request) -> httpx.Response:
784nonlocal nb_retries
785if nb_retries < failures_before_success:
786nb_retries += 1
6634f525stainless-app[bot]1 years ago787if failure_mode == "exception":
788raise RuntimeError("oops")
98d8b2acstainless-app[bot]1 years ago789return httpx.Response(500)
790return httpx.Response(200)
791
792respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
793
794response = client.chat.completions.with_raw_response.create(
795messages=[
796{
bf1ca86cRobert Craigie1 years ago797"content": "string",
575ff607stainless-app[bot]1 years ago798"role": "developer",
98d8b2acstainless-app[bot]1 years ago799}
800],
bf1ca86cRobert Craigie1 years ago801model="gpt-4o",
98d8b2acstainless-app[bot]1 years ago802)
803
804assert response.retries_taken == failures_before_success
5449e208Stainless Bot1 years ago805assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
806
807@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
808@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
809@pytest.mark.respx(base_url=base_url)
810def test_omit_retry_count_header(
811self, client: OpenAI, failures_before_success: int, respx_mock: MockRouter
812) -> None:
813client = client.with_options(max_retries=4)
814
815nb_retries = 0
816
817def retry_handler(_request: httpx.Request) -> httpx.Response:
818nonlocal nb_retries
819if nb_retries < failures_before_success:
820nb_retries += 1
821return httpx.Response(500)
822return httpx.Response(200)
823
824respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
825
826response = client.chat.completions.with_raw_response.create(
827messages=[
828{
829"content": "string",
575ff607stainless-app[bot]1 years ago830"role": "developer",
5449e208Stainless Bot1 years ago831}
832],
833model="gpt-4o",
834extra_headers={"x-stainless-retry-count": Omit()},
835)
836
837assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0
838
839@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
840@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
841@pytest.mark.respx(base_url=base_url)
842def test_overwrite_retry_count_header(
843self, client: OpenAI, failures_before_success: int, respx_mock: MockRouter
844) -> None:
845client = client.with_options(max_retries=4)
846
847nb_retries = 0
848
849def retry_handler(_request: httpx.Request) -> httpx.Response:
850nonlocal nb_retries
851if nb_retries < failures_before_success:
852nb_retries += 1
853return httpx.Response(500)
854return httpx.Response(200)
855
856respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
857
858response = client.chat.completions.with_raw_response.create(
859messages=[
860{
861"content": "string",
575ff607stainless-app[bot]1 years ago862"role": "developer",
5449e208Stainless Bot1 years ago863}
864],
865model="gpt-4o",
866extra_headers={"x-stainless-retry-count": "42"},
867)
868
869assert response.http_request.headers.get("x-stainless-retry-count") == "42"
98d8b2acstainless-app[bot]1 years ago870
871@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
872@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
873@pytest.mark.respx(base_url=base_url)
874def test_retries_taken_new_response_class(
875self, client: OpenAI, failures_before_success: int, respx_mock: MockRouter
876) -> None:
877client = client.with_options(max_retries=4)
878
879nb_retries = 0
880
881def retry_handler(_request: httpx.Request) -> httpx.Response:
882nonlocal nb_retries
883if nb_retries < failures_before_success:
884nb_retries += 1
885return httpx.Response(500)
886return httpx.Response(200)
887
888respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
889
890with client.chat.completions.with_streaming_response.create(
891messages=[
892{
bf1ca86cRobert Craigie1 years ago893"content": "string",
575ff607stainless-app[bot]1 years ago894"role": "developer",
98d8b2acstainless-app[bot]1 years ago895}
896],
bf1ca86cRobert Craigie1 years ago897model="gpt-4o",
98d8b2acstainless-app[bot]1 years ago898) as response:
899assert response.retries_taken == failures_before_success
5449e208Stainless Bot1 years ago900assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
98d8b2acstainless-app[bot]1 years ago901
08b8179aDavid Schnurr2 years ago902
903class TestAsyncOpenAI:
904client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
905
906@pytest.mark.respx(base_url=base_url)
907@pytest.mark.asyncio
908async def test_raw_response(self, respx_mock: MockRouter) -> None:
c5975bd0Stainless Bot2 years ago909respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
08b8179aDavid Schnurr2 years ago910
911response = await self.client.post("/foo", cast_to=httpx.Response)
912assert response.status_code == 200
913assert isinstance(response, httpx.Response)
c5975bd0Stainless Bot2 years ago914assert response.json() == {"foo": "bar"}
08b8179aDavid Schnurr2 years ago915
916@pytest.mark.respx(base_url=base_url)
917@pytest.mark.asyncio
918async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
919respx_mock.post("/foo").mock(
920return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
921)
922
923response = await self.client.post("/foo", cast_to=httpx.Response)
924assert response.status_code == 200
925assert isinstance(response, httpx.Response)
c5975bd0Stainless Bot2 years ago926assert response.json() == {"foo": "bar"}
08b8179aDavid Schnurr2 years ago927
928def test_copy(self) -> None:
929copied = self.client.copy()
930assert id(copied) != id(self.client)
931
932copied = self.client.copy(api_key="another My API Key")
933assert copied.api_key == "another My API Key"
934assert self.client.api_key == "My API Key"
935
936def test_copy_default_options(self) -> None:
937# options that have a default are overridden correctly
938copied = self.client.copy(max_retries=7)
939assert copied.max_retries == 7
940assert self.client.max_retries == 2
941
942copied2 = copied.copy(max_retries=6)
943assert copied2.max_retries == 6
944assert copied.max_retries == 7
945
946# timeout
947assert isinstance(self.client.timeout, httpx.Timeout)
948copied = self.client.copy(timeout=None)
949assert copied.timeout is None
950assert isinstance(self.client.timeout, httpx.Timeout)
951
952def test_copy_default_headers(self) -> None:
953client = AsyncOpenAI(
954base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
955)
956assert client.default_headers["X-Foo"] == "bar"
957
958# does not override the already given value when not specified
959copied = client.copy()
960assert copied.default_headers["X-Foo"] == "bar"
961
962# merges already given headers
963copied = client.copy(default_headers={"X-Bar": "stainless"})
964assert copied.default_headers["X-Foo"] == "bar"
965assert copied.default_headers["X-Bar"] == "stainless"
966
967# uses new values for any already given headers
968copied = client.copy(default_headers={"X-Foo": "stainless"})
969assert copied.default_headers["X-Foo"] == "stainless"
970
971# set_default_headers
972
973# completely overrides already set values
974copied = client.copy(set_default_headers={})
975assert copied.default_headers.get("X-Foo") is None
976
977copied = client.copy(set_default_headers={"X-Bar": "Robert"})
978assert copied.default_headers["X-Bar"] == "Robert"
979
980with pytest.raises(
981ValueError,
982match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
983):
984client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
985
986def test_copy_default_query(self) -> None:
987client = AsyncOpenAI(
988base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
989)
990assert _get_params(client)["foo"] == "bar"
991
992# does not override the already given value when not specified
993copied = client.copy()
994assert _get_params(copied)["foo"] == "bar"
995
996# merges already given params
997copied = client.copy(default_query={"bar": "stainless"})
998params = _get_params(copied)
999assert params["foo"] == "bar"
1000assert params["bar"] == "stainless"
1001
1002# uses new values for any already given headers
1003copied = client.copy(default_query={"foo": "stainless"})
1004assert _get_params(copied)["foo"] == "stainless"
1005
1006# set_default_query
1007
1008# completely overrides already set values
1009copied = client.copy(set_default_query={})
1010assert _get_params(copied) == {}
1011
1012copied = client.copy(set_default_query={"bar": "Robert"})
1013assert _get_params(copied)["bar"] == "Robert"
1014
1015with pytest.raises(
1016ValueError,
1017# TODO: update
1018match="`default_query` and `set_default_query` arguments are mutually exclusive",
1019):
1020client.copy(set_default_query={}, default_query={"foo": "Bar"})
1021
1022def test_copy_signature(self) -> None:
1023# ensure the same parameters that can be passed to the client are defined in the `.copy()` method
1024init_signature = inspect.signature(
1025# mypy doesn't like that we access the `__init__` property.
1026self.client.__init__, # type: ignore[misc]
1027)
1028copy_signature = inspect.signature(self.client.copy)
1029exclude_params = {"transport", "proxies", "_strict_response_validation"}
1030
1031for name in init_signature.parameters.keys():
1032if name in exclude_params:
1033continue
1034
1035copy_param = copy_signature.parameters.get(name)
1036assert copy_param is not None, f"copy() signature is missing the {name} param"
1037
d052708aStainless Bot2 years ago1038def test_copy_build_request(self) -> None:
1039options = FinalRequestOptions(method="get", url="/foo")
1040
1041def build_request(options: FinalRequestOptions) -> None:
1042client = self.client.copy()
1043client._build_request(options)
1044
1045# ensure that the machinery is warmed up before tracing starts.
1046build_request(options)
1047gc.collect()
1048
1049tracemalloc.start(1000)
1050
1051snapshot_before = tracemalloc.take_snapshot()
1052
1053ITERATIONS = 10
1054for _ in range(ITERATIONS):
1055build_request(options)
1056
ce04ec28Stainless Bot2 years ago1057gc.collect()
d052708aStainless Bot2 years ago1058snapshot_after = tracemalloc.take_snapshot()
1059
1060tracemalloc.stop()
1061
1062def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None:
1063if diff.count == 0:
1064# Avoid false positives by considering only leaks (i.e. allocations that persist).
1065return
1066
1067if diff.count % ITERATIONS != 0:
1068# Avoid false positives by considering only leaks that appear per iteration.
1069return
1070
1071for frame in diff.traceback:
1072if any(
1073frame.filename.endswith(fragment)
1074for fragment in [
1075# to_raw_response_wrapper leaks through the @functools.wraps() decorator.
1076#
1077# removing the decorator fixes the leak for reasons we don't understand.
86379b44Stainless Bot2 years ago1078"openai/_legacy_response.py",
d052708aStainless Bot2 years ago1079"openai/_response.py",
1080# pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason.
1081"openai/_compat.py",
1082# Standard library leaks we don't care about.
1083"/logging/__init__.py",
1084]
1085):
1086return
1087
1088leaks.append(diff)
1089
1090leaks: list[tracemalloc.StatisticDiff] = []
1091for diff in snapshot_after.compare_to(snapshot_before, "traceback"):
1092add_leak(leaks, diff)
1093if leaks:
1094for leak in leaks:
1095print("MEMORY LEAK:", leak)
1096for frame in leak.traceback:
1097print(frame)
1098raise AssertionError()
1099
08b8179aDavid Schnurr2 years ago1100async def test_request_timeout(self) -> None:
1101request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
1102timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1103assert timeout == DEFAULT_TIMEOUT
1104
1105request = self.client._build_request(
1106FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
1107)
1108timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1109assert timeout == httpx.Timeout(100.0)
1110
1111async def test_client_timeout_option(self) -> None:
1112client = AsyncOpenAI(
1113base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0)
1114)
1115
1116request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1117timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1118assert timeout == httpx.Timeout(0)
1119
1120async def test_http_client_timeout_option(self) -> None:
1121# custom timeout given to the httpx client should be used
1122async with httpx.AsyncClient(timeout=None) as http_client:
1123client = AsyncOpenAI(
1124base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1125)
1126
1127request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1128timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1129assert timeout == httpx.Timeout(None)
1130
1131# no timeout given to the httpx client should not use the httpx default
1132async with httpx.AsyncClient() as http_client:
1133client = AsyncOpenAI(
1134base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1135)
1136
1137request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1138timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1139assert timeout == DEFAULT_TIMEOUT
1140
1141# explicitly passing the default timeout currently results in it being ignored
1142async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
1143client = AsyncOpenAI(
1144base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1145)
1146
1147request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1148timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1149assert timeout == DEFAULT_TIMEOUT # our default
1150
dae0ec80Stainless Bot2 years ago1151def test_invalid_http_client(self) -> None:
1152with pytest.raises(TypeError, match="Invalid `http_client` arg"):
1153with httpx.Client() as http_client:
1154AsyncOpenAI(
1155base_url=base_url,
1156api_key=api_key,
1157_strict_response_validation=True,
1158http_client=cast(Any, http_client),
1159)
1160
08b8179aDavid Schnurr2 years ago1161def test_default_headers_option(self) -> None:
1162client = AsyncOpenAI(
1163base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
1164)
1165request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1166assert request.headers.get("x-foo") == "bar"
1167assert request.headers.get("x-stainless-lang") == "python"
1168
1169client2 = AsyncOpenAI(
1170base_url=base_url,
1171api_key=api_key,
1172_strict_response_validation=True,
1173default_headers={
1174"X-Foo": "stainless",
1175"X-Stainless-Lang": "my-overriding-header",
1176},
1177)
1178request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
1179assert request.headers.get("x-foo") == "stainless"
1180assert request.headers.get("x-stainless-lang") == "my-overriding-header"
1181
1182def test_validate_headers(self) -> None:
1183client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1184request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1185assert request.headers.get("Authorization") == f"Bearer {api_key}"
1186
e967f5a5Stainless Bot2 years ago1187with pytest.raises(OpenAIError):
30194f19stainless-app[bot]1 years ago1188with update_env(**{"OPENAI_API_KEY": Omit()}):
1189client2 = AsyncOpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
08b8179aDavid Schnurr2 years ago1190_ = client2
1191
1192def test_default_query_option(self) -> None:
1193client = AsyncOpenAI(
1194base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
1195)
1196request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1197url = httpx.URL(request.url)
1198assert dict(url.params) == {"query_param": "bar"}
1199
1200request = client._build_request(
1201FinalRequestOptions(
1202method="get",
1203url="/foo",
eba67815stainless-app[bot]1 years ago1204params={"foo": "baz", "query_param": "overridden"},
08b8179aDavid Schnurr2 years ago1205)
1206)
1207url = httpx.URL(request.url)
eba67815stainless-app[bot]1 years ago1208assert dict(url.params) == {"foo": "baz", "query_param": "overridden"}
08b8179aDavid Schnurr2 years ago1209
1210def test_request_extra_json(self) -> None:
1211request = self.client._build_request(
1212FinalRequestOptions(
1213method="post",
1214url="/foo",
1215json_data={"foo": "bar"},
1216extra_json={"baz": False},
1217),
1218)
1219data = json.loads(request.content.decode("utf-8"))
1220assert data == {"foo": "bar", "baz": False}
1221
1222request = self.client._build_request(
1223FinalRequestOptions(
1224method="post",
1225url="/foo",
1226extra_json={"baz": False},
1227),
1228)
1229data = json.loads(request.content.decode("utf-8"))
1230assert data == {"baz": False}
1231
1232# `extra_json` takes priority over `json_data` when keys clash
1233request = self.client._build_request(
1234FinalRequestOptions(
1235method="post",
1236url="/foo",
1237json_data={"foo": "bar", "baz": True},
1238extra_json={"baz": None},
1239),
1240)
1241data = json.loads(request.content.decode("utf-8"))
1242assert data == {"foo": "bar", "baz": None}
1243
1244def test_request_extra_headers(self) -> None:
1245request = self.client._build_request(
1246FinalRequestOptions(
1247method="post",
1248url="/foo",
1249**make_request_options(extra_headers={"X-Foo": "Foo"}),
1250),
1251)
1252assert request.headers.get("X-Foo") == "Foo"
1253
1254# `extra_headers` takes priority over `default_headers` when keys clash
1255request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
1256FinalRequestOptions(
1257method="post",
1258url="/foo",
1259**make_request_options(
1260extra_headers={"X-Bar": "false"},
1261),
1262),
1263)
1264assert request.headers.get("X-Bar") == "false"
1265
1266def test_request_extra_query(self) -> None:
1267request = self.client._build_request(
1268FinalRequestOptions(
1269method="post",
1270url="/foo",
1271**make_request_options(
1272extra_query={"my_query_param": "Foo"},
1273),
1274),
1275)
31573844Stainless Bot2 years ago1276params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago1277assert params == {"my_query_param": "Foo"}
1278
1279# if both `query` and `extra_query` are given, they are merged
1280request = self.client._build_request(
1281FinalRequestOptions(
1282method="post",
1283url="/foo",
1284**make_request_options(
1285query={"bar": "1"},
1286extra_query={"foo": "2"},
1287),
1288),
1289)
31573844Stainless Bot2 years ago1290params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago1291assert params == {"bar": "1", "foo": "2"}
1292
1293# `extra_query` takes priority over `query` when keys clash
1294request = self.client._build_request(
1295FinalRequestOptions(
1296method="post",
1297url="/foo",
1298**make_request_options(
1299query={"foo": "1"},
1300extra_query={"foo": "2"},
1301),
1302),
1303)
31573844Stainless Bot2 years ago1304params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago1305assert params == {"foo": "2"}
1306
22713fd0Stainless Bot2 years ago1307def test_multipart_repeating_array(self, async_client: AsyncOpenAI) -> None:
1308request = async_client._build_request(
1309FinalRequestOptions.construct(
1310method="get",
1311url="/foo",
1312headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
1313json_data={"array": ["foo", "bar"]},
1314files=[("foo.txt", b"hello world")],
1315)
1316)
1317
1318assert request.read().split(b"\r\n") == [
1319b"--6b7ba517decee4a450543ea6ae821c82",
1320b'Content-Disposition: form-data; name="array[]"',
1321b"",
1322b"foo",
1323b"--6b7ba517decee4a450543ea6ae821c82",
1324b'Content-Disposition: form-data; name="array[]"',
1325b"",
1326b"bar",
1327b"--6b7ba517decee4a450543ea6ae821c82",
1328b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
1329b"Content-Type: application/octet-stream",
1330b"",
1331b"hello world",
1332b"--6b7ba517decee4a450543ea6ae821c82--",
1333b"",
1334]
1335
08b8179aDavid Schnurr2 years ago1336@pytest.mark.respx(base_url=base_url)
1337async def test_basic_union_response(self, respx_mock: MockRouter) -> None:
1338class Model1(BaseModel):
1339name: str
1340
1341class Model2(BaseModel):
1342foo: str
1343
1344respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1345
1346response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1347assert isinstance(response, Model2)
1348assert response.foo == "bar"
1349
1350@pytest.mark.respx(base_url=base_url)
1351async def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
1352"""Union of objects with the same field name using a different type"""
1353
1354class Model1(BaseModel):
1355foo: int
1356
1357class Model2(BaseModel):
1358foo: str
1359
1360respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1361
1362response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1363assert isinstance(response, Model2)
1364assert response.foo == "bar"
1365
1366respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
1367
1368response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1369assert isinstance(response, Model1)
1370assert response.foo == 1
1371
c26014e2Stainless Bot2 years ago1372@pytest.mark.respx(base_url=base_url)
1373async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
1374"""
1375Response that sets Content-Type to something other than application/json but returns json data
1376"""
1377
1378class Model(BaseModel):
1379foo: int
1380
1381respx_mock.get("/foo").mock(
1382return_value=httpx.Response(
1383200,
1384content=json.dumps({"foo": 2}),
1385headers={"Content-Type": "application/text"},
1386)
1387)
1388
1389response = await self.client.get("/foo", cast_to=Model)
1390assert isinstance(response, Model)
1391assert response.foo == 2
1392
f6f38a9bStainless Bot2 years ago1393def test_base_url_setter(self) -> None:
1394client = AsyncOpenAI(
1395base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True
1396)
1397assert client.base_url == "https://example.com/from_init/"
1398
1399client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
1400
1401assert client.base_url == "https://example.com/from_setter/"
1402
0733934fStainless Bot2 years ago1403def test_base_url_env(self) -> None:
1404with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
1405client = AsyncOpenAI(api_key=api_key, _strict_response_validation=True)
1406assert client.base_url == "http://localhost:5000/from/env/"
1407
08b8179aDavid Schnurr2 years ago1408@pytest.mark.parametrize(
1409"client",
1410[
1411AsyncOpenAI(
1412base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1413),
1414AsyncOpenAI(
1415base_url="http://localhost:5000/custom/path/",
1416api_key=api_key,
1417_strict_response_validation=True,
1418http_client=httpx.AsyncClient(),
1419),
1420],
1421ids=["standard", "custom http client"],
1422)
1423def test_base_url_trailing_slash(self, client: AsyncOpenAI) -> None:
1424request = client._build_request(
1425FinalRequestOptions(
1426method="post",
1427url="/foo",
1428json_data={"foo": "bar"},
1429),
1430)
1431assert request.url == "http://localhost:5000/custom/path/foo"
1432
1433@pytest.mark.parametrize(
1434"client",
1435[
1436AsyncOpenAI(
1437base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1438),
1439AsyncOpenAI(
1440base_url="http://localhost:5000/custom/path/",
1441api_key=api_key,
1442_strict_response_validation=True,
1443http_client=httpx.AsyncClient(),
1444),
1445],
1446ids=["standard", "custom http client"],
1447)
1448def test_base_url_no_trailing_slash(self, client: AsyncOpenAI) -> None:
1449request = client._build_request(
1450FinalRequestOptions(
1451method="post",
1452url="/foo",
1453json_data={"foo": "bar"},
1454),
1455)
1456assert request.url == "http://localhost:5000/custom/path/foo"
1457
1458@pytest.mark.parametrize(
1459"client",
1460[
1461AsyncOpenAI(
1462base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1463),
1464AsyncOpenAI(
1465base_url="http://localhost:5000/custom/path/",
1466api_key=api_key,
1467_strict_response_validation=True,
1468http_client=httpx.AsyncClient(),
1469),
1470],
1471ids=["standard", "custom http client"],
1472)
1473def test_absolute_request_url(self, client: AsyncOpenAI) -> None:
1474request = client._build_request(
1475FinalRequestOptions(
1476method="post",
1477url="https://myapi.com/foo",
1478json_data={"foo": "bar"},
1479),
1480)
1481assert request.url == "https://myapi.com/foo"
1482
1483async def test_copied_client_does_not_close_http(self) -> None:
1484client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1485assert not client.is_closed()
1486
1487copied = client.copy()
1488assert copied is not client
1489
a7ebc260Stainless Bot2 years ago1490del copied
08b8179aDavid Schnurr2 years ago1491
1492await asyncio.sleep(0.2)
1493assert not client.is_closed()
1494
1495async def test_client_context_manager(self) -> None:
1496client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1497async with client as c2:
1498assert c2 is client
1499assert not c2.is_closed()
1500assert not client.is_closed()
1501assert client.is_closed()
1502
1503@pytest.mark.respx(base_url=base_url)
1504@pytest.mark.asyncio
1505async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
1506class Model(BaseModel):
1507foo: str
1508
1509respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
1510
1511with pytest.raises(APIResponseValidationError) as exc:
1512await self.client.get("/foo", cast_to=Model)
1513
1514assert isinstance(exc.value.__cause__, ValidationError)
1515
07079085Stainless Bot2 years ago1516async def test_client_max_retries_validation(self) -> None:
1517with pytest.raises(TypeError, match=r"max_retries cannot be None"):
1518AsyncOpenAI(
1519base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None)
1520)
1521
08b8179aDavid Schnurr2 years ago1522@pytest.mark.respx(base_url=base_url)
1523@pytest.mark.asyncio
1524async def test_default_stream_cls(self, respx_mock: MockRouter) -> None:
1525class Model(BaseModel):
1526name: str
1527
1528respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1529
86379b44Stainless Bot2 years ago1530stream = await self.client.post("/foo", cast_to=Model, stream=True, stream_cls=AsyncStream[Model])
1531assert isinstance(stream, AsyncStream)
1532await stream.response.aclose()
08b8179aDavid Schnurr2 years ago1533
1534@pytest.mark.respx(base_url=base_url)
1535@pytest.mark.asyncio
1536async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
1537class Model(BaseModel):
1538name: str
1539
1540respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
1541
1542strict_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1543
1544with pytest.raises(APIResponseValidationError):
1545await strict_client.get("/foo", cast_to=Model)
1546
1547client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
1548
1549response = await client.get("/foo", cast_to=Model)
1550assert isinstance(response, str) # type: ignore[unreachable]
1551
1552@pytest.mark.parametrize(
1553"remaining_retries,retry_after,timeout",
1554[
1555[3, "20", 20],
1556[3, "0", 0.5],
1557[3, "-10", 0.5],
1558[3, "60", 60],
1559[3, "61", 0.5],
1560[3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
1561[3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
1562[3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
1563[3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
1564[3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
1565[3, "99999999999999999999999999999999999", 0.5],
1566[3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
1567[3, "", 0.5],
1568[2, "", 0.5 * 2.0],
1569[1, "", 0.5 * 4.0],
7f6a921cstainless-app[bot]1 years ago1570[-1100, "", 8], # test large number potentially overflowing
08b8179aDavid Schnurr2 years ago1571],
1572)
1573@mock.patch("time.time", mock.MagicMock(return_value=1696004797))
1574@pytest.mark.asyncio
1575async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
1576client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1577
1578headers = httpx.Headers({"retry-after": retry_after})
1579options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
1580calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
1581assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
7aad3405Stainless Bot2 years ago1582
ba4f7a97Stainless Bot2 years ago1583@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1584@pytest.mark.respx(base_url=base_url)
1585async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
1586respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
1587
1588with pytest.raises(APITimeoutError):
1589await self.client.post(
1590"/chat/completions",
76382e3cStainless Bot2 years ago1591body=cast(
1592object,
1593dict(
1594messages=[
1595{
1596"role": "user",
1597"content": "Say this is a test",
1598}
1599],
23444ed9Stainless Bot1 years ago1600model="gpt-4o",
76382e3cStainless Bot2 years ago1601),
ba4f7a97Stainless Bot2 years ago1602),
1603cast_to=httpx.Response,
86379b44Stainless Bot2 years ago1604options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
ba4f7a97Stainless Bot2 years ago1605)
7aad3405Stainless Bot2 years ago1606
1607assert _get_open_connections(self.client) == 0
1608
ba4f7a97Stainless Bot2 years ago1609@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
7aad3405Stainless Bot2 years ago1610@pytest.mark.respx(base_url=base_url)
ba4f7a97Stainless Bot2 years ago1611async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
1612respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
7aad3405Stainless Bot2 years ago1613
ba4f7a97Stainless Bot2 years ago1614with pytest.raises(APIStatusError):
1615await self.client.post(
1616"/chat/completions",
76382e3cStainless Bot2 years ago1617body=cast(
1618object,
1619dict(
1620messages=[
1621{
1622"role": "user",
1623"content": "Say this is a test",
1624}
1625],
23444ed9Stainless Bot1 years ago1626model="gpt-4o",
76382e3cStainless Bot2 years ago1627),
ba4f7a97Stainless Bot2 years ago1628),
1629cast_to=httpx.Response,
86379b44Stainless Bot2 years ago1630options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
7aad3405Stainless Bot2 years ago1631)
1632
ba4f7a97Stainless Bot2 years ago1633assert _get_open_connections(self.client) == 0
98d8b2acstainless-app[bot]1 years ago1634
1635@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1636@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1637@pytest.mark.respx(base_url=base_url)
1638@pytest.mark.asyncio
6634f525stainless-app[bot]1 years ago1639@pytest.mark.parametrize("failure_mode", ["status", "exception"])
98d8b2acstainless-app[bot]1 years ago1640async def test_retries_taken(
6634f525stainless-app[bot]1 years ago1641self,
1642async_client: AsyncOpenAI,
1643failures_before_success: int,
1644failure_mode: Literal["status", "exception"],
1645respx_mock: MockRouter,
98d8b2acstainless-app[bot]1 years ago1646) -> None:
1647client = async_client.with_options(max_retries=4)
1648
1649nb_retries = 0
1650
1651def retry_handler(_request: httpx.Request) -> httpx.Response:
1652nonlocal nb_retries
1653if nb_retries < failures_before_success:
1654nb_retries += 1
6634f525stainless-app[bot]1 years ago1655if failure_mode == "exception":
1656raise RuntimeError("oops")
98d8b2acstainless-app[bot]1 years ago1657return httpx.Response(500)
1658return httpx.Response(200)
1659
1660respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1661
1662response = await client.chat.completions.with_raw_response.create(
1663messages=[
1664{
bf1ca86cRobert Craigie1 years ago1665"content": "string",
575ff607stainless-app[bot]1 years ago1666"role": "developer",
98d8b2acstainless-app[bot]1 years ago1667}
1668],
bf1ca86cRobert Craigie1 years ago1669model="gpt-4o",
98d8b2acstainless-app[bot]1 years ago1670)
1671
1672assert response.retries_taken == failures_before_success
5449e208Stainless Bot1 years ago1673assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
1674
1675@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1676@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1677@pytest.mark.respx(base_url=base_url)
1678@pytest.mark.asyncio
1679async def test_omit_retry_count_header(
1680self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1681) -> None:
1682client = async_client.with_options(max_retries=4)
1683
1684nb_retries = 0
1685
1686def retry_handler(_request: httpx.Request) -> httpx.Response:
1687nonlocal nb_retries
1688if nb_retries < failures_before_success:
1689nb_retries += 1
1690return httpx.Response(500)
1691return httpx.Response(200)
1692
1693respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1694
1695response = await client.chat.completions.with_raw_response.create(
1696messages=[
1697{
1698"content": "string",
575ff607stainless-app[bot]1 years ago1699"role": "developer",
5449e208Stainless Bot1 years ago1700}
1701],
1702model="gpt-4o",
1703extra_headers={"x-stainless-retry-count": Omit()},
1704)
1705
1706assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0
1707
1708@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1709@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1710@pytest.mark.respx(base_url=base_url)
1711@pytest.mark.asyncio
1712async def test_overwrite_retry_count_header(
1713self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1714) -> None:
1715client = async_client.with_options(max_retries=4)
1716
1717nb_retries = 0
1718
1719def retry_handler(_request: httpx.Request) -> httpx.Response:
1720nonlocal nb_retries
1721if nb_retries < failures_before_success:
1722nb_retries += 1
1723return httpx.Response(500)
1724return httpx.Response(200)
1725
1726respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1727
1728response = await client.chat.completions.with_raw_response.create(
1729messages=[
1730{
1731"content": "string",
575ff607stainless-app[bot]1 years ago1732"role": "developer",
5449e208Stainless Bot1 years ago1733}
1734],
1735model="gpt-4o",
1736extra_headers={"x-stainless-retry-count": "42"},
1737)
1738
1739assert response.http_request.headers.get("x-stainless-retry-count") == "42"
98d8b2acstainless-app[bot]1 years ago1740
1741@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1742@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1743@pytest.mark.respx(base_url=base_url)
1744@pytest.mark.asyncio
1745async def test_retries_taken_new_response_class(
1746self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1747) -> None:
1748client = async_client.with_options(max_retries=4)
1749
1750nb_retries = 0
1751
1752def retry_handler(_request: httpx.Request) -> httpx.Response:
1753nonlocal nb_retries
1754if nb_retries < failures_before_success:
1755nb_retries += 1
1756return httpx.Response(500)
1757return httpx.Response(200)
1758
1759respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1760
1761async with client.chat.completions.with_streaming_response.create(
1762messages=[
1763{
bf1ca86cRobert Craigie1 years ago1764"content": "string",
575ff607stainless-app[bot]1 years ago1765"role": "developer",
98d8b2acstainless-app[bot]1 years ago1766}
1767],
bf1ca86cRobert Craigie1 years ago1768model="gpt-4o",
98d8b2acstainless-app[bot]1 years ago1769) as response:
1770assert response.retries_taken == failures_before_success
5449e208Stainless Bot1 years ago1771assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
d8901d28Seth Gilchrist1 years ago1772
1773def test_get_platform(self) -> None:
53ab0467Stainless Bot1 years ago1774# A previous implementation of asyncify could leave threads unterminated when
1775# used with nest_asyncio.
1776#
d8901d28Seth Gilchrist1 years ago1777# Since nest_asyncio.apply() is global and cannot be un-applied, this
1778# test is run in a separate process to avoid affecting other tests.
53ab0467Stainless Bot1 years ago1779test_code = dedent("""
d8901d28Seth Gilchrist1 years ago1780import asyncio
1781import nest_asyncio
1782import threading
1783
1784from openai._utils import asyncify
53ab0467Stainless Bot1 years ago1785from openai._base_client import get_platform
d8901d28Seth Gilchrist1 years ago1786
1787async def test_main() -> None:
1788result = await asyncify(get_platform)()
1789print(result)
1790for thread in threading.enumerate():
1791print(thread.name)
1792
1793nest_asyncio.apply()
1794asyncio.run(test_main())
1795""")
1796with subprocess.Popen(
1797[sys.executable, "-c", test_code],
1798text=True,
1799) as process:
1800try:
1801process.wait(2)
1802if process.returncode:
1803raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code")
1804except subprocess.TimeoutExpired as e:
1805process.kill()
1806raise AssertionError("calling get_platform using asyncify resulted in a hung process") from e