Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/openai/lib/_parsing/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def parse_response(
) -> ParsedResponse[TextFormatT]:
output_list: List[ParsedResponseOutputItem[TextFormatT]] = []

for output in response.output:
for output in response.output or []:
Comment thread
FU-max-boop marked this conversation as resolved.
if output.type == "message":
content_list: List[ParsedContent[TextFormatT]] = []
for item in output.content:
Expand Down
42 changes: 41 additions & 1 deletion src/openai/lib/streaming/responses/_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,10 +356,50 @@ def accumulate_event(self, event: RawResponseStreamEvent) -> ParsedResponseSnaps
output = snapshot.output[event.output_index]
if output.type == "function_call":
output.arguments += event.delta
elif event.type == "response.refusal.delta":
output = snapshot.output[event.output_index]
if output.type == "message":
content = output.content[event.content_index]
assert content.type == "refusal"
content.refusal += event.delta
elif event.type == "response.refusal.done":
output = snapshot.output[event.output_index]
if output.type == "message":
content = output.content[event.content_index]
assert content.type == "refusal"
content.refusal = event.refusal
elif event.type == "response.content_part.done":
output = snapshot.output[event.output_index]
if output.type == "message":
output.content[event.content_index] = construct_type_unchecked(
type_=cast(Any, ParsedContent),
value=event.part.to_dict(),
)
elif event.type == "response.output_item.done":
if event.item.type == "function_call":
snapshot.output[event.output_index] = construct_type_unchecked(
type_=cast(Any, ParsedResponseFunctionToolCall),
value=event.item.to_dict(),
)
elif event.item.type == "message":
snapshot.output[event.output_index] = construct_type_unchecked(
type_=cast(Any, ParsedResponseOutputMessage),
value=event.item.to_dict(),
)
else:
snapshot.output[event.output_index] = event.item
elif event.type == "response.completed":
response = event.response
response_dict: dict[str, object] = response.to_dict()
if response_dict.get("output") is None:
response_dict["output"] = [output.to_dict() for output in snapshot.output]
Comment thread
FU-max-boop marked this conversation as resolved.
response = construct_type_unchecked(
type_=ParsedResponseSnapshot,
value=response_dict,
)
self._completed_response = parse_response(
text_format=self._text_format,
response=event.response,
response=response,
input_tools=self._input_tools,
)

Expand Down
246 changes: 246 additions & 0 deletions tests/lib/responses/test_responses.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from typing import Any
from typing_extensions import TypeVar

import pytest
Expand All @@ -12,6 +13,16 @@
from openai._models import construct_type_unchecked
from openai.types.responses import Response
from openai.lib._parsing._responses import parse_response
from openai.lib.streaming.responses._responses import ResponseStreamState
from openai.types.responses.response_created_event import ResponseCreatedEvent
from openai.types.responses.response_completed_event import ResponseCompletedEvent
from openai.types.responses.response_text_delta_event import ResponseTextDeltaEvent
from openai.types.responses.response_refusal_done_event import ResponseRefusalDoneEvent
from openai.types.responses.response_refusal_delta_event import ResponseRefusalDeltaEvent
from openai.types.responses.response_output_item_done_event import ResponseOutputItemDoneEvent
from openai.types.responses.response_content_part_done_event import ResponseContentPartDoneEvent
from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent
from openai.types.responses.response_content_part_added_event import ResponseContentPartAddedEvent

from ...conftest import base_url
from ..snapshots import make_snapshot_request
Expand All @@ -25,6 +36,79 @@
# `OPENAI_LIVE=1 pytest --inline-snapshot=fix -p no:xdist -o addopts=""`


def _response_payload(*, output: object) -> dict[str, object]:
return {
"id": "resp_null_output",
"object": "response",
"created_at": 0,
"status": "completed",
"error": None,
"incomplete_details": None,
"instructions": None,
"max_output_tokens": None,
"max_tool_calls": None,
"model": "gpt-4o-mini",
"output": output,
"parallel_tool_calls": True,
"previous_response_id": None,
"prompt_cache_key": None,
"reasoning": {"effort": None, "summary": None},
"safety_identifier": None,
"service_tier": "default",
"store": False,
"temperature": 1.0,
"text": {"format": {"type": "text"}, "verbosity": "medium"},
"tool_choice": "auto",
"tools": [],
"top_logprobs": 0,
"top_p": 1.0,
"truncation": "disabled",
"usage": None,
"user": None,
"metadata": {},
}


def _handle_stream_event(state: ResponseStreamState[Any], event_type: Any, **value: object) -> None:
state.handle_event(construct_type_unchecked(type_=event_type, value=value))


