Description
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,
messageconverts 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
- Surface it on
ModelResponse. Carryfinish_reason(or anincomplete_reason) through, and let callers decide. Most general, and it also serves cases beyond this one. - Mirror the
content_filtertreatment. Synthesize a refusal-like item whenfinish_reason == "length"and the message is otherwise empty. Smallest diff, consistent with the existing fix, but it borrowsrefusalfor something that is not a refusal. - 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
weike-zhang commentedon Aug 18, 2026
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.
weike-zhang commentedon Aug 18, 2026
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
lengthterminal throughbuffer_tool_call_streamand synthesizes viasaw_length), and the remaining sibling non-streaming paths (litellm_model.py,any_llm_model.py) are handled in #4514.