fix: GPT-5.6 SSE streams truncated through /v1/responses (chunk framing, raw identity passthrough, gzip window)#988
Open
Jiliac wants to merge 2 commits into
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
chatgpt/gpt-5.6-{sol,terra,luna}(ChatGPT-subscription Codex backend) return HTTP 200 through Plano's/v1/responses, but the client only receivesresponse.created+response.in_progress— no text, noresponse.completed. Fixes #980.Live debugging with a from-source gateway traced this to three stacked defects, each independently able to truncate or corrupt streams:
1. SSE chunk-boundary loss in
SseChunkProcessor(hermesllm)SseStreamItersplits the buffer withstr::lines()and parses each line independently. When a network chunk boundary lands inside adata:/event:line prefix (e.g.da+ta: {…}), the partial line fails with a non-JSON error, whichSseStreamIter::nextsilently skips — it never reaches the incomplete-JSON buffering. Both halves are unparseable, so the event is permanently lost with no log line. A boundary inside a multi-byte UTF-8 char could similarly kill the whole chunk viastr::from_utf8.Fix: byte-level SSE framing in
process_chunk— only bytes up to the last\nare parsed; the trailing partial line is held back and prepended to the next chunk (with a completeness check for a final unterminated line likedata: [DONE], since nothing flushes the buffer at end-of-stream, and a 1 MiB cap so a broken upstream can't grow the buffer unboundedly).2. Lossy parse-and-reconstruct on the identity Responses→Responses path
The "passthrough" path strictly deserialized every event into
ResponsesAPIStreamEventand re-serialized from the struct. Any unmodeled event type or field was dropped or mangled: via-Plano streams lostreasoning.mode/reasoning.context,text.verbosity,tool_usage, …, and a future unknown event type (e.g.response.reasoning_text.delta) would be dropped entirely. Additionally,llm_gateway's per-event loop aborted the whole remaining chunk (return Err(Action::Continue)) when one event failed to parse.Fix: on the identity Responses→Responses path, forward the original wire bytes verbatim (reconstructing the coupled
event: <type>\ndata: <json>\n\nblock from the raw payload — output is byte-identical to upstream). Parsing is still attempted for token counting; unknown events forward raw withprovider_stream_response = None, and incomplete-JSON errors still propagate for cross-chunk recombination (sharedis_incomplete_json_errorhelper, used by both call sites).stream_context.rsnow logs and falls through on unparsed events instead of aborting the chunk. Cross-API arms and Anthropic/ChatCompletions identity behavior are untouched.3. gzip decompressor
window_bits: 9truncates streams (envoy config)This turned out to be the dominant user-visible cause. Envoy's decompressor filter advertises
accept-encodingupstream, so the backend returns gzip. The decompressor was configured withwindow_bits: 9(512-byte inflate window) while the paired compressor useswindow_bits: 10and real upstreams use 15. Gzip headers don't carry the window size, so inflate doesn't fail upfront — it silently stops emitting data once a back-reference exceeds the window. Debug trace of a failing stream:response data decompressed from 1372 bytes to 2638 bytes, then307 → 2, then0for every remaining frame. This truncated any sufficiently large/redundant SSE stream traversing two listeners (gpt-5.4 included, when the client path involved compression).Fix: decompressor
window_bits: 15on all four gzip decompressor blocks (three were at 9; theegress_traffic_llmone had nowindow_bitsand silently used Envoy's default of 12). Note the blast radius: this config applies to all providers' gzip-encoded responses, not just chatgpt — which is intended, since the truncation class affects any of them. Raising the inflate window is strictly safe (a larger window decodes anything a smaller one can;max_inflate_ratiozip-bomb protection is retained).Also: added
gpt-5.6-{sol,terra,luna}to the chatgpt models inprovider_models.yaml(consumed viainclude_str!inProviderId), and a pre-existing one-line clippy fix (for_kv_map) needed to keep the workspace-D warningsgate green.Verification
Unit tests (all inline, ids obfuscated):
test_gpt56_responses_identity_passthrough_full_stream— replays a real captured GPT-5.6 stream (11 events incl. a reasoning output item) through the processor at every 64-byte split offset, asserting all event types emerge and output bytes == input bytes. Failed before the fix; passes now.OutputItem::Reasoningwithoutsummary/ withcontent/encrypted_content/status; omittedlogprobs).cargo test --lib(388 tests across brightstaff/common/hermesllm/llm_gateway/prompt_gateway),cargo fmt --check,cargo clippy --locked --all-targets --all-features -- -D warnings, wasm32-wasip1 release builds.Live through a from-source gateway (
planoai build+up):chatgpt/gpt-5.6-*tiers andchatgpt/gpt-5.4now stream the full ladder (created … output_text.delta … completed) with correct answer text.reasoning.mode,text.verbosity,tool_usage) are preserved byte-for-byte.Notes
ResponsesAPIStreamBuffercross-API path Stoff81/chatgpt5.5 #958 works in.🤖 Generated with Claude Code