Skip to content

fix(chatcmpl): raise ModelBehaviorError on truncated empty completions - #4513

Merged
seratch merged 5 commits into
openai:mainfrom
weike-zhang:fix/surface-length-truncation-refusal
Aug 20, 2026
Merged

seratch merged 5 commits into
openai:mainfrom
weike-zhang:fix/surface-length-truncation-refusal

Conversation

@weike-zhang

@weike-zhang weike-zhang commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #4510

Summary

A Chat Completions completion truncated at zero visible tokens (finish_reason="length", empty content) currently converts to zero output items. Neither ModelResponse nor the streamed events carry finish_reason, so callers cannot tell this apart from a model that genuinely returned nothing — agent loops retry into the same max-token wall.

This surfaces that condition as a ModelBehaviorError on both the non-streaming (OpenAIChatCompletionsModel.get_response) and streaming (ChatCmplStreamHandler.handle_stream) paths. A finish_reason="length" completion with no assistant text, tool call, or refusal raises a ModelBehaviorError instead of collapsing into an empty turn, so the agent loop stops instead of retrying into the same token wall. finish_reason="stop" with empty content remains an empty turn (the asymmetry is intentional).

This deliberately does not synthesize a ResponseOutputRefusal: token-budget exhaustion is not a policy refusal and should not route through model_refusal handlers.

Fix

Non-streaming (openai_chatcompletions.py)

get_response raises when the first choice is terminal with finish_reason="length" and the message is otherwise empty:

if (
    message is not None
    and first_choice is not None
    and first_choice.finish_reason == "length"
    and not message.content
    and not message.refusal
    and not message.tool_calls
):
    raise ModelBehaviorError(
        "Chat Completions response terminated with finish_reason='length' "
        "but produced no assistant text, tool call, or refusal."
    )

The content-filter refusal synthesis is unchanged.

Streaming (chatcmpl_stream_handler.py)

ChatCmplStreamHandler.handle_stream tracks finish_reason == "length" alongside the existing saw_content_filter flag and raises the same ModelBehaviorError at end-of-stream when nothing was emitted (text / refusal / tool calls). The tool-call buffering layer forwards a length-truncated terminal choice (delta-stripped) exactly like it already does for content_filter, so the finish_reason reaches the handler.

The length-error behavior is gated behind an internal raise_on_length_truncation option that only OpenAIChatCompletionsModel.stream_response enables. The shared handler also serves LiteLLM and AnyLLM, whose streaming behavior is unchanged.

Span usage preserved on the error

Before raising, both paths record the request and any reported token usage on the generation span, so a truncated empty completion does not silently drop the request from tracing/usage accounting. Focused tracing assertions cover the streaming and non-streaming error paths, with and without provider-reported usage.

Verification

Driving OpenAIChatCompletionsModel.get_response against a mocked completion with empty assistant content, varying only finish_reason:

Before

finish_reason      output items   what the caller sees
length             0              []
content_filter     1              ['message'] -> ResponseOutputRefusal
stop               0              []            (genuine empty, unchanged)

After (non-streaming)

finish_reason      output items   what the caller sees
length             raises         ModelBehaviorError
content_filter     1              ['message'] -> ResponseOutputRefusal (unchanged)
stop               0              []             (genuine empty, unchanged)

After (streaming) — ChatCmplStreamHandler.handle_stream on a terminal length chunk with no emitted content:

finish_reason      events                                        response.output
length             raises                                        ModelBehaviorError
content_filter     ... refusal.delta ... response.completed      [message -> ResponseOutputRefusal] (unchanged)
stop               response.created, response.completed           []

Non-empty length completions (text / refusal / tool calls) are untouched on both paths.

Tests

Non-streaming (test_openai_chatcompletions.py):

  • test_get_response_raises_on_truncated_empty_turn
  • test_get_response_raises_on_truncated_empty_string_turn
  • test_get_response_traces_error_on_truncated_empty_turn (span records the request)
  • test_get_response_traces_usage_on_truncated_empty_turn (reported tokens preserved on the span)
  • test_get_response_preserves_nonempty_truncated_output (parametrized: text / refusal / tool call)

