Skip to content

fix(litellm): surface content-filter refusals instead of an empty turn - #3769

Merged
seratch merged 6 commits into
openai:mainfrom
seanturner83:fix/litellm-content-filter-refusal
Jul 11, 2026
Merged

seratch merged 6 commits into
openai:mainfrom
seanturner83:fix/litellm-content-filter-refusal

Conversation

@seanturner83

Copy link
Copy Markdown
Contributor

Summary

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 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_filter and carries no content and no 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, and normal completions are unaffected.

Scope: this covers the non-streaming get_response path. The streaming path (stream_response / ChatCmplStreamHandler) currently surfaces refusals only when the provider sends a refusal delta; synthesizing one from a terminal content_filter finish_reason there is a larger change to the stream state machine, left as a follow-up.

Test plan

  • New tests/models/test_litellm_content_filter.py (3 cases): empty + content_filter → ResponseOutputRefusal; content_filter with text → no synthesized refusal; normal stop → unaffected.
  • pytest tests/models -k litellm → 25 passed (3 new + existing, no regressions).
  • ruff format --check, ruff check, and mypy on the changed file all clean.

Issue number

Checks

  • I've added new tests, if relevant
  • I've confirmed all verification steps pass (ruff format/check, mypy, litellm tests)

Co-authored with Claude Opus 4.8.

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 seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]>
@seanturner83

Copy link
Copy Markdown
Contributor Author

Thanks @seratch — added the streaming path in da268a9.

In ChatCmplStreamHandler.handle_stream, when choice 0 terminates with finish_reason == "content_filter" and no real text / refusal / tool call was emitted, it now synthesizes the refusal through the normal event sequence (output_item.added → content_part.added → refusal.delta, finalized by the existing done/completed path). Previously-emitted text or tool calls are left intact.

One real-world wrinkle I verified against a live Bedrock claude-* refusal stream: LiteLLM opens an empty-string ("") text content part before the terminal content_filter chunk, so the guard checks the accumulated text is empty (not that the text part is absent) and drops that empty part so the completed message carries only the ResponseOutputRefusal.

Added two streaming regression tests: the synthesize case (using the real empty-""-then-content_filter chunk shape) and a does-not-clobber case (real text before a late content_filter stands). The synthesize test fails against a naive "part absent" guard, so it pins that regression. Full tests/models litellm suite green; ruff/mypy clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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]>
@seanturner83

Copy link
Copy Markdown
Contributor Author

@codex good catch — fixed in 7b56953.

The empty-"" text part is now closed (content_part.done) and its index reused for the refusal, and the assistant message is not re-announced when a text part already opened it (output_item.added only fires when no prior part exists). So the stream stays coherent: one message open, every content_part.added balanced by a content_part.done.

Proof of fix (live Bedrock claude-fable-5 refusal, end-to-end stream_response → LiteLLM → Converse)

Real content-filtered stream, full event sequence emitted:

response.created
response.output_item.added          # message opened once
response.content_part.added         # (empty) text part
response.output_text.delta          # ""
response.content_part.done          # text part CLOSED
response.content_part.added         # refusal part (same index)
response.refusal.delta              # "Response withheld by the provider's content filter."
response.content_part.done          # refusal part closed
response.output_item.done
response.completed
  • output_item.added count: 1 (not re-announced)
  • content_part.added == content_part.done: 2 == 2 (no dangling part)
  • final response.output: a single ResponseOutputRefusal

Tests

Two streaming cases in test_litellm_chatcompletions_stream.py, using the real empty-""-then-content_filter chunk shape: the synthesize case now also asserts the coherence invariants (one message open; balanced part add/done), and the does-not-clobber case verifies real streamed text before a late content_filter is left intact. Both fail against the pre-fix behaviour. Full tests/models litellm suite (140) green; ruff/mypy clean.

@seanturner83
seanturner83 requested a review from seratch July 10, 2026 20:08

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

@seanturner83

Copy link
Copy Markdown
Contributor Author