def _message_stream_state(content_part: dict[str, object]) -> ResponseStreamState[Any]:
state = ResponseStreamState(text_format=omit, input_tools=omit)
_handle_stream_event(
state,
ResponseCreatedEvent,
type="response.created",
sequence_number=0,
response=_response_payload(output=[]),
)
_handle_stream_event(
state,
ResponseOutputItemAddedEvent,
type="response.output_item.added",
sequence_number=1,
output_index=0,
item={
"id": "msg_1",
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": [],
},
)
_handle_stream_event(
state,
ResponseContentPartAddedEvent,
type="response.content_part.added",
sequence_number=2,
item_id="msg_1",
output_index=0,
content_index=0,
part=content_part,
)
return state


@pytest.mark.respx2(base_url=base_url)
def test_output_text(client: OpenAI, respx2_mock: MockRouter) -> None:
response = make_snapshot_request(
Expand Down Expand Up @@ -72,6 +156,168 @@ def test_parse_response_preserves_program_items(item: dict[str, object]) -> None
assert parsed.output[0].to_dict() == item


def test_parse_response_handles_null_output() -> None:
response = construct_type_unchecked(
type_=Response,
value=_response_payload(output=None),
)

parsed = parse_response(text_format=omit, input_tools=omit, response=response)

assert parsed.output == []


def test_streaming_completed_null_output_preserves_accumulated_snapshot() -> None:
state = _message_stream_state({"type": "output_text", "text": "", "annotations": []})
_handle_stream_event(
state,
ResponseTextDeltaEvent,
type="response.output_text.delta",
sequence_number=3,
item_id="msg_1",
output_index=0,
content_index=0,
delta="hello",
logprobs=[],
)

events = state.handle_event(
construct_type_unchecked(
type_=ResponseCompletedEvent,
value={
"type": "response.completed",
"sequence_number": 4,
"response": _response_payload(output=None),
},
)
)

completed = events[0]
assert completed.type == "response.completed"
assert completed.response.output_text == "hello"
assert state._completed_response is not None
assert state._completed_response.output_text == "hello"


def test_streaming_completed_null_output_applies_finalization_events() -> None:
state = _message_stream_state({"type": "output_text", "text": "", "annotations": []})
_handle_stream_event(
state,
ResponseTextDeltaEvent,
type="response.output_text.delta",
sequence_number=3,
item_id="msg_1",
output_index=0,
content_index=0,
delta="draft",
logprobs=[],
)
final_part = {
"type": "output_text",
"text": "final",
"annotations": [
{
"type": "url_citation",
"url": "https://example.com",
"title": "Example",
"start_index": 0,
"end_index": 5,
}
],
"logprobs": [],
}
_handle_stream_event(
state,
ResponseContentPartDoneEvent,
type="response.content_part.done",
sequence_number=4,
item_id="msg_1",
output_index=0,
content_index=0,
part=final_part,
)
_handle_stream_event(
state,
ResponseOutputItemDoneEvent,
type="response.output_item.done",
sequence_number=5,
output_index=0,
item={
"id": "msg_1",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [final_part],
},
)

events = state.handle_event(
construct_type_unchecked(
type_=ResponseCompletedEvent,
value={
"type": "response.completed",
"sequence_number": 6,
"response": _response_payload(output=None),
},
)
)

completed = events[0]
assert completed.type == "response.completed"
assert completed.response.output[0].to_dict() == {
"id": "msg_1",
"type": "message",
"status": "completed",
"role": "assistant",
"content": [{**final_part, "parsed": None}],
}


def test_streaming_completed_null_output_preserves_refusal_done() -> None:
state = _message_stream_state({"type": "refusal", "refusal": ""})
_handle_stream_event(
state,
ResponseRefusalDeltaEvent,
type="response.refusal.delta",
sequence_number=3,
item_id="msg_1",
output_index=0,
content_index=0,
delta="draft",
)
_handle_stream_event(
state,
ResponseRefusalDoneEvent,
type="response.refusal.done",
sequence_number=4,
item_id="msg_1",
output_index=0,
content_index=0,
refusal="final refusal",
)

events = state.handle_event(
construct_type_unchecked(
type_=ResponseCompletedEvent,
value={
"type": "response.completed",
"sequence_number": 5,
"response": _response_payload(output=None),
},
)
)

completed = events[0]
assert completed.type == "response.completed"
assert completed.response.output[0].to_dict() == {
"id": "msg_1",
"type": "message",
"status": "in_progress",
"role": "assistant",
"content": [{"type": "refusal", "refusal": "final refusal"}],
}


@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"])
def test_stream_method_definition_in_sync(sync: bool, client: OpenAI, async_client: AsyncOpenAI) -> None:
checking_client: OpenAI | AsyncOpenAI = client if sync else async_client
Expand Down