Skip to content

fix(streaming): keep n>1 choices separate in stream_chunk_builder - #36208

Closed
vineethsaivs wants to merge 9 commits into
BerriAI:mainfrom
vineethsaivs:fix-stream-chunk-builder-multiple-choices
Closed

vineethsaivs wants to merge 9 commits into
BerriAI:mainfrom
vineethsaivs:fix-stream-chunk-builder-multiple-choices

Conversation

@vineethsaivs

@vineethsaivs vineethsaivs commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Streamed choices were combined; later choices could escape Noma checks

How it solves it:

  • Preserve choice indices and validate each choice separately
  • Keep anonymization confined to its matching choice

User Flow

Before: multiple streamed completions could escape complete guardrail checking

  1. Request chat completions with stream=true and n=2
  2. A prohibited later choice could be returned unchecked

After: all choices are checked before the stream is returned

  1. Request chat completions with stream=true and n=2
  2. A prohibited later choice blocks the response before output

Relevant issues

Addresses the guardrail review thread in this PR

Linear ticket

Pre-Submission checklist

  • Meaningful regression tests added
  • Five focused tests pass locally; three new regressions fail before
  • Full local make check passes
  • Required upstream CI checks pass
  • Scope stays within correct multiple-choice streaming
  • Updated Greptile confidence is at least 4/5

Screenshots / Proof of Fix

Live proxy/vendor QA is pending because Noma credentials are unavailable

Type

Bug Fix

Caveats

Medium

  • Tests use isolated HTTP clients; live vendor QA remains
  • Keep this PR in draft until that validation is complete

Final Attestation

  • Ready for merge after live QA and required CI

@greptile-apps

greptile-apps Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

The PR should not merge until the removed Noma regression coverage is restored and the stream helpers meet the repository typing requirement

Findings

  1. P2 Noma Coverage Removed ▶
  2. P2 Broad types weaken stream validation ▶

Summary

This PR hardens multi-choice streaming response handling and adds focused regression coverage

  • Builds each streamed choice independently while preserving its index
  • Updates post-call processing for responses containing multiple choices
  • Adds focused stream assembly and response-processing tests

Reviews (3) · Last reviewed commit: "test(guardrails): isolate the Noma test ..."

Comment thread litellm/main.py Outdated
return TextCompletionResponse(**response)


def _streaming_choice_index(choice: Any) -> int | None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Broad types weaken stream validation

The new helpers accept Any and list[Any], preventing static validation of supported chunk shapes and increasing the repository's explicit-Any budget; use strongly typed chunk and choice unions throughout these helpers.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already done in 8441296 and 4dd061f. The helpers now take ModelResponseStream and StreamingChoices rather than Any, and narrowing is isinstance instead of cast.

Comment thread litellm/main.py
@veria-ai

veria-ai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@codecov

codecov Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.30508% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/main.py 98.18% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@vineethsaivs

Copy link
Copy Markdown
Contributor Author

The bot is right about the mechanism, and I would rather flag it clearly than tick a box past it. It is also wider than Noma, so here is what I found

_process_llm_response_check in noma.py takes the first non-empty choice and stops

content = None
for choice in response.choices:
    if isinstance(choice, litellm.Choices) and choice.message.content:
        content = choice.message.content
        break

That is not new, and this PR does not touch it. What this PR changes is how much that loop happens to cover on the streaming path. Before, a streamed n=2 call was reassembled into a single choice holding "AAABBB aaa bbb", so scanning the first choice scanned every completion by accident. After, there are two choices and the scan sees "AAA aaa" only. So the guardrail's coverage on that path narrows, even though the loop is unchanged, purely because the response is now shaped correctly

Two things worth knowing before deciding what to do about it

The same break after the first non-empty choice is in six guardrails, not one: noma, bedrock_guardrails, enkryptai, ibm_guardrails/ibm_detector, lasso and panw_prisma_airs. For a non-streaming n=2 call, every one of them already inspects only the first completion on main today, with or without this PR. So the underlying hole exists now; this PR extends it to the streaming path, where the concatenation was masking it

Noma's write side has the matching problem in the other direction. _replace_llm_response_content writes one anonymized_content string into every choice

# Replace content in all choices
for choice in response.choices:
    if isinstance(choice, litellm.Choices) and choice.message.content:
        choice.message.content = anonymized_content

so with n>1 an anonymized first completion would be copied over the others, replacing text that was never sent to Noma. That is also pre-existing and also only observable once choices are genuinely separate

On what to do, I do not think I should quietly redesign an enterprise vendor integration inside a PR about the stream builder, so I will follow your call. Three options as I see them

Restore the previous coverage cheaply, here: change the read side to scan every non-empty choice joined together rather than the first, which is exactly what Noma saw before this PR for streamed n>1 and strictly more than it sees today for non-streamed n>1. Four lines, no change for n=1, and it does not touch the anonymization contract. I can push it to this branch now if you want it

Fix it properly and separately: scan and anonymize per choice across all six guardrails, which changes the payload shape those vendors receive and is a real design decision. Happy to open that as its own PR with an issue first

