Skip to content

Commit 9fa3f9b

Browse files
authored
Merge pull request #19 from gitcommit90/fix/sse-idle-heartbeat
Fix Claude Code stream idle timeouts
2 parents 960c491 + 6a0b443 commit 9fa3f9b

7 files changed

Lines changed: 251 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car
99

1010
## [Unreleased]
1111

12+
## [0.5.9] - 2026-07-23
13+
14+
### Fixed
15+
16+
- Long-running streamed responses now send protocol-safe keepalives so clients such as Claude Code do not cancel healthy requests during extended model silence.
17+
1218
## [0.5.8] - 2026-07-23
1319

1420
### Fixed
@@ -94,7 +100,8 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car
94100

95101
See [GitHub Releases](https://github.com/gitcommit90/rerouted/releases) for artifact digests and notes prior to the Keep a Changelog narrative. Notable themes in late 0.4.x included signed/notarized distribution, in-app updates, named routes, OAuth account pools, OpenAI chat completions and Responses routing, and launch hardening.
96102

97-
[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.8...HEAD
103+
[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.9...HEAD
104+
[0.5.9]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.9
98105
[0.5.8]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.8
99106
[0.5.7]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.7
100107
[0.5.5]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.5

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@gitcommit90/rerouted",
33
"productName": "ReRouted",
4-
"version": "0.5.8",
4+
"version": "0.5.9",
55
"description": "A local AI router for connected accounts, models, named routes, and automatic fallback.",
66
"author": "gitcommit90",
77
"license": "MIT",

src/lib/anthropic-api.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,12 @@ function writeEvent(sink, type, data) {
378378
sink.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
379379
}
380380

381+
// Claude Code filters SSE comments and ping events before its stream-event
382+
// watchdog. This zero-impact delta reaches that watchdog without adding content
383+
// or completing the response; the real final delta remains authoritative.
384+
const ANTHROPIC_SSE_HEARTBEAT =
385+
'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":null,"stop_sequence":null},"usage":{"output_tokens":0}}\n\n';
386+
381387
async function pipeChatCompletionsSseToAnthropic(streamPipe, sink, requestedModel) {
382388
const parser = createSseParser();
383389
const id = messageId();
@@ -571,6 +577,7 @@ module.exports = {
571577
toChatCompletionsBody,
572578
fromChatCompletion,
573579
pipeChatCompletionsSseToAnthropic,
580+
ANTHROPIC_SSE_HEARTBEAT,
574581
toAnthropicError,
575582
estimateInputTokens,
576583
anthropicUsage,

src/lib/gateway.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const http = require("node:http");
44
const { DEFAULT_PORT } = require("./constants");
55
const logger = require("./logger");
6+
const { createSseHeartbeat, DEFAULT_SSE_HEARTBEAT_MS } = require("./sse");
67
const {
78
toChatCompletionsBody,
89
fromChatCompletion,
@@ -15,10 +16,15 @@ const {
1516
pipeChatCompletionsSseToAnthropic,
1617
toAnthropicError,
1718
estimateInputTokens,
19+
ANTHROPIC_SSE_HEARTBEAT,
1820
} = require("./anthropic-api");
1921

2022
const MAX_JSON_BODY_BYTES = 32 * 1024 * 1024;
2123

24+
function isClaudeCodeRequest(req) {
25+
return /^claude-cli\//i.test(String(req.headers["user-agent"] || ""));
26+
}
27+
2228
/**
2329
* OpenAI-compatible HTTP gateway.
2430
* Auth: Authorization: Bearer <apiKey>
@@ -31,6 +37,7 @@ function createGateway({
3137
port = DEFAULT_PORT,
3238
host = "127.0.0.1",
3339
maxBodyBytes = MAX_JSON_BODY_BYTES,
40+
sseHeartbeatMs = DEFAULT_SSE_HEARTBEAT_MS,
3441
requestActivity,
3542
} = {}) {
3643
let server = null;
@@ -329,13 +336,29 @@ function createGateway({
329336
"Cache-Control": "no-cache",
330337
Connection: "keep-alive",
331338
});
339+
const heartbeat = anthropicRequest && isClaudeCodeRequest(req)
340+
? createSseHeartbeat(res, {
341+
intervalMs: sseHeartbeatMs,
342+
heartbeat: ANTHROPIC_SSE_HEARTBEAT,
343+
})
344+
: null;
345+
const streamSink = heartbeat?.sink || res;
332346
try {
333347
if (responsesRequest) {
334-
await pipeChatCompletionsSseToResponses(result.streamPipe, res, body.model, body);
348+
await pipeChatCompletionsSseToResponses(
349+
result.streamPipe,
350+
streamSink,
351+
body.model,
352+
body
353+
);
335354
} else if (anthropicRequest) {
336-
await pipeChatCompletionsSseToAnthropic(result.streamPipe, res, body.model);
355+
await pipeChatCompletionsSseToAnthropic(
356+
result.streamPipe,
357+
streamSink,
358+
body.model
359+
);
337360
} else {
338-
await result.streamPipe(res);
361+
await result.streamPipe(streamSink);
339362
}
340363
activityStatus = 200;
341364
activityOutcome = "success";
@@ -349,6 +372,8 @@ function createGateway({
349372
);
350373
}
351374
}
375+
} finally {
376+
heartbeat?.stop();
352377
}
353378
if (!res.writableEnded) res.end();
354379
return;

src/lib/sse.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,57 @@ function formatSseData(obj) {
2424
}
2525

2626
const SSE_DONE = "data: [DONE]\n\n";
27+
const SSE_KEEPALIVE = ": keepalive\n\n";
28+
const DEFAULT_SSE_HEARTBEAT_MS = 25_000;
29+
30+
/**
31+
* Wrap a client-facing SSE sink and emit the configured liveness frame whenever
32+
* the stream has otherwise been silent for the configured interval.
33+
*/
34+
function createSseHeartbeat(
35+
res,
36+
{ intervalMs = DEFAULT_SSE_HEARTBEAT_MS, heartbeat = SSE_KEEPALIVE } = {}
37+
) {
38+
let timer = null;
39+
let stopped = false;
40+
41+
function schedule() {
42+
if (timer) clearTimeout(timer);
43+
timer = null;
44+
if (stopped || intervalMs <= 0) return;
45+
timer = setTimeout(() => {
46+
timer = null;
47+
if (stopped || res.writableEnded || res.destroyed) return;
48+
res.write(heartbeat);
49+
schedule();
50+
}, intervalMs);
51+
timer.unref?.();
52+
}
53+
54+
const sink = {
55+
write(...args) {
56+
const written = res.write(...args);
57+
schedule();
58+
return written;
59+
},
60+
get writableEnded() {
61+
return res.writableEnded;
62+
},
63+
get destroyed() {
64+
return res.destroyed;
65+
},
66+
};
67+
68+
schedule();
69+
return {
70+
sink,
71+
stop() {
72+
stopped = true;
73+
if (timer) clearTimeout(timer);
74+
timer = null;
75+
},
76+
};
77+
}
2778

2879
/**
2980
* Parse SSE text stream lines into { event, data } objects.
@@ -126,6 +177,9 @@ module.exports = {
126177
openaiChunk,
127178
formatSseData,
128179
SSE_DONE,
180+
SSE_KEEPALIVE,
181+
DEFAULT_SSE_HEARTBEAT_MS,
182+
createSseHeartbeat,
129183
createSseParser,
130184
chunkToString,
131185
pipeOpenAiSse,

tests/gateway.test.js

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,6 +1797,157 @@ describe("SSE chunk decoding", () => {
17971797
assert.equal(chunkToString(u8), text);
17981798
assert.notEqual(String(u8), text);
17991799
});
1800+
1801+
it("keeps Anthropic Messages clients alive during downstream silence", async () => {
1802+
const store = createStore(tmpConfig());
1803+
const apiKey = store.load().apiKey;
1804+
const gateway = createGateway({
1805+
store,
1806+
sseHeartbeatMs: 10,
1807+
router: {
1808+
async chatCompletions() {
1809+
return {
1810+
ok: true,
1811+
stream: true,
1812+
streamPipe: async (sink) => {
1813+
await new Promise((resolve) => setTimeout(resolve, 35));
1814+
sink.write(
1815+
`data: ${JSON.stringify({
1816+
choices: [{ delta: { role: "assistant", content: "OK" } }],
1817+
})}\n\n`
1818+
);
1819+
sink.write(
1820+
`data: ${JSON.stringify({
1821+
choices: [{ delta: {}, finish_reason: "stop" }],
1822+
})}\n\n`
1823+
);
1824+
sink.write("data: [DONE]\n\n");
1825+
},
1826+
};
1827+
},
1828+
},
1829+
});
1830+
const server = http.createServer((req, res) => gateway.handle(req, res));
1831+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
1832+
1833+
try {
1834+
const response = await fetch(`http://127.0.0.1:${server.address().port}/v1/messages`, {
1835+
method: "POST",
1836+
headers: {
1837+
Authorization: `Bearer ${apiKey}`,
1838+
"Content-Type": "application/json",
1839+
"User-Agent": "claude-cli/2.1.218 (external, sdk-cli)",
1840+
},
1841+
body: JSON.stringify({
1842+
model: "route",
1843+
max_tokens: 8,
1844+
messages: [],
1845+
stream: true,
1846+
}),
1847+
});
1848+
const text = await response.text();
1849+
assert.equal(response.status, 200);
1850+
assert.match(
1851+
text,
1852+
/event: message_delta\ndata: \{"type":"message_delta","delta":\{"stop_reason":null,"stop_sequence":null\},"usage":\{"output_tokens":0\}\}\n\n/
1853+
);
1854+
assert.match(text, /event: content_block_delta/);
1855+
const heartbeatBlock = text.split("\n\n").find(
1856+
(block) => block.startsWith("event: message_delta") &&
1857+
block.includes('"stop_reason":null')
1858+
);
1859+
const heartbeatData = JSON.parse(
1860+
heartbeatBlock.split("\n").find((line) => line.startsWith("data: ")).slice(6)
1861+
);
1862+
assert.deepEqual(heartbeatData.usage, { output_tokens: 0 });
1863+
1864+
const genericResponse = await fetch(
1865+
`http://127.0.0.1:${server.address().port}/v1/messages`,
1866+
{
1867+
method: "POST",
1868+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
1869+
body: JSON.stringify({
1870+
model: "route",
1871+
max_tokens: 8,
1872+
messages: [],
1873+
stream: true,
1874+
}),
1875+
}
1876+
);
1877+
const genericText = await genericResponse.text();
1878+
assert.equal(genericResponse.status, 200);
1879+
assert.equal(
1880+
genericText.split("\n\n").filter(
1881+
(block) => block.startsWith("event: message_delta") &&
1882+
block.includes('"stop_reason":null')
1883+
).length,
1884+
0
1885+
);
1886+
} finally {
1887+
await new Promise((resolve) => server.close(resolve));
1888+
}
1889+
});
1890+
1891+
it("still cancels an active stream when the client disconnects", async () => {
1892+
const store = createStore(tmpConfig());
1893+
const apiKey = store.load().apiKey;
1894+
let finishActivity;
1895+
const activityEnded = new Promise((resolve) => {
1896+
finishActivity = resolve;
1897+
});
1898+
const gateway = createGateway({
1899+
store,
1900+
sseHeartbeatMs: 10,
1901+
requestActivity: {
1902+
begin: () => "activity-1",
1903+
route() {},
1904+
end: (_id, result) => finishActivity(result),
1905+
},
1906+
router: {
1907+
async chatCompletions({ signal }) {
1908+
return {
1909+
ok: true,
1910+
stream: true,
1911+
streamPipe: async () => new Promise((resolve, reject) => {
1912+
if (signal.aborted) reject(signal.reason);
1913+
else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
1914+
}),
1915+
};
1916+
},
1917+
},
1918+
});
1919+
const server = http.createServer((req, res) => gateway.handle(req, res));
1920+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
1921+
1922+
try {
1923+
await new Promise((resolve, reject) => {
1924+
const request = http.request({
1925+
host: "127.0.0.1",
1926+
port: server.address().port,
1927+
path: "/v1/messages",
1928+
method: "POST",
1929+
headers: {
1930+
Authorization: `Bearer ${apiKey}`,
1931+
"Content-Type": "application/json",
1932+
"User-Agent": "claude-cli/2.1.218 (external, sdk-cli)",
1933+
},
1934+
}, (response) => {
1935+
let received = "";
1936+
response.on("data", (chunk) => {
1937+
received += String(chunk);
1938+
if (!received.includes('"stop_reason":null')) return;
1939+
response.destroy();
1940+
resolve();
1941+
});
1942+
});
1943+
request.once("error", reject);
1944+
request.end(JSON.stringify({ model: "route", max_tokens: 8, messages: [], stream: true }));
1945+
});
1946+
assert.deepEqual(await activityEnded, { status: 499, outcome: "canceled" });
1947+
} finally {
1948+
await new Promise((resolve) => server.close(resolve));
1949+
}
1950+
});
18001951
});
18011952

18021953
describe("OAuth → OpenAI SSE translation pipes", () => {

0 commit comments

Comments
 (0)