Files
OmniRoute/open-sse/services/combo/comboCompatFallback.ts
Bob.Hou c75e293a47 fix(api): thread X-OmniRoute-Fallback-Attempts through combo chat (#12339) (#13038)
Right call not to reuse the combo loop's `fallbackCount`: it only increments after a leg fails or is skipped, so the second target would still report 0 at dispatch. Stamping the ordered index (or round-robin offset) at the gate is the only place the number is actually known. This PR also carries the batch's file-size rebaseline, since it merges first and the ceiling has to cover every intermediate state.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded with the other 19 PRs of this batch. Two in-batch conflicts, both additive and resolved by keeping each side: the `ENVIRONMENT.md` table (#13035 + #13011) and the `chatHelpers.ts` import block (#12975 on the tip + #13017).

- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK; `check:docs-counts` migrations ✓
- complexity 2816 / baseline 3218 and cognitive-complexity 1271 / baseline 1437 — both under baseline
- 531 of 532 focused assertions green across the batch's 46 test files
- `check-file-size` rebaselined for the batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_houminxi`, landed on #13038), attributed per PR

The single red is **not this batch**: `tests/unit/combo/quota-weighted-strategy.test.ts` → "A/B isolation: 7 hard-empty + 2 at 0.5% + 1 at 40%, floor=1" asserts an order between two connections of identical weight and flakes on the pure tip too — 2 failures in 4 runs at `origin/release/v3.8.51` with nothing from this batch applied.

⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` reproduce on the pure tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098, untouched here).

Thanks @HouMinXi — the live evidence on these (X500 logs, `storage.sqlite` state, real `/v1/models` probes, the 36-minute outage write-up) is what let a 20-PR batch be reviewed as a unit.
2026-09-11 19:27:33 -03:00

90 lines
3.2 KiB
TypeScript

import type {
ComboLogger,
HandleSingleModel,
IsModelAvailable,
ResolvedComboTarget,
} from "./types";
/**
* Last-resort fallback tier for combo routing (#6238).
*
* `filterTargetsByRequestCompatibility` drops targets that look request-incompatible
* (tool/vision/structured-output unsupported, or below the required context window)
* BEFORE any runtime availability check runs. Its only safety net triggers when ALL
* targets are filtered — not when the kept targets are later all runtime-unavailable
* (circuit-open / cooldown / no credentials). In that case a combo would return
* `503 ALL_ACCOUNTS_INACTIVE` without ever reconsidering a compat-rejected-but-healthy
* target.
*
* This helper makes those compat-rejected targets a genuine fallback tier: only after
* the primary (compat-kept) targets were all skipped without a single real attempt do
* we probe the rejected set. A compat-rejected target is used only if it is actually
* available and returns a successful upstream response; otherwise the caller keeps its
* original error surface unchanged.
*/
export interface CompatFallbackContext {
handleSingleModel: HandleSingleModel;
isModelAvailable?: IsModelAvailable;
/** Returns true when the target's provider connection is in resilience cooldown. */
isProviderInCooldown?: (target: ResolvedComboTarget) => boolean;
log: ComboLogger;
/** Effective combo strategy, threaded into the single-model dispatch for telemetry. */
strategy: string;
}
/**
* Attempt each compatibility-rejected target in order, returning the first successful
* upstream `Response`. Targets that are unavailable, in cooldown, or return a non-ok
* response are skipped. Returns `null` when no rejected target yields a success, so the
* caller can fall through to its existing error/503 path with an unchanged error surface.
*/
export async function attemptCompatRejectedFallback(
rejectedTargets: ResolvedComboTarget[],
body: Record<string, unknown>,
ctx: CompatFallbackContext
): Promise<Response | null> {
if (rejectedTargets.length === 0) return null;
for (let i = 0; i < rejectedTargets.length; i++) {
const target = rejectedTargets[i];
if (ctx.isModelAvailable) {
const available = await ctx.isModelAvailable(target.modelStr, target);
if (!available) {
ctx.log.debug(
"COMBO",
`Last-resort compat fallback: ${target.modelStr} still unavailable — skipping`
);
continue;
}
}
if (ctx.isProviderInCooldown?.(target)) {
ctx.log.debug(
"COMBO",
`Last-resort compat fallback: ${target.modelStr} provider in cooldown — skipping`
);
continue;
}
ctx.log.info(
"COMBO",
`Last-resort compat fallback → ${target.modelStr} (all compat-kept targets were unavailable)`
);
const result = await ctx.handleSingleModel(body, target.modelStr, {
...target,
effectiveComboStrategy: ctx.strategy,
fallbackAttempts: i,
});
if (result.ok) {
ctx.log.info("COMBO", `Last-resort compat fallback succeeded via ${target.modelStr}`);
return result;
}
ctx.log.debug(
"COMBO",
`Last-resort compat fallback: ${target.modelStr} failed (${result.status}) — trying next`
);
}
return null;
}