mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
* 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>
158 lines
5.2 KiB
TypeScript
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;
|
|
}
|
|
});
|