Skip to content

Commit df39ead

Browse files
committed
Fix reasoning stream selection timeouts
1 parent 74f2f39 commit df39ead

5 files changed

Lines changed: 199 additions & 8 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.8] - 2026-07-23
13+
14+
### Fixed
15+
16+
- Reasoning models no longer fail with a synthetic 408 and temporary account lock when Claude thinking or Responses reasoning is the first streamed output.
17+
1218
## [0.5.7] - 2026-07-20
1319

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

8995
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.
9096

91-
[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.7...HEAD
97+
[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.8...HEAD
98+
[0.5.8]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.8
9299
[0.5.7]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.7
93100
[0.5.5]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.5
94101
[0.5.4]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.4

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.7",
4+
"version": "0.5.8",
55
"description": "A local AI router for connected accounts, models, named routes, and automatic fallback.",
66
"author": "gitcommit90",
77
"license": "MIT",

src/lib/router.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,17 @@ function hasProductiveResponsesEvent(text) {
422422
) {
423423
return true;
424424
}
425+
if (
426+
[
427+
"response.reasoning_summary_text.delta",
428+
"response.reasoning_text.delta",
429+
].includes(type) &&
430+
((typeof data?.delta === "string" && data.delta.length > 0) ||
431+
(typeof data?.delta?.text === "string" && data.delta.text.length > 0) ||
432+
(typeof data?.text === "string" && data.text.length > 0))
433+
) {
434+
return true;
435+
}
425436
if (
426437
type === "response.function_call_arguments.delta" &&
427438
typeof data?.delta === "string" &&
@@ -439,15 +450,16 @@ function hasProductiveResponsesEvent(text) {
439450
}
440451
if (
441452
type === "content_block_start" &&
442-
data?.content_block?.type === "tool_use" &&
443-
typeof data.content_block.name === "string" &&
444-
data.content_block.name.length > 0
453+
((data?.content_block?.type === "tool_use" &&
454+
typeof data.content_block.name === "string" &&
455+
data.content_block.name.length > 0) ||
456+
["thinking", "redacted_thinking"].includes(data?.content_block?.type))
445457
) {
446458
return true;
447459
}
448460
if (
449461
type === "content_block_delta" &&
450-
[data?.delta?.text, data?.delta?.partial_json].some(
462+
[data?.delta?.text, data?.delta?.partial_json, data?.delta?.thinking].some(
451463
(value) => typeof value === "string" && value.length > 0
452464
)
453465
) {

tests/router-fallback.test.js

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ function chatgptAccount(id, token, createdAt, extra = {}) {
4848
};
4949
}
5050

51+
function claudeAccount(id, token, createdAt, extra = {}) {
52+
return {
53+
id,
54+
type: "claude",
55+
name: id,
56+
accessToken: token,
57+
models: [{ id: "claude-fable-5", name: "Claude Fable 5", enabled: true }],
58+
enabled: true,
59+
createdAt,
60+
...extra,
61+
};
62+
}
63+
5164
function captureLogger() {
5265
const entries = [];
5366
const add = (level) => (message, meta) => entries.push({ level, message, meta });
@@ -1076,6 +1089,165 @@ describe("same-provider OAuth account fallback", () => {
10761089
assert.match(chunks.join(""), /second/);
10771090
});
10781091

1092+
it("does not time out a Claude stream that starts with native thinking", async () => {
1093+
const store = createStore(tmpConfig());
1094+
store.seed({ providers: [claudeAccount("prov_a", "token-a", 100)] });
1095+
const encoder = new TextEncoder();
1096+
let timedOut = false;
1097+
const router = createRouter({
1098+
store,
1099+
timeoutMs: 20,
1100+
logger: captureLogger(),
1101+
fetchImpl: async (_url, options) =>
1102+
new Response(
1103+
new ReadableStream({
1104+
start(controller) {
1105+
controller.enqueue(
1106+
encoder.encode(
1107+
[
1108+
`event: content_block_start`,
1109+
`data: ${JSON.stringify({
1110+
type: "content_block_start",
1111+
index: 0,
1112+
content_block: { type: "thinking", thinking: "" },
1113+
})}`,
1114+
"",
1115+
`event: content_block_delta`,
1116+
`data: ${JSON.stringify({
1117+
type: "content_block_delta",
1118+
index: 0,
1119+
delta: { type: "thinking_delta", thinking: "Working through it" },
1120+
})}`,
1121+
"",
1122+
"",
1123+
].join("\n")
1124+
)
1125+
);
1126+
options.signal.addEventListener(
1127+
"abort",
1128+
() => {
1129+
timedOut = true;
1130+
controller.error(options.signal.reason || new Error("timed out"));
1131+
},
1132+
{ once: true }
1133+
);
1134+
setTimeout(() => {
1135+
controller.enqueue(
1136+
encoder.encode(
1137+
[
1138+
`event: content_block_stop`,
1139+
`data: ${JSON.stringify({ type: "content_block_stop", index: 0 })}`,
1140+
"",
1141+
`event: content_block_start`,
1142+
`data: ${JSON.stringify({
1143+
type: "content_block_start",
1144+
index: 1,
1145+
content_block: { type: "text", text: "" },
1146+
})}`,
1147+
"",
1148+
`event: content_block_delta`,
1149+
`data: ${JSON.stringify({
1150+
type: "content_block_delta",
1151+
index: 1,
1152+
delta: { type: "text_delta", text: "answer" },
1153+
})}`,
1154+
"",
1155+
"",
1156+
].join("\n")
1157+
)
1158+
);
1159+
controller.close();
1160+
}, 50);
1161+
},
1162+
}),
1163+
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
1164+
),
1165+
});
1166+
1167+
const body = {
1168+
model: "claude/claude-fable-5",
1169+
messages: [{ role: "user", content: "hello" }],
1170+
stream: true,
1171+
};
1172+
body[Symbol.for("rerouted.anthropic.metadata")] = {};
1173+
const result = await router.chatCompletions({ body });
1174+
const chunks = [];
1175+
await result.streamPipe({ write: (chunk) => chunks.push(String(chunk)) });
1176+
1177+
assert.equal(result.ok, true, JSON.stringify(result.error));
1178+
assert.equal(timedOut, false);
1179+
assert.match(chunks.join(""), /Working through it/);
1180+
assert.match(chunks.join(""), /answer/);
1181+
});
1182+
1183+
it("does not time out a Responses stream that starts with reasoning", async () => {
1184+
const store = createStore(tmpConfig());
1185+
store.seed({ providers: [chatgptAccount("prov_a", "token-a", 100)] });
1186+
const encoder = new TextEncoder();
1187+
let timedOut = false;
1188+
const router = createRouter({
1189+
store,
1190+
timeoutMs: 20,
1191+
logger: captureLogger(),
1192+
fetchImpl: async (_url, options) =>
1193+
new Response(
1194+
new ReadableStream({
1195+
start(controller) {
1196+
controller.enqueue(
1197+
encoder.encode(
1198+
`event: response.reasoning_summary_text.delta\ndata: ${JSON.stringify({
1199+
type: "response.reasoning_summary_text.delta",
1200+
delta: "Working through it",
1201+
})}\n\n`
1202+
)
1203+
);
1204+
options.signal.addEventListener(
1205+
"abort",
1206+
() => {
1207+
timedOut = true;
1208+
controller.error(options.signal.reason || new Error("timed out"));
1209+
},
1210+
{ once: true }
1211+
);
1212+
setTimeout(() => {
1213+
controller.enqueue(
1214+
encoder.encode(
1215+
`event: response.output_text.delta\ndata: ${JSON.stringify({
1216+
type: "response.output_text.delta",
1217+
delta: "answer",
1218+
})}\n\n`
1219+
)
1220+
);
1221+
controller.enqueue(
1222+
encoder.encode(
1223+
`event: response.completed\ndata: ${JSON.stringify({
1224+
type: "response.completed",
1225+
})}\n\n`
1226+
)
1227+
);
1228+
controller.close();
1229+
}, 50);
1230+
},
1231+
}),
1232+
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
1233+
),
1234+
});
1235+
1236+
const result = await router.chatCompletions({
1237+
body: {
1238+
model: "chatgpt/gpt-5.4",
1239+
messages: [{ role: "user", content: "hello" }],
1240+
stream: true,
1241+
},
1242+
});
1243+
const chunks = [];
1244+
await result.streamPipe({ write: (chunk) => chunks.push(String(chunk)) });
1245+
1246+
assert.equal(result.ok, true, JSON.stringify(result.error));
1247+
assert.equal(timedOut, false);
1248+
assert.match(chunks.join(""), /answer/);
1249+
});
1250+
10791251
it("propagates a gateway client disconnect through an active stream", async () => {
10801252
const store = createStore(tmpConfig());
10811253
store.seed({ providers: [] });

0 commit comments

Comments
 (0)