Repository navigation
Conversation
get_final_response() previously required response.completed only, so max_output_tokens truncation and failed streams raised RuntimeError even though those terminal events already carry a full Response payload.
gnanirahulnutakki
left a comment
There was a problem hiding this comment.
Reviewed exact commit 9ebe10435f1184ca0852cadac531c3121fb491a5. The existing targeted suite passes (19 tests) and the changed files pass Ruff, but an isolated structured-output reproduction exposes one correctness issue on the new incomplete/failed paths.
| input_tools=self._input_tools, | ||
| ) | ||
| elif event.type == "response.incomplete": | ||
| self._completed_response = parse_response( |
There was a problem hiding this comment.
Avoid parsing truncated structured output on terminal failures
parse_response() calls the strict parse_text() path for every output text, so this raises before the terminal response can be stored whenever an incomplete structured output contains partial JSON. On this exact head I reproduced it with text_format=Answer and a response.incomplete payload whose text is {"value": and incomplete_details.reason == "max_output_tokens"; handle_event() raises Pydantic ValidationError. The same happens for response.failed, while the base commit accepts both events without attempting the parse. Consequently get_final_response() still cannot return the terminal response in the structured-output case this change is meant to fix. All new tests use text_format=omit, so they do not cover this branch. Please preserve the terminal response without strict structured parsing (for example, leave parsed unset for non-completed responses) and add incomplete/failed tests with a supplied Pydantic text format.
There was a problem hiding this comment.
Thanks — confirmed and fixed on this PR.
For response.incomplete / response.failed we now store the terminal response via parse_response(..., text_format=omit, input_tools=omit) so truncated structured JSON (and truncated tool args) no longer raise before get_final_response() can return. response.completed still uses the strict text_format path.
Added regression coverage with text_format=_Answer and truncated payload text {"value": for both incomplete and failed, plus sync/async get_final_response() cases and a completed-path sanity check that still parses successfully.
When text_format is set, parse_response() was still applied to response.incomplete / response.failed payloads, so truncated structured JSON raised ValidationError before get_final_response() could return. Store those terminal responses without strict text/tool parsing.
gnanirahulnutakki
left a comment
There was a problem hiding this comment.
Verified the fix on e896c03a2ac9136bfc348195faadf4b037194b28. Incomplete and failed terminal responses now bypass strict structured-output and tool-argument parsing, while the completed path still parses normally. The focused suite passes all 19 tests, including the new sync/async truncated-Pydantic cases, and both changed files pass Ruff lint and format checks.
|
AI-assisted independent offline check at 18/18 controls pass on this head versus 6/18 on pinned main. This supplements the existing text-output tests with Pydantic function tools converted by SDK _make_tools. Sync/async final accessors retain valid, truncated and schema-invalid arguments as raw strings with parsed_arguments=None for incomplete/failed terminal events, alongside usage/error/incomplete details. Completed valid arguments still parse and completed invalid/truncated arguments still raise ValidationError. Scope: Constructed response.created + terminal event sequences over an in-memory iterator. No earlier function-arguments-done/text-done events are included, so this does not establish a fix for the earlier structured parsing concerns in #3378 or every provider event sequence. No transport or service calls. Python 3.14.7 / Pydantic 2.13.5 and shared local dependencies; this is a bounded matrix, not a full SDK suite or historical dependency matrix. Network denied by the sandbox. Standalone reproducer (run separately with each source tree on PYTHONPATH)import json,asyncio,pydantic,importlib
from openai import omit,pydantic_function_tool
from openai._models import construct_type_unchecked
from openai.types.responses import ResponseStreamEvent
from openai.lib.streaming.responses import ResponseStream,AsyncResponseStream
from openai.resources.responses.responses import _make_tools
class Tool(pydantic.BaseModel):n:int
class Raw:
def __init__(self,events):self.events=events;self.response=None
def __iter__(self):return iter(self.events)
async def __aiter__(self):
for e in self.events:yield e
def close(self):pass
async def aclose(self):pass
async def main():
rows=[]
for async_ in [False,True]:
for status in ['completed','incomplete','failed']:
for name,args in [('valid','{"n":3}'),('truncated','{"n":'),('invalid','{"n":"wrong"}')]:
output={'type':'function_call','id':'fictional','call_id':'fictional','name':'Tool','arguments':args,'status':'completed' if status=='completed' else 'incomplete'}
base={'id':'fictional','object':'response','created_at':0,'model':'fictional','parallel_tool_calls':True,'tool_choice':'auto','tools':[],'output':[],'status':'in_progress'}
terminal={**base,'output':[output],'status':status,'usage':{'input_tokens':2,'output_tokens':3,'total_tokens':5,'input_tokens_details':{'cached_tokens':0},'output_tokens_details':{'reasoning_tokens':0}}}
if status=='incomplete':terminal['incomplete_details']={'reason':'max_output_tokens'}
if status=='failed':terminal['error']={'code':'server_error','message':'fictional'}
events=[construct_type_unchecked(type_=ResponseStreamEvent,value={'type':'response.created','sequence_number':0,'response':base}),construct_type_unchecked(type_=ResponseStreamEvent,value={'type':'response.'+status,'sequence_number':1,'response':terminal})]
stream=(AsyncResponseStream if async_ else ResponseStream)(raw_stream=Raw(events),text_format=omit,input_tools=_make_tools([pydantic_function_tool(Tool)]),starting_after=None)
kind=None;actual=None
try:
result=await stream.get_final_response() if async_ else stream.get_final_response();call=result.output[0]
actual={'status':result.status,'arguments':call.arguments,'parsed':call.parsed_arguments.model_dump() if isinstance(call.parsed_arguments,pydantic.BaseModel) else call.parsed_arguments,'tokens':result.usage.total_tokens,'error':result.error.message if result.error else None,'incomplete':result.incomplete_details.reason if result.incomplete_details else None}
except Exception as exc:kind=type(exc).__name__
expected_error='ValidationError' if status=='completed' and name!='valid' else None
expected={'status':status,'arguments':args,'parsed':{'n':3} if status=='completed' else None,'tokens':5,'error':'fictional' if status=='failed' else None,'incomplete':'max_output_tokens' if status=='incomplete' else None}
rows.append({'async':async_,'status':status,'arguments_case':name,'exception':kind,'actual':actual,'pass':kind==expected_error and (expected_error is not None or actual==expected)})
print(json.dumps({'cases':rows,'passed':sum(x['pass'] for x in rows),'failed':sum(not x['pass'] for x in rows),'scope':'Actual final-response accessor over constructed terminal tool events; no earlier argument-done events, network or provider event sequence proof.'}))
asyncio.run(main()) |
Changes being requested
Summary
ResponseStream.get_final_response()/AsyncResponseStream.get_final_response()only stored a finalParsedResponsewhen aresponse.completedevent arrived. Terminalresponse.incompleteandresponse.failedevents already carry a fullResponsepayload (for example aftermax_output_tokenstruncation), but callingget_final_response()after those streams still raised:This change treats
response.incompleteandresponse.failedas terminal events inResponseStreamState.accumulate_event, matching:ResponseAccumulator(response.completed|response.failed|response.incomplete)get_final_completion(), which returns the snapshot regardless offinish_reasonThe touched code lives under
src/openai/lib/, which CONTRIBUTING.md states is not modified by the generator.Problem
Minimal reproduction (no API key):
Current behavior
response.completed→_completed_responseis set;get_final_response()succeedsresponse.incomplete/response.failed→_completed_responsestaysNone;get_final_response()raisesRuntimeErrorCorrected behavior
All three terminal events populate
_completed_responseviaparse_response(...). Sync and asyncget_final_response()return that parsed response. If no terminal event was seen, the error message now says a terminal response event was missing.Root cause
accumulate_eventonly handledresponse.completedwhen assigning_completed_response.Implementation
Smallest change in
src/openai/lib/streaming/responses/_responses.py:response.incompleteandresponse.failedget_final_response()RuntimeErrormessageNo public request/response shapes changed. No generated API resources/types edited.
Regression tests
Added
tests/lib/responses/test_response_stream_final.py:get_final_response()for all three terminal eventsValidation
Pre-fix (expected failures on incomplete/failed paths):
Post-fix:
Not run / limitations:
./scripts/testagainst the Steady mock server (requires./scripts/mock)./scripts/lintvia Rye (local validation used the project.venvtools equivalent to ruff/pyright/mypy/import check for the changed files)upstream/mainas well (pre-existing; unrelated)Compatibility
response.completedstreams are unchangedRuntimeErroron incomplete/failed streams now receive the terminalParsedResponse(inspectstatus/incomplete_details/erroras needed)Additional context & links
upstream/main@8a6adcb3(release 2.48.0)_completed_responsefor incomplete/failedopenai-nodeResponseAccumulatorhandlesresponse.completed|response.failed|response.incompletetogether