Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions open-sse/executors/theoldllm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions tests/unit/theoldllm-body-double-read-3296.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});