Repository navigation
fix(responses): honor caller stream flag when provider forces SSE (internal copy of #34095) - #41235
Conversation
The Responses handlers decided whether to hand back a streaming iterator from the provider payload's `stream` field, which chatgpt sets unconditionally because the Codex backend only serves SSE. A caller that sent `stream: false` therefore received a raw SSE stream on /v1/responses, and the chat-completions bridge failed with "Unknown items in responses API response: []" once its recovery path lost the raw SSE it reads from Transport streaming still follows the provider payload; only the caller's own `stream` value now decides the response shape. When the provider forces SSE for a non-streaming caller the body is read and aggregated through the existing path
…handler tests never log in
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
| call_type=CallTypes.responses.value, | ||
| ) | ||
|
|
||
| # Return the streaming iterator |
There was a problem hiding this comment.
This comment only restates the return. Repository rules require source comments to explain necessary complex logic or serve tooling before merging
| # Return the streaming iterator |
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!
…_honor_caller_stream_flag # Conflicts: # tests/unit/llms/custom_httpx/test_llm_http_handler.py
…nd drop a redundant comment
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
|
||
| # Check if streaming is requested | ||
| stream = response_api_optional_request_params.get("stream", False) | ||
| caller_requested_stream: Final = bool(stream or (extra_body or {}).get("stream")) |
There was a problem hiding this comment.
Manus streaming skips fake stream
When a Manus caller sets extra_body={"stream": true} without setting the top-level stream flag, this line selects a real streaming iterator. But should_fake_stream receives only the top-level flag, so Manus does not enable the fake-stream path it needs because it does not support real-time streaming. The request is sent through the SSE iterator instead and fails to produce the expected stream. The async handler has the same mismatch.
There was a problem hiding this comment.
Pre-existing: fake_stream is decided in responses/main.py from the top-level flag before this handler runs, the same on main, and extra_body.stream never fed it
There was a problem hiding this comment.
You're right — this mismatch is pre-existing and is not introduced by this PR. fake_stream is determined earlier from the top-level stream flag, and this handler has never propagated extra_body.stream into that decision (on main or in this change). The new caller_requested_stream only selects the response shape after that existing decision, so this comment does not identify a regression here. I’m withdrawing it.
|
bugbot run |
…ses, chat and messages
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit f1cdfae. Configure here.
| eventually(rig.wire.received.qsize, lambda size: size >= 1) | ||
| liveliness: Final = rig.gateway.request("GET", "/health/liveliness", None) | ||
| assert liveliness.status_code == 200, liveliness.text | ||
| assert not pending.done(), pending.result().text |
There was a problem hiding this comment.
There was a problem hiding this comment.
Python evaluates an assert's message only when the condition fails, so pending.result() runs only once the future is already done and returns immediately
There was a problem hiding this comment.
You're right — the assertion message is evaluated only when not pending.done() is false. In that case, pending.done() guarantees the future has completed, so pending.result() returns immediately (assuming it was not cancelled). This finding is invalid; no change is needed.
…104.0 The ChatGPT backend forces SSE, so a caller that did not ask for a stream got a streaming iterator back and the Responses bridge raised "Unknown items in responses API response: []" on the empty response.completed event, which Claude Code then retried indefinitely. BerriAI/litellm#41235 fixes it on main but no release carries it yet. install.sh runs a version-gated patch after the package install (a no-op on any version but 1.104.0). A test fails on the next pin bump so the backport is removed with it. Claude-Session: https://claude.ai/code/session_01GNp7KskRZytipEspz97Gv3
…kport 1.106.0.dev2 carries BerriAI/litellm#41235, so the temporary llm_http_handler patch (chatgpt_stream_fix.py, its install.sh call, its provisioning heredoc and its tests) goes away. The hashed lock is recompiled for the dev build, which is on PyPI. Claude-Session: https://claude.ai/code/session_01QHHRiPR4g2QCz6STF7pdYe
TLDR
Problem this solves:
chatgpt/*ignores a caller'sstream: falsesince v1.90.0/v1/responseshands back raw SSE instead of JSON/v1/chat/completionsand/v1/messagesintermittently 500 withUnknown items in responses API response: []How it solves it:
streamvalue (top level or inextra_body) picks the shapeUser Flow
Before: a developer calling a ChatGPT subscription model without streaming gets back a body their client cannot parse
"stream": false, or with nostreamat allcontent-type: text/event-streamand ten SSE frames instead of one JSON objectCannot use 'in' operator to search for 'object' in data:followed by the whole streamUnknown items in responses API response: [], so only"stream": trueworks reliablyAfter: the same requests come back in the shape the caller asked for
"stream": false, or with nostreamat allcontent-type: application/jsonand one response object whosestatusiscompleted"stream": truestill streams on all three routesRelevant issues
Fixes #34094
Supersedes #34095, the community PR these commits come from
Affected release
Regression since v1.90.0, introduced by #30189
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/unit/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Delays 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
Last updated: f1cdfae. QA and /live-pr-risk ran on 78b0bea, the last commit with a product diff; f1cdfae adds the audit's integration tests only
Both legs boot a no-DB proxy from the named commit with
--num_workers 2on a random free port ($PORTbelow), the master key as$KEY, and thisconfig.yaml, which pointsm-terraat the real ChatGPT subscription backend (the provider reads the stored subscription token fromCHATGPT_TOKEN_DIR, so there is noapi_key):The clients are the ones the issue names: the Node
openaiSDK (7.28.0, what n8n wraps), the PythonopenaiSDK (2.33.0, sync and async), the PythonanthropicSDK (0.84.0) for/v1/messages, and curl replays of the same bodies. Every call sendsReply with exactly the word: pong. The SDK scripts print one line per call. Response ids are account-scoped and left outsdk_cases.py(run aspython sdk_cases.py $PORT):node_case.mjs(run asnode node_case.mjs $PORT):Before (a3a5246)
/v1/responses
Run the Node client without
streamRun the Python SDK cases
Replay the non-streaming body with curl
Observe an SSE body on a 200 (the same with
streamleft out)/v1/chat/completions
Run the Python SDK cases
Replay the non-streaming body with curl
Observe a 500 (the backend's final event sometimes carries the text and sometimes not, so this fails on some calls and not others)
Send the same curl six times in a row and read the status codes
Send it with
"stream": trueand observe it stream/v1/messages
Run the Anthropic SDK cases
Replay the non-streaming body with curl
Observe the same intermittent 500
Send the same curl six times in a row and read the status codes
Send it with
"stream": trueand observe it streamAfter (78b0bea)
/v1/responses
Run the Node client without
streamRun the Python SDK cases
Replay the non-streaming body with curl
Observe one JSON response object (the same with
streamleft out, and"stream": truestill returns 10 SSE frames ending inresponse.completedand[DONE])/v1/chat/completions
Run the Python SDK cases
Replay the non-streaming body with curl
Observe one chat completion
Send the same curl six times in a row and read the status codes
Send it with
"stream": trueand observe it stream/v1/messages
Run the Anthropic SDK cases
Replay the non-streaming body with curl
Observe one message object
Send the same curl six times in a row and read the status codes
Send it with
"stream": trueand observe it streamObservations from the run:
inputgets 400Input must be a listfrom the backend; pre-existing, unchangedinstructions; pre-existing, unchangedreasoning.effort) comes back as 400 on both legslitellm.responsesfixed the same way; checked liveAudit round 1 of 3 (head 78b0bea, merge base a3a5246)
Rig: a two-worker proxy from each commit against an audit-owned Postgres and Redis in the CI environment (
LITELLM_MODE=PRODUCTION, local cost map,router_settings.num_retriesseeded to 0), the Codex backend scripted by a wire double that replays the frame shape captured live on 2026-10-05 (tests/integration/_support/codex_vendor.py), and an agentic-loop probe callback at the destination edge (tests/integration/_support/agentic_probe.py). Each cell boots its own two-worker proxy withCHATGPT_TOKEN_DIRandCHATGPT_API_BASEat the double, since the chatgpt chat path ignores a deployment'sapi_base. Cells:tests/integration/providers/test_chatgpt_responses_caller_stream_wire.py(30) andtests/integration/providers/test_chatgpt_responses_caller_stream_chaos.py(3), run asuv run --no-sync python tests/integration/run.py providers <node ids>. Head runs h5 and h6: 33 collected, 33 passed each, identical selections, seeds 4106601 and order 0, no skips or retries. Base run b1: 33 collected, 13 passed, 20 red, every red the SSE body or the bridge 500 the cell exists to catch. Controls c1, once on the head: 3 collected, 3 passed (test_bedrock_gpt_responses_native_wire.py::test_openai_sdk_responses_request_is_served_by_the_native_responses_route,test_responses_minted_reasoning_replay_chaos.py::test_login_backed_and_foundry_deployments_forward_the_minted_item_unchanged[chatgpt-sync]and[chatgpt-stream])test_openai_sdk_request_without_a_stream_flag_gets_the_aggregated_json_responsetest_async_openai_sdk_request_with_stream_false_gets_the_aggregated_json_responsetest_raw_request_without_a_stream_key_gets_json_not_ssetest_openai_sdk_stream_request_still_streamstest_async_openai_sdk_stream_request_still_streamstest_openai_sdk_chat_completion_is_bridged_to_a_json_answertest_async_openai_sdk_chat_completion_is_bridged_to_a_json_answertest_openai_sdk_chat_completion_stream_still_streamstest_anthropic_sdk_message_is_bridged_to_a_json_answertest_anthropic_sdk_message_stream_still_streamstest_identical_request_is_served_from_the_response_cache_as_jsontest_agentic_hook_sees_the_aggregated_response_oncetest_agentic_hook_stays_out_of_a_stream_requesttest_odd_stream_values_decide_the_shape_by_their_truth[null]test_odd_stream_values_decide_the_shape_by_their_truth[empty-string]test_odd_stream_values_decide_the_shape_by_their_truth[empty-list]test_odd_stream_values_decide_the_shape_by_their_truth[zero]test_odd_stream_values_decide_the_shape_by_their_truth[false-twice]test_odd_stream_values_decide_the_shape_by_their_truth[true-then-false]test_odd_stream_values_decide_the_shape_by_their_truth[one]test_odd_stream_values_decide_the_shape_by_their_truth[string-false]test_odd_stream_values_decide_the_shape_by_their_truth[five-kb-string]test_odd_stream_values_decide_the_shape_by_their_truth[true-twice]test_extra_body_stream_true_streamstest_extra_body_stream_false_gets_jsontest_string_input_is_refused_by_the_vendor_as_a_400test_vendor_401_reaches_the_caller_as_401test_vendor_response_failed_event_reaches_the_caller_as_a_json_errortest_vendor_stream_dying_mid_transfer_answers_a_json_error_and_leaves_the_proxy_servingtest_repeated_identical_cache_busted_requests_each_land_oncetest_mixed_burst_across_the_three_endpoints_answers_each_in_its_own_shapetest_vendor_outage_fails_each_call_cleanly_and_the_restarted_vendor_serves_the_next_bursttest_worker_sigkill_mid_burst_leaves_the_sibling_answering_jsonAudit observations:
extra_body.streamis merged into the vendor body after the transform on both legs, so{"extra_body": {"stream": false}}reaches the backend asstream: false; the wire double tolerates it and the real backend was not probed with it. On the base that override was the one way a caller got JSON; on the head the caller's own flag decides the shape either waymsg_<uuid>unrelated to the upstream id (both legs), so that cell lands the spend row byx-litellm-call-idresponse.failedframe for a non-stream caller becomes a JSON error status on the head (an SSE 200 pass-through on the base); an upstream abort mid-stream becomes an error status with a JSON body while/health/livelinesskeeps answering and the next call succeedsstreamvalues decide the shape by truthiness on the head (1,"false", a 5 KB string and a duplicatedtruestream;null,"",[],0and a duplicatedfalseget JSON), no 400, proxy upLiteLLM_SpendLogsType
🐛 Bug Fix
Changes
The two Responses handlers in
litellm/llms/custom_httpx/llm_http_handler.pydecided whether to hand the caller a streaming iterator by readingstreamoff the provider payload:datathere is what goes out to the provider, not what the caller sent.ChatGPTResponsesAPIConfig.transform_responses_api_requestsetsrequest["stream"] = Trueunconditionally because the Codex backend only serves SSE, sodata.get("stream")is always true for this provider and a caller that sentstream: falsewas silently moved onto the streaming path. Other Responses providers are unaffected; chatgpt is the only one whose transform touchesstreamat allOn
/v1/responsesthat meant a raw SSE body was returned to a caller expecting one JSON object. On/v1/chat/completionsand/v1/messagesthe Responses bridge fell into_collect_response_from_stream, which keeps only theresponsefrom theresponse.completedevent; when the Codex backend emits that event with an emptyoutputthe request failed withUnknown items in responses API response: []. The recovery path for that case in_recover_output_items_from_raw_ssere-parses the raw SSE fromlogging_obj.model_call_details["original_response"], and the only place that populates it for this provider istransform_response_api_response, which lives on the non-streaming path and was no longer being reachedThat merge line came from #30189, which set out to stop provider transforms that force
stream: truefrom being sent through the non-streaming branch, ChatGPT Codex models included. The transport half of that is right and this PR keeps it. The problem is that a singlestreamvariable was carrying two decisions at once: what to pass to httpx, and whether to return an iterator or an aggregated body. Merging the provider payload into it fixed the transport half and silently changed the response shape along with itThis keeps transport streaming exactly as it was, so a provider that only serves SSE is still read as a stream, and narrows the response-shape decision to the caller's own
streamvalue, captured before the transform runs. When the provider forces SSE for a non-streaming caller the body is read and aggregated through the existing path. The bridge half of #30189, which preserves a top-levelstream=Trueinto the Responses request, is untouchedTests drive both handlers through the real
ChatGPTResponsesAPIConfigand a real SSE body, so the aggregation that turnsresponse.output_item.doneframes into one completed response actually runs and the returned text is asserted. A caller sendingstream: false, and one omitting it, gets an aggregatedResponsesAPIResponse; a caller asking for streaming still gets an iterator that yields throughresponse.completed; andfake_streamproduces a mock iterator only for a streaming caller. Astream: truesent throughextra_bodycounts as the caller's own ask too, so an SDK caller on that path keeps getting the iterator main gave them (a test per handler covers it). The two existing..._streams_when_provider_transform_adds_streamtests from #30189 keep their transport-level assertions.ChatGPTResponsesAPIConfignow takes itsAuthenticatorthrough the constructor (defaulting to a real one), so the handler tests pass a mock and never start the device-code login that hung theAll Other ProvidersCI jobCaveats (if any)
Low
instructionstext; pre-existingstream; one that did would now aggregate for non-stream callers, which is the intended shapeinputtochatgpt/*gets 400Input must be a listfrom the backend; pre-existing, out of scopeasync_should_run_agentic_loopoverride) now sees chatgpt non-stream responses: once per/v1/responsescall and twice per bridged chat or messages call (responses layer, then chat layer), which is how the chat-to-responses bridge already fires foropenai/responses/*on main; left alone, bridge behaviormiscandproxy-infra-rootjobs (tests/unit/test_seed_tracing_fixtures.py, two tests, andtests/unit/proxy/test_component_allowlists.py::test_gateway_plus_backend_covers_full_app), inherited from a merge ref built against main between fix(lens): reconstruct native coding agent conversations #44711 (2190003) and its fix fix(ci): namespace claude session ids in tracing seeds and allowlist /v1/logs on backend #44761 (ff50846, merged 2026-10-06T02:39Z); main is green since and the next build on this PR picks the fixed main up, so nothing to fix hereFinal Attestation
The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR
78b0bea passes /live-pr-risk
f1cdfae passes /audit
Note
Medium Risk
Changes core Responses API routing for ChatGPT subscription models—a high-traffic path—but behavior is narrowly scoped to separating transport streaming from caller-facing shape, with extensive regression tests.
Overview
Fixes a regression where ChatGPT/Codex always sets upstream
stream: true, so the Responses handler treated every call as streaming and returned raw SSE (or broke chat/messages bridges) even when the caller omittedstreamor sentstream: false.Response shape is now driven only by the caller’s
stream(includingextra_body.stream), while httpx still streams when the provider requires SSE. Non-streaming callers get a buffered, aggregated JSONResponsesAPIResponse; streaming callers still get the existing iterators (includingfake_streammocks only when they asked to stream).ChatGPTResponsesAPIConfigaccepts an optionalAuthenticatorfor unit tests so CI does not run device login.Adds unit coverage for sync/async aggregation vs streaming and integration rigs (Codex wire double, agentic probe, burst/outage/worker-kill chaos) across
/v1/responses, chat, and messages.Reviewed by Cursor Bugbot for commit f1cdfae. Bugbot is set up for automated code reviews on this repo. Configure here.