openai/openai-python

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v1.67.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

tests/test_client.py

1831lines · 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
14543c59stainless-app[bot]1 years ago9import time
08b8179aDavid Schnurr2 years ago10import asyncio
11import inspect
d8901d28Seth Gilchrist1 years ago12import subprocess
d052708aStainless Bot2 years ago13import tracemalloc
31573844Stainless Bot2 years ago14from typing import Any, Union, cast
d8901d28Seth Gilchrist1 years ago15from textwrap import dedent
08b8179aDavid Schnurr2 years ago16from unittest import mock
6634f525stainless-app[bot]1 years ago17from typing_extensions import Literal
08b8179aDavid Schnurr2 years ago18
19import httpx
20import pytest
21from respx import MockRouter
22from pydantic import ValidationError
23
24from openai import OpenAI, AsyncOpenAI, APIResponseValidationError
30194f19stainless-app[bot]1 years ago25from openai._types import Omit
300f58bbstainless-app[bot]1 years ago26from openai._utils import maybe_transform
08b8179aDavid Schnurr2 years ago27from openai._models import BaseModel, FinalRequestOptions
86379b44Stainless Bot2 years ago28from openai._constants import RAW_RESPONSE_HEADER
08b8179aDavid Schnurr2 years ago29from openai._streaming import Stream, AsyncStream
a47375b7Stainless Bot2 years ago30from openai._exceptions import OpenAIError, APIStatusError, APITimeoutError, APIResponseValidationError
31from openai._base_client import DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, make_request_options
300f58bbstainless-app[bot]1 years ago32from openai.types.chat.completion_create_params import CompletionCreateParamsNonStreaming
08b8179aDavid Schnurr2 years ago33
0733934fStainless Bot2 years ago34from .utils import update_env
35
08b8179aDavid Schnurr2 years ago36base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
37api_key = "My API Key"
38
39
40def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]:
41request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
42url = httpx.URL(request.url)
43return dict(url.params)
44
45
ba4f7a97Stainless Bot2 years ago46def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float:
47return 0.1
7aad3405Stainless Bot2 years ago48
49
50def _get_open_connections(client: OpenAI | AsyncOpenAI) -> int:
51transport = client._client._transport
52assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport)
53
54pool = transport._pool
55return len(pool._requests)
56
57
08b8179aDavid Schnurr2 years ago58class TestOpenAI:
59client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
60
61@pytest.mark.respx(base_url=base_url)
62def test_raw_response(self, respx_mock: MockRouter) -> None:
c5975bd0Stainless Bot2 years ago63respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
08b8179aDavid Schnurr2 years ago64
65response = self.client.post("/foo", cast_to=httpx.Response)
66assert response.status_code == 200
67assert isinstance(response, httpx.Response)
c5975bd0Stainless Bot2 years ago68assert response.json() == {"foo": "bar"}
08b8179aDavid Schnurr2 years ago69
70@pytest.mark.respx(base_url=base_url)
71def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
72respx_mock.post("/foo").mock(
73return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
74)
75
76response = self.client.post("/foo", cast_to=httpx.Response)
77assert response.status_code == 200
78assert isinstance(response, httpx.Response)
c5975bd0Stainless Bot2 years ago79assert response.json() == {"foo": "bar"}
08b8179aDavid Schnurr2 years ago80
81def test_copy(self) -> None:
82copied = self.client.copy()
83assert id(copied) != id(self.client)
84
85copied = self.client.copy(api_key="another My API Key")
86assert copied.api_key == "another My API Key"
87assert self.client.api_key == "My API Key"
88
89def test_copy_default_options(self) -> None:
90# options that have a default are overridden correctly
91copied = self.client.copy(max_retries=7)
92assert copied.max_retries == 7
93assert self.client.max_retries == 2
94
95copied2 = copied.copy(max_retries=6)
96assert copied2.max_retries == 6
97assert copied.max_retries == 7
98
99# timeout
100assert isinstance(self.client.timeout, httpx.Timeout)
101copied = self.client.copy(timeout=None)
102assert copied.timeout is None
103assert isinstance(self.client.timeout, httpx.Timeout)
104
105def test_copy_default_headers(self) -> None:
106client = OpenAI(
107base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
108)
109assert client.default_headers["X-Foo"] == "bar"
110
111# does not override the already given value when not specified
112copied = client.copy()
113assert copied.default_headers["X-Foo"] == "bar"
114
115# merges already given headers
116copied = client.copy(default_headers={"X-Bar": "stainless"})
117assert copied.default_headers["X-Foo"] == "bar"
118assert copied.default_headers["X-Bar"] == "stainless"
119
120# uses new values for any already given headers
121copied = client.copy(default_headers={"X-Foo": "stainless"})
122assert copied.default_headers["X-Foo"] == "stainless"
123
124# set_default_headers
125
126# completely overrides already set values
127copied = client.copy(set_default_headers={})
128assert copied.default_headers.get("X-Foo") is None
129
130copied = client.copy(set_default_headers={"X-Bar": "Robert"})
131assert copied.default_headers["X-Bar"] == "Robert"
132
133with pytest.raises(
134ValueError,
135match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
136):
137client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
138
139def test_copy_default_query(self) -> None:
140client = OpenAI(
141base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
142)
143assert _get_params(client)["foo"] == "bar"
144
145# does not override the already given value when not specified
146copied = client.copy()
147assert _get_params(copied)["foo"] == "bar"
148
149# merges already given params
150copied = client.copy(default_query={"bar": "stainless"})
151params = _get_params(copied)
152assert params["foo"] == "bar"
153assert params["bar"] == "stainless"
154
155# uses new values for any already given headers
156copied = client.copy(default_query={"foo": "stainless"})
157assert _get_params(copied)["foo"] == "stainless"
158
159# set_default_query
160
161# completely overrides already set values
162copied = client.copy(set_default_query={})
163assert _get_params(copied) == {}
164
165copied = client.copy(set_default_query={"bar": "Robert"})
166assert _get_params(copied)["bar"] == "Robert"
167
168with pytest.raises(
169ValueError,
170# TODO: update
171match="`default_query` and `set_default_query` arguments are mutually exclusive",
172):
173client.copy(set_default_query={}, default_query={"foo": "Bar"})
174
175def test_copy_signature(self) -> None:
176# ensure the same parameters that can be passed to the client are defined in the `.copy()` method
177init_signature = inspect.signature(
178# mypy doesn't like that we access the `__init__` property.
179self.client.__init__, # type: ignore[misc]
180)
181copy_signature = inspect.signature(self.client.copy)
182exclude_params = {"transport", "proxies", "_strict_response_validation"}
183
184for name in init_signature.parameters.keys():
185if name in exclude_params:
186continue
187
188copy_param = copy_signature.parameters.get(name)
189assert copy_param is not None, f"copy() signature is missing the {name} param"
190
d052708aStainless Bot2 years ago191def test_copy_build_request(self) -> None:
192options = FinalRequestOptions(method="get", url="/foo")
193
194def build_request(options: FinalRequestOptions) -> None:
195client = self.client.copy()
196client._build_request(options)
197
198# ensure that the machinery is warmed up before tracing starts.
199build_request(options)
200gc.collect()
201
202tracemalloc.start(1000)
203
204snapshot_before = tracemalloc.take_snapshot()
205
206ITERATIONS = 10
207for _ in range(ITERATIONS):
208build_request(options)
209
ce04ec28Stainless Bot2 years ago210gc.collect()
d052708aStainless Bot2 years ago211snapshot_after = tracemalloc.take_snapshot()
212
213tracemalloc.stop()
214
215def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None:
216if diff.count == 0:
217# Avoid false positives by considering only leaks (i.e. allocations that persist).
218return
219
220if diff.count % ITERATIONS != 0:
221# Avoid false positives by considering only leaks that appear per iteration.
222return
223
224for frame in diff.traceback:
225if any(
226frame.filename.endswith(fragment)
227for fragment in [
228# to_raw_response_wrapper leaks through the @functools.wraps() decorator.
229#
230# removing the decorator fixes the leak for reasons we don't understand.
86379b44Stainless Bot2 years ago231"openai/_legacy_response.py",
d052708aStainless Bot2 years ago232"openai/_response.py",
233# pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason.
234"openai/_compat.py",
235# Standard library leaks we don't care about.
236"/logging/__init__.py",
237]
238):
239return
240
241leaks.append(diff)
242
243leaks: list[tracemalloc.StatisticDiff] = []
244for diff in snapshot_after.compare_to(snapshot_before, "traceback"):
245add_leak(leaks, diff)
246if leaks:
247for leak in leaks:
248print("MEMORY LEAK:", leak)
249for frame in leak.traceback:
250print(frame)
251raise AssertionError()
252
08b8179aDavid Schnurr2 years ago253def test_request_timeout(self) -> None:
254request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
255timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
256assert timeout == DEFAULT_TIMEOUT
257
258request = self.client._build_request(
259FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
260)
261timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
262assert timeout == httpx.Timeout(100.0)
263
264def test_client_timeout_option(self) -> None:
265client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0))
266
267request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
268timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
269assert timeout == httpx.Timeout(0)
270
271def test_http_client_timeout_option(self) -> None:
272# custom timeout given to the httpx client should be used
273with httpx.Client(timeout=None) as http_client:
274client = OpenAI(
275base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
276)
277
278request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
279timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
280assert timeout == httpx.Timeout(None)
281
282# no timeout given to the httpx client should not use the httpx default
283with httpx.Client() as http_client:
284client = OpenAI(
285base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
286)
287
288request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
289timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
290assert timeout == DEFAULT_TIMEOUT
291
292# explicitly passing the default timeout currently results in it being ignored
293with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
294client = OpenAI(
295base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
296)
297
298request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
299timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
300assert timeout == DEFAULT_TIMEOUT # our default
301
dae0ec80Stainless Bot2 years ago302async def test_invalid_http_client(self) -> None:
303with pytest.raises(TypeError, match="Invalid `http_client` arg"):
304async with httpx.AsyncClient() as http_client:
305OpenAI(
306base_url=base_url,
307api_key=api_key,
308_strict_response_validation=True,
309http_client=cast(Any, http_client),
310)
311
08b8179aDavid Schnurr2 years ago312def test_default_headers_option(self) -> None:
313client = OpenAI(
314base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
315)
316request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
317assert request.headers.get("x-foo") == "bar"
318assert request.headers.get("x-stainless-lang") == "python"
319
320client2 = OpenAI(
321base_url=base_url,
322api_key=api_key,
323_strict_response_validation=True,
324default_headers={
325"X-Foo": "stainless",
326"X-Stainless-Lang": "my-overriding-header",
327},
328)
329request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
330assert request.headers.get("x-foo") == "stainless"
331assert request.headers.get("x-stainless-lang") == "my-overriding-header"
332
333def test_validate_headers(self) -> None:
334client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
335request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
336assert request.headers.get("Authorization") == f"Bearer {api_key}"
337
e967f5a5Stainless Bot2 years ago338with pytest.raises(OpenAIError):
30194f19stainless-app[bot]1 years ago339with update_env(**{"OPENAI_API_KEY": Omit()}):
340client2 = OpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
08b8179aDavid Schnurr2 years ago341_ = client2
342
343def test_default_query_option(self) -> None:
344client = OpenAI(
345base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
346)
347request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
348url = httpx.URL(request.url)
349assert dict(url.params) == {"query_param": "bar"}
350
351request = client._build_request(
352FinalRequestOptions(
353method="get",
354url="/foo",
eba67815stainless-app[bot]1 years ago355params={"foo": "baz", "query_param": "overridden"},
08b8179aDavid Schnurr2 years ago356)
357)
358url = httpx.URL(request.url)
eba67815stainless-app[bot]1 years ago359assert dict(url.params) == {"foo": "baz", "query_param": "overridden"}
08b8179aDavid Schnurr2 years ago360
361def test_request_extra_json(self) -> None:
362request = self.client._build_request(
363FinalRequestOptions(
364method="post",
365url="/foo",
366json_data={"foo": "bar"},
367extra_json={"baz": False},
368),
369)
370data = json.loads(request.content.decode("utf-8"))
371assert data == {"foo": "bar", "baz": False}
372
373request = self.client._build_request(
374FinalRequestOptions(
375method="post",
376url="/foo",
377extra_json={"baz": False},
378),
379)
380data = json.loads(request.content.decode("utf-8"))
381assert data == {"baz": False}
382
383# `extra_json` takes priority over `json_data` when keys clash
384request = self.client._build_request(
385FinalRequestOptions(
386method="post",
387url="/foo",
388json_data={"foo": "bar", "baz": True},
389extra_json={"baz": None},
390),
391)
392data = json.loads(request.content.decode("utf-8"))
393assert data == {"foo": "bar", "baz": None}
394
395def test_request_extra_headers(self) -> None:
396request = self.client._build_request(
397FinalRequestOptions(
398method="post",
399url="/foo",
400**make_request_options(extra_headers={"X-Foo": "Foo"}),
401),
402)
403assert request.headers.get("X-Foo") == "Foo"
404
405# `extra_headers` takes priority over `default_headers` when keys clash
406request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
407FinalRequestOptions(
408method="post",
409url="/foo",
410**make_request_options(
411extra_headers={"X-Bar": "false"},
412),
413),
414)
415assert request.headers.get("X-Bar") == "false"
416
417def test_request_extra_query(self) -> None:
418request = self.client._build_request(
419FinalRequestOptions(
420method="post",
421url="/foo",
422**make_request_options(
423extra_query={"my_query_param": "Foo"},
424),
425),
426)
31573844Stainless Bot2 years ago427params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago428assert params == {"my_query_param": "Foo"}
429
430# if both `query` and `extra_query` are given, they are merged
431request = self.client._build_request(
432FinalRequestOptions(
433method="post",
434url="/foo",
435**make_request_options(
436query={"bar": "1"},
437extra_query={"foo": "2"},
438),
439),
440)
31573844Stainless Bot2 years ago441params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago442assert params == {"bar": "1", "foo": "2"}
443
444# `extra_query` takes priority over `query` when keys clash
445request = self.client._build_request(
446FinalRequestOptions(
447method="post",
448url="/foo",
449**make_request_options(
450query={"foo": "1"},
451extra_query={"foo": "2"},
452),
453),
454)
31573844Stainless Bot2 years ago455params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago456assert params == {"foo": "2"}
457
22713fd0Stainless Bot2 years ago458def test_multipart_repeating_array(self, client: OpenAI) -> None:
459request = client._build_request(
460FinalRequestOptions.construct(
461method="get",
462url="/foo",
463headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
464json_data={"array": ["foo", "bar"]},
465files=[("foo.txt", b"hello world")],
466)
467)
468
469assert request.read().split(b"\r\n") == [
470b"--6b7ba517decee4a450543ea6ae821c82",
471b'Content-Disposition: form-data; name="array[]"',
472b"",
473b"foo",
474b"--6b7ba517decee4a450543ea6ae821c82",
475b'Content-Disposition: form-data; name="array[]"',
476b"",
477b"bar",
478b"--6b7ba517decee4a450543ea6ae821c82",
479b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
480b"Content-Type: application/octet-stream",
481b"",
482b"hello world",
483b"--6b7ba517decee4a450543ea6ae821c82--",
484b"",
485]
486
08b8179aDavid Schnurr2 years ago487@pytest.mark.respx(base_url=base_url)
488def test_basic_union_response(self, respx_mock: MockRouter) -> None:
489class Model1(BaseModel):
490name: str
491
492class Model2(BaseModel):
493foo: str
494
495respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
496
497response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
498assert isinstance(response, Model2)
499assert response.foo == "bar"
500
501@pytest.mark.respx(base_url=base_url)
502def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
503"""Union of objects with the same field name using a different type"""
504
505class Model1(BaseModel):
506foo: int
507
508class Model2(BaseModel):
509foo: str
510
511respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
512
513response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
514assert isinstance(response, Model2)
515assert response.foo == "bar"
516
517respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
518
519response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
520assert isinstance(response, Model1)
521assert response.foo == 1
522
c26014e2Stainless Bot2 years ago523@pytest.mark.respx(base_url=base_url)
524def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
525"""
526Response that sets Content-Type to something other than application/json but returns json data
527"""
528
529class Model(BaseModel):
530foo: int
531
532respx_mock.get("/foo").mock(
533return_value=httpx.Response(
534200,
535content=json.dumps({"foo": 2}),
536headers={"Content-Type": "application/text"},
537)
538)
539
540response = self.client.get("/foo", cast_to=Model)
541assert isinstance(response, Model)
542assert response.foo == 2
543
f6f38a9bStainless Bot2 years ago544def test_base_url_setter(self) -> None:
545client = OpenAI(base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True)
546assert client.base_url == "https://example.com/from_init/"
547
548client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
549
550assert client.base_url == "https://example.com/from_setter/"
551
0733934fStainless Bot2 years ago552def test_base_url_env(self) -> None:
553with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
554client = OpenAI(api_key=api_key, _strict_response_validation=True)
555assert client.base_url == "http://localhost:5000/from/env/"
556
08b8179aDavid Schnurr2 years ago557@pytest.mark.parametrize(
558"client",
559[
560OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
561OpenAI(
562base_url="http://localhost:5000/custom/path/",
563api_key=api_key,
564_strict_response_validation=True,
565http_client=httpx.Client(),
566),
567],
568ids=["standard", "custom http client"],
569)
570def test_base_url_trailing_slash(self, client: OpenAI) -> None:
571request = client._build_request(
572FinalRequestOptions(
573method="post",
574url="/foo",
575json_data={"foo": "bar"},
576),
577)
578assert request.url == "http://localhost:5000/custom/path/foo"
579
580@pytest.mark.parametrize(
581"client",
582[
583OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
584OpenAI(
585base_url="http://localhost:5000/custom/path/",
586api_key=api_key,
587_strict_response_validation=True,
588http_client=httpx.Client(),
589),
590],
591ids=["standard", "custom http client"],
592)
593def test_base_url_no_trailing_slash(self, client: OpenAI) -> None:
594request = client._build_request(
595FinalRequestOptions(
596method="post",
597url="/foo",
598json_data={"foo": "bar"},
599),
600)
601assert request.url == "http://localhost:5000/custom/path/foo"
602
603@pytest.mark.parametrize(
604"client",
605[
606OpenAI(base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True),
607OpenAI(
608base_url="http://localhost:5000/custom/path/",
609api_key=api_key,
610_strict_response_validation=True,
611http_client=httpx.Client(),
612),
613],
614ids=["standard", "custom http client"],
615)
616def test_absolute_request_url(self, client: OpenAI) -> None:
617request = client._build_request(
618FinalRequestOptions(
619method="post",
620url="https://myapi.com/foo",
621json_data={"foo": "bar"},
622),
623)
624assert request.url == "https://myapi.com/foo"
625
626def test_copied_client_does_not_close_http(self) -> None:
627client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
628assert not client.is_closed()
629
630copied = client.copy()
631assert copied is not client
632
a7ebc260Stainless Bot2 years ago633del copied
08b8179aDavid Schnurr2 years ago634
635assert not client.is_closed()
636
637def test_client_context_manager(self) -> None:
638client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
639with client as c2:
640assert c2 is client
641assert not c2.is_closed()
642assert not client.is_closed()
643assert client.is_closed()
644
645@pytest.mark.respx(base_url=base_url)
646def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
647class Model(BaseModel):
648foo: str
649
650respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
651
652with pytest.raises(APIResponseValidationError) as exc:
653self.client.get("/foo", cast_to=Model)
654
655assert isinstance(exc.value.__cause__, ValidationError)
656
07079085Stainless Bot2 years ago657def test_client_max_retries_validation(self) -> None:
658with pytest.raises(TypeError, match=r"max_retries cannot be None"):
659OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None))
660
08b8179aDavid Schnurr2 years ago661@pytest.mark.respx(base_url=base_url)
662def test_default_stream_cls(self, respx_mock: MockRouter) -> None:
663class Model(BaseModel):
664name: str
665
666respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
667
86379b44Stainless Bot2 years ago668stream = self.client.post("/foo", cast_to=Model, stream=True, stream_cls=Stream[Model])
669assert isinstance(stream, Stream)
670stream.response.close()
08b8179aDavid Schnurr2 years ago671
672@pytest.mark.respx(base_url=base_url)
673def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
674class Model(BaseModel):
675name: str
676
677respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
678
679strict_client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
680
681with pytest.raises(APIResponseValidationError):
682strict_client.get("/foo", cast_to=Model)
683
684client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
685
686response = client.get("/foo", cast_to=Model)
687assert isinstance(response, str) # type: ignore[unreachable]
688
689@pytest.mark.parametrize(
690"remaining_retries,retry_after,timeout",
691[
692[3, "20", 20],
693[3, "0", 0.5],
694[3, "-10", 0.5],
695[3, "60", 60],
696[3, "61", 0.5],
697[3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
698[3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
699[3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
700[3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
701[3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
702[3, "99999999999999999999999999999999999", 0.5],
703[3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
704[3, "", 0.5],
705[2, "", 0.5 * 2.0],
706[1, "", 0.5 * 4.0],
7f6a921cstainless-app[bot]1 years ago707[-1100, "", 8], # test large number potentially overflowing
08b8179aDavid Schnurr2 years ago708],
709)
710@mock.patch("time.time", mock.MagicMock(return_value=1696004797))
711def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
712client = OpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
713
714headers = httpx.Headers({"retry-after": retry_after})
715options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
716calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
717assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
718
ba4f7a97Stainless Bot2 years ago719@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
720@pytest.mark.respx(base_url=base_url)
721def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
722respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
723
724with pytest.raises(APITimeoutError):
725self.client.post(
726"/chat/completions",
76382e3cStainless Bot2 years ago727body=cast(
728object,
300f58bbstainless-app[bot]1 years ago729maybe_transform(
730dict(
731messages=[
732{
733"role": "user",
734"content": "Say this is a test",
735}
736],
737model="gpt-4o",
738),
739CompletionCreateParamsNonStreaming,
76382e3cStainless Bot2 years ago740),
ba4f7a97Stainless Bot2 years ago741),
742cast_to=httpx.Response,
86379b44Stainless Bot2 years ago743options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
ba4f7a97Stainless Bot2 years ago744)
7aad3405Stainless Bot2 years ago745
746assert _get_open_connections(self.client) == 0
747
ba4f7a97Stainless Bot2 years ago748@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
7aad3405Stainless Bot2 years ago749@pytest.mark.respx(base_url=base_url)
ba4f7a97Stainless Bot2 years ago750def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
751respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
7aad3405Stainless Bot2 years ago752
ba4f7a97Stainless Bot2 years ago753with pytest.raises(APIStatusError):
754self.client.post(
755"/chat/completions",
76382e3cStainless Bot2 years ago756body=cast(
757object,
300f58bbstainless-app[bot]1 years ago758maybe_transform(
759dict(
760messages=[
761{
762"role": "user",
763"content": "Say this is a test",
764}
765],
766model="gpt-4o",
767),
768CompletionCreateParamsNonStreaming,
76382e3cStainless Bot2 years ago769),
ba4f7a97Stainless Bot2 years ago770),
771cast_to=httpx.Response,
86379b44Stainless Bot2 years ago772options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
7aad3405Stainless Bot2 years ago773)
774
ba4f7a97Stainless Bot2 years ago775assert _get_open_connections(self.client) == 0
7aad3405Stainless Bot2 years ago776
98d8b2acstainless-app[bot]1 years ago777@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
778@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
779@pytest.mark.respx(base_url=base_url)
6634f525stainless-app[bot]1 years ago780@pytest.mark.parametrize("failure_mode", ["status", "exception"])
781def test_retries_taken(
782self,
783client: OpenAI,
784failures_before_success: int,
785failure_mode: Literal["status", "exception"],
786respx_mock: MockRouter,
787) -> None:
98d8b2acstainless-app[bot]1 years ago788client = client.with_options(max_retries=4)
789
790nb_retries = 0
791
792def retry_handler(_request: httpx.Request) -> httpx.Response:
793nonlocal nb_retries
794if nb_retries < failures_before_success:
795nb_retries += 1
6634f525stainless-app[bot]1 years ago796if failure_mode == "exception":
797raise RuntimeError("oops")
98d8b2acstainless-app[bot]1 years ago798return httpx.Response(500)
799return httpx.Response(200)
800
801respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
802
803response = client.chat.completions.with_raw_response.create(
804messages=[
805{
bf1ca86cRobert Craigie1 years ago806"content": "string",
575ff607stainless-app[bot]1 years ago807"role": "developer",
98d8b2acstainless-app[bot]1 years ago808}
809],
bf1ca86cRobert Craigie1 years ago810model="gpt-4o",
98d8b2acstainless-app[bot]1 years ago811)
812
813assert response.retries_taken == failures_before_success
5449e208Stainless Bot1 years ago814assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
815
816@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
817@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
818@pytest.mark.respx(base_url=base_url)
819def test_omit_retry_count_header(
820self, client: OpenAI, failures_before_success: int, respx_mock: MockRouter
821) -> None:
822client = client.with_options(max_retries=4)
823
824nb_retries = 0
825
826def retry_handler(_request: httpx.Request) -> httpx.Response:
827nonlocal nb_retries
828if nb_retries < failures_before_success:
829nb_retries += 1
830return httpx.Response(500)
831return httpx.Response(200)
832
833respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
834
835response = client.chat.completions.with_raw_response.create(
836messages=[
837{
838"content": "string",
575ff607stainless-app[bot]1 years ago839"role": "developer",
5449e208Stainless Bot1 years ago840}
841],
842model="gpt-4o",
843extra_headers={"x-stainless-retry-count": Omit()},
844)
845
846assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0
847
848@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
849@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
850@pytest.mark.respx(base_url=base_url)
851def test_overwrite_retry_count_header(
852self, client: OpenAI, failures_before_success: int, respx_mock: MockRouter
853) -> None:
854client = client.with_options(max_retries=4)
855
856nb_retries = 0
857
858def retry_handler(_request: httpx.Request) -> httpx.Response:
859nonlocal nb_retries
860if nb_retries < failures_before_success:
861nb_retries += 1
862return httpx.Response(500)
863return httpx.Response(200)
864
865respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
866
867response = client.chat.completions.with_raw_response.create(
868messages=[
869{
870"content": "string",
575ff607stainless-app[bot]1 years ago871"role": "developer",
5449e208Stainless Bot1 years ago872}
873],
874model="gpt-4o",
875extra_headers={"x-stainless-retry-count": "42"},
876)
877
878assert response.http_request.headers.get("x-stainless-retry-count") == "42"
98d8b2acstainless-app[bot]1 years ago879
880@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
881@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
882@pytest.mark.respx(base_url=base_url)
883def test_retries_taken_new_response_class(
884self, client: OpenAI, failures_before_success: int, respx_mock: MockRouter
885) -> None:
886client = client.with_options(max_retries=4)
887
888nb_retries = 0
889
890def retry_handler(_request: httpx.Request) -> httpx.Response:
891nonlocal nb_retries
892if nb_retries < failures_before_success:
893nb_retries += 1
894return httpx.Response(500)
895return httpx.Response(200)
896
897respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
898
899with client.chat.completions.with_streaming_response.create(
900messages=[
901{
bf1ca86cRobert Craigie1 years ago902"content": "string",
575ff607stainless-app[bot]1 years ago903"role": "developer",
98d8b2acstainless-app[bot]1 years ago904}
905],
bf1ca86cRobert Craigie1 years ago906model="gpt-4o",
98d8b2acstainless-app[bot]1 years ago907) as response:
908assert response.retries_taken == failures_before_success
5449e208Stainless Bot1 years ago909assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
98d8b2acstainless-app[bot]1 years ago910
08b8179aDavid Schnurr2 years ago911
912class TestAsyncOpenAI:
913client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
914
915@pytest.mark.respx(base_url=base_url)
916@pytest.mark.asyncio
917async def test_raw_response(self, respx_mock: MockRouter) -> None:
c5975bd0Stainless Bot2 years ago918respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
08b8179aDavid Schnurr2 years ago919
920response = await self.client.post("/foo", cast_to=httpx.Response)
921assert response.status_code == 200
922assert isinstance(response, httpx.Response)
c5975bd0Stainless Bot2 years ago923assert response.json() == {"foo": "bar"}
08b8179aDavid Schnurr2 years ago924
925@pytest.mark.respx(base_url=base_url)
926@pytest.mark.asyncio
927async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None:
928respx_mock.post("/foo").mock(
929return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}')
930)
931
932response = await self.client.post("/foo", cast_to=httpx.Response)
933assert response.status_code == 200
934assert isinstance(response, httpx.Response)
c5975bd0Stainless Bot2 years ago935assert response.json() == {"foo": "bar"}
08b8179aDavid Schnurr2 years ago936
937def test_copy(self) -> None:
938copied = self.client.copy()
939assert id(copied) != id(self.client)
940
941copied = self.client.copy(api_key="another My API Key")
942assert copied.api_key == "another My API Key"
943assert self.client.api_key == "My API Key"
944
945def test_copy_default_options(self) -> None:
946# options that have a default are overridden correctly
947copied = self.client.copy(max_retries=7)
948assert copied.max_retries == 7
949assert self.client.max_retries == 2
950
951copied2 = copied.copy(max_retries=6)
952assert copied2.max_retries == 6
953assert copied.max_retries == 7
954
955# timeout
956assert isinstance(self.client.timeout, httpx.Timeout)
957copied = self.client.copy(timeout=None)
958assert copied.timeout is None
959assert isinstance(self.client.timeout, httpx.Timeout)
960
961def test_copy_default_headers(self) -> None:
962client = AsyncOpenAI(
963base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
964)
965assert client.default_headers["X-Foo"] == "bar"
966
967# does not override the already given value when not specified
968copied = client.copy()
969assert copied.default_headers["X-Foo"] == "bar"
970
971# merges already given headers
972copied = client.copy(default_headers={"X-Bar": "stainless"})
973assert copied.default_headers["X-Foo"] == "bar"
974assert copied.default_headers["X-Bar"] == "stainless"
975
976# uses new values for any already given headers
977copied = client.copy(default_headers={"X-Foo": "stainless"})
978assert copied.default_headers["X-Foo"] == "stainless"
979
980# set_default_headers
981
982# completely overrides already set values
983copied = client.copy(set_default_headers={})
984assert copied.default_headers.get("X-Foo") is None
985
986copied = client.copy(set_default_headers={"X-Bar": "Robert"})
987assert copied.default_headers["X-Bar"] == "Robert"
988
989with pytest.raises(
990ValueError,
991match="`default_headers` and `set_default_headers` arguments are mutually exclusive",
992):
993client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"})
994
995def test_copy_default_query(self) -> None:
996client = AsyncOpenAI(
997base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"}
998)
999assert _get_params(client)["foo"] == "bar"
1000
1001# does not override the already given value when not specified
1002copied = client.copy()
1003assert _get_params(copied)["foo"] == "bar"
1004
1005# merges already given params
1006copied = client.copy(default_query={"bar": "stainless"})
1007params = _get_params(copied)
1008assert params["foo"] == "bar"
1009assert params["bar"] == "stainless"
1010
1011# uses new values for any already given headers
1012copied = client.copy(default_query={"foo": "stainless"})
1013assert _get_params(copied)["foo"] == "stainless"
1014
1015# set_default_query
1016
1017# completely overrides already set values
1018copied = client.copy(set_default_query={})
1019assert _get_params(copied) == {}
1020
1021copied = client.copy(set_default_query={"bar": "Robert"})
1022assert _get_params(copied)["bar"] == "Robert"
1023
1024with pytest.raises(
1025ValueError,
1026# TODO: update
1027match="`default_query` and `set_default_query` arguments are mutually exclusive",
1028):
1029client.copy(set_default_query={}, default_query={"foo": "Bar"})
1030
1031def test_copy_signature(self) -> None:
1032# ensure the same parameters that can be passed to the client are defined in the `.copy()` method
1033init_signature = inspect.signature(
1034# mypy doesn't like that we access the `__init__` property.
1035self.client.__init__, # type: ignore[misc]
1036)
1037copy_signature = inspect.signature(self.client.copy)
1038exclude_params = {"transport", "proxies", "_strict_response_validation"}
1039
1040for name in init_signature.parameters.keys():
1041if name in exclude_params:
1042continue
1043
1044copy_param = copy_signature.parameters.get(name)
1045assert copy_param is not None, f"copy() signature is missing the {name} param"
1046
d052708aStainless Bot2 years ago1047def test_copy_build_request(self) -> None:
1048options = FinalRequestOptions(method="get", url="/foo")
1049
1050def build_request(options: FinalRequestOptions) -> None:
1051client = self.client.copy()
1052client._build_request(options)
1053
1054# ensure that the machinery is warmed up before tracing starts.
1055build_request(options)
1056gc.collect()
1057
1058tracemalloc.start(1000)
1059
1060snapshot_before = tracemalloc.take_snapshot()
1061
1062ITERATIONS = 10
1063for _ in range(ITERATIONS):
1064build_request(options)
1065
ce04ec28Stainless Bot2 years ago1066gc.collect()
d052708aStainless Bot2 years ago1067snapshot_after = tracemalloc.take_snapshot()
1068
1069tracemalloc.stop()
1070
1071def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.StatisticDiff) -> None:
1072if diff.count == 0:
1073# Avoid false positives by considering only leaks (i.e. allocations that persist).
1074return
1075
1076if diff.count % ITERATIONS != 0:
1077# Avoid false positives by considering only leaks that appear per iteration.
1078return
1079
1080for frame in diff.traceback:
1081if any(
1082frame.filename.endswith(fragment)
1083for fragment in [
1084# to_raw_response_wrapper leaks through the @functools.wraps() decorator.
1085#
1086# removing the decorator fixes the leak for reasons we don't understand.
86379b44Stainless Bot2 years ago1087"openai/_legacy_response.py",
d052708aStainless Bot2 years ago1088"openai/_response.py",
1089# pydantic.BaseModel.model_dump || pydantic.BaseModel.dict leak memory for some reason.
1090"openai/_compat.py",
1091# Standard library leaks we don't care about.
1092"/logging/__init__.py",
1093]
1094):
1095return
1096
1097leaks.append(diff)
1098
1099leaks: list[tracemalloc.StatisticDiff] = []
1100for diff in snapshot_after.compare_to(snapshot_before, "traceback"):
1101add_leak(leaks, diff)
1102if leaks:
1103for leak in leaks:
1104print("MEMORY LEAK:", leak)
1105for frame in leak.traceback:
1106print(frame)
1107raise AssertionError()
1108
08b8179aDavid Schnurr2 years ago1109async def test_request_timeout(self) -> None:
1110request = self.client._build_request(FinalRequestOptions(method="get", url="/foo"))
1111timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1112assert timeout == DEFAULT_TIMEOUT
1113
1114request = self.client._build_request(
1115FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))
1116)
1117timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1118assert timeout == httpx.Timeout(100.0)
1119
1120async def test_client_timeout_option(self) -> None:
1121client = AsyncOpenAI(
1122base_url=base_url, api_key=api_key, _strict_response_validation=True, timeout=httpx.Timeout(0)
1123)
1124
1125request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1126timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1127assert timeout == httpx.Timeout(0)
1128
1129async def test_http_client_timeout_option(self) -> None:
1130# custom timeout given to the httpx client should be used
1131async with httpx.AsyncClient(timeout=None) as http_client:
1132client = AsyncOpenAI(
1133base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1134)
1135
1136request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1137timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1138assert timeout == httpx.Timeout(None)
1139
1140# no timeout given to the httpx client should not use the httpx default
1141async with httpx.AsyncClient() as http_client:
1142client = AsyncOpenAI(
1143base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1144)
1145
1146request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1147timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1148assert timeout == DEFAULT_TIMEOUT
1149
1150# explicitly passing the default timeout currently results in it being ignored
1151async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
1152client = AsyncOpenAI(
1153base_url=base_url, api_key=api_key, _strict_response_validation=True, http_client=http_client
1154)
1155
1156request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1157timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore
1158assert timeout == DEFAULT_TIMEOUT # our default
1159
dae0ec80Stainless Bot2 years ago1160def test_invalid_http_client(self) -> None:
1161with pytest.raises(TypeError, match="Invalid `http_client` arg"):
1162with httpx.Client() as http_client:
1163AsyncOpenAI(
1164base_url=base_url,
1165api_key=api_key,
1166_strict_response_validation=True,
1167http_client=cast(Any, http_client),
1168)
1169
08b8179aDavid Schnurr2 years ago1170def test_default_headers_option(self) -> None:
1171client = AsyncOpenAI(
1172base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"}
1173)
1174request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1175assert request.headers.get("x-foo") == "bar"
1176assert request.headers.get("x-stainless-lang") == "python"
1177
1178client2 = AsyncOpenAI(
1179base_url=base_url,
1180api_key=api_key,
1181_strict_response_validation=True,
1182default_headers={
1183"X-Foo": "stainless",
1184"X-Stainless-Lang": "my-overriding-header",
1185},
1186)
1187request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
1188assert request.headers.get("x-foo") == "stainless"
1189assert request.headers.get("x-stainless-lang") == "my-overriding-header"
1190
1191def test_validate_headers(self) -> None:
1192client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1193request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1194assert request.headers.get("Authorization") == f"Bearer {api_key}"
1195
e967f5a5Stainless Bot2 years ago1196with pytest.raises(OpenAIError):
30194f19stainless-app[bot]1 years ago1197with update_env(**{"OPENAI_API_KEY": Omit()}):
1198client2 = AsyncOpenAI(base_url=base_url, api_key=None, _strict_response_validation=True)
08b8179aDavid Schnurr2 years ago1199_ = client2
1200
1201def test_default_query_option(self) -> None:
1202client = AsyncOpenAI(
1203base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"}
1204)
1205request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
1206url = httpx.URL(request.url)
1207assert dict(url.params) == {"query_param": "bar"}
1208
1209request = client._build_request(
1210FinalRequestOptions(
1211method="get",
1212url="/foo",
eba67815stainless-app[bot]1 years ago1213params={"foo": "baz", "query_param": "overridden"},
08b8179aDavid Schnurr2 years ago1214)
1215)
1216url = httpx.URL(request.url)
eba67815stainless-app[bot]1 years ago1217assert dict(url.params) == {"foo": "baz", "query_param": "overridden"}
08b8179aDavid Schnurr2 years ago1218
1219def test_request_extra_json(self) -> None:
1220request = self.client._build_request(
1221FinalRequestOptions(
1222method="post",
1223url="/foo",
1224json_data={"foo": "bar"},
1225extra_json={"baz": False},
1226),
1227)
1228data = json.loads(request.content.decode("utf-8"))
1229assert data == {"foo": "bar", "baz": False}
1230
1231request = self.client._build_request(
1232FinalRequestOptions(
1233method="post",
1234url="/foo",
1235extra_json={"baz": False},
1236),
1237)
1238data = json.loads(request.content.decode("utf-8"))
1239assert data == {"baz": False}
1240
1241# `extra_json` takes priority over `json_data` when keys clash
1242request = self.client._build_request(
1243FinalRequestOptions(
1244method="post",
1245url="/foo",
1246json_data={"foo": "bar", "baz": True},
1247extra_json={"baz": None},
1248),
1249)
1250data = json.loads(request.content.decode("utf-8"))
1251assert data == {"foo": "bar", "baz": None}
1252
1253def test_request_extra_headers(self) -> None:
1254request = self.client._build_request(
1255FinalRequestOptions(
1256method="post",
1257url="/foo",
1258**make_request_options(extra_headers={"X-Foo": "Foo"}),
1259),
1260)
1261assert request.headers.get("X-Foo") == "Foo"
1262
1263# `extra_headers` takes priority over `default_headers` when keys clash
1264request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request(
1265FinalRequestOptions(
1266method="post",
1267url="/foo",
1268**make_request_options(
1269extra_headers={"X-Bar": "false"},
1270),
1271),
1272)
1273assert request.headers.get("X-Bar") == "false"
1274
1275def test_request_extra_query(self) -> None:
1276request = self.client._build_request(
1277FinalRequestOptions(
1278method="post",
1279url="/foo",
1280**make_request_options(
1281extra_query={"my_query_param": "Foo"},
1282),
1283),
1284)
31573844Stainless Bot2 years ago1285params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago1286assert params == {"my_query_param": "Foo"}
1287
1288# if both `query` and `extra_query` are given, they are merged
1289request = self.client._build_request(
1290FinalRequestOptions(
1291method="post",
1292url="/foo",
1293**make_request_options(
1294query={"bar": "1"},
1295extra_query={"foo": "2"},
1296),
1297),
1298)
31573844Stainless Bot2 years ago1299params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago1300assert params == {"bar": "1", "foo": "2"}
1301
1302# `extra_query` takes priority over `query` when keys clash
1303request = self.client._build_request(
1304FinalRequestOptions(
1305method="post",
1306url="/foo",
1307**make_request_options(
1308query={"foo": "1"},
1309extra_query={"foo": "2"},
1310),
1311),
1312)
31573844Stainless Bot2 years ago1313params = dict(request.url.params)
08b8179aDavid Schnurr2 years ago1314assert params == {"foo": "2"}
1315
22713fd0Stainless Bot2 years ago1316def test_multipart_repeating_array(self, async_client: AsyncOpenAI) -> None:
1317request = async_client._build_request(
1318FinalRequestOptions.construct(
1319method="get",
1320url="/foo",
1321headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"},
1322json_data={"array": ["foo", "bar"]},
1323files=[("foo.txt", b"hello world")],
1324)
1325)
1326
1327assert request.read().split(b"\r\n") == [
1328b"--6b7ba517decee4a450543ea6ae821c82",
1329b'Content-Disposition: form-data; name="array[]"',
1330b"",
1331b"foo",
1332b"--6b7ba517decee4a450543ea6ae821c82",
1333b'Content-Disposition: form-data; name="array[]"',
1334b"",
1335b"bar",
1336b"--6b7ba517decee4a450543ea6ae821c82",
1337b'Content-Disposition: form-data; name="foo.txt"; filename="upload"',
1338b"Content-Type: application/octet-stream",
1339b"",
1340b"hello world",
1341b"--6b7ba517decee4a450543ea6ae821c82--",
1342b"",
1343]
1344
08b8179aDavid Schnurr2 years ago1345@pytest.mark.respx(base_url=base_url)
1346async def test_basic_union_response(self, respx_mock: MockRouter) -> None:
1347class Model1(BaseModel):
1348name: str
1349
1350class Model2(BaseModel):
1351foo: str
1352
1353respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1354
1355response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1356assert isinstance(response, Model2)
1357assert response.foo == "bar"
1358
1359@pytest.mark.respx(base_url=base_url)
1360async def test_union_response_different_types(self, respx_mock: MockRouter) -> None:
1361"""Union of objects with the same field name using a different type"""
1362
1363class Model1(BaseModel):
1364foo: int
1365
1366class Model2(BaseModel):
1367foo: str
1368
1369respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1370
1371response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1372assert isinstance(response, Model2)
1373assert response.foo == "bar"
1374
1375respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1}))
1376
1377response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2]))
1378assert isinstance(response, Model1)
1379assert response.foo == 1
1380
c26014e2Stainless Bot2 years ago1381@pytest.mark.respx(base_url=base_url)
1382async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
1383"""
1384Response that sets Content-Type to something other than application/json but returns json data
1385"""
1386
1387class Model(BaseModel):
1388foo: int
1389
1390respx_mock.get("/foo").mock(
1391return_value=httpx.Response(
1392200,
1393content=json.dumps({"foo": 2}),
1394headers={"Content-Type": "application/text"},
1395)
1396)
1397
1398response = await self.client.get("/foo", cast_to=Model)
1399assert isinstance(response, Model)
1400assert response.foo == 2
1401
f6f38a9bStainless Bot2 years ago1402def test_base_url_setter(self) -> None:
1403client = AsyncOpenAI(
1404base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True
1405)
1406assert client.base_url == "https://example.com/from_init/"
1407
1408client.base_url = "https://example.com/from_setter" # type: ignore[assignment]
1409
1410assert client.base_url == "https://example.com/from_setter/"
1411
0733934fStainless Bot2 years ago1412def test_base_url_env(self) -> None:
1413with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
1414client = AsyncOpenAI(api_key=api_key, _strict_response_validation=True)
1415assert client.base_url == "http://localhost:5000/from/env/"
1416
08b8179aDavid Schnurr2 years ago1417@pytest.mark.parametrize(
1418"client",
1419[
1420AsyncOpenAI(
1421base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1422),
1423AsyncOpenAI(
1424base_url="http://localhost:5000/custom/path/",
1425api_key=api_key,
1426_strict_response_validation=True,
1427http_client=httpx.AsyncClient(),
1428),
1429],
1430ids=["standard", "custom http client"],
1431)
1432def test_base_url_trailing_slash(self, client: AsyncOpenAI) -> None:
1433request = client._build_request(
1434FinalRequestOptions(
1435method="post",
1436url="/foo",
1437json_data={"foo": "bar"},
1438),
1439)
1440assert request.url == "http://localhost:5000/custom/path/foo"
1441
1442@pytest.mark.parametrize(
1443"client",
1444[
1445AsyncOpenAI(
1446base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1447),
1448AsyncOpenAI(
1449base_url="http://localhost:5000/custom/path/",
1450api_key=api_key,
1451_strict_response_validation=True,
1452http_client=httpx.AsyncClient(),
1453),
1454],
1455ids=["standard", "custom http client"],
1456)
1457def test_base_url_no_trailing_slash(self, client: AsyncOpenAI) -> None:
1458request = client._build_request(
1459FinalRequestOptions(
1460method="post",
1461url="/foo",
1462json_data={"foo": "bar"},
1463),
1464)
1465assert request.url == "http://localhost:5000/custom/path/foo"
1466
1467@pytest.mark.parametrize(
1468"client",
1469[
1470AsyncOpenAI(
1471base_url="http://localhost:5000/custom/path/", api_key=api_key, _strict_response_validation=True
1472),
1473AsyncOpenAI(
1474base_url="http://localhost:5000/custom/path/",
1475api_key=api_key,
1476_strict_response_validation=True,
1477http_client=httpx.AsyncClient(),
1478),
1479],
1480ids=["standard", "custom http client"],
1481)
1482def test_absolute_request_url(self, client: AsyncOpenAI) -> None:
1483request = client._build_request(
1484FinalRequestOptions(
1485method="post",
1486url="https://myapi.com/foo",
1487json_data={"foo": "bar"},
1488),
1489)
1490assert request.url == "https://myapi.com/foo"
1491
1492async def test_copied_client_does_not_close_http(self) -> None:
1493client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1494assert not client.is_closed()
1495
1496copied = client.copy()
1497assert copied is not client
1498
a7ebc260Stainless Bot2 years ago1499del copied
08b8179aDavid Schnurr2 years ago1500
1501await asyncio.sleep(0.2)
1502assert not client.is_closed()
1503
1504async def test_client_context_manager(self) -> None:
1505client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1506async with client as c2:
1507assert c2 is client
1508assert not c2.is_closed()
1509assert not client.is_closed()
1510assert client.is_closed()
1511
1512@pytest.mark.respx(base_url=base_url)
1513@pytest.mark.asyncio
1514async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None:
1515class Model(BaseModel):
1516foo: str
1517
1518respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}}))
1519
1520with pytest.raises(APIResponseValidationError) as exc:
1521await self.client.get("/foo", cast_to=Model)
1522
1523assert isinstance(exc.value.__cause__, ValidationError)
1524
07079085Stainless Bot2 years ago1525async def test_client_max_retries_validation(self) -> None:
1526with pytest.raises(TypeError, match=r"max_retries cannot be None"):
1527AsyncOpenAI(
1528base_url=base_url, api_key=api_key, _strict_response_validation=True, max_retries=cast(Any, None)
1529)
1530
08b8179aDavid Schnurr2 years ago1531@pytest.mark.respx(base_url=base_url)
1532@pytest.mark.asyncio
1533async def test_default_stream_cls(self, respx_mock: MockRouter) -> None:
1534class Model(BaseModel):
1535name: str
1536
1537respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
1538
86379b44Stainless Bot2 years ago1539stream = await self.client.post("/foo", cast_to=Model, stream=True, stream_cls=AsyncStream[Model])
1540assert isinstance(stream, AsyncStream)
1541await stream.response.aclose()
08b8179aDavid Schnurr2 years ago1542
1543@pytest.mark.respx(base_url=base_url)
1544@pytest.mark.asyncio
1545async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None:
1546class Model(BaseModel):
1547name: str
1548
1549respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
1550
1551strict_client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1552
1553with pytest.raises(APIResponseValidationError):
1554await strict_client.get("/foo", cast_to=Model)
1555
1556client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=False)
1557
1558response = await client.get("/foo", cast_to=Model)
1559assert isinstance(response, str) # type: ignore[unreachable]
1560
1561@pytest.mark.parametrize(
1562"remaining_retries,retry_after,timeout",
1563[
1564[3, "20", 20],
1565[3, "0", 0.5],
1566[3, "-10", 0.5],
1567[3, "60", 60],
1568[3, "61", 0.5],
1569[3, "Fri, 29 Sep 2023 16:26:57 GMT", 20],
1570[3, "Fri, 29 Sep 2023 16:26:37 GMT", 0.5],
1571[3, "Fri, 29 Sep 2023 16:26:27 GMT", 0.5],
1572[3, "Fri, 29 Sep 2023 16:27:37 GMT", 60],
1573[3, "Fri, 29 Sep 2023 16:27:38 GMT", 0.5],
1574[3, "99999999999999999999999999999999999", 0.5],
1575[3, "Zun, 29 Sep 2023 16:26:27 GMT", 0.5],
1576[3, "", 0.5],
1577[2, "", 0.5 * 2.0],
1578[1, "", 0.5 * 4.0],
7f6a921cstainless-app[bot]1 years ago1579[-1100, "", 8], # test large number potentially overflowing
08b8179aDavid Schnurr2 years ago1580],
1581)
1582@mock.patch("time.time", mock.MagicMock(return_value=1696004797))
1583@pytest.mark.asyncio
1584async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None:
1585client = AsyncOpenAI(base_url=base_url, api_key=api_key, _strict_response_validation=True)
1586
1587headers = httpx.Headers({"retry-after": retry_after})
1588options = FinalRequestOptions(method="get", url="/foo", max_retries=3)
1589calculated = client._calculate_retry_timeout(remaining_retries, options, headers)
1590assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType]
7aad3405Stainless Bot2 years ago1591
ba4f7a97Stainless Bot2 years ago1592@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1593@pytest.mark.respx(base_url=base_url)
1594async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
1595respx_mock.post("/chat/completions").mock(side_effect=httpx.TimeoutException("Test timeout error"))
1596
1597with pytest.raises(APITimeoutError):
1598await self.client.post(
1599"/chat/completions",
76382e3cStainless Bot2 years ago1600body=cast(
1601object,
300f58bbstainless-app[bot]1 years ago1602maybe_transform(
1603dict(
1604messages=[
1605{
1606"role": "user",
1607"content": "Say this is a test",
1608}
1609],
1610model="gpt-4o",
1611),
1612CompletionCreateParamsNonStreaming,
76382e3cStainless Bot2 years ago1613),
ba4f7a97Stainless Bot2 years ago1614),
1615cast_to=httpx.Response,
86379b44Stainless Bot2 years ago1616options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
ba4f7a97Stainless Bot2 years ago1617)
7aad3405Stainless Bot2 years ago1618
1619assert _get_open_connections(self.client) == 0
1620
ba4f7a97Stainless Bot2 years ago1621@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
7aad3405Stainless Bot2 years ago1622@pytest.mark.respx(base_url=base_url)
ba4f7a97Stainless Bot2 years ago1623async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None:
1624respx_mock.post("/chat/completions").mock(return_value=httpx.Response(500))
7aad3405Stainless Bot2 years ago1625
ba4f7a97Stainless Bot2 years ago1626with pytest.raises(APIStatusError):
1627await self.client.post(
1628"/chat/completions",
76382e3cStainless Bot2 years ago1629body=cast(
1630object,
300f58bbstainless-app[bot]1 years ago1631maybe_transform(
1632dict(
1633messages=[
1634{
1635"role": "user",
1636"content": "Say this is a test",
1637}
1638],
1639model="gpt-4o",
1640),
1641CompletionCreateParamsNonStreaming,
76382e3cStainless Bot2 years ago1642),
ba4f7a97Stainless Bot2 years ago1643),
1644cast_to=httpx.Response,
86379b44Stainless Bot2 years ago1645options={"headers": {RAW_RESPONSE_HEADER: "stream"}},
7aad3405Stainless Bot2 years ago1646)
1647
ba4f7a97Stainless Bot2 years ago1648assert _get_open_connections(self.client) == 0
98d8b2acstainless-app[bot]1 years ago1649
1650@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1651@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1652@pytest.mark.respx(base_url=base_url)
1653@pytest.mark.asyncio
6634f525stainless-app[bot]1 years ago1654@pytest.mark.parametrize("failure_mode", ["status", "exception"])
98d8b2acstainless-app[bot]1 years ago1655async def test_retries_taken(
6634f525stainless-app[bot]1 years ago1656self,
1657async_client: AsyncOpenAI,
1658failures_before_success: int,
1659failure_mode: Literal["status", "exception"],
1660respx_mock: MockRouter,
98d8b2acstainless-app[bot]1 years ago1661) -> None:
1662client = async_client.with_options(max_retries=4)
1663
1664nb_retries = 0
1665
1666def retry_handler(_request: httpx.Request) -> httpx.Response:
1667nonlocal nb_retries
1668if nb_retries < failures_before_success:
1669nb_retries += 1
6634f525stainless-app[bot]1 years ago1670if failure_mode == "exception":
1671raise RuntimeError("oops")
98d8b2acstainless-app[bot]1 years ago1672return httpx.Response(500)
1673return httpx.Response(200)
1674
1675respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1676
1677response = await client.chat.completions.with_raw_response.create(
1678messages=[
1679{
bf1ca86cRobert Craigie1 years ago1680"content": "string",
575ff607stainless-app[bot]1 years ago1681"role": "developer",
98d8b2acstainless-app[bot]1 years ago1682}
1683],
bf1ca86cRobert Craigie1 years ago1684model="gpt-4o",
98d8b2acstainless-app[bot]1 years ago1685)
1686
1687assert response.retries_taken == failures_before_success
5449e208Stainless Bot1 years ago1688assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
1689
1690@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1691@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1692@pytest.mark.respx(base_url=base_url)
1693@pytest.mark.asyncio
1694async def test_omit_retry_count_header(
1695self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1696) -> None:
1697client = async_client.with_options(max_retries=4)
1698
1699nb_retries = 0
1700
1701def retry_handler(_request: httpx.Request) -> httpx.Response:
1702nonlocal nb_retries
1703if nb_retries < failures_before_success:
1704nb_retries += 1
1705return httpx.Response(500)
1706return httpx.Response(200)
1707
1708respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1709
1710response = await client.chat.completions.with_raw_response.create(
1711messages=[
1712{
1713"content": "string",
575ff607stainless-app[bot]1 years ago1714"role": "developer",
5449e208Stainless Bot1 years ago1715}
1716],
1717model="gpt-4o",
1718extra_headers={"x-stainless-retry-count": Omit()},
1719)
1720
1721assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0
1722
1723@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1724@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1725@pytest.mark.respx(base_url=base_url)
1726@pytest.mark.asyncio
1727async def test_overwrite_retry_count_header(
1728self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1729) -> None:
1730client = async_client.with_options(max_retries=4)
1731
1732nb_retries = 0
1733
1734def retry_handler(_request: httpx.Request) -> httpx.Response:
1735nonlocal nb_retries
1736if nb_retries < failures_before_success:
1737nb_retries += 1
1738return httpx.Response(500)
1739return httpx.Response(200)
1740
1741respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1742
1743response = await client.chat.completions.with_raw_response.create(
1744messages=[
1745{
1746"content": "string",
575ff607stainless-app[bot]1 years ago1747"role": "developer",
5449e208Stainless Bot1 years ago1748}
1749],
1750model="gpt-4o",
1751extra_headers={"x-stainless-retry-count": "42"},
1752)
1753
1754assert response.http_request.headers.get("x-stainless-retry-count") == "42"
98d8b2acstainless-app[bot]1 years ago1755
1756@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
1757@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
1758@pytest.mark.respx(base_url=base_url)
1759@pytest.mark.asyncio
1760async def test_retries_taken_new_response_class(
1761self, async_client: AsyncOpenAI, failures_before_success: int, respx_mock: MockRouter
1762) -> None:
1763client = async_client.with_options(max_retries=4)
1764
1765nb_retries = 0
1766
1767def retry_handler(_request: httpx.Request) -> httpx.Response:
1768nonlocal nb_retries
1769if nb_retries < failures_before_success:
1770nb_retries += 1
1771return httpx.Response(500)
1772return httpx.Response(200)
1773
1774respx_mock.post("/chat/completions").mock(side_effect=retry_handler)
1775
1776async with client.chat.completions.with_streaming_response.create(
1777messages=[
1778{
bf1ca86cRobert Craigie1 years ago1779"content": "string",
575ff607stainless-app[bot]1 years ago1780"role": "developer",
98d8b2acstainless-app[bot]1 years ago1781}
1782],
bf1ca86cRobert Craigie1 years ago1783model="gpt-4o",
98d8b2acstainless-app[bot]1 years ago1784) as response:
1785assert response.retries_taken == failures_before_success
5449e208Stainless Bot1 years ago1786assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
d8901d28Seth Gilchrist1 years ago1787
1788def test_get_platform(self) -> None:
53ab0467Stainless Bot1 years ago1789# A previous implementation of asyncify could leave threads unterminated when
1790# used with nest_asyncio.
1791#
d8901d28Seth Gilchrist1 years ago1792# Since nest_asyncio.apply() is global and cannot be un-applied, this
1793# test is run in a separate process to avoid affecting other tests.
53ab0467Stainless Bot1 years ago1794test_code = dedent("""
d8901d28Seth Gilchrist1 years ago1795import asyncio
1796import nest_asyncio
1797import threading
1798
1799from openai._utils import asyncify
53ab0467Stainless Bot1 years ago1800from openai._base_client import get_platform
d8901d28Seth Gilchrist1 years ago1801
1802async def test_main() -> None:
1803result = await asyncify(get_platform)()
1804print(result)
1805for thread in threading.enumerate():
1806print(thread.name)
1807
1808nest_asyncio.apply()
1809asyncio.run(test_main())
1810""")
1811with subprocess.Popen(
1812[sys.executable, "-c", test_code],
1813text=True,
1814) as process:
14543c59stainless-app[bot]1 years ago1815timeout = 10 # seconds
1816
1817start_time = time.monotonic()
1818while True:
1819return_code = process.poll()
1820if return_code is not None:
1821if return_code != 0:
1822raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code")
1823
1824# success
1825break
1826
1827if time.monotonic() - start_time > timeout:
1828process.kill()
1829raise AssertionError("calling get_platform using asyncify resulted in a hung process")
1830
1831time.sleep(0.1)