Summary
The ext_proc handler fails to parse request bodies when clients send Transfer-Encoding: chunked. Two distinct failure modes observed, both traced to FULL_DUPLEX_STREAMED request body mode interacting with chunked TE:
- Framing contamination — chunked TE hex/CRLF bytes leak into body →
"invalid character 'a' looking for beginning of value"
- Cross-stage truncation — when two ext_proc filters are chained, the second filter receives a truncated body →
"unexpected end of JSON input"
Both are 100% reproducible with chunked TE. Neither occurs with Content-Length.
Root Cause
Why FULL_DUPLEX_STREAMED is used
The framework removed buffered mode in PR #38 ("remove buffered mode, use streaming only") and PR #169 added response chunk processing for streaming metering. FULL_DUPLEX_STREAMED is needed for responses (SSE streaming from providers), but requests don't need streaming — the framework accumulates the full request body in memory before parsing (server.go:162-168).
The EnvoyFilter sets FULL_DUPLEX_STREAMED for both directions because the framework is streaming-only. However, Envoy's ext_proc processing_mode allows setting request and response body modes independently.
What goes wrong
When request_body_mode: FULL_DUPLEX_STREAMED and the client sends Transfer-Encoding: chunked:
Bug 1 (single filter): Envoy passes raw chunked framing bytes into ext_proc body chunks. The accumulated body contains a0f\r\n{"model":"gpt-5.5"...}\r\n0\r\n\r\n instead of clean JSON. json.Unmarshal at request.go:67 fails.
Bug 2 (chained filters): Cross-stage log tracing with a single x-request-id shows:
payload-pre-processing: receives ~170 body chunks → reassembles correctly → parses model: gpt-5.5 ✅
payload-processing: receives one truncated chunk with EoS:true → unexpected end of JSON input ❌
The first filter gets the complete body; the second filter gets a truncated copy. The context canceled on the first filter is an effect (stage 2 fails → Envoy tears down the chain), not the cause.
Reproduction
# Generate a test body (~43KB)
python3 -c "
import json
msgs = [{'role':'user','content':'analyze this: ' + 'x'*500}]
for i in range(20):
msgs.append({'role':'assistant','content':[{'type':'output_text','text':'result ' + 'y'*2000}]})
msgs.append({'role':'user','content':'check function ' + str(i)})
json.dump({'model':'gpt-5.5','input':msgs}, open('body.json','w'))
"
# FAILS — 100% failure rate (chunked TE)
curl -T body.json -H "Transfer-Encoding: chunked" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
https://<gateway>/v1/responses
# WORKS — 100% success rate (Content-Length)
curl -d @body.json \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
https://<gateway>/v1/responses
Production frequency: ~7 occurrences per 48h with Claude Code / Codex clients, auto-retried.
Suggested Fix
Primary fix (EnvoyFilter / operator): Change request_body_mode from FULL_DUPLEX_STREAMED to BUFFERED in the EnvoyFilter. Envoy then de-chunks and buffers the complete request body before sending to ext_proc, fixing both symptoms. Response body mode stays FULL_DUPLEX_STREAMED for SSE streaming — the two modes are independent per the ext_proc spec.
The framework already accumulates the full request body in memory before parsing (server.go:162-168), so BUFFERED matches the actual behavior. The pre-processing filter (read-only model extraction) especially has no need for request streaming.
The EnvoyFilter is generated by the MaaS operator (maas.opendatahub.io/v1alpha1 Config/default), so the one-line change belongs in the operator's kustomize manifests (deployment/base/payload-processing/manager/envoy-filter.yaml in opendatahub-io/models-as-a-service).
Defensive fix (framework, PR #210): tryDecodeChunked() in HandleRequestBody detects and strips chunked TE framing before json.Unmarshal. This fixes bug 1 (framing contamination) for any deployment using FULL_DUPLEX_STREAMED, but cannot fix bug 2 (truncated body — the bytes are genuinely missing). Validated on cluster: 15/15 chunked TE requests pass with the framework fix.
Environment
llm-d-inference-payload-processor v0.1.0-rc.3
- Envoy ext_proc with
request_body_mode: FULL_DUPLEX_STREAMED
- Two chained ext_proc filters:
payload-pre-processing + payload-processing
- Observed with OpenAI Responses API (
/v1/responses) and Anthropic Messages API (/v1/messages)
- Clients: Codex CLI, Claude Code
Summary
The ext_proc handler fails to parse request bodies when clients send
Transfer-Encoding: chunked. Two distinct failure modes observed, both traced toFULL_DUPLEX_STREAMEDrequest body mode interacting with chunked TE:"invalid character 'a' looking for beginning of value""unexpected end of JSON input"Both are 100% reproducible with chunked TE. Neither occurs with
Content-Length.Root Cause
Why
FULL_DUPLEX_STREAMEDis usedThe framework removed buffered mode in PR #38 ("remove buffered mode, use streaming only") and PR #169 added response chunk processing for streaming metering.
FULL_DUPLEX_STREAMEDis needed for responses (SSE streaming from providers), but requests don't need streaming — the framework accumulates the full request body in memory before parsing (server.go:162-168).The EnvoyFilter sets
FULL_DUPLEX_STREAMEDfor both directions because the framework is streaming-only. However, Envoy's ext_procprocessing_modeallows setting request and response body modes independently.What goes wrong
When
request_body_mode: FULL_DUPLEX_STREAMEDand the client sendsTransfer-Encoding: chunked:Bug 1 (single filter): Envoy passes raw chunked framing bytes into ext_proc body chunks. The accumulated body contains
a0f\r\n{"model":"gpt-5.5"...}\r\n0\r\n\r\ninstead of clean JSON.json.Unmarshalatrequest.go:67fails.Bug 2 (chained filters): Cross-stage log tracing with a single
x-request-idshows:payload-pre-processing: receives ~170 body chunks → reassembles correctly → parsesmodel: gpt-5.5✅payload-processing: receives one truncated chunk withEoS:true→unexpected end of JSON input❌The first filter gets the complete body; the second filter gets a truncated copy. The
context canceledon the first filter is an effect (stage 2 fails → Envoy tears down the chain), not the cause.Reproduction
Production frequency: ~7 occurrences per 48h with Claude Code / Codex clients, auto-retried.
Suggested Fix
Primary fix (EnvoyFilter / operator): Change
request_body_modefromFULL_DUPLEX_STREAMEDtoBUFFEREDin the EnvoyFilter. Envoy then de-chunks and buffers the complete request body before sending to ext_proc, fixing both symptoms. Response body mode staysFULL_DUPLEX_STREAMEDfor SSE streaming — the two modes are independent per the ext_proc spec.The framework already accumulates the full request body in memory before parsing (
server.go:162-168), soBUFFEREDmatches the actual behavior. The pre-processing filter (read-only model extraction) especially has no need for request streaming.The EnvoyFilter is generated by the MaaS operator (
maas.opendatahub.io/v1alpha1 Config/default), so the one-line change belongs in the operator's kustomize manifests (deployment/base/payload-processing/manager/envoy-filter.yamlinopendatahub-io/models-as-a-service).Defensive fix (framework, PR #210):
tryDecodeChunked()inHandleRequestBodydetects and strips chunked TE framing beforejson.Unmarshal. This fixes bug 1 (framing contamination) for any deployment usingFULL_DUPLEX_STREAMED, but cannot fix bug 2 (truncated body — the bytes are genuinely missing). Validated on cluster: 15/15 chunked TE requests pass with the framework fix.Environment
llm-d-inference-payload-processorv0.1.0-rc.3request_body_mode: FULL_DUPLEX_STREAMEDpayload-pre-processing+payload-processing/v1/responses) and Anthropic Messages API (/v1/messages)