diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0916d..786f7c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car ## [Unreleased] +## [0.5.8] - 2026-07-23 + +### Fixed + +- Reasoning models no longer fail with a synthetic 408 and temporary account lock when Claude thinking or Responses reasoning is the first streamed output. + ## [0.5.7] - 2026-07-20 ### Changed @@ -88,7 +94,8 @@ Release tags use the form `vX.Y.Z` and match `package.json`. GitHub Releases car 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. -[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.7...HEAD +[Unreleased]: https://github.com/gitcommit90/rerouted/compare/v0.5.8...HEAD +[0.5.8]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.8 [0.5.7]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.7 [0.5.5]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.5 [0.5.4]: https://github.com/gitcommit90/rerouted/releases/tag/v0.5.4 diff --git a/package-lock.json b/package-lock.json index 82bda63..418ea14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gitcommit90/rerouted", - "version": "0.5.7", + "version": "0.5.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gitcommit90/rerouted", - "version": "0.5.7", + "version": "0.5.8", "license": "MIT", "bin": { "rerouted": "src/cli/index.js" diff --git a/package.json b/package.json index 8f96f04..fe77720 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@gitcommit90/rerouted", "productName": "ReRouted", - "version": "0.5.7", + "version": "0.5.8", "description": "A local AI router for connected accounts, models, named routes, and automatic fallback.", "author": "gitcommit90", "license": "MIT", diff --git a/src/lib/router.js b/src/lib/router.js index ef1a361..0c221fc 100644 --- a/src/lib/router.js +++ b/src/lib/router.js @@ -422,6 +422,17 @@ function hasProductiveResponsesEvent(text) { ) { return true; } + if ( + [ + "response.reasoning_summary_text.delta", + "response.reasoning_text.delta", + ].includes(type) && + ((typeof data?.delta === "string" && data.delta.length > 0) || + (typeof data?.delta?.text === "string" && data.delta.text.length > 0) || + (typeof data?.text === "string" && data.text.length > 0)) + ) { + return true; + } if ( type === "response.function_call_arguments.delta" && typeof data?.delta === "string" && @@ -439,15 +450,16 @@ function hasProductiveResponsesEvent(text) { } if ( type === "content_block_start" && - data?.content_block?.type === "tool_use" && - typeof data.content_block.name === "string" && - data.content_block.name.length > 0 + ((data?.content_block?.type === "tool_use" && + typeof data.content_block.name === "string" && + data.content_block.name.length > 0) || + ["thinking", "redacted_thinking"].includes(data?.content_block?.type)) ) { return true; } if ( type === "content_block_delta" && - [data?.delta?.text, data?.delta?.partial_json].some( + [data?.delta?.text, data?.delta?.partial_json, data?.delta?.thinking].some( (value) => typeof value === "string" && value.length > 0 ) ) { diff --git a/tests/router-fallback.test.js b/tests/router-fallback.test.js index ec29d32..8378c09 100644 --- a/tests/router-fallback.test.js +++ b/tests/router-fallback.test.js @@ -48,6 +48,19 @@ function chatgptAccount(id, token, createdAt, extra = {}) { }; } +function claudeAccount(id, token, createdAt, extra = {}) { + return { + id, + type: "claude", + name: id, + accessToken: token, + models: [{ id: "claude-fable-5", name: "Claude Fable 5", enabled: true }], + enabled: true, + createdAt, + ...extra, + }; +} + function captureLogger() { const entries = []; const add = (level) => (message, meta) => entries.push({ level, message, meta }); @@ -1076,6 +1089,165 @@ describe("same-provider OAuth account fallback", () => { assert.match(chunks.join(""), /second/); }); + it("does not time out a Claude stream that starts with native thinking", async () => { + const store = createStore(tmpConfig()); + store.seed({ providers: [claudeAccount("prov_a", "token-a", 100)] }); + const encoder = new TextEncoder(); + let timedOut = false; + const router = createRouter({ + store, + timeoutMs: 20, + logger: captureLogger(), + fetchImpl: async (_url, options) => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + [ + `event: content_block_start`, + `data: ${JSON.stringify({ + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: "" }, + })}`, + "", + `event: content_block_delta`, + `data: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "Working through it" }, + })}`, + "", + "", + ].join("\n") + ) + ); + options.signal.addEventListener( + "abort", + () => { + timedOut = true; + controller.error(options.signal.reason || new Error("timed out")); + }, + { once: true } + ); + setTimeout(() => { + controller.enqueue( + encoder.encode( + [ + `event: content_block_stop`, + `data: ${JSON.stringify({ type: "content_block_stop", index: 0 })}`, + "", + `event: content_block_start`, + `data: ${JSON.stringify({ + type: "content_block_start", + index: 1, + content_block: { type: "text", text: "" }, + })}`, + "", + `event: content_block_delta`, + `data: ${JSON.stringify({ + type: "content_block_delta", + index: 1, + delta: { type: "text_delta", text: "answer" }, + })}`, + "", + "", + ].join("\n") + ) + ); + controller.close(); + }, 50); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ), + }); + + const body = { + model: "claude/claude-fable-5", + messages: [{ role: "user", content: "hello" }], + stream: true, + }; + body[Symbol.for("rerouted.anthropic.metadata")] = {}; + const result = await router.chatCompletions({ body }); + const chunks = []; + await result.streamPipe({ write: (chunk) => chunks.push(String(chunk)) }); + + assert.equal(result.ok, true, JSON.stringify(result.error)); + assert.equal(timedOut, false); + assert.match(chunks.join(""), /Working through it/); + assert.match(chunks.join(""), /answer/); + }); + + it("does not time out a Responses stream that starts with reasoning", async () => { + const store = createStore(tmpConfig()); + store.seed({ providers: [chatgptAccount("prov_a", "token-a", 100)] }); + const encoder = new TextEncoder(); + let timedOut = false; + const router = createRouter({ + store, + timeoutMs: 20, + logger: captureLogger(), + fetchImpl: async (_url, options) => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `event: response.reasoning_summary_text.delta\ndata: ${JSON.stringify({ + type: "response.reasoning_summary_text.delta", + delta: "Working through it", + })}\n\n` + ) + ); + options.signal.addEventListener( + "abort", + () => { + timedOut = true; + controller.error(options.signal.reason || new Error("timed out")); + }, + { once: true } + ); + setTimeout(() => { + controller.enqueue( + encoder.encode( + `event: response.output_text.delta\ndata: ${JSON.stringify({ + type: "response.output_text.delta", + delta: "answer", + })}\n\n` + ) + ); + controller.enqueue( + encoder.encode( + `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + })}\n\n` + ) + ); + controller.close(); + }, 50); + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ), + }); + + const result = await router.chatCompletions({ + body: { + model: "chatgpt/gpt-5.4", + messages: [{ role: "user", content: "hello" }], + stream: true, + }, + }); + const chunks = []; + await result.streamPipe({ write: (chunk) => chunks.push(String(chunk)) }); + + assert.equal(result.ok, true, JSON.stringify(result.error)); + assert.equal(timedOut, false); + assert.match(chunks.join(""), /answer/); + }); + it("propagates a gateway client disconnect through an active stream", async () => { const store = createStore(tmpConfig()); store.seed({ providers: [] });