Files
OmniRoute/tests/unit/codex-sse-capacity-fallback.test.ts
Diego Rodrigues de Sa e Souza 3f457cc77b fix(codex): surface capacity errors embedded in 200-OK SSE streams (#6710)
* fix(codex): surface capacity errors embedded in 200-OK SSE streams

Codex sometimes answers with HTTP 200 and a text/event-stream body whose
payload carries a transient error mid-stream (e.g. "Selected model is at
capacity...", server_is_overloaded, service_unavailable_error). Because the
outer HTTP status was 200, this looked like a successful response to every
caller — no retry, no circuit breaker, and no combo/account fallback ever
engaged, so a healthy account sat idle while the request silently failed or
truncated.

Add peekCodexSseTransientError() to open-sse/executors/codex.ts: it peeks the
first bytes of a text/event-stream Codex response, pattern-matches the known
transient-error signatures, and converts a match into a real 503 Response via
errorResponse() (Hard Rule #12 — sanitized, never raw upstream text). A 503 is
already a recognized provider-failure status in accountFallback.ts, so combo
routing and connection cooldown pick it up automatically. When no error
signature is found, the peeked prefix is prepended back onto the remaining
upstream body so the passthrough stays byte-identical to the unmodified
response.

Regression guard: tests/unit/codex-sse-capacity-fallback.test.ts — a
model-at-capacity payload and a server_is_overloaded/service_unavailable_error
payload both convert to 503; a normal single-chunk SSE stream and one split
across multiple network chunks both reassemble byte-for-byte unchanged.

Inspired-by: https://github.com/decolua/9router/pull/2452 (sub-bug #3 only —
OmniRoute already covers PR #2452's other two sub-bugs: service_tier "fast"
normalization and reasoning_effort "max" normalization).

Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com>

* chore(6710): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first)

---------

Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com>
2026-07-10 19:39:23 -03:00

158 lines
5.2 KiB
TypeScript

// Sub-bug #3 of upstream decolua/9router#2452 (@ryanngit): Codex sometimes answers
// with HTTP 200 and a text/event-stream body whose payload carries a transient
// "model at capacity" / overloaded error mid-stream. Left unhandled, the 200
// status makes this look like a successful response — no retry, no circuit
// breaker, no combo/account fallback engages, so the client either hangs or gets
// a truncated stream while a healthy account sits idle. This must be detected and
// converted into a real error Response (503) so accountFallback.ts / combo
// routing rotates to another account.
import test from "node:test";
import assert from "node:assert/strict";
import { CodexExecutor, __setCodexWebSocketTransportForTesting } from "../../open-sse/executors/codex.ts";
test.afterEach(() => {
__setCodexWebSocketTransportForTesting(undefined);
});
function sseStreamFromChunks(chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
let i = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (i >= chunks.length) {
controller.close();
return;
}
controller.enqueue(encoder.encode(chunks[i]));
i++;
},
});
}
test("CodexExecutor.execute converts a 200-OK SSE stream carrying a model-at-capacity error into a 503 Response", async () => {
const executor = new CodexExecutor();
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
sseStreamFromChunks([
'event: error\ndata: {"error":{"message":"Selected model is at capacity. Please try a different model."}}\n\n',
]),
{
status: 200,
headers: { "Content-Type": "text/event-stream" },
}
);
try {
const result = await executor.execute({
model: "gpt-5.5",
body: { model: "gpt-5.5", input: [{ role: "user", content: "hello" }] },
stream: true,
credentials: { accessToken: "codex-token" },
});
assert.notEqual(result.response.status, 200);
assert.equal(result.response.status, 503);
const body = await result.response.json();
assert.match(body.error.message, /at capacity/i);
// Hard Rule #12: never leak raw stack/paths in the sanitized error body.
assert.equal(body.error.message.includes("at /"), false);
} finally {
globalThis.fetch = originalFetch;
}
});
test("CodexExecutor.execute converts server_is_overloaded / service_unavailable_error SSE payloads into a 503 Response", async () => {
const executor = new CodexExecutor();
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
sseStreamFromChunks([
'event: error\ndata: {"error":{"type":"server_is_overloaded","message":"The server is overloaded. Please retry later."}}\n\n',
]),
{
status: 200,
headers: { "Content-Type": "text/event-stream" },
}
);
try {
const result = await executor.execute({
model: "gpt-5.5",
body: { model: "gpt-5.5", input: [{ role: "user", content: "hello" }] },
stream: true,
credentials: { accessToken: "codex-token" },
});
assert.equal(result.response.status, 503);
} finally {
globalThis.fetch = originalFetch;
}
});
test("CodexExecutor.execute reassembles a normal 200-OK SSE stream byte-intact after peeking for transient errors", async () => {
const executor = new CodexExecutor();
const originalFetch = globalThis.fetch;
const normalSse =
'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Hello"}\n\n' +
'event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\n';
globalThis.fetch = async () =>
new Response(sseStreamFromChunks([normalSse]), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
try {
const result = await executor.execute({
model: "gpt-5.5",
body: { model: "gpt-5.5", input: [{ role: "user", content: "hello" }] },
stream: true,
credentials: { accessToken: "codex-token" },
});
assert.equal(result.response.status, 200);
const text = await result.response.text();
assert.equal(text, normalSse);
} finally {
globalThis.fetch = originalFetch;
}
});
test("CodexExecutor.execute reassembles a normal SSE stream split across multiple network chunks", async () => {
const executor = new CodexExecutor();
const originalFetch = globalThis.fetch;
const chunks = [
'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Hel',
'lo"}\n\n',
'event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\n',
];
const expected = chunks.join("");
globalThis.fetch = async () =>
new Response(sseStreamFromChunks(chunks), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
try {
const result = await executor.execute({
model: "gpt-5.5",
body: { model: "gpt-5.5", input: [{ role: "user", content: "hello" }] },
stream: true,
credentials: { accessToken: "codex-token" },
});
assert.equal(result.response.status, 200);
const text = await result.response.text();
assert.equal(text, expected);
} finally {
globalThis.fetch = originalFetch;
}
});