Repository navigation
fix(litellm): surface content-filter refusals instead of an empty turn - #3769
Conversation
Some providers (notably Anthropic on Amazon Bedrock) signal a safety block only via `finish_reason == "content_filter"` with an empty message and no `refusal` field. `LitellmModel.get_response` then converts that message to zero output items, so the caller sees an "empty turn" indistinguishable from any other — driving agent loops into fruitless retries against a model that will keep refusing. When a completion finishes with `content_filter` and carries no content or tool calls, synthesize a `refusal` on the message so the existing converter path emits a `ResponseOutputRefusal` (and Runner raises ModelRefusalError) rather than silently dropping the turn. A content_filter response that still carries text is left untouched. Note: this covers the non-streaming `get_response` path. The streaming path (`stream_response` / ChatCmplStreamHandler) surfaces refusals only when the provider sends a `refusal` delta; synthesizing one from a terminal `content_filter` finish_reason there is a larger change left as follow-up. Test: tests/models/test_litellm_content_filter.py. Co-Authored-By: Claude Opus 4.8 <[email protected]>
seratch
left a comment
There was a problem hiding this comment.
Before merging, please extend the same behavior to the streaming path.
In ChatCmplStreamHandler, when choice index 0 terminates with finish_reason == "content_filter" and no text, refusal, or tool call has been emitted, please synthesize the refusal through the normal refusal event sequence. Do not replace previously emitted text or tool calls. Add a LiteLLM streaming regression test that verifies the completed response contains ResponseOutputRefusal and that the refusal events are coherent.
This is required because the released ModelRefusalError behavior applies across Runner entry points, and provider adapters must keep streaming and non-streaming terminal semantics aligned.
Extends the get_response content-filter handling to stream_response, per maintainer review on openai#3769. When a Chat Completions stream terminates with finish_reason == "content_filter" and nothing else was emitted (no real text, refusal, or tool call), synthesize a refusal through the normal refusal event sequence (output_item.added -> content_part.added -> refusal.delta, finalized by the existing done/completed path) so the completed response carries a ResponseOutputRefusal instead of an empty assistant turn. Previously-emitted text or tool calls are left intact. Bedrock's Converse stream (via LiteLLM) opens an empty-string ("") text content part before the terminal content_filter chunk, so the guard checks the accumulated text is empty rather than that the part is absent, and drops that empty text part so the completed message carries only the refusal. Verified against a live Bedrock claude-fable-5 refusal stream fed through the handler. Tests: two streaming cases in test_litellm_chatcompletions_stream.py (synthesizes-refusal on the empty-""-then-content_filter shape; does-not-clobber real streamed text). The synthesize case fails against a naive "part absent" guard, so it pins the empty-text regression. Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
Thanks @seratch — added the streaming path in In One real-world wrinkle I verified against a live Bedrock Added two streaming regression tests: the synthesize case (using the real empty- |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: da268a9918
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Addresses Codex review on openai#3769. When Bedrock opens an empty-"" text content part before the terminal content_filter chunk, the prior code cleared the text part from the final Response but, on the event stream, emitted a second output_item.added and a content_part.added at the same index while never closing the original text part — an incoherent sequence for streaming consumers even though the completed Response looked correct. Now, when an empty text part was already opened: close it with a content_part.done, reuse its index for the refusal, and do NOT re-announce the assistant message (output_item.added) — the message was already opened by the text part. The message is announced only when no prior part exists. Verified against a live Bedrock claude-fable-5 refusal stream end-to-end (stream_response -> litellm -> Converse): exactly one output_item.added, every content_part.added has a matching content_part.done, final output is a single ResponseOutputRefusal. Test asserts the coherence invariants (one message open; balanced part add/done) and fails against the pre-fix incoherent sequence. Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
@codex good catch — fixed in The empty- Proof of fix (live Bedrock
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7b569539b2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
@seratch — a scoping question for you on @codex's follow-up (it's suggesting the empty text part be prevented from opening at all, rather than closed/reused). The current revision ( The alternative is to suppress the leading empty- Which would you prefer — keep the current content_filter-scoped fix, or suppress the empty leading delta in the shared content branch? Happy to do either; just flagging the blast-radius trade-off before touching shared code. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7b569539b2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…test types Codex follow-up: reusing the empty text part's content_index for the refusal meant a consumer replaying by (item_id, content_index) saw two different part types at the same slot. Rather than touch the shared content branch, take Codex's alternative: close the empty text part at its index and open the refusal at the NEXT index, so each opened content part has a distinct slot and the completed response (refusal only) can't diverge from the replayed stream. The empty text part is still dropped from the final message. Also fix a typecheck failure in the test (the CI pyright/mypy gate, which the earlier local run missed): assert the terminal event isinstance ResponseCompletedEvent before reading `.response`, so the ~50-member event union narrows. Verified live against Bedrock claude-fable-5: index 0 = text part added+done, index 1 = refusal added+delta+done, one output_item.added, final output a single ResponseOutputRefusal. Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
@seratch — resolved this without touching the shared content branch, using @codex's own alternative suggestion ("ensure each opened content part gets a distinct index"). In Live Bedrock Also fixed the |
|
Note To use Codex here, create a Codex account and connect to github. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 728b9510b3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
Thanks for the updates. The underlying fix is valid, but the current streaming sequence is still inconsistent: it closes and removes the empty text part at content index 0, emits the refusal at index 1, and then returns a completed message whose only content part is the refusal at position 0. Please suppress empty-string content deltas before they open a text part in ChatCmplStreamHandler, then emit the synthesized refusal at the contiguous index used by the completed response. Please also update the regression test to assert that no text part or response.output_text.delta is emitted and that the refusal event indexes match the refusal’s position in response.completed. This will keep raw-event replay and the final response aligned. |
…ndex 0 Per @seratch: rather than open an empty text part for a leading "" content delta and then close/reindex it, drop that delta before it opens a text part. The synthesized content-filter refusal then lands at content index 0 in both the streamed events and response.completed, so raw-event replay and the final response stay aligned (previously the refusal streamed at index 1 while the completed response placed it at position 0). The suppression is scoped to a leading empty ("") delta when no text part has opened yet; empty deltas after real text keep their existing behavior. With the empty part gone, the synthesis block's close/distinct-index logic is dead and removed — the block now just announces the message and emits the refusal at the first content index. Tests updated to assert no text part / no output_text.delta is emitted and that the refusal's streamed content_index (0) matches its position in response.completed. Live-verified against Bedrock claude-fable-5: 0 output_text.delta, 1 output_item.added, one content_part.added (ResponseOutputRefusal) at index 0, completed content[0] a single refusal. Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
Done — implemented exactly as you described in The leading empty- Tests updated per your ask: assert no text part / no Live proof — Bedrock
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 52c3a4e4be
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
Thanks, the empty leading content-part issue is fixed in 52c3a4e. One index mismatch remains when the stream contains reasoning before the terminal content filter. Reasoning is a separate output item, so it must affect output_index, not the assistant message's content_index. The synthesized refusal is the only content part in that message and must therefore use content_index=0, even when a reasoning item exists. Please remove the reasoning-based increment from this path and add a regression test covering reasoning followed by an empty content delta and terminal content_filter. The test should verify that refusal added/delta/done events use the assistant message's output index and content_index=0, matching response.completed. |
…er reasoning Per @seratch: a reasoning item is a separate output item — it shifts the assistant message's output_index (via assistant_message_output_index), not its content_index, and is never appended to the message's content. So the reasoning-based `refusal_index += 1` was wrong: the synthesized refusal is the sole content part of its message and must be content_index 0 regardless of any preceding reasoning. Removed the increment. Added a regression test: reasoning delta -> empty "" content delta -> terminal content_filter. Asserts the reasoning item is output[0], the assistant message is output[1], and every refusal added/delta event uses output_index 1 + content_index 0, matching the refusal's position in response.completed. The test fails against the pre-fix increment (refusal streamed at content_index 1). Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
Good catch — fixed in You're right: a reasoning item is a separate output item, so it shifts the assistant message's Added the regression test you asked for: reasoning delta → empty
It's non-vacuous — fails against the pre-fix increment (refusal streamed at |
Summary
Some providers — notably Anthropic on Amazon Bedrock — signal a safety block only via
finish_reason == "content_filter"with an empty message and norefusalfield.LitellmModel.get_responsethen converts that message to zero output items, so the caller sees an "empty turn" indistinguishable from any other model hiccup.In an agent loop this is costly: the run can't tell a refusal from a transient empty response, so it re-prompts the same (refusing) model until it exhausts its turn budget. We hit this on Bedrock
claude-*models where an offensive-security prompt is content-filtered — the agent burned its entire budget re-prompting a model that would never comply.The fix: when a completion finishes with
content_filterand carries no content and no tool calls, synthesize arefusalon the message so the existing converter path emits aResponseOutputRefusal(andRunnerraisesModelRefusalError) rather than silently dropping the turn. Acontent_filterresponse that still carries text is left untouched, and normal completions are unaffected.Scope: this covers the non-streaming
get_responsepath. The streaming path (stream_response/ChatCmplStreamHandler) currently surfaces refusals only when the provider sends arefusaldelta; synthesizing one from a terminalcontent_filterfinish_reason there is a larger change to the stream state machine, left as a follow-up.Test plan
tests/models/test_litellm_content_filter.py(3 cases): empty +content_filter→ResponseOutputRefusal;content_filterwith text → no synthesized refusal; normalstop→ unaffected.pytest tests/models -k litellm→ 25 passed (3 new + existing, no regressions).ruff format --check,ruff check, andmypyon the changed file all clean.Issue number
Checks
Co-authored with Claude Opus 4.8.