Skip to content

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

Closed

Description

@ErenAta16

A completion truncated at zero visible tokens (finish_reason="length", empty content) converts to zero output items, and ModelResponse does not carry finish_reason, so the caller cannot tell it apart from a model that genuinely returned nothing.

This is the same failure the project already fixed for content_filter, and the reasoning written next to that fix applies here word for word.

Measured

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

finish_reason      output items   what the caller sees
content_filter     1              ['message'] -> ResponseOutputRefusal
length             0              []
stop               0              []

ModelResponse exposes output, usage, response_id, request_id. finish_reason is read once for a logger.debug line and then discarded, so nothing downstream can recover it.

Mechanism

Converter.message_to_output_items drops an empty message in two steps:

if message.content:            # "" is falsy, no text part is appended
    message_item.content.append(ResponseOutputText(...))
...
if message_item.content:       # still empty, so the message itself is never appended
    items.append(message_item)

That is correct for content=None. For a truncated turn it discards the one thing that distinguishes it.

Why I think this is in scope rather than by design

The content_filter guard in openai_chatcompletions.py:304, and its siblings in litellm_model.py, any_llm_model.py and the stream handler, exist because of exactly this shape. The comment says so:

Without this, message converts to zero output items and the caller sees an indistinguishable "empty turn", which drives agent loops into fruitless retries.

and the test for it is explicit:

A content-filter block (empty message, finish_reason=content_filter) must become an explicit ResponseOutputRefusal, not zero output items.

So "zero output items for an empty message" is already treated as a bug when the provider had something to say. Truncation is the other case where the provider had something to say.

I am not arguing that every empty message should produce an item. finish_reason="stop" with empty content is genuinely an empty turn and returning nothing for it seems right. The asymmetry is that length carries information and it is dropped.

Reachability

Any max_tokens small enough to be exhausted before the first visible token produces this shape. It is easiest to hit by accident with reasoning models, where the budget is consumed by reasoning tokens and the visible content can be empty while the request is nowhere near "finished". An agent loop then sees an empty turn, has no signal that the budget was the cause, and retries into the same wall.

Options

  1. Surface it on ModelResponse. Carry finish_reason (or an incomplete_reason) through, and let callers decide. Most general, and it also serves cases beyond this one.
  2. Mirror the content_filter treatment. Synthesize a refusal-like item when finish_reason == "length" and the message is otherwise empty. Smallest diff, consistent with the existing fix, but it borrows refusal for something that is not a refusal.
  3. Raise. A truncated-to-nothing turn is arguably a configuration error worth failing on rather than returning an empty result.

I lean towards 1 with 2 as a fallback, but this is a design call and I did not want to presume, especially as it touches four model paths.

Happy to send the PR for whichever shape you prefer, with tests covering the three finish reasons on both the streaming and non-streaming paths.

Environment

openai-agents 0.21.1, main at ebb746d, Python 3.12.12.

Activity

  1. weike-zhang commented on Aug 18, 2026

    @weike-zhang
    Contributor

    Implemented option 2 (mirror the content_filter treatment) for the non-streaming path in PR #4513 — the reporter's own fallback, reusing the existing precedent so it stays a minimal diff. Kept to the reported path only; litellm/any_llm/streaming still share the gap and are noted as follow-ups. Option 1 (surface finish_reason on ModelResponse) can layer on top if the maintainers prefer it.

  2. weike-zhang commented on Aug 18, 2026

    @weike-zhang
    Contributor

    Correction to my earlier comment: the "kept to the reported path only" wording predates commit 45e02bd. The PR #4513 diff actually covers both the non-streaming and streaming Chat Completions paths (streaming forwards the delta-stripped length terminal through buffer_tool_call_stream and synthesizes via saw_length), and the remaining sibling non-streaming paths (litellm_model.py, any_llm_model.py) are handled in #4514.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions