fix(lib): only raise finish-reason errors when there is parseable input - #3589
fix(lib): only raise finish-reason errors when there is parseable input#3589hsusul wants to merge 1 commit into
Conversation
`parse_chat_completion` raised `LengthFinishReasonError` / `ContentFilterFinishReasonError` for any choice whose `finish_reason` was `length` / `content_filter`, regardless of whether structured-output parsing was requested. This is inconsistent with the streaming accumulator (`ChatCompletionStreamState._accumulate_chunk`), which only raises these when `has_parseable_input` is true. As a result a plain `client.chat.completions.stream(...)` (no `response_format` and no parseable tools) that stops at the token limit would iterate to completion without raising, but then raise `LengthFinishReasonError` from `get_final_completion()` — even though `client.chat.completions.create()` never raises for the same response and the length/content-filter restriction is documented only for `.parse()` (structured outputs). Guard both raises with `has_parseable_input`, mirroring the accumulator, so the errors are only raised when there is actually something to parse.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f33b811813
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # 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) |
There was a problem hiding this comment.
Preserve finish errors for one-shot tool iterables
When tools is a generator (which the public Iterable annotation permits), both sync and async chat.completions.parse() first consume it in _validate_input_tools (completions.py:179 and completions.py:1790) and then pass the exhausted iterator here. Consequently input_tools becomes empty and raise_on_incomplete is false, so a request that supplied a strict parseable tool but finishes with length or content_filter now silently returns an incomplete completion instead of raising. Materialize the tools before validation or otherwise preserve their parseability state.
Useful? React with 👍 / 👎.
Changes being requested
parse_chat_completion(src/openai/lib/_parsing/_completions.py) raisesLengthFinishReasonError/ContentFilterFinishReasonErrorfor any choice whosefinish_reasonislength/content_filter, regardless of whether structured-outputparsing was actually requested.
That is inconsistent with the streaming accumulator
(
ChatCompletionStreamState._accumulate_chunk), which only raises these errors whenhas_parseable_input(...)is true:Because
get_final_completion()funnels throughparse_chat_completion, a plainclient.chat.completions.stream(...)— noresponse_format, no parseable tools — thatstops at the token limit iterates to completion without raising (the accumulator
suppresses it), but then raises
LengthFinishReasonErrorfromget_final_completion():This is surprising for two reasons:
client.chat.completions.create()never raises for thesame response — it just returns a completion with
finish_reason="length".helpers.mddocuments the length / content-filter raise only under "Differencesfrom
.create()" forchat.completions.parse()(structured outputs); it is not adocumented behavior of a plain
.stream().Fix
Compute
has_parseable_input(...)once inparse_chat_completionand guard both raiseswith it, mirroring the accumulator. Behavior is unchanged whenever there is something to
parse:
.parse(response_format=Model)atlength.parse(tools=[strict fn])atlength.stream(response_format=Model)atlength.stream()→get_final_completion()atlength.parse()(no format/tools) atlength.create())has_parseable_inputis already defined in the same module, so no new import is needed.Tests
tests/lib/chat/test_completions_streaming.py:test_non_parse_stream_terminal_finish_reason_does_not_raise[length|content_filter]—drives
ChatCompletionStreamState(no network) with a content chunk + a terminalfinish chunk; asserts
get_final_completion()returns the accumulated completion withthe content and
finish_reasonpreserved. Fails onmainfor both parameters.test_parse_stream_length_finish_still_raises— with aresponse_format, the terminallengthchunk still raisesLengthFinishReasonError(guards the unchanged path).Validation
Run in an isolated worktree off
upstream/main@0c09a3fe(Python 3.9, pydantic 2.12.5,pyright 1.1.399):
pytest tests/lib/chat tests/lib/test_pydantic.py -o addopts=""→ all pass; the threenew tests fail on
mainfor the right reason and pass after the change.pytest tests/lib -o addopts=""→ 258 passed. The single failure(
test_bedrock_auth_conformance.py::test_retry_signing_fixture) is pre-existing andnetwork-dependent — it fails identically on unmodified
upstream/mainand is unrelatedto this change.
ruff check+ruff format --checkon both files → clean.pyrightonsrc/openai/lib/_parsing/_completions.py→ 0 errors; the test file'sstrict-mode error count is unchanged by this PR.
No API key, paid call, or live service was used.
Additional context & links
unconditional
parse_chat_completionraises both landed together in the structuredoutputs commit, so the two paths have disagreed since then.