From 1721d169f87f9d8b710987d4142dbcd4f920aee7 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 6 Jun 2026 13:16:01 -0300 Subject: [PATCH] fix(theoldllm): read upstream body once to avoid [502] body-already-read (#3296) On the cached-token path the executor never enters the refresh branch, so the same upstream Response was read with .text() twice (token-rejection check + final body). A Response body is single-use, so the second read threw 'Body is unusable: Body has already been read', caught and surfaced as [502]. Read the body once into finalBody and only re-read after a token-rejection refetch. Co-authored-by: onizukashonan14-png --- CHANGELOG.md | 1 + open-sse/executors/theoldllm.ts | 10 ++-- .../theoldllm-body-double-read-3296.test.ts | 53 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 tests/unit/theoldllm-body-double-read-3296.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dc8b84d4c4c..f76a2c6dc3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ _Development cycle in progress β€” entries are added as work merges into `releas ### πŸ”§ Bug Fixes +- **fix(theoldllm):** stop the `[502]: Body is unusable: Body has already been read` error on the cached-token path β€” the executor read the same upstream `Response` body with `.text()` twice; it now reads it once and only re-reads after a token-rejection refetch. (#3296 β€” thanks @onizukashonan14-png) - **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) visible under the "Show configured only" filter β€” they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 β€” thanks @uniQta) - **fix(cli):** `omniroute update` no longer always fails on a global install β€” `getCurrentVersion()` and `createBackup()` now resolve `package.json`/`bin` relative to the script (`import.meta.url`) instead of `process.cwd()` (the user's working dir on a global npm/brew install β†’ *"Could not determine current version"*), and the backup copies the `cli` directory with `cpSync({recursive:true})` instead of `copyFileSync`, which threw a swallowed `EISDIR` β†’ *"Failed to create backup. Aborting"*. (#3295 β€” thanks @uniQta) - **fix(sse):** harden the passthrough stream against empty upstream responses β€” emit a synthetic retry chunk on an empty `choices: []` (fixes a Copilot Chat crash) and log empty post-`tool_calls` completions; also registers **MiniMax M3** (1M context) across 8 provider tiers. ([#3297](https://github.com/diegosouzapw/OmniRoute/pull/3297), #3110 β€” thanks @wilsonicdev) diff --git a/open-sse/executors/theoldllm.ts b/open-sse/executors/theoldllm.ts index 94ba8d0f7f80..a9dfbba9efbb 100644 --- a/open-sse/executors/theoldllm.ts +++ b/open-sse/executors/theoldllm.ts @@ -407,9 +407,12 @@ export class TheOldLlmExecutor extends BaseExecutor { upstream = await directFetch(token, reqBody, signal); } - const upstreamBody = await upstream.text(); + // Read the body once β€” a Response body is single-use, so re-reading the + // same Response throws "Body has already been read" (#3296). Only re-read + // when a token rejection forces a fresh fetch below. + let finalBody = await upstream.text(); - if (isTokenRejected(upstream.status, upstreamBody)) { + if (isTokenRejected(upstream.status, finalBody)) { log?.warn?.("THEOLDLLM", `Token rejected (${upstream.status}), refreshing…`); invalidateToken(); try { @@ -419,10 +422,9 @@ export class TheOldLlmExecutor extends BaseExecutor { log?.warn?.("THEOLDLLM", "Token refresh failed, retrying with existing token"); } upstream = await directFetch(token, reqBody, signal); + finalBody = await upstream.text(); } - const finalBody = await upstream.text(); - if (upstream.status === 200 && finalBody) { const payload = stream ? finalBody diff --git a/tests/unit/theoldllm-body-double-read-3296.test.ts b/tests/unit/theoldllm-body-double-read-3296.test.ts new file mode 100644 index 000000000000..c57aec8fdc13 --- /dev/null +++ b/tests/unit/theoldllm-body-double-read-3296.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { TheOldLlmExecutor, tokenCache } from "../../open-sse/executors/theoldllm.ts"; + +const SSE_BODY = + 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n' + + 'data: {"choices":[{"delta":{"content":" world"}}]}\n' + + "data: [DONE]\n"; + +// #3296: with a valid cached token the executor takes the direct-fetch path and +// never enters the token-refresh branch. It read the SAME upstream Response with +// .text() twice (once for the token-rejection check, once for the final body), +// which throws "Body is unusable: Body has already been read" β†’ caught β†’ [502]. +test("theoldllm does not double-read the upstream body on the cached-token path (#3296)", async () => { + const originalFetch = globalThis.fetch; + // Pre-populate the cached token so execute() uses the direct fetch (no Playwright). + tokenCache.value = "cached-token"; + tokenCache.expiresAt = Date.now() + 60_000; + + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + return new Response(SSE_BODY, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + }) as typeof fetch; + + try { + const executor = new TheOldLlmExecutor(); + const result = await executor.execute({ + model: "gpt-5.4", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: {} as never, + signal: null, + }); + + // Before the fix this was 502 with "Body has already been read". + assert.equal(result.response.status, 200); + assert.equal(fetchCalls, 1, "should fetch upstream exactly once on the cached-token path"); + + const json = (await result.response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + assert.equal(json.choices?.[0]?.message?.content, "Hello world"); + } finally { + globalThis.fetch = originalFetch; + tokenCache.value = ""; + tokenCache.expiresAt = 0; + } +});