Or land this as is and track the guardrail behaviour separately, on the grounds that the single-choice scan is the actual defect and n>1 streaming with a guardrail is narrow

My preference is the first, because it means this PR does not reduce anyone's coverage relative to today, and it leaves the real fix free to land later. Say the word and I will push it

Separately, on the Codecov note: the uncovered lines are the isinstance(chunk, dict) and getattr fallbacks in the two accessor helpers. Chunks reach stream_chunk_builder as ModelResponseStream in the tests, so only the object branch runs. I can add a dict-shaped chunk case to cover them if you would like the patch coverage up

@codspeed

codspeed Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing vineethsaivs:fix-stream-chunk-builder-multiple-choices (93c8d9e) with litellm_internal_staging (252c71c)

Open in CodSpeed

@vineethsaivs

Copy link
Copy Markdown
Contributor Author

Two review threads outstanding here, taking both.

The Noma finding (@veria-ai) is real about Noma and not caused by this PR

Verified the mechanism you describe. NomaGuardrail._process_llm_response_check does stop at the first non-empty choice:

content = None
for choice in response.choices:
    if isinstance(choice, litellm.Choices) and choice.message.content:
        content = choice.message.content
        break

so with n > 1 only choice 0 is sent to the Noma API.

Where I disagree is the attribution. That same method is what the non-streaming path uses: async_post_call_success_hook routes to _check_llm_response, which calls _process_llm_response_check. A plain non-streaming n=3 request has always produced three separate choices, so choices 1 and 2 are already unscanned on main today, with no involvement from this PR.

What this PR changes is that the streaming path now produces the same shape as the non-streaming path. Before it, stream_chunk_builder collapsed every index into one choice, so n=3 came back as a single choice holding three completions' deltas interleaved. That was not a security property, it was the bug: the caller got one garbled completion instead of three, and Noma happened to be handed the garbled blob.

So the fix belongs in noma.py, scanning every Choices rather than breaking at the first, and it is needed whether or not this PR lands. It is a different file with a different owner and a security-visible behaviour change, so I would rather not fold it into a stream_chunk_builder fix where it would be easy to miss. Happy to open it separately, or to add it here if a maintainer would prefer one PR.

The Any typing finding (@greptile-apps)

Fair in principle, and I would rather explain the constraint than widen the Any budget silently.

The helpers are _streaming_choice_index(choice), _streaming_chunk_choices(chunk), _distinct_streaming_choice_indices(chunks), _replace_chunk_choices(chunk, choices) and _chunks_for_streaming_choice_index(chunks, index). They sit on stream_chunk_builder's input, and that input is genuinely heterogeneous: the collected list can hold ModelResponseStream, provider dicts, and raw str/bytes SSE frames. That last case is not hypothetical, it is the subject of #37943, where a raw frame reaching the assembler raises AttributeError: 'bytes' object has no attribute 'get'.

A union would therefore have to be ModelResponseStream | dict | str | bytes | Any, which does not buy static validation, and narrowing to ModelResponseStream would make these helpers assert a shape the function does not actually receive.

_build_streaming_choices_per_index already returns the concrete list[Choices] | None, which is the boundary where the shape is known. If a maintainer wants a StreamingChunk alias declared in litellm/types/ and used here, I will do that, but I did not want to introduce a repo-wide type on my own initiative inside a bugfix.

@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Both responses are sound.

Noma attribution: Confirmed. _process_llm_response_check has always broken on the first non-empty choice:

content = None
for choice in response.choices:
    if isinstance(choice, litellm.Choices) and choice.message.content:
        content = choice.message.content
        break

And async_post_call_success_hook (the non-streaming post_call path) routes to the same method. So non-streaming n=2 calls have always only had choice 0 checked — this PR is not the source of that gap. Agreeing with your attribution: the fix belongs in noma.py, iterating over all choices rather than breaking early. Opening it as a separate PR (or issue) makes sense given it's a security-visible behavior change in a different file with a different owner.

