Repository navigation
fix(chatcmpl): raise ModelBehaviorError on truncated empty completions - #4513
Conversation
There was a problem hiding this comment.
💡 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".
|
Thanks for the review — addressed. I've pushed commit 45e02bd extending the same treatment to the streaming path: the handler now tracks |
ErenAta16
left a comment
There was a problem hiding this comment.
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_errorMeasured 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.
|
Thanks for the careful read-through and the measurements — especially the 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 ( The remaining gap — the two sibling non-streaming paths On the |
There was a problem hiding this comment.
💡 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
left a comment
There was a problem hiding this comment.
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.4returnedfinish_reason="length"withcontent=""when reasoning consumed the completion budget. - A few providers on OpenRouter returned
finish_reason="length"withcontent=null. - In every successful raw response, the provider's
refusalfield wasnull.
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 Noneandcontent == ""; - behave consistently in streaming and non-streaming paths;
- preserve genuine provider refusals when the upstream
refusalfield is populated; - include regression tests confirming that token exhaustion does not produce
ResponseOutputRefusalor 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 == "".
There was a problem hiding this comment.
💡 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".
|
@seratch Thanks for the detailed review. I've reworked the fix per your guidance: |
seratch
left a comment
There was a problem hiding this comment.
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:
- Gate the new length-error behavior behind an internal
ChatCmplStreamHandleroption enabled only byOpenAIChatCompletionsModel. The shared handler also serves LiteLLM and AnyLLM, whose non-streaming paths remain unchanged; #4514 was closed because that extension need was not demonstrated. - 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.
|
Thanks for the review! Addressed all three points in fb3e0b14:
@seratch could you take another look? |
ErenAta16
left a comment
There was a problem hiding this comment.
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.
Fixes #4510
Summary
A Chat Completions completion truncated at zero visible tokens (
finish_reason="length", empty content) currently converts to zero output items. NeitherModelResponsenor the streamed events carryfinish_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
ModelBehaviorErroron both the non-streaming (OpenAIChatCompletionsModel.get_response) and streaming (ChatCmplStreamHandler.handle_stream) paths. Afinish_reason="length"completion with no assistant text, tool call, or refusal raises aModelBehaviorErrorinstead 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 throughmodel_refusalhandlers.Fix
Non-streaming (
openai_chatcompletions.py)get_responseraises when the first choice is terminal withfinish_reason="length"and the message is otherwise empty:The content-filter refusal synthesis is unchanged.
Streaming (
chatcmpl_stream_handler.py)ChatCmplStreamHandler.handle_streamtracksfinish_reason == "length"alongside the existingsaw_content_filterflag and raises the sameModelBehaviorErrorat 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 forcontent_filter, so the finish_reason reaches the handler.The length-error behavior is gated behind an internal
raise_on_length_truncationoption that onlyOpenAIChatCompletionsModel.stream_responseenables. 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_responseagainst a mocked completion with empty assistant content, varying onlyfinish_reason:Before
After (non-streaming)
After (streaming) —
ChatCmplStreamHandler.handle_streamon a terminallengthchunk with no emitted content:Non-empty
lengthcompletions (text / refusal / tool calls) are untouched on both paths.Tests
Non-streaming (
test_openai_chatcompletions.py):test_get_response_raises_on_truncated_empty_turntest_get_response_raises_on_truncated_empty_string_turntest_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_texttest_buffered_stream_raises_on_length_truncationtest_buffered_stream_length_does_not_clobber_texttest_buffer_tool_call_stream_forwards_length_finish_reasontest_streamed_span_preserves_usage_on_length_truncationtest_streamed_span_counts_request_on_length_truncation_without_usagetest_openai_chatcompletions.py+test_openai_chatcompletions_stream.pygreen (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 theopenai_chatcompletionsnon-streaming + streaming paths to match how thecontent_filterfixes were merged individually (#3769, #3897, #4188, #4234) — happy to extend the same treatment to those two sibling paths in a follow-up.