Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.d/fixes/15494-combo-target-timeout-lockout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(combo):** a local per-target timeout records a model lockout without tripping the provider breaker ([#15494](https://github.com/diegosouzapw/OmniRoute/pull/15494) — thanks @HouMinXi)
23 changes: 23 additions & 0 deletions open-sse/services/combo/comboPredicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,29 @@ export function isRequestScopedUpstreamFailure(error?: {
);
}

export function isComboTargetTimeoutFailure(error?: {
code?: string | null;
type?: string | null;
}): boolean {
const code = typeof error?.code === "string" ? error.code.toLowerCase() : "";
const type = typeof error?.type === "string" ? error.type.toLowerCase() : "";
return code === "combo_target_timeout" || type === "combo_target_timeout";
}

/**
* Model lockout is per model, not per provider. A local target timeout must
* lock that model so the next request does not spend another gate wait on it.
* Other request-scoped failures (context length, local queue) stay unlocked.
* The provider breaker still uses the request-scoped flag and does not see this.
*/
export function shouldRecordModelLockoutForComboFailure(
requestScopedFailure: boolean,
error?: { code?: string | null; type?: string | null }
): boolean {
if (!requestScopedFailure) return true;
return isComboTargetTimeoutFailure(error);
}

/** Request-scoped classification that also has access to the HTTP body. */
export function isComboRequestScopedFailure(
response: Response,
Expand Down
10 changes: 8 additions & 2 deletions open-sse/services/combo/executeTargetAttempt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
shouldSkipForPredictedTtft,
shouldRecordProviderBreakerFailure,
isComboRequestScopedFailure as isScopedFailure,
shouldRecordModelLockoutForComboFailure,
isStreamReadinessFailureErrorBody,
isStreamEarlyEofErrorBody,
isLocalKeyPolicyBreachErrorBody,
Expand Down Expand Up @@ -1077,7 +1078,7 @@ export async function executeTargetAttempt(opts: {
provider &&
rawModel &&
retry === 0 &&
!scopedFailure &&
shouldRecordModelLockoutForComboFailure(scopedFailure, structuredError) &&
!isConnectionScopedClaudeQuota
) {
const mlSettings = resolveModelLockoutSettings(deps.settings);
Expand Down Expand Up @@ -1167,7 +1168,12 @@ export async function executeTargetAttempt(opts: {
if (i > 0) state.fallbackCount++;
// Wire combo failures into the resilience dashboard (model-level lockout)
// alongside the provider-level cooldown below — they govern different scopes.
if (provider && rawModel && !scopedFailure && !isConnectionScopedClaudeQuota) {
if (
provider &&
rawModel &&
shouldRecordModelLockoutForComboFailure(scopedFailure, structuredError) &&
!isConnectionScopedClaudeQuota
) {
const mlSettings = resolveModelLockoutSettings(deps.settings);
if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) {
recordModelLockoutFailure(
Expand Down
34 changes: 32 additions & 2 deletions tests/unit/combo/combo-target-timeout-standards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-target-timeout-std-secret";

const { handleComboChat } = await import("../../../open-sse/services/combo.ts");
const { isComboRequestScopedFailure, shouldRecordProviderBreakerFailure } =
await import("../../../open-sse/services/combo/comboPredicates.ts");
const {
isComboRequestScopedFailure,
shouldRecordProviderBreakerFailure,
shouldRecordModelLockoutForComboFailure,
} = await import("../../../open-sse/services/combo/comboPredicates.ts");
const { applyComboTargetExhaustion } =
await import("../../../open-sse/services/combo/targetExhaustion.ts");
const { getProviderBreakerState } = await import("../../../open-sse/services/accountFallback.ts");
Expand Down Expand Up @@ -97,6 +100,33 @@ test.beforeEach(() => {

// ── Decision seam: breaker + request-scoped classification ──────────────────

test("decision seam: typed combo_target_timeout still records a model lockout", () => {
const scoped = isComboRequestScopedFailure(
new Response("timed out", { status: 504 }),
"Model openai/slow timed out",
{ code: "combo_target_timeout", type: "combo_target_timeout" }
);
assert.equal(scoped, true, "timeout stays request-scoped for the provider breaker");
assert.equal(
shouldRecordModelLockoutForComboFailure(scoped, {
code: "combo_target_timeout",
type: "combo_target_timeout",
}),
true,
"a locally timed-out model must be locked so the next request does not wait on it again"
);
});

test("decision seam: other request-scoped failures still skip model lockout", () => {
assert.equal(
shouldRecordModelLockoutForComboFailure(true, {
code: "context_length_exceeded",
type: "invalid_request_error",
}),
false
);
});

test("decision seam: typed combo_target_timeout 504 is request-scoped and does not record breaker failure", () => {
const decision = decideProviderBreakerRecord({
status: 504,
Expand Down
Loading