Any typing: The constraint is real. stream_chunk_builder genuinely receives ModelResponseStream | dict | str | bytes (and #37943 confirms raw frames reach it), so a union wouldn't add meaningful static validation — it would just be ModelResponseStream | dict | str | bytes | Any. The _build_streaming_choices_per_index return type being concrete (list[Choices] | None) is exactly where the shape becomes known, which is the right boundary. A StreamingChunk type alias in litellm/types/ is the cleanest long-term resolution if maintainers want it, but leaving that to a maintainer call on a bugfix PR is the right call.

build_base_response emits a single hardcoded choice and every assembly step
reads choices[0], while get_combined_content joins the delta content of every
choice of every chunk into one string. A streamed n=2 call therefore came back
as one choice holding both completions concatenated, which is text no model
produced, plus whichever finish_reason arrived last.

The reassembled response is what feeds cost and token accounting, what the
caller gets with complete_response=True, and what goes into the response
cache, so a cached streamed n=2 call later replays the concatenation.

Assemble each choice index on its own by recursing over the chunks filtered to
that index, then swap the results in after usage has been calculated. Usage is
still computed over the full chunk list, so it is unchanged. A single-choice
stream sees no behaviour change: the recursion only runs when the chunks carry
more than one distinct index.
The strict gate flagged the new helpers for ANN401 (bare `Any` on `choice`,
`chunk` and the `_replace_chunk_choices` return) and ANN001 (`start_time` and
`end_time` unannotated). Each one has a concrete type: the chunk and choice
helpers already branch on `isinstance(..., dict)`, so the union of the pydantic
model and a dict is what they actually take, and the two times are the
`datetime.datetime | None` pair threaded into stream_chunk_builder.

That also puts the already-imported StreamingChoices to use, clearing an F401.
Net ruff difference on the file is 7 findings removed and none added.
…pers

Giving the helpers concrete types traded ANN401 for LIT001, since `dict[...]`
and `list[...]` are mutable collections in an annotation. Eleven of the fourteen
are genuinely read-only and become `Mapping` / `Sequence`.

`_replace_chunk_choices` narrowed with `isinstance(chunk, dict)`, which does not
narrow a `Mapping`, so it now narrows on `ModelResponseStream` and the mapping
branch stays a mapping. Same two expected inputs, same result for each.

The remaining three have to be real lists and say why: two are forwarded to
`stream_chunk_builder`, whose `chunks` and `messages` parameters are lists, and
the third is assigned to `ModelResponse.choices`.

`tests/test_litellm/test_main.py` is 98 passed before and 98 passed after.
LIT002 covers construction as well as annotation, so the lists, the local set
and the two mapping literals in the per-index helpers each carry a `# mutable-ok:`
reason now. Nothing moves at runtime: tests/test_litellm/test_main.py is 98
passed before and after.
The per-index builder asserted its result with two nested cast() calls, which the
repo's LIT006 budget counts as unchecked assertions. Move the narrowing into a
small helper that isinstance-checks the response and its first choice and returns
None when either is not what the merge needs, so the caller's fallback to the
single-response path is reached by a real check rather than by faith. The two
one-shot locals in the index/choices readers pick up the Final annotation LIT010
asks for.

No behaviour change: the same inputs produce the same choices, and the same
inputs fall back.

Signed-off-by: Vineeth Sai <vineethsai4444@gmail.com>
… Any

The helper isinstance-narrows its argument, so it does not need a dynamically
typed annotation; object works and keeps the repo's ANN401 budget unchanged.

Signed-off-by: Vineeth Sai <vineethsai4444@gmail.com>
ModelResponse.choices is declared list[Choices], so the second isinstance was an
unnecessary check basedpyright counts against the budget; only the response
itself can be the streaming variant.

Signed-off-by: Vineeth Sai <vineethsai4444@gmail.com>
@vineethsaivs
vineethsaivs force-pushed the fix-stream-chunk-builder-multiple-choices branch from 4dd061f to 4771bba Compare September 8, 2026 19:26
@vineethsaivs
vineethsaivs requested a review from a team September 8, 2026 19:26
@yuneng-berri
yuneng-berri deleted the branch BerriAI:main September 13, 2026 04:39
@yuneng-berri yuneng-berri reopened this Sep 13, 2026
@vineethsaivs
vineethsaivs marked this pull request as draft September 16, 2026 20:14
@vineethsaivs

Copy link
Copy Markdown
Contributor Author

@greptileai

@vineethsaivs
vineethsaivs marked this pull request as ready for review September 18, 2026 20:12
Comment on lines +1 to +14
import json
from collections.abc import AsyncIterator
from typing import Final

import httpx
import pytest
import pytest_asyncio
import respx

import litellm
from litellm import ModelResponse
from litellm import Choices, ModelResponse
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.guardrails.guardrail_hooks.noma import (
NomaGuardrail,
NomaV2Guardrail,
initialize_guardrail,
)
import litellm.proxy.guardrails.guardrail_hooks.noma.noma as noma_legacy_module
from litellm.proxy.guardrails.guardrail_hooks.noma.noma import NomaBlockedMessage
from litellm.proxy.guardrails.init_guardrails import init_guardrails_v2
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import Choices, Message
from litellm.proxy.guardrails.guardrail_hooks.noma.noma import NomaBlockedMessage, NomaGuardrail
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Noma coverage removed

This deletes existing configuration, failure-mode, and lifecycle tests. Repository rules prohibit weakening modified tests, so this requirement must be satisfied before merging

Rule Used: What: Flag any modifications to existing tests and verify they don't weaken test coverage or mask regressions. Why: Developers may alter tests to make failing code pass rather than fix the actual bug, hiding regressions. Good: ``` // Test updated t... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@devin-ai-integration
devin-ai-integration Bot changed the base branch from litellm_internal_staging to main September 23, 2026 15:13
@vineethsaivs

Copy link
Copy Markdown
Contributor Author

Closing this: main now deliberately folds a later choice's tool calls into the first choice (65160a9), which this change would undo, and #40986 already scans every choice for Noma. The branch had also picked up a bad rewrite of test_noma.py, so it should not be merged as is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants