Commit 59f7af4
authored
fix(stream): prevent tool-call truncation, premature termination, upstream leaks + timeout hardening (#3)
* fix(stream): prevent tool-call truncation, premature end, and upstream leaks
Multiple bugs caused tool-call responses to be cut off mid-stream or
end abruptly with no finish_reason / message_stop. Each was reproduced
with a script before fixing and is covered by a regression test.
Critical fixes
- upstream: preserve NDJSON lines across backpressure. nodeReaderToStream
returned out of read() when push() returned false, dropping the rest
of the chunk's parsed lines forever. Tool-call deltas often arrive
bundled in a single TCP packet, so this was the primary cause of
"tool call truncated mid-stream".
- stream: synthesize a finish chunk (OpenAI) / message_delta + message_stop
(Anthropic) when the upstream closes without a finish event (network
drop, upstream crash mid-tool-call). Previously the client saw a clean
end-of-stream with no terminal event.
- translate: set sawFinish=true on error events. Error events already
emit a terminal chunk; without the flag, the server synthesized a
second one on stream end (duplicate finish_reason chunks / duplicate
message_stop, which the Anthropic SDK rejects).
Tool-call correctness
- openai: stable per-toolCallId streaming index. tool-call-delta events
without an explicit index all defaulted to 0, so parallel tool calls
were merged into one. Now allocate via Map<toolCallId, number> and
reuse for subsequent deltas + final tool-call.
- anthropic: reuse the open tool_use block when a final tool-call arrives
for the same toolCallId after deltas, instead of opening a second
block (which produced duplicate tool_use blocks).
Resource hygiene
- upstream: cancel the upstream reader when the consumer stream is
destroyed (client disconnect). Without this, CC kept generating
tokens nobody read, burning the user's quota until upstream timeout.
- server: wire destroyStreamOnClientDisconnect on non-streaming paths
too, so client disconnects abort the upstream fetch instead of
silently draining into a discarded response.
- server: replace the ad-hoc stream.on('data'/'end'/'error') handlers
with a pumpStream helper that applies client-side backpressure via
pause-on-drain, wraps encoder.emit in try/catch (isolates encoder
bugs instead of crashing the process), and consistently checks
res.writableEnded.
Tests
- 4 new upstream tests (backpressure, reader-cancel-on-destroy).
- 5 new e2e tests (truncated OpenAI stream, truncated Anthropic stream,
upstream error without duplicate finish, duplicate message_stop,
stable tool-call indices).
- 3 new unit tests for the encoders (sawFinish on error, stable indices,
tool-call reuses open block).
- All 144 tests pass; tsc --noEmit clean.
* fix(upstream): add streaming idle timeout + make timeouts configurable
The previous 5-minute timeout only protected the time-to-first-byte
phase: clearTimeout() was called the moment fetch() resolved (response
headers), leaving the entire streaming phase with no protection at all.
Two new failure modes resulted:
- A stalled upstream (TCP open, no chunks arriving mid-tool-call) would
hang the consumer forever. The client's own timeout would eventually
fire, but the proxy kept the upstream connection open and the request
slot occupied until CC's own server-side timeout (if any) released it.
- The 5-minute connect timeout was hardcoded — slow reasoning models
with long initial processing would be killed at the boundary with no
way for the operator to bump it.
Fixes
- config: add CC_UPSTREAM_TIMEOUT_MS (default 600000 / 10 min, up from
5 min) and CC_IDLE_TIMEOUT_MS (default 120000 / 2 min, 0 disables).
Both parse-positive-int guarded.
- upstream: nodeReaderToStream now arms an idle timer before each
reader.read() and disarms it on chunk arrival. If no data arrives
within idleTimeoutMs, the reader is cancelled with an
IdleTimeoutError that propagates through the stream's error path
(which the existing pumpStream turns into a clean finish for the
client). The timer is unref'd so it never keeps the event loop alive.
- upstream: caller's AbortSignal is now plumbed into nodeReaderToStream
so a client disconnect during a stalled read immediately destroys the
stream instead of waiting for the idle timer.
- server: both /v1/chat/completions and /v1/messages pass the new
timeout options through.
Docs
- README + .env.example document the two new env vars.
Tests
- upstream: idle timeout fires after the configured interval and
surfaces an IdleTimeoutError; idleTimeoutMs=0 disables it.
- config: defaults, env-var override, 0-disabled, and invalid-fallback
cases.
- All 149 tests pass; tsc --noEmit clean.
* fix(translate): non-streaming tool-call dedup, content order, message_start on empty stream
Final-pass audit surfaced eight remaining correctness and resource
hygiene issues across the OpenAI/Anthropic translation layers and the
streaming server. None of them are the original 'tool call truncated'
class — they're separate contract violations and edge cases.
Critical
- openai: buildNonStreamingResponse now deduplicates tool-call-delta +
final tool-call with the same id (the streaming encoder was fixed in
the previous commit but the non-streaming builder was not). Without
this, clients received two tool_calls entries with the same id, which
some clients call twice and some dedupe wrong.
- anthropic: handleFinish now emits message_start when no content event
arrived first (empty response, max_tokens=0, immediate refusal). The
Anthropic SDK requires message_start as the first event of a stream
and throws on its absence — the error and finishRecords paths already
guarded this, but handleFinish did not.
High
- anthropic: non-streaming response now orders content blocks
[thinking, text, tool_use] per the extended-thinking contract.
Previously emitted [text, thinking, tool_use], which broke Claude
Code's thinking-block continuation logic. Existing test asserted the
wrong order; fixed.
- server: post-pumpStream [DONE] write now guards res.destroyed, not
just res.writableEnded, preventing ERR_STREAM_DESTROYED on a socket
torn down by mid-stream client disconnect.
- server: parseBody now calls req.destroy() on 413 so the client
socket is freed immediately instead of lingering until the upload
completes (was holding the connection open for the full oversized
body even after rejecting).
- openai/server: pumpStream onError no longer mixes a non-chunk
{error:...} envelope with valid chunks — both error paths (encoder
error event + stream-level error) now emit uniform content+finish
chat.completion.chunk records. Some clients were parsing the bare
envelope as a tool call named 'error'.
Medium
- server: removed dead destroyStreamOnClientDisconnect calls in the
streaming paths (mid-stream disconnect is already handled via the
abort signal plumbed into nodeReaderToStream; the call after
pumpStream returns is a no-op since the stream has ended/errored).
Low
- anthropic: top_p is now propagated into ccBody.params (was silently
dropped, affecting Anthropic clients that tune sampling).
Tests
- translate: tool-call-delta + tool-call same-id merge in non-streaming
builder.
- translate-anthropic: message_start ordering when finish arrives with
no prior content; content block ordering thinking-before-text.
- e2e: stream-level error produces uniform chunks (no out-of-band
envelope).
- All 152 tests pass; tsc --noEmit clean.
* chore: remove Docker support (unnecessary for a localhost proxy)1 parent 748704c commit 59f7af4
17 files changed
Lines changed: 1028 additions & 187 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
8 | 19 | | |
9 | 20 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
38 | | - | |
39 | | - | |
40 | | - | |
41 | | - | |
42 | | - | |
43 | | - | |
44 | | - | |
45 | | - | |
46 | | - | |
47 | | - | |
48 | | - | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
75 | | - | |
76 | | - | |
77 | | - | |
78 | | - | |
79 | | - | |
80 | | - | |
81 | | - | |
82 | | - | |
83 | | - | |
84 | | - | |
85 | | - | |
86 | | - | |
87 | | - | |
88 | | - | |
89 | | - | |
90 | | - | |
91 | | - | |
92 | | - | |
93 | | - | |
94 | | - | |
95 | 75 | | |
96 | 76 | | |
97 | 77 | | |
| |||
This file was deleted.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | | - | |
| 1 | + | |
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
36 | | - | |
37 | | - | |
38 | | - | |
39 | | - | |
40 | | - | |
41 | | - | |
42 | | - | |
43 | | - | |
44 | | - | |
45 | | - | |
46 | | - | |
47 | | - | |
48 | | - | |
49 | | - | |
50 | 35 | | |
51 | 36 | | |
52 | 37 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
73 | 73 | | |
74 | 74 | | |
75 | 75 | | |
76 | | - | |
77 | | - | |
78 | | - | |
79 | | - | |
80 | | - | |
81 | | - | |
82 | | - | |
83 | | - | |
84 | | - | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
85 | 87 | | |
86 | 88 | | |
87 | 89 | | |
| |||
This file was deleted.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
18 | 22 | | |
19 | 23 | | |
20 | 24 | | |
| |||
86 | 90 | | |
87 | 91 | | |
88 | 92 | | |
89 | | - | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
90 | 123 | | |
0 commit comments