Streaming (test_openai_chatcompletions_stream.py):

  • test_handler_stream_raises_on_length_truncation (option enabled)
  • test_handler_stream_length_is_ignored_without_internal_option (LiteLLM/AnyLLM behavior unchanged)
  • test_handler_stream_length_does_not_clobber_text
  • test_buffered_stream_raises_on_length_truncation
  • test_buffered_stream_length_does_not_clobber_text
  • test_buffer_tool_call_stream_forwards_length_finish_reason
  • test_streamed_span_preserves_usage_on_length_truncation
  • test_streamed_span_counts_request_on_length_truncation_without_usage

test_openai_chatcompletions.py + test_openai_chatcompletions_stream.py green (161 passed); ruff clean; mypy at baseline (no new errors).

Scope note

The same gap still exists on the sibling Chat Completions model paths (litellm_model.py, any_llm_model.py). I kept this PR to the openai_chatcompletions non-streaming + streaming paths to match how the content_filter fixes were merged individually (#3769, #3897, #4188, #4234) — happy to extend the same treatment to those two sibling paths in a follow-up.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 738034b1a8

ℹ️ 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".

@weike-zhang

Copy link
Copy Markdown
Contributor Author

Thanks for the review — addressed. I've pushed commit 45e02bd extending the same treatment to the streaming path: the handler now tracks finish_reason == "length" alongside saw_content_filter and synthesizes the truncation refusal at end-of-stream when nothing was emitted, and the tool-call buffering layer forwards a length-truncated terminal choice (delta-stripped) exactly like it already does for content_filter. Streaming length now produces a ResponseOutputRefusal instead of response.output == [], matching the non-streaming path. Added 5 mirror tests (direct handler, buffered, and forwarding). 242 tests + ruff + mypy all clean.

@ErenAta16 ErenAta16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I filed #4510, so this is the fix for my report. Checked it out and measured rather than reading the diff.

All four empty shapes behave correctly, including the one I would have expected to slip:

finish_reason=length, content=None              -> ResponseOutputRefusal
finish_reason=length, content=""                -> ResponseOutputRefusal
finish_reason=length, content="partial answer"  -> ResponseOutputText     (no synthetic refusal)
finish_reason=length, content=" "               -> ResponseOutputText     (no synthetic refusal)

tests/models/ is 833 passed on the branch.

The third row is the one that mattered most to me and I want to say why, because it is the risk that does not exist for content_filter. Truncation is common and most truncated turns do emit content, so a guard that fired on finish_reason alone would have attached a refusal to a large share of ordinary responses. The state.text_content_index_and_output is None and ... and not state.function_calls condition is what prevents that, and it holds on both paths.

You did more than your comment claims, and it is the right call. You wrote "kept to the reported path only", but the diff also forwards delta-stripped length choices through buffer_tool_call_stream and tracks saw_length in handle_stream. Without the buffering change the handler never sees the finish_reason at all, so the streaming half would have been dead code. Worth correcting the description so a reviewer does not go looking for a gap that is not there.

One thing I would want decided before this lands, not a defect in the diff.

A refusal is not just an output shape here, it is a control-flow signal. turn_resolution.py:953-979 extracts it and raises:

refusal = ItemHelpers.extract_refusal(message_items[-1].raw_item) if message_items else None
if refusal:
    refusal_error = ModelRefusalError(refusal)
    ...
    error_kind="model_refusal"
    raise refusal_error

Measured on this branch:

finish_reason=content_filter  extract_refusal -> "Response withheld by the provider's content filter."
finish_reason=length          extract_refusal -> "Response truncated because the provider's maximum token limit was reached."
                              both -> ModelRefusalError, error_kind="model_refusal"

So a run that hit its token budget now terminates as a model refusal. That is a better outcome than the silent empty turn I reported, and I do not think it should block the PR. But the two events want different handling: a content filter is a policy decision you should not retry, while a zero-token truncation is a budget problem whose usual remedy is a larger max_tokens or a continuation. Anything branching on error_kind == "model_refusal" will now treat the second as the first.

Three ways out, and the choice is the maintainers':

Leave it. An explicit, mislabelled error still beats an invisible empty turn, and the refusal text names the real cause.

Give truncation its own kind, for example a model_truncated alongside model_refusal in RunErrorHandlerKind. Larger, and it touches the public error surface.

Keep the synthesized output but let turn_resolution distinguish the two, so the shape stays uniform and only the raised error differs.

I lean toward the first for this PR and the second as a follow-up, because the finish_reason is the thing ModelResponse still does not carry, which was the other half of what I reported. Option 1 in my issue, surfacing finish_reason, would make that distinction available without guessing from the refusal string.

@seratch flagging the semantics rather than the code, since the diff itself is faithful to the content_filter precedent it copies.

@weike-zhang

Copy link
Copy Markdown
Contributor Author

Thanks for the careful read-through and the measurements — especially the content=" " case, which is exactly the one I wanted to guard against.

Clarifying the coverage (you're right, my earlier wording was stale). The "kept to the reported path only" line was written before I pushed commit 45e02bd. The diff as it stands covers both the non-streaming (get_response) and streaming (ChatCmplStreamHandler.handle_stream) paths — buffer_tool_call_stream forwards the delta-stripped length terminal choice so the handler actually sees the finish_reason, and saw_length drives the end-of-stream synthesis. Without the buffering change the streaming half would indeed be dead code. The PR body has been updated to reflect this; only the earlier issue comment still says otherwise and I'll correct it.

The remaining gap — the two sibling non-streaming paths litellm_model.py and any_llm_model.py — is now handled in a separate PR: #4514.

On the model_refusal control-flow signal: agreed this is not a defect in the diff, and I agree with your leaning — keep this PR as-is (an explicit, mislabelled error still beats an invisible empty turn, and the refusal text names the real cause), and treat a distinct model_truncated kind (or surfacing finish_reason on ModelResponse, option 1 from the issue) as a follow-up. I won't touch the public error surface here; leaving the semantic decision to the maintainers.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ad2e9141e8

ℹ️ 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".

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing this. I verified the behavior against real Chat Completions APIs, and the underlying issue is real and provider-agnostic:

  • OpenAI gpt-5.4 returned finish_reason="length" with content="" when reasoning consumed the completion budget.
  • A few providers on OpenRouter returned finish_reason="length" with content=null.
  • In every successful raw response, the provider's refusal field was null.

Because these responses represent token or reasoning-budget exhaustion, converting them into ResponseOutputRefusal misclassifies them as policy refusals. It would also incorrectly route them through model_refusal handlers and potentially raise ModelRefusalError.

Please surface a non-refusal error such as ModelBehaviorError when Chat Completions terminates with finish_reason="length" without final assistant text, a tool call, or an actual refusal. The handling should:

  • cover both content is None and content == "";
  • behave consistently in streaming and non-streaming paths;
  • preserve genuine provider refusals when the upstream refusal field is populated;
  • include regression tests confirming that token exhaustion does not produce ResponseOutputRefusal or invoke refusal handling.

The behavior needs fixing, but manufacturing a refusal is not the correct semantic representation.

Per review feedback: finish_reason='length' with no assistant text, tool
call, or refusal is token-budget exhaustion, not a policy refusal. Surface
ModelBehaviorError instead of manufacturing ResponseOutputRefusal so it
does not route through model_refusal handlers. Consistent across streaming
and non-streaming paths, covering content is None and content == "".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 527400155a

ℹ️ 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".

@weike-zhang

Copy link
Copy Markdown
Contributor Author

@seratch Thanks for the detailed review. I've reworked the fix per your guidance: finish_reason='length' with no assistant text, tool call, or genuine refusal now surfaces ModelBehaviorError instead of manufacturing ResponseOutputRefusal — in both the streaming and non-streaming paths, covering content is None and content == "". The content_filter refusal synthesis and genuine provider refusals are unchanged, and regression tests confirm token exhaustion no longer produces a refusal or invokes refusal handling. Could you take another look?

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reworking this. The core semantics are now correct: an empty finish_reason="length" terminal represents token exhaustion, so ModelBehaviorError is preferable to ResponseOutputRefusal.

Before merge, please address two remaining points:

  1. Gate the new length-error behavior behind an internal ChatCmplStreamHandler option enabled only by OpenAIChatCompletionsModel. The shared handler also serves LiteLLM and AnyLLM, whose non-streaming paths remain unchanged; #4514 was closed because that extension need was not demonstrated.
  2. Preserve the available request and token usage on the generation span before raising, for both streaming and non-streaming calls, with focused tracing assertions.

Please also update the title and description so they describe the current non-refusal error behavior rather than the superseded refusal implementation.

@weike-zhang weike-zhang changed the title fix(chatcmpl): surface truncated empty completions as refusal fix(chatcmpl): raise ModelBehaviorError on truncated empty completions Aug 20, 2026
@weike-zhang

weike-zhang commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Addressed all three points in fb3e0b14:

  1. Gated behind an internal option: the length-error behavior is now behind a raise_on_length_truncation internal option on ChatCmplStreamHandler.handle_stream, enabled only by OpenAIChatCompletionsModel.stream_response. LiteLLM/AnyLLM streaming behavior is unchanged (a length terminal with no output keeps producing an empty turn).
  2. Span usage preserved before raising: both non-streaming and streaming paths now record the request and any reported token usage on the generation span before the ModelBehaviorError surfaces, with focused tracing assertions for both (with and without provider-reported usage).
  3. Title/description updated: retitled to fix(chatcmpl): raise ModelBehaviorError on truncated empty completions and rewrote the body to describe the non-refusal error behavior.

@seratch could you take another look?

@seratch seratch added this to the 0.22.x milestone Aug 20, 2026
@seratch
seratch enabled auto-merge (squash) August 20, 2026 07:45
@seratch
seratch merged commit e26a7d8 into openai:main Aug 20, 2026
17 checks passed

@ErenAta16 ErenAta16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked fb3e0b14 against all three points. They are addressed, and the gating claim holds structurally rather than only by intent.

I grepped every caller of handle_stream in src/ rather than trusting the description, since "LiteLLM and AnyLLM are unchanged" is exactly the kind of claim that quietly stops being true when a fourth caller appears:

src/agents/extensions/models/any_llm_model.py:752
src/agents/extensions/models/litellm_model.py:430
src/agents/models/openai_chatcompletions.py:476

Three callers, and only the third passes raise_on_length_truncation=True. litellm_model.py calls it with response, stream, model=self.model and nothing else, so it takes the False default and the length terminal keeps producing an empty turn there. That is the behaviour I was worried about breaking.

The usage preservation covers both branches, which was the half I expected to be missed:

if usage is not None:
    response.usage = cls._build_response_usage(usage)
else:
    _mark_request_completed_without_usage(response)

so a provider that reports no usage still gets the request recorded on the span rather than the span being abandoned mid-flight.

One optional nit, take it or leave it. raise_on_length_truncation is positional-or-keyword, since handle_stream has no * in its signature. For a flag whose entire purpose is that exactly one caller may enable it, making the tail keyword-only would enforce that mechanically instead of relying on the docstring. Nothing can hit it accidentally today, because it would take six positional arguments to reach, so this is hygiene rather than a defect.

Nothing else from me. The model_refusal question we set aside is still the right follow-up rather than part of this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A completion truncated at zero tokens (finish_reason=length) returns zero output items and is indistinguishable from an empty turn

3 participants