@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 (7b56953) keeps the stream coherent: when the leading content="" delta has opened an (empty) text part, the synthesized refusal closes it (content_part.done), reuses its index, and does not re-announce the assistant message. Verified live against Bedrock claude-fable-5 — exactly one output_item.added, content_part.added count == content_part.done count, final output is a single ResponseOutputRefusal. Codex's remaining point is that content index 0 is reused across the (closed) text part and the refusal, which a consumer replaying raw events could find surprising even though the sequence is balanced.

The alternative is to suppress the leading empty-"" content delta from opening a text part in the first place (in the shared delta.content is not None branch). That's cleaner and sidesteps the index reuse, but it touches the general Chat Completions path used by all providers, not just the LiteLLM/Bedrock content-filter case — so I didn't want to make that change unilaterally on a bugfix PR.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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]>
@seanturner83

Copy link
Copy Markdown
Contributor Author

@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 728b951: when the leading empty-"" delta has opened a text part, close it at its index and open the synthesized refusal at the next index (not the text part's). So no (item_id, content_index) slot ever carries two part types, the empty text part is still dropped from response.completed, and the general Chat Completions path is untouched.

Live Bedrock claude-fable-5 refusal, per-index events:

index 0: content_part.added(text)    -> content_part.done(text)      # empty, dropped from final
index 1: content_part.added(refusal) -> refusal.delta -> content_part.done(refusal)
output_item.added: 1   |   final response.output: [ResponseOutputRefusal]

Also fixed the typecheck CI failure (my earlier local run checked the source file but not the test): the terminal event is now isinstance-narrowed to ResponseCompletedEvent before reading .response. make mypy/make pyright clean on the changed files, full tests/models litellm suite (140) green. So my earlier scope question is moot — no shared-path change needed.

@chatgpt-codex-connector

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

@seratch

seratch commented Jul 11, 2026

Copy link
Copy Markdown
Member

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]>
@seanturner83

Copy link
Copy Markdown
Contributor Author

Done — implemented exactly as you described in 52c3a4e.

The leading empty-"" content delta is now suppressed before it opens a text part (scoped to a leading empty delta when no text part has opened yet; empty deltas after real text are unchanged). With no empty part to reconcile, the synthesized refusal is emitted at the first content index (0) — the same index it occupies in response.completed — so raw-event replay and the final response stay aligned. The old close/distinct-index logic is now dead and removed.

Tests updated per your ask: assert no text part / no response.output_text.delta is emitted, and that the refusal's streamed content_index (0) matches its position in response.completed.

Live proof — Bedrock claude-fable-5, real content-filtered stream_response

output_item.added count : 1
output_text.delta count : 0
content_part.added      : idx=0  part=ResponseOutputRefusal
refusal.delta           : idx=0  delta="Response withheld by the provider's content filter."
completed .output len   : 1
completed content[0]    : ResponseOutputRefusal  (position 0)

Streamed index == completed position == 0. ruff/mypy clean, full tests/models litellm suite (140) green, and the synthesize test still fails against the pre-fix handler (non-vacuous).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

@seratch

seratch commented Jul 11, 2026

Copy link
Copy Markdown
Member

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]>
@seanturner83

Copy link
Copy Markdown
Contributor Author

Good catch — fixed in a2f7e4b.

You're right: a reasoning item is a separate output item, so 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. The synthesized refusal is the sole content part of its message, so I removed the reasoning-based refusal_index += 1 — it's now unconditionally content_index=0.

Added the regression test you asked for: reasoning delta → empty "" content delta → terminal content_filter. It asserts:

  • output[0] is the ResponseReasoningItem, output[1] is the assistant message
  • the refusal is the message's sole content part
  • every refusal added/delta event uses output_index=1 and content_index=0, matching the refusal's position in response.completed
  • the empty "" delta still opens no text part

It's non-vacuous — fails against the pre-fix increment (refusal streamed at content_index=1 while response.completed has it at 0). ruff/mypy clean, full tests/models litellm suite now 141 green.

@seratch seratch added this to the 0.18.x milestone Jul 11, 2026
@seratch
seratch enabled auto-merge (squash) July 11, 2026 00:51
@seratch
seratch merged commit 6cf3600 into openai:main Jul 11, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants