fix(responses): synthesize missing streaming lifecycle events for native providers - #32310
Conversation
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
081a081 to
9d71352
Compare
Greptile SummaryThis PR adds a
Confidence Score: 5/5Safe to merge. The gap filler is idempotent and only injects events not already emitted by the upstream, so compliant providers are unaffected and the fix is purely additive for truncating providers. The core algorithm correctly tracks per-output_index lifecycle state, the hook fires before accumulation so guardrail hooks govern the synthesized done-event text, and all edge cases (function-call items, refusals, multi-item responses, already-complete streams) are tested with real iterator classes. No functional regressions were identified. No files require special attention. The synthesized output_item.added/output_item.done events bypass _encode_container_id_on_output_item (noted in previous review and already documented), but this only affects container routing metadata on synthesized events for truncating providers.
|
| Filename | Overview |
|---|---|
| litellm/responses/streaming_iterator.py | Adds ~390 lines: the _ResponsesLifecycleGapFiller class, helpers (_obj_get, _safe_int, _safe_str, _build_bag, _ResponsesStreamItemState), and a small integration into the async/sync __anext__ loops via _pending_events. Logic is correct, idempotent, and hook-ordered. No blocking issues found. |
| tests/test_litellm/responses/test_streaming_iterator.py | Adds comprehensive mock-only lifecycle synthesis tests: truncated text, idempotency, function-call, proxy serialization, and redacting hook. All tests use real iterator classes with a dependency-injected fake SSE stream. No real network calls. |
| tests/llm_responses_api_testing/test_base_responses_api_streaming_iterator.py | Two existing assertions relaxed from len == 1 to delta in chunks and is last to reflect the new synthesized opener events. Change is justified and explained in comments; new tests in test_streaming_iterator.py provide stronger coverage of the exact event order. |
Reviews (3): Last reviewed commit: "fix(responses): synthesize missing strea..." | Re-trigger Greptile
9d71352 to
4288827
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…ive providers (BerriAI#20975) Native /responses providers whose upstream truncates the streaming lifecycle (emitting only response.output_text.delta frames followed by response.completed) left strict clients like the OpenAI Codex CLI with no active item, failing hard with "OutputTextDelta without active item" The live async/sync streaming iterators did a strict one-chunk-to-one-event passthrough with no memory of which lifecycle events had been seen, so when the upstream omitted the response.created / response.in_progress / response.output_item.added / response.content_part.added openers and the matching output_text.done / content_part.done / output_item.done teardown, those events were never produced. The chat-completions bridge and the fake-stream/Mock/Cached paths already synthesize the full sequence; only the native live passthrough did not Add an idempotent, seen-tracking gap filler that the live iterators drain before pulling the next SSE frame. It synthesizes the missing openers and teardown, anchoring them to the same item_id / output_index / content_index as the deltas and backfilling done text from the accumulated deltas, and it is a no-op for providers that already emit the full spec sequence so compliant OpenAI / Azure / vLLM streams pass through byte-for-byte. Mock and Cached iterators override the loop and stay untouched The post-call streaming deployment hook runs on each real provider chunk before the gap filler accumulates it, so the synthesized done events carry post-hook (for example guardrail-redacted) text rather than the raw provider delta; a hook that redacts response.output_text.delta content is therefore not bypassed on the teardown Claude-Session: https://claude.ai/code/session_01HWegvoX1BdLDD34VD8H3mg
4288827 to
f3f3563
Compare
|
This seems breaking Codex to use GPT-5.6. |
Relevant issues
Fixes #20975
Linear ticket
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewDelays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Reproduce against a live proxy pointed at a native provider that truncates the streaming lifecycle (the issue reproduces on
github_copilot/*, Azuregpt-5, ollama cloud, and vLLM). Standard OpenAI already emits the full sequence, so it will not show a difference; that is exactly the idempotency guarantee.litellm/proxy/dev_config.yaml, for example agithub_copilot/gpt-5or Azuregpt-5entrypython litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.logOn
litellm_internal_stagingthe stream is onlyresponse.output_text.deltaframes followed byresponse.completed. On this branch the same request now emitsresponse.created,response.in_progress,response.output_item.added, andresponse.content_part.addedbefore the firstoutput_text.delta, andresponse.output_text.done,response.content_part.done,response.output_item.donebeforeresponse.completed, anchored to the sameitem_id/output_index/content_indexas the deltas.base_url = http://localhost:4000/v1) and confirm it renders output with noOutputTextDelta without active itemin~/.codex/log/codex-tui.logType
🐛 Bug Fix
Changes
Streaming a Responses API request through the proxy with
stream: truewas producing onlyresponse.output_text.deltaframes and a trailingresponse.completed, dropping the required lifecycle wrapper events. Strict clients reject that shape; the OpenAI Codex CLI fails hard withOutputTextDelta without active itembecause no active item is ever openedThere are three
/responsesstreaming code paths. The chat-completions bridge and the fake-stream / Mock / Cached paths already synthesize the full spec sequence. The gap was the native live passthrough inlitellm/responses/streaming_iterator.py, which transformed one upstream SSE line into exactly one event and emitted it with no memory of which lifecycle events had been seen, so when the upstream omitted the openers and teardown they were never produced. Theexclude_none/exclude_unsetserialization in the proxy is not involved; it can only strip unset fields, never drop a whole event, since thetypediscriminator is always setThis adds an idempotent, seen-tracking gap filler that the live async and sync iterators drain before pulling the next SSE frame. Given one transformed upstream event it prepends only the openers and teardown that have not already been seen, tracks state per
output_indexso multi-item responses stay correct, and backfills theoutput_text.donetext from the accumulated deltas. Because every injection is guarded on "not already seen", providers that already emit the full sequence pass through byte for byte, so compliant OpenAI, Azure, and vLLM streams are unchanged. The synthesized openers carry a generatedresp_{uuid}id since the real id only arrives atresponse.completed, and the realcompletedid is left untouched so clients can still use it for follow-up GETs. Message items get acontent_part.added/content_part.donepair; function-call items get anoutput_item.added/function_call_arguments.done/output_item.donetriple with no content part. Mock and Cached iterators override the loop and drain their own prebuilt events, so they are untouched and never double-synthesizeThe post-call streaming deployment hook runs on each real provider chunk before the gap filler accumulates it, so the synthesized done events carry post-hook text (for example a guardrail-redacted delta) rather than the raw provider content; a hook that redacts
response.output_text.deltais therefore not bypassed on the teardownWebSocket mode and the
cursor_data_generatorchat-chunk re-transform are out of scope and left as follow-upsTests drive the real
ResponsesAPIStreamingIteratorandSyncResponsesAPIStreamingIteratorwith the realOpenAIResponsesAPIConfig, feeding a dependency-injected fake SSE byte stream rather than monkeypatching the code under test. They cover the truncated-text case (full ordered sequence, opener anchoring, accumulated done text), idempotency on an already-complete stream (identical types and counts, no duplicate openers), a truncated function-call stream, round-tripping every synthesized event through the exactmodel_dump_json(exclude_none=True, exclude_unset=True)call the proxy uses so no required field is stripped off the wire, and a redacting streaming hook proving the synthesized*.donetext reflects the post-hook (redacted) content