diff --git a/changelog.d/fixes/pending-opencode-empty-rejection-rotation.md b/changelog.d/fixes/pending-opencode-empty-rejection-rotation.md new file mode 100644 index 0000000000..82a88c5905 --- /dev/null +++ b/changelog.d/fixes/pending-opencode-empty-rejection-rotation.md @@ -0,0 +1 @@ +- **fix(executors):** OpencodeExecutor rotates (or retries once on a single-account direct path) on upstream 400 empty-body rejections — malformed completion envelopes with no error field were propagated as success and killed client sessions. Bounded +1 attempt per request; body reads are conditioned on status 400 so successful/streaming responses are never buffered. 400s carrying an error field keep propagating immediately. diff --git a/open-sse/executors/accountRotation.ts b/open-sse/executors/accountRotation.ts index a64321e83d..67115bdd8d 100644 --- a/open-sse/executors/accountRotation.ts +++ b/open-sse/executors/accountRotation.ts @@ -1,7 +1,7 @@ /** * Shared multi-account rotation mechanics for noauth executors that round-robin * across several "accounts" (fingerprints), each with an optional dedicated - * proxy — currently `OpencodeExecutor` and `MimocodeExecutor`. + * proxy — currently `OpencodeExecutor`. * * Extracted after both executors independently implemented the same * pickAccount/markCooldown/markSuccess skeleton with the same exponential @@ -120,3 +120,58 @@ export function maskAccountId(fingerprint: string): string { export function isNetworkErrorRotatable(account: RotatableAccount): boolean { return account.proxy !== null; } + +/** + * Detect an *empty* upstream rejection: a 400 whose body carries no usable + * completion — the kind `OpencodeExecutor` must rotate/retry on instead of + * propagating as a fatal success. + * + * Signature is deliberately strict and scoped to the observed malformed + * envelope (`choices[0].message` with no `error`, no real `content`, + * `finish_reason: null`): + * - status must be exactly 400 (anything else → false); + * - body must parse and contain a `choices` array with at least one entry + * holding a `message` object; + * - an `error` field (present or empty) → false, so genuine 400s keep + * propagating immediately (#10460 precedent: classify by signature before + * rotating); + * - `tool_calls` / `reasoning_content` → false (real content); + * - `message.content` absent / null / "" → eligible; any other value + * (non-empty text, number, block array…) → false (conservative); + * - a literal `finish_reason` (not null) → false (a completed, if empty, turn). + * + * Does NOT reuse `detectMalformedNonStream` (diagnostics.ts): that classifier + * also flags `{error:{…}}` bodies as `empty_choices`, which would rotate on + * real errors — a false-positive class with a history here. + */ +export function isEmptyUpstreamRejection(status: number, bodyText: string): boolean { + if (status !== 400) return false; + let parsed: unknown; + try { + parsed = JSON.parse(bodyText); + } catch { + return false; + } + const choices = (parsed as { choices?: unknown })?.choices; + if (!Array.isArray(choices) || choices.length === 0) return false; + const first = choices[0] as { message?: unknown; finish_reason?: unknown }; + if (typeof first !== "object" || first === null) return false; + const rawMessage = (first as { message?: unknown }).message; + if (typeof rawMessage === "undefined" || rawMessage === null) return false; + if (typeof parsed !== "object" || parsed === null) return false; + if ("error" in (parsed as Record)) return false; + const msg = rawMessage as Record; + if ("tool_calls" in msg) return false; + if ("reasoning_content" in msg) return false; + const content = msg.content; + if (content !== undefined && content !== null && content !== "") return false; + if (first.finish_reason !== null && first.finish_reason !== undefined) return false; + return true; +} + +/** Best-effort extraction of the upstream `chatcmpl_*` id from a response body, + * for observability logging. Returns `"unknown"` when absent or unparseable. */ +export function extractChatcmplId(bodyText: string): string { + const match = /"id"\s*:\s*"(chatcmpl_[^"]+)"/.exec(bodyText); + return match ? match[1] : "unknown"; +} diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index c00ae258a3..0829bfa871 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -15,6 +15,8 @@ import { markSuccess as markAccountSuccess, maskAccountId, isNetworkErrorRotatable, + isEmptyUpstreamRejection, + extractChatcmplId, } from "./accountRotation.ts"; import { isNetworkRotationSharedEgressGuardEnabled } from "@/shared/utils/featureFlags"; @@ -253,14 +255,41 @@ export class OpencodeExecutor extends BaseExecutor { try { this.syncAccountsFromCredentials(input.credentials); + const { log } = input; const hasProxies = this.accounts.some((a) => a.proxy !== null); - // Fast path: no multi-account proxy wiring configured → original behavior. + // Fast path: no multi-account proxy wiring configured → original behavior, + // plus exactly ONE bounded retry when the upstream answers a 400 empty + // rejection (same predicate and logging as the rotation loop). Everything + // else passes untouched: this path deliberately preserves BaseExecutor's + // intra-URL 429 retries (no skipUpstreamRetry here). if (this.accounts.length === 1 && !hasProxies) { - return await super.execute(input); + const single = (await super.execute(input)) as HttpExecuteResult; + if (single.response.status === 400) { + let bodyText: string | null = null; + try { + bodyText = await single.response.clone().text(); + } catch { + log?.debug?.("OPENCODE", "body read failed on direct account"); + } + if (bodyText !== null) { + if (isEmptyUpstreamRejection(400, bodyText)) { + const chatcmplId = extractChatcmplId(bodyText); + log?.warn?.( + "OPENCODE", + `upstream empty rejection on direct account (${chatcmplId}), retrying once…` + ); + return await super.execute(input); + } + log?.debug?.( + "OPENCODE", + "400 without error field, signature not matched on direct account — observing" + ); + } + } + return single; } - const { log } = input; // This loop only ever dispatches through super.execute() (the HTTP request // path), which always resolves the object-shaped arm of ExecutorExecuteResult // — the bare-Response arm belongs to web/scraping executors only (base.ts:290). @@ -277,8 +306,13 @@ export class OpencodeExecutor extends BaseExecutor { // network call, but proxied accounts (independent egress) are still // tried normally. let sharedEgressDown = false; + // Bounded extra attempts for empty upstream rejections: +1 for a single + // account (retry the same one), none for a multi-account fleet (rotation + // through the accounts is the retry). Avoids an unbounded loop on a + // persistently malformed upstream. + const emptyRejectionBudget = this.accounts.length === 1 ? 1 : 0; - for (let attempt = 0; attempt < this.accounts.length; attempt++) { + for (let attempt = 0; attempt < this.accounts.length + emptyRejectionBudget; attempt++) { const account = this.pickAccount(); const masked = maskAccountId(account.fingerprint); @@ -354,6 +388,34 @@ export class OpencodeExecutor extends BaseExecutor { continue; } + // Empty upstream rejection (malformed 400: no error field, no real + // content, finish_reason null — see isEmptyUpstreamRejection). Rotate/ + // retry instead of propagating it as a fatal success: the observed + // envelope was marking subagent sessions as failed. Read the body ONLY + // for a 400 (never a 200/streaming — that would buffer the good path); + // classify, log, and continue. Neitheries markCooldown nor markSuccess: + // the failure is upstream's, not this account's. + if (status === 400) { + let bodyText: string | null = null; + try { + bodyText = await result.response.clone().text(); + } catch { + log?.debug?.("OPENCODE", "body read failed on empty rejection check"); + } + if (bodyText !== null && isEmptyUpstreamRejection(400, bodyText)) { + const chatcmplId = extractChatcmplId(bodyText); + log?.warn?.( + "OPENCODE", + `upstream empty rejection on account ${masked} (${chatcmplId}), rotating to next…` + ); + continue; + } + // A 400 carrying a real error (or non-empty content): propagate + // immediately, untouched — same as before this change. + this.markSuccess(account); + return result; + } + this.markSuccess(account); return result; } diff --git a/tests/unit/account-rotation.test.ts b/tests/unit/account-rotation.test.ts index f4864ee2dd..a682f02a0d 100644 --- a/tests/unit/account-rotation.test.ts +++ b/tests/unit/account-rotation.test.ts @@ -7,6 +7,8 @@ import { markSuccess, maskAccountId, isNetworkErrorRotatable, + isEmptyUpstreamRejection, + extractChatcmplId, type RotatableAccount, } from "../../open-sse/executors/accountRotation.ts"; @@ -114,3 +116,100 @@ describe("accountRotation", () => { assert.strictEqual(isNetworkErrorRotatable(withoutProxy), false); }); }); + +describe("isEmptyUpstreamRejection", () => { + it("matches the observed malformed completion envelope (no error field, empty content, null finish_reason)", () => { + const observed = + '{"id":"chatcmpl_44fn2g6e7kk","object":"chat.completion","created":1787419957,"model":"muse-spark-1.2-contributor-free","choices":[{"index":0,"message":{"role":"assistant"},"finish_reason":null}]}'; + assert.strictEqual(isEmptyUpstreamRejection(400, observed), true); + }); + + it("does not match a non-400 status", () => { + const observed = + '{"id":"chatcmpl_44fn2g6e7kk","object":"chat.completion","created":1787419957,"model":"muse-spark-1.2-contributor-free","choices":[{"index":0,"message":{"role":"assistant"},"finish_reason":null}]}'; + assert.strictEqual(isEmptyUpstreamRejection(200, observed), false); + assert.strictEqual(isEmptyUpstreamRejection(429, observed), false); + assert.strictEqual(isEmptyUpstreamRejection(502, observed), false); + }); + + it("does not match when an error field is present", () => { + const withError = JSON.stringify({ + error: { message: "bad request", type: "invalid_request_error" }, + }); + assert.strictEqual(isEmptyUpstreamRejection(400, withError), false); + const emptyError = JSON.stringify({ error: {} }); + assert.strictEqual(isEmptyUpstreamRejection(400, emptyError), false); + }); + + it("does not match when content is non-empty or tool_calls present", () => { + const nonEmpty = JSON.stringify({ + choices: [{ message: { role: "assistant", content: "hi" }, finish_reason: "stop" }], + }); + assert.strictEqual(isEmptyUpstreamRejection(400, nonEmpty), false); + + const toolCalls = JSON.stringify({ + choices: [ + { message: { role: "assistant", tool_calls: [{ id: "x" }] }, finish_reason: "tool_calls" }, + ], + }); + assert.strictEqual(isEmptyUpstreamRejection(400, toolCalls), false); + }); + + it("does not match when content is a non-string non-null value (number, block array)", () => { + const numericContent = JSON.stringify({ + choices: [{ message: { role: "assistant", content: 123 }, finish_reason: null }], + }); + assert.strictEqual( + isEmptyUpstreamRejection(400, numericContent), + false, + "non-string non-null content is not eligible" + ); + + const reasoningContent = JSON.stringify({ + choices: [ + { message: { role: "assistant", reasoning_content: "thinking" }, finish_reason: null }, + ], + }); + assert.strictEqual(isEmptyUpstreamRejection(400, reasoningContent), false); + }); + + it("does not match when choices or message are absent", () => { + const noChoices = JSON.stringify({ id: "chatcmpl_x", model: "muse" }); + assert.strictEqual(isEmptyUpstreamRejection(400, noChoices), false); + const noMessage = JSON.stringify({ choices: [{ finish_reason: null }] }); + assert.strictEqual(isEmptyUpstreamRejection(400, noMessage), false); + }); + + it("does not match when finish_reason is a literal value (not null)", () => { + const stopReason = JSON.stringify({ + choices: [{ message: { role: "assistant" }, finish_reason: "stop" }], + }); + assert.strictEqual(isEmptyUpstreamRejection(400, stopReason), false); + }); + + it("matches an empty string content (treated as eligible)", () => { + const emptyContent = JSON.stringify({ + choices: [{ message: { role: "assistant", content: "" }, finish_reason: null }], + }); + assert.strictEqual(isEmptyUpstreamRejection(400, emptyContent), true); + }); + + it("returns false for unparseable JSON rather than throwing", () => { + assert.strictEqual(isEmptyUpstreamRejection(400, "not json"), false); + assert.strictEqual(isEmptyUpstreamRejection(400, ""), false); + }); +}); + +describe("extractChatcmplId", () => { + it("extracts the chatcmpl id from an observed envelope", () => { + const observed = + '{"id":"chatcmpl_44fn2g6e7kk","object":"chat.completion","created":1787419957,"model":"muse-spark-1.2-contributor-free","choices":[{"index":0,"message":{"role":"assistant"},"finish_reason":null}]}'; + assert.strictEqual(extractChatcmplId(observed), "chatcmpl_44fn2g6e7kk"); + }); + + it("falls back to 'unknown' when no id is present", () => { + assert.strictEqual(extractChatcmplId("{choices:[]}"), "unknown"); + assert.strictEqual(extractChatcmplId(""), "unknown"); + assert.strictEqual(extractChatcmplId("not json"), "unknown"); + }); +}); diff --git a/tests/unit/opencode-empty-rejection-rotation.test.ts b/tests/unit/opencode-empty-rejection-rotation.test.ts new file mode 100644 index 0000000000..784a4b83a7 --- /dev/null +++ b/tests/unit/opencode-empty-rejection-rotation.test.ts @@ -0,0 +1,416 @@ +import { describe, it, beforeEach, afterEach, before, after } from "node:test"; +import assert from "node:assert"; +import net from "node:net"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import type { ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; +import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; +import { + isEmptyUpstreamRejection, + extractChatcmplId, +} from "../../open-sse/executors/accountRotation.ts"; + +/** + * Empty-upstream-rejection rotation (#design opencode-empty-rejection-rotation). + * + * An upstream 400 whose body carries no usable completion (the observed malformed + * envelope: `choices[0].message` with no error field, no real content, + * `finish_reason: null`) must be rotated/retried instead of propagated as a fatal + * success — that was killing subagent sessions. These tests pin the wiring: + * + * 1. A 400 empty rejection rotates to the next account (and its proxy). + * 2. The retry budget is bounded: +1 attempt for a single account, exactly N + * for an N-account all-empty run (propagate the last 400, never loop forever). + * 3. A 400 carrying a real error field (or non-empty content) still propagates + * immediately — no cooldown, no success, no rotation. + * 4. The 200/success path is never cloned or read (anti-bufferisation). + * + * The dispatch layer is mocked by stubbing globalThis.fetch (exactly what the + * #4954 proxy integration test does). Three throwaway TCP listeners stand in for + * the per-account proxies so runWithProxyContext's reachability probe passes. + */ + +const log: ExecutorLog = { debug() {}, info() {}, warn() {}, error() {} }; + +const ACCOUNT_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ACCOUNT_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const ACCOUNT_C = "cccccccccccccccccccccccccccccccc"; + +const EMPTY_BODY = + '{"id":"chatcmpl_44fn2g6e7kk","object":"chat.completion","created":1787419957,"model":"muse-spark-1.2-contributor-free","choices":[{"index":0,"message":{"role":"assistant"},"finish_reason":null}]}'; +const ERROR_BODY = JSON.stringify({ + error: { message: "bad request", type: "invalid_request_error" }, +}); + +let serverA: net.Server; +let serverB: net.Server; +let serverC: net.Server; +let portA = 0; +let portB = 0; +let portC = 0; + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve((server.address() as net.AddressInfo).port); + }); + }); +} + +before(async () => { + serverA = net.createServer((s) => s.destroy()); + serverB = net.createServer((s) => s.destroy()); + serverC = net.createServer((s) => s.destroy()); + portA = await listen(serverA); + portB = await listen(serverB); + portC = await listen(serverC); +}); + +after(() => { + serverA?.close(); + serverB?.close(); + serverC?.close(); +}); + +function portFor(fp: string): number { + if (fp === ACCOUNT_A) return portA; + if (fp === ACCOUNT_B) return portB; + return portC; +} + +/** `fingerprints` accounts; `proxied` is the subset that get a dedicated proxy + * (defaults to all). A proxy-less account shares the default egress. */ +function credentialsFor( + fingerprints: string[], + proxied: string[] = [...fingerprints] +): ProviderCredentials { + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { + fingerprints, + ...(proxied.length > 0 && { + accountProxies: proxied.map((fp) => ({ + fingerprint: fp, + proxy: { type: "http", host: "127.0.0.1", port: portFor(fp) }, + })), + }), + }, + }; +} + +/** A Response subclass that counts clone() so we can assert the executor never + * buffers a 200/streaming response. Note: `clone()` returns a plain Response, so + * only `clone()` is reliably counted (a read on the clone hits the native + * method, not this override) — counting clones is the meaningful invariant. */ +class SpyResponse extends Response { + static clones = 0; + clone(): Response { + SpyResponse.clones++; + return super.clone(); + } +} + +interface PlanStep { + status: number; + body?: string; + throw?: Error; +} + +describe("OpencodeExecutor empty-rejection rotation", () => { + let originalFetch: typeof globalThis.fetch; + let observed: Array<{ source: string; host: string | null; port: string | null }>; + const GUARD_FLAG = "NETWORK_ROTATION_SHARED_EGRESS_GUARD"; + let savedGuardFlag: string | undefined; + + beforeEach(() => { + originalFetch = globalThis.fetch; + observed = []; + SpyResponse.clones = 0; + savedGuardFlag = process.env[GUARD_FLAG]; + delete process.env[GUARD_FLAG]; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (savedGuardFlag === undefined) delete process.env[GUARD_FLAG]; + else process.env[GUARD_FLAG] = savedGuardFlag; + }); + + function installFetch(plan: PlanStep[]) { + let call = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + observed.push({ + source: resolved.source, + host: resolved.proxyUrl ? new URL(resolved.proxyUrl).hostname : null, + port: resolved.proxyUrl ? new URL(resolved.proxyUrl).port : null, + }); + const step = plan[Math.min(call, plan.length - 1)]; + call++; + if (step.throw) throw step.throw; + return new SpyResponse(step.body ?? JSON.stringify({ ok: step.status === 200 }), { + status: step.status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + } + + /** + * Launches the executor. Asserts the predicate itself behaves (regression guard + * for the design's signature — the wiring tests below depend on it). + */ + it("predicate matches the observed envelope and rejects real errors", () => { + assert.strictEqual(isEmptyUpstreamRejection(400, EMPTY_BODY), true); + assert.strictEqual(isEmptyUpstreamRejection(200, EMPTY_BODY), false); + assert.strictEqual(isEmptyUpstreamRejection(400, ERROR_BODY), false); + assert.strictEqual(extractChatcmplId(EMPTY_BODY), "chatcmpl_44fn2g6e7kk"); + }); + + it("rotates to the next account on an empty 400 rejection (loop)", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status: 400, body: EMPTY_BODY }, { status: 200 }]); + + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsFor([ACCOUNT_A, ACCOUNT_B]), + log, + }); + + assert.strictEqual( + (result as { response: Response }).response.status, + 200, + "must rotate past the empty 400" + ); + assert.ok(observed.length >= 2, "should have dispatched on a second account"); + assert.ok( + observed.some((o) => o.port === String(portA)), + "first attempt on account A" + ); + assert.ok( + observed.some((o) => o.port === String(portB)), + "rotated attempt on account B" + ); + }); + + it("caps an all-empty N-account run at N attempts and propagates the last 400 intact", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 400, body: EMPTY_BODY }, + { status: 400, body: EMPTY_BODY }, + { status: 400, body: EMPTY_BODY }, + { status: 200 }, + ]); + + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsFor([ACCOUNT_A, ACCOUNT_B, ACCOUNT_C]), + log, + }); + + assert.strictEqual( + (result as { response: Response }).response.status, + 400, + "must propagate the last empty 400" + ); + assert.strictEqual(observed.length, 3, "must NOT exceed N attempts (no infinite loop)"); + assert.ok(SpyResponse.clones >= 1, "the empty 400 path must read the body to classify it"); + const propagated = await (result as { response: Response }).response.clone().text(); + assert.strictEqual(propagated, EMPTY_BODY, "propagated 400 body must stay intact"); + for (const p of observed) { + assert.strictEqual(p.source, "context", "every dispatch must egress through a proxy context"); + } + }); + + it("retries the same proxied account once when it is the only account", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 400, body: EMPTY_BODY }, + { status: 400, body: EMPTY_BODY }, + ]); + + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsFor([ACCOUNT_A]), + log, + }); + + assert.strictEqual((result as { response: Response }).response.status, 400); + assert.strictEqual(observed.length, 2, "exactly one bounded retry on the sole account"); + assert.ok( + observed.every((o) => o.port === String(portA)), + "both attempts egress through the single account's proxy" + ); + }); + + it("coexists with 429 rotation and 200 success in the same request", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status: 429 }, { status: 400, body: EMPTY_BODY }, { status: 200 }]); + + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsFor([ACCOUNT_A, ACCOUNT_B, ACCOUNT_C]), + log, + }); + + assert.strictEqual( + (result as { response: Response }).response.status, + 200, + "final response should succeed" + ); + assert.strictEqual(observed.length, 3, "429 + empty-400 + success across three accounts"); + assert.ok( + observed.some((o) => o.port === String(portA)), + "account A (429)" + ); + assert.ok( + observed.some((o) => o.port === String(portB)), + "account B (empty 400)" + ); + assert.ok( + observed.some((o) => o.port === String(portC)), + "account C (200)" + ); + }); + + it("propagates a 400 carrying an error field immediately (no rotation)", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status: 400, body: ERROR_BODY }]); + + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsFor([ACCOUNT_A, ACCOUNT_B]), + log, + }); + + assert.strictEqual( + (result as { response: Response }).response.status, + 400, + "real error 400 must propagate" + ); + assert.strictEqual(observed.length, 1, "must NOT rotate on a genuine error 400"); + }); + + it("never clones or reads the body of a 200 via the loop", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status: 200 }, { status: 200 }, { status: 200 }]); + + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsFor([ACCOUNT_A, ACCOUNT_B, ACCOUNT_C]), + log, + }); + + assert.strictEqual((result as { response: Response }).response.status, 200); + assert.strictEqual(SpyResponse.clones, 0, "loop 200 must never be cloned"); + }); + + it("retries once via the fast path when a direct account answers an empty 400", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 400, body: EMPTY_BODY }, + { status: 400, body: EMPTY_BODY }, + ]); + + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsFor([ACCOUNT_A], []), + log, + }); + + assert.strictEqual((result as { response: Response }).response.status, 400); + assert.strictEqual(observed.length, 2, "fast path must retry the direct account exactly once"); + }); + + it("propagates the second 400 intact when the fast path retries and empty-rejects again", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([ + { status: 400, body: EMPTY_BODY }, + { status: 400, body: EMPTY_BODY }, + ]); + + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsFor([ACCOUNT_A], []), + log, + }); + + assert.strictEqual((result as { response: Response }).response.status, 400); + const propagated = await (result as { response: Response }).response.clone().text(); + assert.strictEqual(propagated, EMPTY_BODY, "second rejection propagates with intact body"); + assert.strictEqual(observed.length, 2, "exactly one retry, no loop"); + }); + + it("never clones or reads the body of a 200 via the fast path", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status: 200 }]); + + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsFor([ACCOUNT_A], []), + log, + }); + + assert.strictEqual((result as { response: Response }).response.status, 200); + assert.strictEqual(SpyResponse.clones, 0, "fast path 200 must never be cloned"); + }); + + it("rotates to a proxied account after a proxy-less account empty-rejects (shared-egress guard on by default)", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + installFetch([{ status: 400, body: EMPTY_BODY }, { status: 200 }]); + + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + // A proxy-less, B proxied: B must still be tried and succeed. + credentials: credentialsFor([ACCOUNT_A, ACCOUNT_B], [ACCOUNT_B]), + log, + }); + + assert.strictEqual( + (result as { response: { status: number } }).response.status, + 200, + "the proxied account (B) must still be tried and must succeed" + ); + assert.strictEqual(observed.length, 2, "exactly one empty rejection (A) then one success (B)"); + assert.ok( + observed.some((o) => o.source === "direct"), + "first dispatch on the proxy-less account" + ); + assert.ok( + observed.some((o) => o.port === String(portB)), + "rotated dispatch on the proxied account" + ); + }); +});