Skip to content
Closed
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
47 changes: 46 additions & 1 deletion src/combos/failover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,9 +401,12 @@ export function comboFailureCooldownScope(
): ComboFailureCooldownScope {
const code = normalizedFailureCode(options?.code);
// Request-shape refusals first: an oversized request must not cool a healthy target.
// Some native transports surface a definite zero-output model overflow as a generic
// upstream_server_error plus precise context-window prose, so honor the bounded classifier too.
if (
status === 413
|| REQUEST_SHAPE_FAILURE_CODES.has(code)
|| isDefiniteContextOverflow(message)
|| isRequestLocalFreePromptCap(status, message, options?.code)
|| isProviderTargetContextOverflow(status, message, options?.code)
|| isRequestLocalTargetIncompatibility(status, message, options?.code)
Expand Down Expand Up @@ -451,6 +454,44 @@ function isProviderTargetContextOverflow(
&& /\bprompt\s+\d+\s*>\s*\d+\s+maximum context length\b/i.test(message);
}

/**
* Confirm context overflow from the provider MESSAGE, not merely from a JSON code token.
* Upstreams control both fields and can emit contradictory envelopes; a stray
* `context_length_exceeded` code beside an unrelated refusal must not authorize replay.
* Bounded wrapper unwrapping covers OpenCodex's own `Provider error N: {...}` envelope.
*/
function isDefiniteContextOverflow(message: string): boolean {
if (message.length > 16_384) return false;
let text = message.trim();
for (let depth = 0; depth < 4; depth += 1) {
const providerPrefix = /^Provider error \d{3}:\s*/.exec(text);
if (providerPrefix) text = text.slice(providerPrefix[0].length).trim();
let payload: unknown;
try { payload = JSON.parse(text); } catch {
const normalized = text.toLowerCase();
return normalized === "context_length_exceeded"
|| normalized.includes("exceeds the context window")
|| normalized.includes("context window exceeded")
|| normalized.includes("context length exceeded")
|| normalized.includes("maximum context length")
|| normalized.includes("maximum context window")
|| normalized.includes("too many tokens");
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
const record = payload as Record<string, unknown>;
const response = record.response && typeof record.response === "object" && !Array.isArray(record.response)
? record.response as Record<string, unknown>
: undefined;
const candidates = [record.error, response?.error, response?.last_error, record.last_error, record];
const source = candidates.find((candidate): candidate is Record<string, unknown> =>
!!candidate && typeof candidate === "object" && !Array.isArray(candidate)
&& typeof (candidate as Record<string, unknown>).message === "string");
if (!source || typeof source.message !== "string") return false;
text = source.message.trim();
}
return false;
}

export function comboFailureDecision(
status: number,
message: string,
Expand All @@ -472,6 +513,10 @@ export function comboFailureDecision(
if (isModelLifecycleGone(status, message, options?.code)) return "hop";
const error = classifyError(status, "upstream_error", message);
if (isCyberPolicyCode(error.code)) return "stop";
// A definite context-window refusal is target-local inside a heterogeneous combo: this
// model cannot fit the turn, but a later model may have a larger window. Non-replayable
// post-send transport ambiguity was already rejected above, before this classification.
if (isDefiniteContextOverflow(message)) return "hop";
// A provider can expose its own target hard cap with a non-semantic vendor code
// (for example 5059 + invalid_request_prompt_too_long). That is evidence that this
// target is too small, not that every later combo target is incapable of serving it.
Expand Down Expand Up @@ -510,7 +555,7 @@ export function comboFailureDecision(
// per-request cap, not provider-wide evidence), so keep its hop verdict explicit here.
if (failureCode === "free_rate_limited") return "hop";
if (isRequestLocalTargetIncompatibility(status, message, options?.code)) return "hop";
if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
if (["origin_rejected", "invalid_request_error"].includes(error.code ?? "")) {
return "stop";
}
// 402 (payment required) and 425 (too early) are provider-state signals, not verdicts about
Expand Down
8 changes: 4 additions & 4 deletions tests/codex-integration/combos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,7 @@ describe("combo failure policy and advancement", () => {
for (const status of [401, 403, 404, 408, 429, 500, 503]) {
expect(comboFailureDecision(status, "provider failure")).toBe("hop");
}
expect(comboFailureDecision(400, "context_length_exceeded")).toBe("stop");
expect(comboFailureDecision(400, "context_length_exceeded")).toBe("hop");
expect(comboFailureDecision(403, '{"code":"origin_rejected"}')).toBe("stop");
expect(comboFailureDecision(413, "request too large")).toBe("stop");
expect(comboFailureDecision(409, "conflict")).toBe("stop");
Expand All @@ -874,9 +874,9 @@ describe("combo failure policy and advancement", () => {
// verdict by echoing the token, so that shape must NOT hop.
expect(comboFailureDecision(413, 'refused', { code: 'input_admission_refused' })).toBe('hop');
expect(comboFailureDecision(400, 'upstream mentions input_admission_refused in prose')).toBe('stop');
// An UPSTREAM context verdict still stops: retrying that elsewhere is guesswork, and a
// generic 413 with no structured code keeps its existing conservative handling.
expect(comboFailureDecision(400, "context_length_exceeded")).toBe("stop");
// An UPSTREAM context verdict is target-local in a heterogeneous combo: another model may
// have a larger window. A generic 413 with no structured context signal stays conservative.
expect(comboFailureDecision(400, "context_length_exceeded")).toBe("hop");
const providerHardCap = JSON.stringify({ error: {
message: "Prompt 346030 > 262144 maximum context length",
type: "invalid_request_prompt_too_long",
Expand Down
76 changes: 76 additions & 0 deletions tests/helpers/combo-context-overflow-cases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { expect, test } from "bun:test";
import type { OcxConfig, OcxProviderConfig } from "../../src/types";

interface ComboHarness<Server> {
serve(handler: () => Response | Promise<Response>): Server;
baseUrl(server: Server): string;
chatSuccess(text: string, model?: string): Response;
provider(adapter: string, url: string, apiKey: string, extra?: Partial<OcxProviderConfig>): OcxProviderConfig;
comboConfig(providers: OcxConfig["providers"]): OcxConfig;
post(config: OcxConfig, raw?: Record<string, unknown>): Promise<Response>;
chatStream(text: string): Response;
collectSse(response: Response): Promise<unknown[]>;
}

/** Register under the caller's isolated homes, mock state and server cleanup hooks. */
export function registerComboContextOverflowCases<Server>({
serve, baseUrl, chatSuccess, provider, comboConfig, post, chatStream, collectSse,
}: ComboHarness<Server>): void {
test("context overflow advances while exhausted retryable targets return the sanitized last status", async () => {
let stopBackupHits = 0;
const context = serve(() => Response.json({ error: { code: "context_length_exceeded", message: "too many tokens" } }, { status: 400 }));
const unused = serve(() => {
stopBackupHits += 1;
return chatSuccess("larger context fallback");
});
const stopConfig = comboConfig({
a: provider("openai-chat", baseUrl(context), "key-a"),
b: provider("openai-chat", baseUrl(unused), "key-b"),
});
const stopped = await post(stopConfig);
expect(stopped.status).toBe(200);
expect(stopBackupHits).toBe(1);
expect(await stopped.text()).toContain("larger context fallback");

const order: string[] = [];
const first = serve(() => {
order.push("a");
return new Response("secret sk-a-should-redact", { status: 503 });
});
const last = serve(() => {
order.push("b");
return Response.json({ error: { message: "missing model" } }, { status: 404 });
});
const exhausted = await post(comboConfig({
a: provider("openai-chat", baseUrl(first), "key-a"),
b: provider("openai-chat", baseUrl(last), "key-b"),
}));
expect(exhausted.status).toBe(404);
expect(order).toEqual(["a", "b"]);
expect(await exhausted.text()).not.toContain("sk-a-should-redact");
});

test("zero-output context overflow 502 hops to a healthy combo target", async () => {
let backupHits = 0;
const capped = serve(() => new Response([
"event: response.created",
'data: {"type":"response.created","response":{"id":"resp_context","status":"in_progress"}}',
"",
"event: response.failed",
'data: {"type":"response.failed","response":{"id":"resp_context","status":"failed","error":{"type":"server_error","code":"upstream_server_error","message":"Your input exceeds the context window of this model. Please adjust your input and try again."}}}',
"",
"",
].join("\n"), { headers: { "content-type": "text/event-stream" } }));
const backup = serve(() => {
backupHits += 1;
return chatStream("larger context backup");
});
const response = await post(comboConfig({
a: provider("openai-responses", baseUrl(capped), "key-a"),
b: provider("openai-chat", baseUrl(backup), "key-b"),
}), { stream: true });
expect(response.status).toBe(200);
expect(backupHits).toBe(1);
expect(JSON.stringify(await collectSse(response))).toContain("larger context backup");
});
}
12 changes: 11 additions & 1 deletion tests/routing/router-combo-failover-classification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ describe("combo failure cooldown scope", () => {
}
// Hyphenated spellings normalize to the same codes.
expect(comboFailureCooldownScope(400, "refused", { code: "input-admission-refused" })).toBe("none");
expect(comboFailureCooldownScope(502,
"Your input exceeds the context window of this model. Please adjust your input and try again.",
{ code: "upstream_server_error" })).toBe("none");
// A provider's own per-target hard cap (vendor code 5059) is equally request-shaped.
expect(comboFailureCooldownScope(
400,
Expand Down Expand Up @@ -248,14 +251,21 @@ describe("request-local optional control incompatibility", () => {
});
test("hard refusal and non-replayable codes take precedence over a compatible message", () => {
const message = JSON.stringify({ error: unsupportedUser });
for (const code of ["origin_rejected", "context_length_exceeded", "upstream_no_response", "upstream_closed_before_response"]) {
for (const code of ["origin_rejected", "upstream_no_response", "upstream_closed_before_response"]) {
expect(comboFailureDecision(400, message, { code })).toBe("stop");
}
expect(comboFailureDecision(400, "context length exceeded", { code: "context_length_exceeded" })).toBe("hop");
expect(comboFailureDecision(499, message)).toBe("stop");
expect(comboFailureDecision(413, message)).toBe("stop");
});
});

test("zero-output upstream context overflow is target-local and may hop", () => {
const message = "Your input exceeds the context window of this model. Please adjust your input and try again.";
const body = JSON.stringify({ error: { type: "server_error", code: "upstream_server_error", message } });
expect(comboFailureDecision(502, body, { code: "upstream_server_error" })).toBe("hop");
});

describe("bounded optional-control error envelopes", () => {
const wrapped = (message: string, code = "invalid_request_error") => JSON.stringify({
error: { type: "invalid_request_error", code, message: `Provider error 400: ${message}` },
Expand Down
11 changes: 5 additions & 6 deletions tests/routing/routing-policy-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,7 @@ describe("policy candidate fallback", () => {
expect(response.status).toBe(400);
expect(seenModels).toEqual(["policy/daily"]);
});
test("an upstream context_length_exceeded still stops the chain (#1524)", async () => {
// The mirror-image contract. An upstream verdict is about the REQUEST, so retrying it
// elsewhere is guesswork -- and hopping would burn every candidate on a doomed request.
test("an upstream context_length_exceeded advances to the next policy candidate", async () => {
const trace = policyTrace();
const logCtx = { requestedModel: "policy/daily", routeDecision: trace, attempts: [] } as unknown as RequestLogContext;
const seenModels: string[] = [];
Expand All @@ -153,16 +151,17 @@ describe("policy candidate fallback", () => {
seenModels.push(String(body.model));
ctx.routeDecision = trace;
seedAttempt(ctx, "provider", String(body.model));
return Response.json(
if (seenModels.length === 1) return Response.json(
{ error: { message: "context length exceeded", type: "invalid_request_error", code: "context_length_exceeded" } },
{ status: 400 },
);
return Response.json({ id: "resp", object: "response", status: "completed", output: [] });
};

const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, {}, { runCore });

expect(response.status).toBe(400);
expect(seenModels).toEqual(["policy/daily"]);
expect(response.status).toBe(200);
expect(seenModels).toEqual(["policy/daily", "provider-b/model-b"]);
});
test("retries the next policy candidate and keeps distinct physical attempts", async () => {
const trace = policyTrace();
Expand Down
34 changes: 2 additions & 32 deletions tests/server/server-combo-failover-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { registerComboContextOverflowCases } from "../helpers/combo-context-overflow-cases";
import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation";
import { afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test";
import { logsFromApiBody } from "../helpers/logs-api";
Expand Down Expand Up @@ -2085,38 +2086,7 @@ describe("server combo failover 030 activation matrix", () => {
expect(primaryHits.every(hit => hit.webTool === valid)).toBe(true);
});

test("context 400 stops while exhausted retryable targets return the sanitized last status", async () => {
let stopBackupHits = 0;
const context = serve(() => Response.json({ error: { code: "context_length_exceeded", message: "too many tokens" } }, { status: 400 }));
const unused = serve(() => {
stopBackupHits += 1;
return chatSuccess("must not run");
});
const stopConfig = comboConfig({
a: provider("openai-chat", baseUrl(context), "key-a"),
b: provider("openai-chat", baseUrl(unused), "key-b"),
});
const stopped = await post(stopConfig);
expect(stopped.status).toBe(400);
expect(stopBackupHits).toBe(0);

const order: string[] = [];
const first = serve(() => {
order.push("a");
return new Response("secret sk-a-should-redact", { status: 503 });
});
const last = serve(() => {
order.push("b");
return Response.json({ error: { message: "missing model" } }, { status: 404 });
});
const exhausted = await post(comboConfig({
a: provider("openai-chat", baseUrl(first), "key-a"),
b: provider("openai-chat", baseUrl(last), "key-b"),
}));
expect(exhausted.status).toBe(404);
expect(order).toEqual(["a", "b"]);
expect(await exhausted.text()).not.toContain("sk-a-should-redact");
});
registerComboContextOverflowCases({ serve, baseUrl, chatSuccess, provider, comboConfig, post, chatStream, collectSse });

test("provider-specific prompt-too-long 400 hops to a larger-context combo target", async () => {
let backupHits = 0;
Expand Down
Loading