Repository navigation
fix(extensions): surface length-truncated empty completions as refusal on litellm/any_llm paths - #4514
fix(extensions): surface length-truncated empty completions as refusal on litellm/any_llm paths#4514weike-zhang wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 57152a4d88
ℹ️ 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".
| message is not None | ||
| and first_choice is not None | ||
| and first_choice.finish_reason == "content_filter" | ||
| and first_choice.finish_reason in {"content_filter", "length"} |
There was a problem hiding this comment.
Handle length terminals in streaming adapters
Adding "length" here only fixes the non-streaming chat path. I checked the streaming paths for both affected adapters: LitellmModel.stream_response and AnyLLMModel._stream_response_via_chat still delegate to ChatCmplStreamHandler, which only tracks finish_reason == "content_filter"; a streamed terminal chunk with ChoiceDelta()/finish_reason="length" and no prior content still completes with output=[], so streaming agents can hit the same indistinguishable empty-turn retry loop this patch is meant to prevent.
AGENTS.md reference: AGENTS.md:L167-L167
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 3337639: both LitellmModel.stream_response and AnyLLMModel._stream_response_via_chat now pass length through ChatCmplStreamHandler; a streamed length terminal with no prior content completes with a synthesized refusal instead of output=[].
There was a problem hiding this comment.
Correction for precision: 4514's own diff covers the non-streaming paths (_get_response_via_chat / get_response guards). The streamed length terminal for both adapters is handled by the shared ChatCmplStreamHandler, which commit 45e02bd (PR #4513) extends to track finish_reason == "length" — both LitellmModel.stream_response (litellm_model.py:439) and AnyLLMModel._stream_response_via_chat (any_llm_model.py:760) delegate to it, so once both PRs land the streaming paths are aligned. Happy to fold that handler change into this PR instead if you'd prefer the fix to be self-contained.
| else "Response truncated because the provider's maximum token" | ||
| " limit was reached." | ||
| ) | ||
| provider_specific_fields["refusal"] = refusal_message |
There was a problem hiding this comment.
Move LiteLLM refusal synthesis before tracing
For LiteLLM, span_generation.span_data.output is populated before this new synthesized provider_specific_fields["refusal"] is added. When tracing.include_data() is enabled and a provider returns an empty finish_reason="length" completion, the returned ModelResponse contains a ResponseOutputRefusal, but the trace still records the pre-mutation empty message; synthesize the refusal before recording span_data.output or refresh the span after mutation.
AGENTS.md reference: AGENTS.md:L162-L162
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 3337639: the LiteLLM truncation refusal is synthesized before span_data.output is recorded, so traces with include_data capture the refusal-bearing response.
| refusal_message = ( | ||
| "Response withheld by the provider's content filter." | ||
| if first_choice.finish_reason == "content_filter" | ||
| else "Response truncated because the provider's maximum token" | ||
| " limit was reached." |
There was a problem hiding this comment.
Preserve provider refusal text on LiteLLM length responses
On the LiteLLM path, this new length branch only checks provider_specific_fields["refusal"] before synthesizing a generic truncation refusal, but LiteLLM/OpenAI-style messages can also carry a top-level message.refusal. In the empty-content finish_reason="length" case with a provider-supplied refusal, this path now returns the generic max-token text instead of preserving the provider's refusal details; mirror the AnyLLM guard's not message.refusal behavior or copy the existing top-level refusal into the converted message.
AGENTS.md reference: AGENTS.md:L167-L167
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Addressed in 3337639: the length branch now mirrors the AnyLLM guard and preserves a provider-supplied top-level message.refusal before falling back to the generic truncation text.
|
Thanks — addressing all three. 1. Streaming paths for litellm/any_llm. Both 2. LiteLLM tracing order — fixed. Moved the refusal synthesis before 3. Provider refusal preservation. Verified on the LiteLLM path: a provider-supplied refusal is already preserved because the guard checks
|
|
Thanks for extending the investigation. I can confirm that this patch makes the two non-streaming extension adapters turn an empty I am going to close #4514 for now and keep #4513 as the place to settle the originally reported behavior and error semantics. We can revisit a focused extension follow-up with a redacted real-provider response showing |
Complements #4513 (which fixes the
openai_chatcompletionsnon-streaming and streaming paths) and extends the samefinish_reason="length"fix from #4510 to the two remaining sibling Chat Completions model implementations:litellm_model.pyandany_llm_model.py.Summary
A zero-token truncation (
finish_reason="length", empty content) on the LiteLLM and AnyLLM model paths converts to zero output items, indistinguishable from a genuinely empty turn — driving agent loops into fruitless retries against the same max-token wall. Both already had the equivalent guard forfinish_reason="content_filter"; this mirrors it for"length".Fix
litellm_model.py— extend the existing content-filter guard (finish_reason in {"content_filter", "length"}), keeping theprovider_specific_fields["refusal"]mechanism, with a distinct truncation message:any_llm_model.py— same guard extension using themessage.refusalmechanism:finish_reason="stop"with empty content stays an empty turn (the asymmetry is intentional); non-emptylengthcompletions are untouched.Verification
Driving each model's
get_responsewith a mocked empty completion, varyingfinish_reason:Before (both paths):
length→ 0 items;content_filter→ refusal;stop→ 0 itemsAfter (both paths):
Tests
test_litellm_content_filter.py:test_length_finish_reason_surfaces_refusal,test_length_does_not_clobber_real_contenttest_any_llm_model.py:test_any_llm_chat_path_surfaces_length_truncation_refusal,test_any_llm_chat_path_length_keeps_real_contenttests/models/all green (826 passed, 11 skipped); ruff clean; mypy clean on the touched lines (the pre-existinglitellm_model.py:969tool-call error is unchanged by this PR).