From af535ce6a523eca39438f117a3e55f16064567a9 Mon Sep 17 00:00:00 2001 From: Robert Craigie Date: Thu, 26 Sep 2024 23:12:51 +0100 Subject: [PATCH 1/3] chore(pydantic v1): exclude specific properties when rich printing (#1751) --- src/openai/_models.py | 10 +++++++++- tests/lib/chat/_utils.py | 11 +++-------- tests/test_legacy_response.py | 4 ++++ tests/test_response.py | 4 ++++ tests/utils.py | 13 +++++++++++++ 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/openai/_models.py b/src/openai/_models.py index d6f42d3d4d..710401defd 100644 --- a/src/openai/_models.py +++ b/src/openai/_models.py @@ -2,7 +2,7 @@ import os import inspect -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast +from typing import TYPE_CHECKING, Any, Type, Tuple, Union, Generic, TypeVar, Callable, Optional, cast from datetime import date, datetime from typing_extensions import ( Unpack, @@ -10,6 +10,7 @@ ClassVar, Protocol, Required, + Sequence, ParamSpec, TypedDict, TypeGuard, @@ -72,6 +73,8 @@ P = ParamSpec("P") +ReprArgs = Sequence[Tuple[Optional[str], Any]] + @runtime_checkable class _ConfigProtocol(Protocol): @@ -94,6 +97,11 @@ def model_fields_set(self) -> set[str]: class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] extra: Any = pydantic.Extra.allow # type: ignore + @override + def __repr_args__(self) -> ReprArgs: + # we don't want these attributes to be included when something like `rich.print` is used + return [arg for arg in super().__repr_args__() if arg[0] not in {"_request_id", "__exclude_fields__"}] + if TYPE_CHECKING: _request_id: Optional[str] = None """The ID of the request, returned via the X-Request-ID header. Useful for debugging requests and reporting issues to OpenAI. diff --git a/tests/lib/chat/_utils.py b/tests/lib/chat/_utils.py index dcc32b17fd..af08db417c 100644 --- a/tests/lib/chat/_utils.py +++ b/tests/lib/chat/_utils.py @@ -1,14 +1,14 @@ from __future__ import annotations -import io import inspect from typing import Any, Iterable from typing_extensions import TypeAlias -import rich import pytest import pydantic +from ...utils import rich_print_str + ReprArgs: TypeAlias = "Iterable[tuple[str | None, Any]]" @@ -26,12 +26,7 @@ def __repr_args__(self: pydantic.BaseModel) -> ReprArgs: with monkeypatch.context() as m: m.setattr(pydantic.BaseModel, "__repr_args__", __repr_args__) - buf = io.StringIO() - - console = rich.console.Console(file=buf, width=120) - console.print(obj) - - string = buf.getvalue() + string = rich_print_str(obj) # we remove all `fn_name..` occurences # so that we can share the same snapshots between diff --git a/tests/test_legacy_response.py b/tests/test_legacy_response.py index a6fec9f2de..f50a77c24d 100644 --- a/tests/test_legacy_response.py +++ b/tests/test_legacy_response.py @@ -11,6 +11,8 @@ from openai._base_client import FinalRequestOptions from openai._legacy_response import LegacyAPIResponse +from .utils import rich_print_str + class PydanticModel(pydantic.BaseModel): ... @@ -85,6 +87,8 @@ def test_response_basemodel_request_id(client: OpenAI) -> None: assert obj.foo == "hello!" assert obj.bar == 2 assert obj.to_dict() == {"foo": "hello!", "bar": 2} + assert "_request_id" not in rich_print_str(obj) + assert "__exclude_fields__" not in rich_print_str(obj) def test_response_parse_annotated_type(client: OpenAI) -> None: diff --git a/tests/test_response.py b/tests/test_response.py index 97c56e0035..e1fe332f2f 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -18,6 +18,8 @@ from openai._streaming import Stream from openai._base_client import FinalRequestOptions +from .utils import rich_print_str + class ConcreteBaseAPIResponse(APIResponse[bytes]): ... @@ -175,6 +177,8 @@ def test_response_basemodel_request_id(client: OpenAI) -> None: assert obj.foo == "hello!" assert obj.bar == 2 assert obj.to_dict() == {"foo": "hello!", "bar": 2} + assert "_request_id" not in rich_print_str(obj) + assert "__exclude_fields__" not in rich_print_str(obj) @pytest.mark.asyncio diff --git a/tests/utils.py b/tests/utils.py index 165f4e5bfd..8d5397f28e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import os import inspect import traceback @@ -8,6 +9,8 @@ from datetime import date, datetime from typing_extensions import Literal, get_args, get_origin, assert_type +import rich + from openai._types import Omit, NoneType from openai._utils import ( is_dict, @@ -138,6 +141,16 @@ def _assert_list_type(type_: type[object], value: object) -> None: assert_type(inner_type, entry) # type: ignore +def rich_print_str(obj: object) -> str: + """Like `rich.print()` but returns the string instead""" + buf = io.StringIO() + + console = rich.console.Console(file=buf, width=120) + console.print(obj) + + return buf.getvalue() + + @contextlib.contextmanager def update_env(**new_env: str | Omit) -> Iterator[None]: old = os.environ.copy() From 0189e28b0b062a28b16343da0460a4f0f4e17a9a Mon Sep 17 00:00:00 2001 From: Robert Craigie Date: Thu, 26 Sep 2024 23:32:48 +0100 Subject: [PATCH 2/3] feat(structured outputs): add support for accessing raw responses (#1748) --- src/openai/resources/beta/chat/completions.py | 246 +++++++++++++----- tests/lib/chat/test_completions.py | 177 ++++++++++++- 2 files changed, 355 insertions(+), 68 deletions(-) diff --git a/src/openai/resources/beta/chat/completions.py b/src/openai/resources/beta/chat/completions.py index ea3526778d..da6f189929 100644 --- a/src/openai/resources/beta/chat/completions.py +++ b/src/openai/resources/beta/chat/completions.py @@ -2,16 +2,21 @@ from __future__ import annotations -from typing import Dict, List, Union, Iterable, Optional +from typing import Dict, List, Type, Union, Iterable, Optional, cast from functools import partial from typing_extensions import Literal import httpx +from .... import _legacy_response from ...._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from ...._utils import maybe_transform, async_maybe_transform +from ...._compat import cached_property from ...._resource import SyncAPIResource, AsyncAPIResource +from ...._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper from ...._streaming import Stream from ....types.chat import completion_create_params +from ...._base_client import make_request_options from ....lib._parsing import ( ResponseFormatT, validate_input_tools as _validate_input_tools, @@ -20,6 +25,7 @@ ) from ....types.chat_model import ChatModel from ....lib.streaming.chat import ChatCompletionStreamManager, AsyncChatCompletionStreamManager +from ....types.chat.chat_completion import ChatCompletion from ....types.chat.chat_completion_chunk import ChatCompletionChunk from ....types.chat.parsed_chat_completion import ParsedChatCompletion from ....types.chat.chat_completion_tool_param import ChatCompletionToolParam @@ -31,6 +37,25 @@ class Completions(SyncAPIResource): + @cached_property + def with_raw_response(self) -> CompletionsWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return the + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/openai/openai-python#accessing-raw-response-data-eg-headers + """ + return CompletionsWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> CompletionsWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/openai/openai-python#with_streaming_response + """ + return CompletionsWithStreamingResponse(self) + def parse( self, *, @@ -113,39 +138,55 @@ class MathResponse(BaseModel): **(extra_headers or {}), } - raw_completion = self._client.chat.completions.create( - messages=messages, - model=model, - response_format=_type_to_response_format(response_format), - frequency_penalty=frequency_penalty, - function_call=function_call, - functions=functions, - logit_bias=logit_bias, - logprobs=logprobs, - max_completion_tokens=max_completion_tokens, - max_tokens=max_tokens, - n=n, - parallel_tool_calls=parallel_tool_calls, - presence_penalty=presence_penalty, - seed=seed, - service_tier=service_tier, - stop=stop, - stream_options=stream_options, - temperature=temperature, - tool_choice=tool_choice, - tools=tools, - top_logprobs=top_logprobs, - top_p=top_p, - user=user, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - return _parse_chat_completion( - response_format=response_format, - chat_completion=raw_completion, - input_tools=tools, + def parser(raw_completion: ChatCompletion) -> ParsedChatCompletion[ResponseFormatT]: + return _parse_chat_completion( + response_format=response_format, + chat_completion=raw_completion, + input_tools=tools, + ) + + return self._post( + "/chat/completions", + body=maybe_transform( + { + "messages": messages, + "model": model, + "frequency_penalty": frequency_penalty, + "function_call": function_call, + "functions": functions, + "logit_bias": logit_bias, + "logprobs": logprobs, + "max_completion_tokens": max_completion_tokens, + "max_tokens": max_tokens, + "n": n, + "parallel_tool_calls": parallel_tool_calls, + "presence_penalty": presence_penalty, + "response_format": _type_to_response_format(response_format), + "seed": seed, + "service_tier": service_tier, + "stop": stop, + "stream": False, + "stream_options": stream_options, + "temperature": temperature, + "tool_choice": tool_choice, + "tools": tools, + "top_logprobs": top_logprobs, + "top_p": top_p, + "user": user, + }, + completion_create_params.CompletionCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + post_parser=parser, + ), + # we turn the `ChatCompletion` instance into a `ParsedChatCompletion` + # in the `parser` function above + cast_to=cast(Type[ParsedChatCompletion[ResponseFormatT]], ChatCompletion), + stream=False, ) def stream( @@ -247,6 +288,25 @@ def stream( class AsyncCompletions(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncCompletionsWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return the + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/openai/openai-python#accessing-raw-response-data-eg-headers + """ + return AsyncCompletionsWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncCompletionsWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/openai/openai-python#with_streaming_response + """ + return AsyncCompletionsWithStreamingResponse(self) + async def parse( self, *, @@ -329,39 +389,55 @@ class MathResponse(BaseModel): **(extra_headers or {}), } - raw_completion = await self._client.chat.completions.create( - messages=messages, - model=model, - response_format=_type_to_response_format(response_format), - frequency_penalty=frequency_penalty, - function_call=function_call, - functions=functions, - logit_bias=logit_bias, - logprobs=logprobs, - max_completion_tokens=max_completion_tokens, - max_tokens=max_tokens, - n=n, - parallel_tool_calls=parallel_tool_calls, - presence_penalty=presence_penalty, - seed=seed, - service_tier=service_tier, - stop=stop, - stream_options=stream_options, - temperature=temperature, - tool_choice=tool_choice, - tools=tools, - top_logprobs=top_logprobs, - top_p=top_p, - user=user, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - return _parse_chat_completion( - response_format=response_format, - chat_completion=raw_completion, - input_tools=tools, + def parser(raw_completion: ChatCompletion) -> ParsedChatCompletion[ResponseFormatT]: + return _parse_chat_completion( + response_format=response_format, + chat_completion=raw_completion, + input_tools=tools, + ) + + return await self._post( + "/chat/completions", + body=await async_maybe_transform( + { + "messages": messages, + "model": model, + "frequency_penalty": frequency_penalty, + "function_call": function_call, + "functions": functions, + "logit_bias": logit_bias, + "logprobs": logprobs, + "max_completion_tokens": max_completion_tokens, + "max_tokens": max_tokens, + "n": n, + "parallel_tool_calls": parallel_tool_calls, + "presence_penalty": presence_penalty, + "response_format": _type_to_response_format(response_format), + "seed": seed, + "service_tier": service_tier, + "stop": stop, + "stream": False, + "stream_options": stream_options, + "temperature": temperature, + "tool_choice": tool_choice, + "tools": tools, + "top_logprobs": top_logprobs, + "top_p": top_p, + "user": user, + }, + completion_create_params.CompletionCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + post_parser=parser, + ), + # we turn the `ChatCompletion` instance into a `ParsedChatCompletion` + # in the `parser` function above + cast_to=cast(Type[ParsedChatCompletion[ResponseFormatT]], ChatCompletion), + stream=False, ) def stream( @@ -461,3 +537,39 @@ def stream( response_format=response_format, input_tools=tools, ) + + +class CompletionsWithRawResponse: + def __init__(self, completions: Completions) -> None: + self._completions = completions + + self.parse = _legacy_response.to_raw_response_wrapper( + completions.parse, + ) + + +class AsyncCompletionsWithRawResponse: + def __init__(self, completions: AsyncCompletions) -> None: + self._completions = completions + + self.parse = _legacy_response.async_to_raw_response_wrapper( + completions.parse, + ) + + +class CompletionsWithStreamingResponse: + def __init__(self, completions: Completions) -> None: + self._completions = completions + + self.parse = to_streamed_response_wrapper( + completions.parse, + ) + + +class AsyncCompletionsWithStreamingResponse: + def __init__(self, completions: AsyncCompletions) -> None: + self._completions = completions + + self.parse = async_to_streamed_response_wrapper( + completions.parse, + ) diff --git a/tests/lib/chat/test_completions.py b/tests/lib/chat/test_completions.py index d66630fa3a..7702a98d49 100644 --- a/tests/lib/chat/test_completions.py +++ b/tests/lib/chat/test_completions.py @@ -3,7 +3,7 @@ import os import json from enum import Enum -from typing import Any, List, Callable, Optional +from typing import Any, List, Callable, Optional, Awaitable from typing_extensions import Literal, TypeVar import httpx @@ -773,6 +773,139 @@ def test_parse_non_strict_tools(client: OpenAI) -> None: ) +@pytest.mark.respx(base_url=base_url) +def test_parse_pydantic_raw_response(client: OpenAI, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch) -> None: + class Location(BaseModel): + city: str + temperature: float + units: Literal["c", "f"] + + response = _make_snapshot_request( + lambda c: c.beta.chat.completions.with_raw_response.parse( + model="gpt-4o-2024-08-06", + messages=[ + { + "role": "user", + "content": "What's the weather like in SF?", + }, + ], + response_format=Location, + ), + content_snapshot=snapshot( + '{"id": "chatcmpl-ABrDYCa8W1w66eUxKDO8TQF1m6trT", "object": "chat.completion", "created": 1727389540, "model": "gpt-4o-2024-08-06", "choices": [{"index": 0, "message": {"role": "assistant", "content": "{\\"city\\":\\"San Francisco\\",\\"temperature\\":58,\\"units\\":\\"f\\"}", "refusal": null}, "logprobs": null, "finish_reason": "stop"}], "usage": {"prompt_tokens": 79, "completion_tokens": 14, "total_tokens": 93, "completion_tokens_details": {"reasoning_tokens": 0}}, "system_fingerprint": "fp_5050236cbd"}' + ), + mock_client=client, + respx_mock=respx_mock, + ) + assert response.http_request.headers.get("x-stainless-helper-method") == "beta.chat.completions.parse" + + completion = response.parse() + message = completion.choices[0].message + assert message.parsed is not None + assert isinstance(message.parsed.city, str) + assert print_obj(completion, monkeypatch) == snapshot( + """\ +ParsedChatCompletion[Location]( + choices=[ + ParsedChoice[Location]( + finish_reason='stop', + index=0, + logprobs=None, + message=ParsedChatCompletionMessage[Location]( + content='{"city":"San Francisco","temperature":58,"units":"f"}', + function_call=None, + parsed=Location(city='San Francisco', temperature=58.0, units='f'), + refusal=None, + role='assistant', + tool_calls=[] + ) + ) + ], + created=1727389540, + id='chatcmpl-ABrDYCa8W1w66eUxKDO8TQF1m6trT', + model='gpt-4o-2024-08-06', + object='chat.completion', + service_tier=None, + system_fingerprint='fp_5050236cbd', + usage=CompletionUsage( + completion_tokens=14, + completion_tokens_details=CompletionTokensDetails(reasoning_tokens=0), + prompt_tokens=79, + total_tokens=93 + ) +) +""" + ) + + +@pytest.mark.respx(base_url=base_url) +@pytest.mark.asyncio +async def test_async_parse_pydantic_raw_response( + async_client: AsyncOpenAI, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + class Location(BaseModel): + city: str + temperature: float + units: Literal["c", "f"] + + response = await _make_async_snapshot_request( + lambda c: c.beta.chat.completions.with_raw_response.parse( + model="gpt-4o-2024-08-06", + messages=[ + { + "role": "user", + "content": "What's the weather like in SF?", + }, + ], + response_format=Location, + ), + content_snapshot=snapshot( + '{"id": "chatcmpl-ABrDQWOiw0PK5JOsxl1D9ooeQgznq", "object": "chat.completion", "created": 1727389532, "model": "gpt-4o-2024-08-06", "choices": [{"index": 0, "message": {"role": "assistant", "content": "{\\"city\\":\\"San Francisco\\",\\"temperature\\":65,\\"units\\":\\"f\\"}", "refusal": null}, "logprobs": null, "finish_reason": "stop"}], "usage": {"prompt_tokens": 79, "completion_tokens": 14, "total_tokens": 93, "completion_tokens_details": {"reasoning_tokens": 0}}, "system_fingerprint": "fp_5050236cbd"}' + ), + mock_client=async_client, + respx_mock=respx_mock, + ) + assert response.http_request.headers.get("x-stainless-helper-method") == "beta.chat.completions.parse" + + completion = response.parse() + message = completion.choices[0].message + assert message.parsed is not None + assert isinstance(message.parsed.city, str) + assert print_obj(completion, monkeypatch) == snapshot( + """\ +ParsedChatCompletion[Location]( + choices=[ + ParsedChoice[Location]( + finish_reason='stop', + index=0, + logprobs=None, + message=ParsedChatCompletionMessage[Location]( + content='{"city":"San Francisco","temperature":65,"units":"f"}', + function_call=None, + parsed=Location(city='San Francisco', temperature=65.0, units='f'), + refusal=None, + role='assistant', + tool_calls=[] + ) + ) + ], + created=1727389532, + id='chatcmpl-ABrDQWOiw0PK5JOsxl1D9ooeQgznq', + model='gpt-4o-2024-08-06', + object='chat.completion', + service_tier=None, + system_fingerprint='fp_5050236cbd', + usage=CompletionUsage( + completion_tokens=14, + completion_tokens_details=CompletionTokensDetails(reasoning_tokens=0), + prompt_tokens=79, + total_tokens=93 + ) +) +""" + ) + + @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) def test_parse_method_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None: checking_client: OpenAI | AsyncOpenAI = client if sync else async_client @@ -824,3 +957,45 @@ def _on_response(response: httpx.Response) -> None: client.close() return result + + +async def _make_async_snapshot_request( + func: Callable[[AsyncOpenAI], Awaitable[_T]], + *, + content_snapshot: Any, + respx_mock: MockRouter, + mock_client: AsyncOpenAI, +) -> _T: + live = os.environ.get("OPENAI_LIVE") == "1" + if live: + + async def _on_response(response: httpx.Response) -> None: + # update the content snapshot + assert json.dumps(json.loads(await response.aread())) == content_snapshot + + respx_mock.stop() + + client = AsyncOpenAI( + http_client=httpx.AsyncClient( + event_hooks={ + "response": [_on_response], + } + ) + ) + else: + respx_mock.post("/chat/completions").mock( + return_value=httpx.Response( + 200, + content=content_snapshot._old_value, + headers={"content-type": "application/json"}, + ) + ) + + client = mock_client + + result = await func(client) + + if live: + await client.close() + + return result From f331b6315aa3fcdf64763f29c8900206adb5e188 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 22:33:11 +0000 Subject: [PATCH 3/3] release: 1.50.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- src/openai/_version.py | 2 +- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 5153ca9d4c..97e4d742db 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.49.0" + ".": "1.50.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a8156c611..20be5b530b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.50.0 (2024-09-26) + +Full Changelog: [v1.49.0...v1.50.0](https://github.com/openai/openai-python/compare/v1.49.0...v1.50.0) + +### Features + +* **structured outputs:** add support for accessing raw responses ([#1748](https://github.com/openai/openai-python/issues/1748)) ([0189e28](https://github.com/openai/openai-python/commit/0189e28b0b062a28b16343da0460a4f0f4e17a9a)) + + +### Chores + +* **pydantic v1:** exclude specific properties when rich printing ([#1751](https://github.com/openai/openai-python/issues/1751)) ([af535ce](https://github.com/openai/openai-python/commit/af535ce6a523eca39438f117a3e55f16064567a9)) + ## 1.49.0 (2024-09-26) Full Changelog: [v1.48.0...v1.49.0](https://github.com/openai/openai-python/compare/v1.48.0...v1.49.0) diff --git a/pyproject.toml b/pyproject.toml index aa4c7c1d76..f7061c5059 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai" -version = "1.49.0" +version = "1.50.0" description = "The official Python library for the openai API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/openai/_version.py b/src/openai/_version.py index 32d54fb9d0..e05b612a12 100644 --- a/src/openai/_version.py +++ b/src/openai/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "openai" -__version__ = "1.49.0" # x-release-please-version +__version__ = "1.50.0" # x-release-please-version