Commit c26014e2

Stainless Bot <107565488+stainless-bot@users.noreply.github.com>
2023-11-21 08:24:05
fix(client): attempt to parse unknown json content types (#854)
1 parent 2343e63
Changed files (4)
src/openai/_base_client.py
@@ -74,7 +74,12 @@ from ._constants import (
     RAW_RESPONSE_HEADER,
 )
 from ._streaming import Stream, AsyncStream
-from ._exceptions import APIStatusError, APITimeoutError, APIConnectionError
+from ._exceptions import (
+    APIStatusError,
+    APITimeoutError,
+    APIConnectionError,
+    APIResponseValidationError,
+)
 
 log: logging.Logger = logging.getLogger(__name__)
 
@@ -518,13 +523,16 @@ class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]):
         if cast_to is UnknownResponse:
             return cast(ResponseT, data)
 
-        if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol):
-            return cast(ResponseT, cast_to.build(response=response, data=data))
+        try:
+            if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol):
+                return cast(ResponseT, cast_to.build(response=response, data=data))
 
-        if self._strict_response_validation:
-            return cast(ResponseT, validate_type(type_=cast_to, value=data))
+            if self._strict_response_validation:
+                return cast(ResponseT, validate_type(type_=cast_to, value=data))
 
-        return cast(ResponseT, construct_type(type_=cast_to, value=data))
+            return cast(ResponseT, construct_type(type_=cast_to, value=data))
+        except pydantic.ValidationError as err:
+            raise APIResponseValidationError(response=response, body=data) from err
 
     @property
     def qs(self) -> Querystring:
src/openai/_models.py
@@ -263,6 +263,19 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object:
     return construct_type(value=value, type_=type_)
 
 
+def is_basemodel(type_: type) -> bool:
+    """Returns whether or not the given type is either a `BaseModel` or a union of `BaseModel`"""
+    origin = get_origin(type_) or type_
+    if is_union(type_):
+        for variant in get_args(type_):
+            if is_basemodel(variant):
+                return True
+
+        return False
+
+    return issubclass(origin, BaseModel) or issubclass(origin, GenericModel)
+
+
 def construct_type(*, value: object, type_: type) -> object:
     """Loose coercion to the expected type with construction of nested values.
 
src/openai/_response.py
@@ -1,17 +1,17 @@
 from __future__ import annotations
 
 import inspect
+import logging
 import datetime
 import functools
 from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast
 from typing_extensions import Awaitable, ParamSpec, get_args, override, get_origin
 
 import httpx
-import pydantic
 
 from ._types import NoneType, UnknownResponse, BinaryResponseContent
 from ._utils import is_given
-from ._models import BaseModel
+from ._models import BaseModel, is_basemodel
 from ._constants import RAW_RESPONSE_HEADER
 from ._exceptions import APIResponseValidationError
 
@@ -23,6 +23,8 @@ if TYPE_CHECKING:
 P = ParamSpec("P")
 R = TypeVar("R")
 
+log: logging.Logger = logging.getLogger(__name__)
+
 
 class APIResponse(Generic[R]):
     _cast_to: type[R]
@@ -174,6 +176,18 @@ class APIResponse(Generic[R]):
         # in the response, e.g. application/json; charset=utf-8
         content_type, *_ = response.headers.get("content-type").split(";")
         if content_type != "application/json":
+            if is_basemodel(cast_to):
+                try:
+                    data = response.json()
+                except Exception as exc:
+                    log.debug("Could not read JSON from response data due to %s - %s", type(exc), exc)
+                else:
+                    return self._client._process_response_data(
+                        data=data,
+                        cast_to=cast_to,  # type: ignore
+                        response=response,
+                    )
+
             if self._client._strict_response_validation:
                 raise APIResponseValidationError(
                     response=response,
@@ -188,14 +202,11 @@ class APIResponse(Generic[R]):
 
         data = response.json()
 
-        try:
-            return self._client._process_response_data(
-                data=data,
-                cast_to=cast_to,  # type: ignore
-                response=response,
-            )
-        except pydantic.ValidationError as err:
-            raise APIResponseValidationError(response=response, body=data) from err
+        return self._client._process_response_data(
+            data=data,
+            cast_to=cast_to,  # type: ignore
+            response=response,
+        )
 
     @override
     def __repr__(self) -> str:
tests/test_client.py
@@ -401,6 +401,27 @@ class TestOpenAI:
         assert isinstance(response, Model1)
         assert response.foo == 1
 
+    @pytest.mark.respx(base_url=base_url)
+    def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
+        """
+        Response that sets Content-Type to something other than application/json but returns json data
+        """
+
+        class Model(BaseModel):
+            foo: int
+
+        respx_mock.get("/foo").mock(
+            return_value=httpx.Response(
+                200,
+                content=json.dumps({"foo": 2}),
+                headers={"Content-Type": "application/text"},
+            )
+        )
+
+        response = self.client.get("/foo", cast_to=Model)
+        assert isinstance(response, Model)
+        assert response.foo == 2
+
     def test_base_url_env(self) -> None:
         with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
             client = OpenAI(api_key=api_key, _strict_response_validation=True)
@@ -939,6 +960,27 @@ class TestAsyncOpenAI:
         assert isinstance(response, Model1)
         assert response.foo == 1
 
+    @pytest.mark.respx(base_url=base_url)
+    async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None:
+        """
+        Response that sets Content-Type to something other than application/json but returns json data
+        """
+
+        class Model(BaseModel):
+            foo: int
+
+        respx_mock.get("/foo").mock(
+            return_value=httpx.Response(
+                200,
+                content=json.dumps({"foo": 2}),
+                headers={"Content-Type": "application/text"},
+            )
+        )
+
+        response = await self.client.get("/foo", cast_to=Model)
+        assert isinstance(response, Model)
+        assert response.foo == 2
+
     def test_base_url_env(self) -> None:
         with update_env(OPENAI_BASE_URL="http://localhost:5000/from/env"):
             client = AsyncOpenAI(api_key=api_key, _strict_response_validation=True)