mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-10-19 05:20:05 +00:00
Batch-merged with the owner's 2026-10-09 PR wave (/merge-batch). Validated in one combined tree of 57 boarded PRs on top of release/v3.8.52 (35ef635c): typecheck:core, check:open-sse-typecheck, check-deps, changelog-integrity, complexity + cognitive ratchets, check:docs-all, i18n UI coverage, ESLint on all 247 changed source files — all green for this wave; 89 focused unit test files (739/744 pass under node:test, the 4 UI .tsx files re-run under vitest 13/13, check-deps fixed in #15912). Remaining reds in that tree belong only to held/draft PRs outside this wave (#15790 file-size, #15780 open-sse types).
This commit is contained in:
parent
9453e01ae0
commit
f04df4e077
5 changed files with 137 additions and 16 deletions
1
changelog.d/fixes/15289-round-robin-context-budget.md
Normal file
1
changelog.d/fixes/15289-round-robin-context-budget.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Return an actionable context-length error when round-robin or nested combo attempts are exhausted predominantly by context overflow, preserving diagnostics and the attempt limit (#15289).
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
* and the client only saw the opaque "Maximum combo retry limit reached".
|
||||
*/
|
||||
import { errorResponseWithComboDiagnostics } from "../../utils/error.ts";
|
||||
import type { ComboDiagnostics } from "../../utils/error.ts";
|
||||
import { isContextOverflow400 } from "./comboPredicates.ts";
|
||||
import { buildComboDiag } from "./executeTargetClassify.ts";
|
||||
import type { AttemptLoopState } from "./attemptLoopTypes.ts";
|
||||
|
|
@ -20,6 +21,33 @@ interface BudgetErrorEntry {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
/** Runtime-unit loops do not otherwise retain error text for terminal diagnostics. */
|
||||
export async function readBudgetFailure(response: Response): Promise<BudgetErrorEntry> {
|
||||
let error = "";
|
||||
if (response.status === 400 || response.status === 413) {
|
||||
try {
|
||||
error = await response.clone().text();
|
||||
} catch {
|
||||
// An unreadable body is not proof of context overflow.
|
||||
}
|
||||
}
|
||||
return { status: response.status, error };
|
||||
}
|
||||
|
||||
export function buildContextBudgetResponse(
|
||||
lastError: string | null | undefined,
|
||||
errors: ReadonlyArray<BudgetErrorEntry>,
|
||||
diagnostics: ComboDiagnostics
|
||||
): Response {
|
||||
if (isContextOverflowDominant(lastError, errors)) {
|
||||
return errorResponseWithComboDiagnostics(400, CONTEXT_OVERFLOW_BUDGET_MESSAGE, diagnostics, {
|
||||
code: "context_length_exceeded",
|
||||
type: "invalid_request_error",
|
||||
});
|
||||
}
|
||||
return errorResponseWithComboDiagnostics(503, "Maximum combo retry limit reached", diagnostics);
|
||||
}
|
||||
|
||||
/** True when the last failure and at least half of the recorded failures were context overflows. */
|
||||
export function isContextOverflowDominant(
|
||||
lastError: string | null | undefined,
|
||||
|
|
@ -49,15 +77,8 @@ export function buildBudgetExhaustedResponse(
|
|||
const reasoningExhausted = /reasoning consumed \d+\/\d+ tokens/.test(state.lastError || "");
|
||||
const failureReason = reasoningExhausted ? "reasoning_budget_exhausted" : "max_attempts_exceeded";
|
||||
const diag = buildComboDiag(state, traceInvocationId, failureReason);
|
||||
if (!reasoningExhausted && isContextOverflowDominant(state.lastError, state.comboErrors)) {
|
||||
return errorResponseWithComboDiagnostics(400, CONTEXT_OVERFLOW_BUDGET_MESSAGE, diag, {
|
||||
code: "context_length_exceeded",
|
||||
type: "invalid_request_error",
|
||||
});
|
||||
if (!reasoningExhausted) {
|
||||
return buildContextBudgetResponse(state.lastError, state.comboErrors, diag);
|
||||
}
|
||||
return errorResponseWithComboDiagnostics(
|
||||
503,
|
||||
reasoningExhausted ? REASONING_EXHAUSTED_MESSAGE : "Maximum combo retry limit reached",
|
||||
diag
|
||||
);
|
||||
return errorResponseWithComboDiagnostics(503, REASONING_EXHAUSTED_MESSAGE, diag);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
readProseRetryAfter,
|
||||
} from "../../utils/error.ts";
|
||||
import { buildRecoveryHint } from "./pinRecovery.ts";
|
||||
import { buildContextBudgetResponse } from "./budgetExhaustion.ts";
|
||||
import { formatExhaustedConnectionKey } from "./comboDiagFormat.ts";
|
||||
import { collectQuotaWindowExclusions, formatQuotaSkipMessage } from "./quotaSkipDiagnostics.ts";
|
||||
import { recordComboRequest, recordPersistedSkipBypass } from "../comboMetrics.ts";
|
||||
|
|
@ -619,7 +620,6 @@ export async function handleRoundRobinCombo({
|
|||
throw err;
|
||||
}
|
||||
|
||||
// Retry loop within this model
|
||||
try {
|
||||
for (let retry = 0; retry <= maxRetries; retry++) {
|
||||
globalAttempts++;
|
||||
|
|
@ -628,7 +628,7 @@ export async function handleRoundRobinCombo({
|
|||
"COMBO-RR",
|
||||
`Maximum combo attempts (${maxGlobalAttempts}) exceeded. Terminating loop to prevent runaway requests.`
|
||||
);
|
||||
return errorResponseWithComboDiagnostics(503, "Maximum combo retry limit reached", {
|
||||
return buildContextBudgetResponse(lastError, rrOutcomes, {
|
||||
poolSize: modelCount,
|
||||
attempted: globalAttempts,
|
||||
excluded: [
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
* @changes
|
||||
* - [2026-07-24] [Composer] - Skip execute-mode units at concurrency cap before dispatch
|
||||
*/
|
||||
import { errorResponse, errorResponseWithComboDiagnostics } from "../../utils/error.ts";
|
||||
import { errorResponse } from "../../utils/error.ts";
|
||||
import { buildContextBudgetResponse, readBudgetFailure } from "./budgetExhaustion.ts";
|
||||
import type { ComboDiagnostics } from "../../utils/error.ts";
|
||||
import { recordComboRequest } from "../comboMetrics.ts";
|
||||
import { resolveDelayMs, requestScopedReplayKey } from "./comboPredicates.ts";
|
||||
|
|
@ -214,11 +215,13 @@ export async function executeRuntimeUnitCombo(args: {
|
|||
let fallbackCount = 0;
|
||||
let observedFailure = false;
|
||||
let allObservedFailuresQuota = true;
|
||||
const budgetFailures: Array<Awaited<ReturnType<typeof readBudgetFailure>>> = [];
|
||||
const targetFailureTrust = new Map<
|
||||
string,
|
||||
{ observedFailure: boolean; allObservedFailuresQuota: boolean }
|
||||
>();
|
||||
const observeFailure = async (response: Response, unit: ResolvedComboUnit): Promise<boolean> => {
|
||||
budgetFailures.push(await readBudgetFailure(response));
|
||||
const quotaExhausted = await isQuotaExhaustionResponse(
|
||||
response,
|
||||
unit.kind === "model" ? unit.provider : null,
|
||||
|
|
@ -280,9 +283,9 @@ export async function executeRuntimeUnitCombo(args: {
|
|||
}
|
||||
args.nesting.attemptBudget.count += 1;
|
||||
if (args.nesting.attemptBudget.count > args.nesting.attemptBudget.limit) {
|
||||
lastResponse = errorResponseWithComboDiagnostics(
|
||||
503,
|
||||
"Maximum combo retry limit reached",
|
||||
lastResponse = buildContextBudgetResponse(
|
||||
budgetFailures.at(-1)?.error,
|
||||
budgetFailures,
|
||||
buildAttemptBudgetDiag()
|
||||
);
|
||||
await observeFailure(lastResponse, unit);
|
||||
|
|
|
|||
96
tests/unit/combo-remaining-overflow-budget-15289.test.ts
Normal file
96
tests/unit/combo-remaining-overflow-budget-15289.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ResolvedComboUnit } from "../../open-sse/services/combo/types.ts";
|
||||
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "combo-budget-15289-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
|
||||
const { executeRuntimeUnitCombo } = await import("../../open-sse/services/combo/runtimeUnits.ts");
|
||||
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
const log = { info() {}, warn() {}, error() {}, debug() {} };
|
||||
|
||||
test.after(() => {
|
||||
resetDbInstance();
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function failure(overflow: boolean): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: overflow ? "Input exceeds the context window" : "upstream unavailable",
|
||||
code: overflow ? "context_length_exceeded" : "service_unavailable",
|
||||
},
|
||||
}),
|
||||
{ status: overflow ? 400 : 503, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
for (const strategy of ["round-robin", "nested"] as const) {
|
||||
for (const failures of [
|
||||
[true, true, true],
|
||||
[false, false, true],
|
||||
[true, true, false],
|
||||
]) {
|
||||
test(`#15289 ${strategy} budget preserves dominant context cause: ${failures}`, async () => {
|
||||
const models = ["openai/budget-a", "anthropic/budget-b", "gemini/budget-c", "codex/budget-d"];
|
||||
let called = 0;
|
||||
const handleSingleModel = async () => failure(failures[called++]);
|
||||
const config = { maxRetries: 0, maxGlobalAttempts: 3, retryDelayMs: 0, fallbackDelayMs: 0 };
|
||||
const response =
|
||||
strategy === "round-robin"
|
||||
? await handleComboChat({
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
combo: { name: `rr-15289-${failures}`, strategy, models, config },
|
||||
handleSingleModel,
|
||||
isModelAvailable: async () => true,
|
||||
log,
|
||||
settings: {},
|
||||
allCombos: [],
|
||||
})
|
||||
: (
|
||||
await executeRuntimeUnitCombo({
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
combo: { name: "nested-15289", strategy: "pipeline" },
|
||||
strategy: "pipeline",
|
||||
units: models.map((modelStr, i): ResolvedComboUnit => ({
|
||||
kind: "model",
|
||||
modelStr,
|
||||
provider: modelStr.split("/")[0],
|
||||
stepId: `s${i}`,
|
||||
executionKey: `s${i}`,
|
||||
providerId: null,
|
||||
connectionId: null,
|
||||
weight: 1,
|
||||
label: null,
|
||||
})),
|
||||
handleSingleModel,
|
||||
log,
|
||||
config,
|
||||
allCombos: [],
|
||||
baseOptions: {},
|
||||
nesting: {
|
||||
depth: 0,
|
||||
maxDepth: 5,
|
||||
visitedComboNames: [],
|
||||
rootComboName: "nested-15289",
|
||||
attemptBudget: { count: 0, limit: 3 },
|
||||
},
|
||||
runCombo: async () => failure(true),
|
||||
})
|
||||
).response;
|
||||
const body = await response.json();
|
||||
assert.equal(called, 3, "the global dispatch budget is unchanged");
|
||||
const dominant = failures[2] && failures.filter(Boolean).length >= 2;
|
||||
assert.equal(response.status, dominant ? 400 : 503);
|
||||
assert.equal(body.error.code, dominant ? "context_length_exceeded" : "service_unavailable");
|
||||
assert.match(body.error.message, dominant ? /context window/ : /Maximum combo retry limit/);
|
||||
assert.equal(body.diagnostics.terminalReason, "max_attempts_exceeded");
|
||||
assert.equal(body.diagnostics.attemptOrder.length, 3);
|
||||
assert.equal(body.diagnostics.poolSize, 4);
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue