diff --git a/src/openai/lib/_parsing/_completions.py b/src/openai/lib/_parsing/_completions.py index 7a1bded1de..510ff68833 100644 --- a/src/openai/lib/_parsing/_completions.py +++ b/src/openai/lib/_parsing/_completions.py @@ -94,12 +94,20 @@ def parse_chat_completion( else: input_tools = [] + # `length` / `content_filter` finish reasons only prevent us from producing a valid + # parsed result when there is actually something to parse. For a plain completion + # (no `response_format` and no parseable tools) there is nothing to parse, so we + # mirror the streaming accumulator (`ChatCompletionStreamState`), which guards these + # errors with `has_parseable_input`, and leave the completion untouched — matching + # `chat.completions.create()`. + raise_on_incomplete = has_parseable_input(response_format=response_format, input_tools=input_tools) + choices: list[ParsedChoice[ResponseFormatT]] = [] for choice in chat_completion.choices: - if choice.finish_reason == "length": + if raise_on_incomplete and choice.finish_reason == "length": raise LengthFinishReasonError(completion=chat_completion) - if choice.finish_reason == "content_filter": + if raise_on_incomplete and choice.finish_reason == "content_filter": raise ContentFilterFinishReasonError() message = choice.message diff --git a/tests/lib/chat/test_completions_streaming.py b/tests/lib/chat/test_completions_streaming.py index 598a41ee2b..86e18800af 100644 --- a/tests/lib/chat/test_completions_streaming.py +++ b/tests/lib/chat/test_completions_streaming.py @@ -30,6 +30,7 @@ ParsedChatCompletionSnapshot, ) from openai.lib._parsing._completions import ResponseFormatT +from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice, ChoiceDelta from ..utils import print_obj from ...conftest import base_url @@ -1069,6 +1070,55 @@ def streamer(client: OpenAI) -> Iterator[ChatCompletionChunk]: ) +def _chunk(delta: ChoiceDelta, finish_reason: str | None) -> ChatCompletionChunk: + return ChatCompletionChunk.construct( + id="chatcmpl-test", + object="chat.completion.chunk", + created=0, + model="gpt-4o-2024-08-06", + choices=[ChunkChoice.construct(index=0, delta=delta, finish_reason=finish_reason)], + ) + + +def _content_chunk(text: str) -> ChatCompletionChunk: + return _chunk(ChoiceDelta.construct(role="assistant", content=text), finish_reason=None) + + +def _finish_chunk(finish_reason: str) -> ChatCompletionChunk: + return _chunk(ChoiceDelta.construct(), finish_reason=finish_reason) + + +@pytest.mark.parametrize("finish_reason", ["length", "content_filter"]) +def test_non_parse_stream_terminal_finish_reason_does_not_raise(finish_reason: str) -> None: + # A plain stream (no `response_format` and no parseable tools) has nothing to parse, + # so a `length` / `content_filter` finish reason must not raise from + # `get_final_completion()` — matching `chat.completions.create()` and the + # streaming accumulator, which already suppresses these for non-parse streams. + state: ChatCompletionStreamState[None] = ChatCompletionStreamState() + + # accumulating the chunks must not raise + state.handle_chunk(_content_chunk("partial answer that got cut o")) + state.handle_chunk(_finish_chunk(finish_reason)) + + completion = state.get_final_completion() + assert completion.choices[0].finish_reason == finish_reason + assert completion.choices[0].message.content == "partial answer that got cut o" + assert completion.choices[0].message.parsed is None + + +def test_parse_stream_length_finish_still_raises() -> None: + # When a `response_format` is given there *is* something to parse, so the terminal + # `length` finish reason must still raise (unchanged behavior). + class Location(BaseModel): + city: str + + state: ChatCompletionStreamState[Location] = ChatCompletionStreamState(response_format=Location) + state.handle_chunk(_content_chunk('{"city":"San Francisc')) + + with pytest.raises(openai.LengthFinishReasonError): + state.handle_chunk(_finish_chunk("length")) + + @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) def test_stream_method_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None: checking_client: OpenAI | AsyncOpenAI = client if sync else async_client