Download server/src/lib/fallback-loop.ts from automindy/fokemal: direct link, hf CLI and curl.
- Browser
- Download file 88.3 kB
-
https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/lib/fallback-loop.ts
- Command line
-
hf download hf://spaces/automindy/fokemal/server/src/lib/fallback-loop.ts
-
curl -L -o fallback-loop.ts https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/lib/fallback-loop.ts
88.3 kB
| import { nextMonthResetAt } from '../services/key-budget.js'; | |
| // One shared provider retry/fallback loop for every OpenAI-, Responses- and | |
| // Anthropic-shaped chat surface (routes/proxy.ts legacy /completions and | |
| // /chat/completions, routes/responses.ts, routes/anthropic.ts). Each surface | |
| // used to carry its own ~150-line copy of the attempt loop, and the copies had | |
| // drifted (a 403 that was benched for a day on three surfaces but only 90s on | |
| // /v1/responses; an exhaustion body that returned a 400 for a provider-invalid | |
| // request on three surfaces but always 429 on /v1/messages; a Retry-After that | |
| // was honored everywhere except /v1/responses). This module is the single | |
| // source of truth for the parts that MUST behave identically β cooldown | |
| // selection, per-key failure bookkeeping, exhaustion status β while each | |
| // surface keeps its own request/stream translation as a thin `dispatch` adapter. | |
| // | |
| // Almost pure control-flow + accounting: no Express, no wire-format knowledge. | |
| // The per-surface bytes (SSE framing, error-body shape, context handoff, group | |
| // routing) live in the caller's hooks. The one side effect beyond bookkeeping is | |
| // the fire-and-forget key revalidation kicked off on an upstream 401 (below). | |
| import type { RouteResult } from '../services/router.js'; | |
| import { recordRateLimitHit, recordModelFailure, recordSuccess, hasOtherUsableKey, routableKeyIdsForModel, formatResetEta } from '../services/router.js'; | |
| import { safeHeaderValue } from './header-value.js'; | |
| import { | |
| recordRequest, | |
| recordTokens, | |
| setCooldown, | |
| getActiveCooldownsForKeys, | |
| getCooldownDecisionForLimit, | |
| getSoonestCooldownExpiry, | |
| getPaymentRequiredCooldownMs, | |
| getModelForbiddenCooldownMs, | |
| learnLimitFromError, | |
| type CooldownDecision, | |
| type CooldownSource, | |
| } from '../services/ratelimit.js'; | |
| import { | |
| isRetryableError, | |
| isRateLimitSignal, | |
| isKeyAuthError, | |
| isClientAbortError, | |
| isHedgeAbortError, | |
| isDailyQuotaExhaustedError, | |
| isPaymentRequiredError, | |
| isModelNotFoundError, | |
| isModelAccessForbiddenError, | |
| isAccountSuspendedError, | |
| isProviderBadRequestError, | |
| isProviderDegradedError, | |
| isProviderLevelError, | |
| isContextTooLargeError, | |
| isTimeoutErrorText, | |
| isStreamTruncatedError, | |
| } from './error-classify.js'; | |
| import { sanitizeProviderErrorMessage, summarizeAttemptError } from './error-redaction.js'; | |
| import { benchForTools, clearToolRejections, noteToolRejection, toolCapabilityKey } from './tool-capability.js'; | |
| import { parseProviderReportedSize } from './provider-size-parser.js'; | |
| import { checkKeyHealth, markKeyHealthyFromRequest } from '../services/health.js'; | |
| import { noteModelRetirementSignal } from '../services/model-retirement.js'; | |
| import { getDb, getSetting } from '../db/index.js'; | |
| import { newBreaker, recordBreakerFailure } from './guardrails.js'; | |
| import { getRequestTrace, newRequestTrace, runWithRequestTrace, type AttemptOutcome, type AttemptTraceRecord, type RequestTrace } from './attempt-trace.js'; | |
| import { logRequest, persistRequestAttempts } from './request-log.js'; | |
| import { withKeyProxy } from './proxy.js'; | |
| import { getEndpointTimeBudgetMs } from './ttfb-budget.js'; | |
| import { learnOutputCapFromError, learnedOutputCap } from './output-cap.js'; | |
| // Every surface caps failover hops at the same number. | |
| export const FALLBACK_MAX_RETRIES = 20; | |
| // ββ Model-level failure benching βββββββββββββββββββββββββββββββββββββββββββββ | |
| // A model that keeps failing upstream (401/429/5xx/empty-stream/timeout) must | |
| // not keep being picked by auto-routing: every dead-end attempt wastes seconds | |
| // of user-visible latency. The per-key cooldown above benches the *key*; this | |
| // is the model-level counterpart β a sliding window of failures *across keys* | |
| // that benches the whole MODEL once the streak is convincing. The window counts | |
| // across keys, so the bench must span keys too: benching only the key that | |
| // happened to fail last leaves every sibling key serving the same sick model | |
| // until each one trips its own streak. Recovery stays automatic: the bench is | |
| // 'heuristic', the only source the cooldown-probe job may clear early, and it | |
| // re-validates each benched key once half the bench has been served (the probe | |
| // exercises the credential, not the model, so a model still sick behind a good | |
| // key simply re-trips the streak). Client-cancels never reach | |
| // recordRetryableFailure (the loop returns on isClientAbortError before this | |
| // bookkeeping), so they don't count. | |
| export const MODEL_FAILURE_WINDOW_MS = 15 * 60 * 1000; // sliding window: 15 min | |
| export const MODEL_FAILURE_THRESHOLD = 3; // failures within window | |
| export const MODEL_FAILURE_COOLDOWN_MS = 10 * 60 * 1000; // bench duration: 10 min | |
| const modelFailureTimestamps = new Map<number, number[]>(); // model_db_id β times | |
| /** Record one retryable upstream failure for a model. Once the sliding window | |
| * holds β₯ MODEL_FAILURE_THRESHOLD failures, bench the model on EVERY key that | |
| * can route to it so the model sinks out of routing until upstream heals, then | |
| * reset the counter (one bench per streak). */ | |
| function noteModelFailure(route: RouteResult, now: number): void { | |
| const window = (modelFailureTimestamps.get(route.modelDbId) ?? []) | |
| .filter(t => now - t < MODEL_FAILURE_WINDOW_MS); | |
| window.push(now); | |
| modelFailureTimestamps.set(route.modelDbId, window); | |
| if (window.length < MODEL_FAILURE_THRESHOLD) return; | |
| // Fall back to the failing key alone when the model's key set can't be read | |
| // (model row gone, DB unavailable) β a narrower bench beats none. | |
| const keyIds = routableKeyIdsForModel(route.modelDbId); | |
| const targets = keyIds.length > 0 ? keyIds : [route.keyId]; | |
| const benchUntil = now + MODEL_FAILURE_COOLDOWN_MS; | |
| const active = getActiveCooldownsForKeys(targets, now); | |
| for (const keyId of targets) { | |
| // Never SHORTEN an existing bench: a provider-stated reset or an escalated | |
| // ladder step outlasting this window knows more than this heuristic does, | |
| // and overwriting it would also drop its non-probeable provenance. | |
| const current = active.get(keyId) | |
| ?.find(c => c.platform === route.platform && c.modelId === route.modelId); | |
| if (current && current.expiresAtMs >= benchUntil) continue; | |
| setCooldown(route.platform, route.modelId, keyId, MODEL_FAILURE_COOLDOWN_MS, 'heuristic'); | |
| } | |
| modelFailureTimestamps.delete(route.modelDbId); | |
| } | |
| /** A served request is the strongest counter-evidence: clear the failure | |
| * window so a recovered model is not benched for a stale streak. */ | |
| function clearModelFailure(route: RouteResult): void { | |
| modelFailureTimestamps.delete(route.modelDbId); | |
| } | |
| /** Bench one key on every enabled model of its platform it can route to (the | |
| * route's own model is benched by the caller). For KEY-level verdicts only β | |
| * an account suspension, an empty balance β never for a model-level one. | |
| * Also rules the key out on those models for the rest of THIS request via | |
| * state.skipKeys, so the loop's next hop cannot land on the same account. | |
| * Never throws: an unreadable catalog leaves the narrower per-route bench in | |
| * place. | |
| */ | |
| function benchKeyAcrossPlatform( | |
| route: RouteResult, | |
| durationMs: number, | |
| source: CooldownSource, | |
| state: FallbackState, | |
| reason: string, | |
| ): void { | |
| let modelIds: string[] = []; | |
| try { | |
| modelIds = (getDb().prepare( | |
| 'SELECT model_id FROM models WHERE platform = ? AND enabled = 1 AND (key_id IS NULL OR key_id = ?)', | |
| ).all(route.platform, route.keyId) as { model_id: string }[]).map(r => r.model_id); | |
| } catch (dbErr: any) { | |
| console.warn(`[FallbackLoop] could not list ${route.platform} models to bench key ${route.keyId}: ${dbErr?.message ?? dbErr}`); | |
| return; | |
| } | |
| let benched = 0; | |
| for (const modelId of modelIds) { | |
| if (modelId === route.modelId) continue; | |
| setCooldown(route.platform, modelId, route.keyId, durationMs, source); | |
| state.skipKeys.add(`${route.platform}:${modelId}:${route.keyId}`); | |
| benched++; | |
| } | |
| console.warn(`[FallbackLoop] ${route.platform} key ${route.keyId} ${reason}; benched it on ${benched + 1} model(s) for ${Math.round(durationMs / 60_000)}min`); | |
| } | |
| // ββ Wall-clock retry budget ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Serial failover has no time bound of its own: the observed worst case was a | |
| // 38.8s TTFB over 11 attempts, and the theoretical worst is maxRetries x the | |
| // per-attempt HTTP timeout. The budget is checked before STARTING each retry | |
| // and, when abortInFlight is available, while waiting for its first byte. | |
| // Successful endpoint TTFB history can widen the configured base budget. | |
| // The first attempt always runs, and so does the FIRST retry: when | |
| // attempt 0 alone consumes the whole budget (a slow-failing model), refusing | |
| // attempt 1 would make failover structurally impossible for exactly the | |
| // requests that need it (#751). The budget stops attempts >= 2 only. | |
| // 0 disables the budget entirely. | |
| // Precedence mirrors the response cache: the settings-table value wins when | |
| // present (runtime-tunable), then the env var, then the default. | |
| export const DEFAULT_FALLBACK_TIME_BUDGET_MS = 45_000; | |
| // Share of the wall-clock budget an aborted attempt must have been silent for | |
| // before the hedge abort counts as provider health rather than bad luck. An | |
| // attempt late in a ladder inherits only the scraps of the budget its | |
| // predecessors left, and killing it proves nothing about that route; one that | |
| // owned most of the window and still sent no first byte is stalled. Kept a | |
| // fraction rather than a constant because the budget IS the declared patience | |
| // β raising FALLBACK_TIME_BUDGET_MS should raise the evidence bar with it. | |
| export const HEDGE_BENCH_MIN_SILENT_FRACTION = 0.75; | |
| export const FALLBACK_TIME_BUDGET_SETTING = 'fallback_time_budget_ms'; | |
| export function getFallbackTimeBudgetMs(): number { | |
| let stored: string | undefined; | |
| try { | |
| stored = getSetting(FALLBACK_TIME_BUDGET_SETTING); | |
| } catch { | |
| stored = undefined; // DB not ready β never throw on the proxy hot path | |
| } | |
| const candidates = [stored, process.env.FALLBACK_TIME_BUDGET_MS]; | |
| for (const raw of candidates) { | |
| if (raw === undefined || raw.trim() === '') continue; | |
| const n = Number(raw); | |
| if (Number.isFinite(n) && n >= 0) return n; | |
| } | |
| return DEFAULT_FALLBACK_TIME_BUDGET_MS; | |
| } | |
| // Mutable per-request skip state threaded through the loop and mutated by | |
| // recordRetryableFailure / recordAuthFailure. skipKeys entries are | |
| // "platform:modelId:keyId"; skipModels holds model_db_ids ruled out for the | |
| // rest of this request; skipPlatforms holds platforms ruled out wholesale | |
| // (#788) β every model and every key of them β for the rest of this request. | |
| // observedTotalTokens is the provider-reported REQUESTED size parsed from the | |
| // first 413 / context-length body of this request, used to inflate the | |
| // routing estimate on the next attempt so models whose tpm_limit / | |
| // context_window can't fit it are skipped before they fire (#507). All four | |
| // are request-scoped only: nothing here outlives the response, so a provider | |
| // that blipped once is a fresh candidate on the very next request. | |
| export interface FallbackState { | |
| skipKeys: Set<string>; | |
| skipModels: Set<number>; | |
| skipPlatforms: Set<string>; | |
| // Highest REQUESTED token count seen so far this request across all 413 / | |
| // context-length rejections. Max wins (never decreases): a smaller later | |
| // reading is the same request, not a smaller one. | |
| observedTotalTokens?: number; | |
| observedInputTokens?: number; | |
| // The request carries `tools` (#1230). Set by the surface right after | |
| // newFallbackState(); lets the failure/success paths learn which models | |
| // reject tool calls in practice. Unset means "not a tool request". | |
| wantsTools?: boolean; | |
| // Models (tool-capability keys) that answered provider_bad_request to THIS | |
| // tool request. Counted once per request however many keys were tried. | |
| toolRejects?: Set<string>; | |
| // Per-request tally of DISTINCT models that answered model_not_found, keyed | |
| // by platform (#1218): a stale catalog misses on every sibling model in a | |
| // row, and the per-model skip can't see the pattern. From | |
| // MODEL_NOT_FOUND_PLATFORM_LIMIT distinct misses the platform joins | |
| // skipPlatforms for the rest of the request. | |
| modelNotFoundPlatforms: Map<string, Set<number>>; | |
| } | |
| /** Total for the next dispatch. Input-only observations still need output space. */ | |
| export function fallbackRoutingTokens(state: FallbackState, estimatedTotal: number, outputReserve: number): number { | |
| return Math.max( | |
| estimatedTotal, | |
| state.observedTotalTokens ?? 0, | |
| state.observedInputTokens == null ? 0 : state.observedInputTokens + Math.max(0, outputReserve), | |
| ); | |
| } | |
| export function newFallbackState(): FallbackState { | |
| return { skipKeys: new Set<string>(), skipModels: new Set<number>(), skipPlatforms: new Set<string>(), modelNotFoundPlatforms: new Map() }; | |
| } | |
| // DISTINCT model_not_found hops on one platform, within one request, before the | |
| // whole platform is ruled out: a healthy catalog never misses three different | |
| // models in a single failover chain β a stale/broken one does it routinely | |
| // (NavyAI: 24 models tried in one session, 0 ok, #1218). 3 keeps one fluke | |
| // removal from condemning a provider. | |
| export const MODEL_NOT_FOUND_PLATFORM_LIMIT = 3; | |
| // Milliseconds until the next UTC midnight β when most providers' daily free | |
| // allocations reset. Floored at one minute so a hit seconds before midnight | |
| // still records a real bench instead of a no-op. | |
| export function msUntilNextUtcMidnight(now = Date.now()): number { | |
| const d = new Date(now); | |
| const next = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1); | |
| return Math.max(next - now, 60_000); | |
| } | |
| const pacificDate = new Intl.DateTimeFormat('en-US', { | |
| timeZone: 'America/Los_Angeles', year: 'numeric', month: 'numeric', day: 'numeric', | |
| }); | |
| const pacificHour = new Intl.DateTimeFormat('en-US', { | |
| timeZone: 'America/Los_Angeles', hour: 'numeric', hourCycle: 'h23', | |
| }); | |
| /** Gemini daily quotas reset at midnight Pacific, including DST transitions. | |
| * https://ai.google.dev/gemini-api/docs/rate-limits */ | |
| export function msUntilNextPacificMidnight(now = Date.now()): number { | |
| const parts = pacificDate.formatToParts(now); | |
| const part = (name: string) => Number(parts.find(p => p.type === name)!.value); | |
| // 08:00 UTC on the next Pacific date is either midnight (PST) or 01:00 | |
| // (PDT). Read the offset on that date, not now: DST days have 23/25 hours. | |
| const candidate = Date.UTC(part('year'), part('month') - 1, part('day') + 1, 8); | |
| const midnight = candidate - Number(pacificHour.format(candidate)) * 3_600_000; | |
| return Math.max(midnight - now, 60_000); | |
| } | |
| /** | |
| * The one true cooldown-duration selection after a retryable upstream failure: | |
| * - 402 out-of-credits β a full day (PAYMENT_REQUIRED_COOLDOWN_MS) | |
| * - 403 model-not-on-tier β a full day (MODEL_FORBIDDEN_COOLDOWN_MS), because a | |
| * tier/subscription gate won't clear on the next minute window (issue #256). | |
| * Both honour the operator's cooldown ceiling (#952): relay endpoints emit | |
| * these for transient reasons, and a day is then far too long. | |
| * - a 429 that says the DAILY free allocation is spent (Cloudflare "used up | |
| * your daily free allocation of 10,000 neurons") β benched until the next | |
| * UTC midnight, like the 402 path. The old transient 90s cooldown made the | |
| * router re-pick a dead-for-the-day provider all day long. An explicit | |
| * provider Retry-After wins over the midnight heuristic: rolling daily | |
| * windows (Groq RPD "try again in 7m12s" with a Retry-After header) reset | |
| * well before midnight, and the provider knows its own reset time best. | |
| * Gemini daily violations instead wait until midnight Pacific; a shorter | |
| * RetryInfo can refer to a simultaneous per-minute violation. | |
| * - anything else β the transient/daily escalation ladder, honoring the | |
| * provider's Retry-After as a floor (getCooldownDurationForLimit). | |
| */ | |
| export function cooldownForError(route: RouteResult, err: any): number { | |
| return cooldownDecisionForError(route, err).durationMs; | |
| } | |
| /** | |
| * cooldownForError plus the provenance tag the cooldown-probe recovery job | |
| * keys off (see CooldownSource in services/ratelimit.ts): 402 β 'credit' and | |
| * 403 β 'tier' (a key-validation probe passing proves nothing about credits or | |
| * tier, so those are never probed); a daily-quota bench β 'authoritative' (the | |
| * expiry is the provider's own reset time, whether from Retry-After or the | |
| * UTC-midnight convention β a fact, not a guess); everything else defers to | |
| * getCooldownDecisionForLimit, which tags 'authoritative' only when an explicit | |
| * Retry-After actually determined the expiry. | |
| */ | |
| export function cooldownDecisionForError(route: RouteResult, err: any): CooldownDecision { | |
| if (isPaymentRequiredError(err)) return { durationMs: getPaymentRequiredCooldownMs(), source: 'credit' }; | |
| // Before the model-forbidden check: a suspended account is a 403 too, but | |
| // it is the KEY that is out, for as long as an empty balance would be β and | |
| // under the same operator ceiling (#952). | |
| if (isAccountSuspendedError(err)) return { durationMs: getPaymentRequiredCooldownMs(), source: 'credit' }; | |
| if (isModelAccessForbiddenError(err)) return { durationMs: getModelForbiddenCooldownMs(), source: 'tier' }; | |
| if (isDailyQuotaExhaustedError(err)) { | |
| if (route.platform === 'google') { | |
| // RetryInfo can describe a simultaneous per-minute violation. It cannot | |
| // reopen a daily allowance before the Pacific reset (#1339). | |
| return { durationMs: Math.max(msUntilNextPacificMidnight(), err?.retryAfterMs ?? 0), source: 'authoritative' }; | |
| } | |
| return { durationMs: err?.retryAfterMs ?? msUntilNextUtcMidnight(), source: 'authoritative' }; | |
| } | |
| return getCooldownDecisionForLimit( | |
| route.platform, | |
| route.modelId, | |
| route.keyId, | |
| { rpd: route.rpdLimit, tpd: route.tpdLimit }, | |
| err?.retryAfterMs, | |
| // Only a real 429/rate-limit error may feed the null-limits exhaustion | |
| // heuristic; a timeout or 5xx is retryable but says nothing about quota, | |
| // so it stays on the short transient bench instead of the ladder (#592). | |
| { quotaSignal: isRateLimitSignal(err) }, | |
| ); | |
| } | |
| // ββ Truncated-stream streak (#1218) ββββββββββββββββββββββββββββββββββββββββββ | |
| // A stream that answers 200, sends partial SSE, then dies without [DONE] / | |
| // finish_reason ("stream ended unexpectedly") is retried normally β one | |
| // truncation is common on flaky free gateways. But the same route producing | |
| // them back-to-back is a sick edge: bench it briefly so the ladder stops | |
| // re-paying the round trip. Success on the route resets the streak. | |
| export const TRUNCATION_STREAK_LIMIT = 3; | |
| // 5 min vs the ordinary 90s transient bench: the streak must add REAL distance | |
| // over the default or it changes nothing β the escalation ladder already gives | |
| // a single truncation 90s. | |
| export const TRUNCATION_BENCH_MS = 5 * 60 * 1000; | |
| const truncationStreaks = new Map<string, number>(); // "platform:modelId:keyId" | |
| export function resetTruncationStreaks(): void { | |
| truncationStreaks.clear(); | |
| } | |
| // ββ Empty-completion streak (issue #751) βββββββββββββββββββββββββββββββββββββ | |
| // The skipBench exemption below assumes an empty 'length' completion is | |
| // REQUEST behavior (this turn's max_tokens spent on hidden reasoning). A | |
| // model+key that produces them consecutively is broken, not unlucky β with an | |
| // unconditional exemption it stayed penalty-free forever while failing every | |
| // request routed to it. At the streak limit the exemption lifts and the | |
| // failure takes the normal cooldown/penalty/limit-learning path (and counts | |
| // toward the breaker). Format violations (skipModelForRequest) never accrue: | |
| // they are model behavior for THIS request's response_format, not key health. | |
| export const EMPTY_COMPLETION_STREAK_LIMIT = 3; | |
| const emptyCompletionStreaks = new Map<string, number>(); // "platform:modelId:keyId" | |
| export function resetEmptyCompletionStreaks(): void { | |
| emptyCompletionStreaks.clear(); | |
| } | |
| /** Drop every model's sliding failure window. Module state outlives a test | |
| * case, so without this a case inherits the previous one's failure counts and | |
| * trips the threshold early β clearing `rate_limit_cooldowns` alone does not | |
| * reach it. */ | |
| export function resetModelFailureWindows(): void { | |
| modelFailureTimestamps.clear(); | |
| } | |
| /** Operator clear (#952): drop every model's failure window and report how many | |
| * models were mid-streak. The benches those streaks produced are ordinary | |
| * cooldown rows and go with clearAllCooldowns. */ | |
| export function clearModelFailureWindows(): number { | |
| const count = modelFailureTimestamps.size; | |
| modelFailureTimestamps.clear(); | |
| return count; | |
| } | |
| // Advance (or break) the streak for this failure and report whether the | |
| // skipBench exemption still holds. Called exactly once per retryable failure, | |
| // from recordRetryableFailure; the loop reuses its returned decision for the | |
| // breaker so the two consumers can never disagree. | |
| function consumeSkipBenchExemption(route: RouteResult, err: any): boolean { | |
| const key = `${route.platform}:${route.modelId}:${route.keyId}`; | |
| if (err?.skipBench !== true) { | |
| // A normally-penalized failure breaks the streak: the cooldown ladder is | |
| // already handling whatever is wrong with this model+key. | |
| emptyCompletionStreaks.delete(key); | |
| return false; | |
| } | |
| if (err?.skipModelForRequest === true) return true; | |
| const streak = (emptyCompletionStreaks.get(key) ?? 0) + 1; | |
| emptyCompletionStreaks.set(key, streak); | |
| return streak < EMPTY_COMPLETION_STREAK_LIMIT; | |
| } | |
| /** | |
| * Apply the full per-key failure bookkeeping shared by every surface after a | |
| * retryable failure: | |
| * - rule out the WHOLE model for the rest of the request on a 404 (removed | |
| * upstream) or 403 (off this key's tier) β a sibling key would fail it the | |
| * same way (PR #111 / issue #256); | |
| * - bench this model+key via cooldownForError; | |
| * - demote the model in the scorer ONLY when the failure exhausted it β i.e. | |
| * no sibling key can still serve it (#454 gate). skipKeys already contains | |
| * the just-failed key here, preserving #479's "count budget across keys" | |
| * semantics: hasOtherUsableKey excludes both the failed key and skipKeys; | |
| * - learn a provider-reported ceiling (e.g. a Groq 413 "TPM: Limit 30000") | |
| * from the error body so the next pre-check fails over before the 413. | |
| * | |
| * Reasoning-truncation exemption: an error thrown with `skipBench: true` (a | |
| * reasoning model that spent the whole max_tokens budget on hidden reasoning, | |
| * finish_reason 'length') still fails over β the key is skipped for THIS | |
| * request β but is NOT a provider-health signal, so no cooldown, no model | |
| * penalty, and no limit-learning are recorded. Benching those was costing | |
| * healthy models a 90s cooldown + a scorer penalty per truncated turn. The | |
| * exemption is streak-bounded (#751): from the EMPTY_COMPLETION_STREAK_LIMITth | |
| * consecutive empty completion on the same model+key it stops applying, until | |
| * a success (or a normally-penalized failure) resets the streak. | |
| * | |
| * Returns whether the skipBench exemption held for this failure, so the loop | |
| * can keep the breaker in lockstep with the bench decision. | |
| * | |
| * Callers add the just-failed key to skipKeys via this function (do not pre-add). | |
| */ | |
| export function recordRetryableFailure(route: RouteResult, err: any, state: FallbackState, now: number = Date.now()): boolean { | |
| // `skipModelForRequest: true` = the failure is MODEL behavior, not key | |
| // state (ignored response_format, JSON truncated at max_tokens): a sibling | |
| // key would reproduce it exactly, so rule out the whole model for this | |
| // request instead of burning one failover hop per key. | |
| // Context-too-large is MODEL-level too: a sibling key serves the same model | |
| // with the same context window (and, for Groq-style per-key TPM 413s, the | |
| // same tier ceiling), so it would reject the same request identically. | |
| if (isModelNotFoundError(err) || isModelAccessForbiddenError(err) || isContextTooLargeError(err) || err?.skipModelForRequest === true) { | |
| state.skipModels.add(route.modelDbId); | |
| } | |
| // A stale CATALOG (not a single dead model) answers model-not-found on every | |
| // sibling model in a row: NavyAI served 24 models over one session, 0 ok, | |
| // each re-paying a 2.5β10s round trip (#1218). The per-model skip above | |
| // can't see the pattern β each miss is a different model. Count DISTINCT | |
| // model_not_found hops per platform within one request; from the threshold, | |
| // rule the whole platform out for the rest of the request: the catalog (or | |
| // the key's access to it) is broken, and the next PROVIDER is the better | |
| // hop. Same request-scoped lifetime as skipModels (#111/#256 semantics). | |
| // Custom relays are exempt: every relay shares the one platform id 'custom' | |
| // (#651), so three misses spread over three different relays would rule out | |
| // every healthy relay too. Their misses stay per-model. | |
| if (isModelNotFoundError(err) && !route.endpointScope) { | |
| const seen = state.modelNotFoundPlatforms.get(route.platform) ?? new Set<number>(); | |
| seen.add(route.modelDbId); | |
| state.modelNotFoundPlatforms.set(route.platform, seen); | |
| if (seen.size >= MODEL_NOT_FOUND_PLATFORM_LIMIT) { | |
| state.skipPlatforms.add(route.platform); | |
| } | |
| } | |
| // A model-level 404/410 that says the model is GONE (not merely missing right | |
| // now) outlives this request: persist it once the evidence is strong enough, | |
| // or the retired model burns a fallback slot on every request forever (#634). | |
| // The trace object identifies the request, so one request's failover across | |
| // sibling keys counts as the single observation it is. | |
| noteModelRetirementSignal(route, err, getRequestTrace()); | |
| // Provider-reported request size (#507): if the just-failed attempt was a | |
| // size-related rejection that names the request's real token count, latch it | |
| // onto the request state. The route() closure on the next attempt picks it | |
| // up and feeds it as the routing estimate, so the existing tpm_limit / | |
| // context_window gates in router.ts skip low-ceiling models on retry | |
| // instead of letting them fire and 413 again. Only fires on a size-class | |
| // error β the parser is conservative and returns null for opaque bodies | |
| // (every non-supported platform, the bare "Request Entity Too Large" | |
| // Groq shape, and the github limit-only case), so behavior is unchanged | |
| // when there's nothing to latch. Max wins so a smaller later reading of | |
| // the same request can't drop the estimate. | |
| if (isContextTooLargeError(err) || isProviderBadRequestError(err)) { | |
| const reported = parseProviderReportedSize(route.platform, err?.message); | |
| if (reported != null) { | |
| const field = reported.kind === 'input' ? 'observedInputTokens' : 'observedTotalTokens'; | |
| state[field] = Math.max(state[field] ?? 0, reported.tokens); | |
| } | |
| } | |
| state.skipKeys.add(`${route.platform}:${route.modelId}:${route.keyId}`); | |
| // #788: provider-level failures (5xx / timeout / transport / degraded) mean | |
| // the PROVIDER is sick, not this key β every key AND every model of that | |
| // platform would fail identically. Rule out the whole platform for this | |
| // request so the loop moves to the NEXT provider instead of burning one | |
| // failover hop per key. Key-scoped failures (auth/quota) stay on the | |
| // single-key path, and the per-key cooldown below is still the only thing | |
| // that outlives the request. | |
| if (isProviderLevelError(err)) { | |
| state.skipPlatforms.add(route.platform); | |
| } | |
| // Too big for this model is a fact about the REQUEST, not the model's | |
| // health: it still serves every smaller request. Skip it for this request | |
| // (above) and learn the reported ceiling so the router sizes it out next | |
| // time, but no cooldown and no penalty. Benching it let one oversized agent | |
| // turn (Claude Code ships ~16k tokens of tool schemas) sink every small-TPM | |
| // model it touched, so later ordinary requests found the pool rate limited. | |
| if (isContextTooLargeError(err)) { | |
| learnLimitFromError(route.modelDbId, err); | |
| return false; | |
| } | |
| // A max_tokens above this model's output ceiling (Claude Code asks for | |
| // 128000; Groq gpt-oss and Ollama Cloud's Nemotron stop at 65536) is the | |
| // request's shape, not the model's health and not missing tool support, so | |
| // it stays off the cooldown, penalty and tool-rejection books. A ceiling | |
| // learned just now makes the same route usable again at once (the next | |
| // attempt is clamped to it); one that was already applied and still got | |
| // rejected rules the model out for this request. | |
| const capBefore = learnedOutputCap(route.platform, route.modelId); | |
| const ceiling = learnOutputCapFromError(route, err); | |
| if (ceiling != null) { | |
| if (capBefore == null || ceiling < capBefore) { | |
| state.skipKeys.delete(`${route.platform}:${route.modelId}:${route.keyId}`); | |
| } else { | |
| state.skipModels.add(route.modelDbId); | |
| } | |
| return false; | |
| } | |
| if (consumeSkipBenchExemption(route, err)) return true; | |
| const decision = cooldownDecisionForError(route, err); | |
| setCooldown(route.platform, route.modelId, route.keyId, decision.durationMs, decision.source); | |
| // A truncated stream (200 + partial SSE, no [DONE]/finish_reason) on the | |
| // SAME route repeatedly is a sick edge, not bad luck β but a single one is | |
| // common enough on flaky free gateways that benching on first sight would | |
| // over-fire. Streak-bounded like empty completions (#751): N truncations in | |
| // a row on this platform+model+key bench the route for one cooldown window; | |
| // a success resets the streak (recordUpstreamSuccess). | |
| if (isStreamTruncatedError(err)) { | |
| const tk = `${route.platform}:${route.modelId}:${route.keyId}`; | |
| const streak = (truncationStreaks.get(tk) ?? 0) + 1; | |
| if (streak >= TRUNCATION_STREAK_LIMIT) { | |
| truncationStreaks.delete(tk); | |
| setCooldown(route.platform, route.modelId, route.keyId, TRUNCATION_BENCH_MS, 'heuristic'); | |
| console.warn(`[FallbackLoop] ${route.platform} ${route.modelId} key ${route.keyId}: ${streak} consecutive truncated streams β benching the route for ${Math.round(TRUNCATION_BENCH_MS / 1000)}s`); | |
| } else { | |
| truncationStreaks.set(tk, streak); | |
| } | |
| } else { | |
| // Any other failure class on this route breaks the truncation streak: the | |
| // cooldown ladder is already handling whatever that is. | |
| truncationStreaks.delete(`${route.platform}:${route.modelId}:${route.keyId}`); | |
| } | |
| // A 400 "bad request" answer to a request that carries `tools` is how a | |
| // model that does not really support tool calling shows up (#1230). The 90s | |
| // transient cooldown above forgets it, so remember it per model+endpoint; | |
| // after a few distinct requests the router tries that model last for tool | |
| // requests (lib/tool-capability.ts). Only the final classification counts: | |
| // context-too-large, model-not-found and degraded 400s are other problems. | |
| if (state.wantsTools === true && classifyAttemptError(err) === 'provider_bad_request') { | |
| const tk = toolCapabilityKey(route.platform, route.modelId, route.endpointScope); | |
| const seen = (state.toolRejects ??= new Set<string>()); | |
| if (!seen.has(tk)) { | |
| seen.add(tk); | |
| noteToolRejection(tk, now); | |
| } | |
| } | |
| // A suspended ACCOUNT fails every model behind the key identically. The | |
| // per-route cooldown above benches only the model that happened to be | |
| // tried, so the next request picks the platform's next model and pays the | |
| // same round trip again β on a platform with a large catalog that is the | |
| // whole failover budget, every request, until each model has tripped | |
| // separately. Bench the key across the platform's catalog in one go, and | |
| // rule the platform out for the rest of this request: a sibling key would | |
| // be a different account, but with the budget already spent on this one | |
| // the next PROVIDER is the better hop. | |
| if (isAccountSuspendedError(err)) { | |
| state.skipPlatforms.add(route.platform); | |
| benchKeyAcrossPlatform(route, decision.durationMs, decision.source, state, 'reports the account suspended'); | |
| } else if (isPaymentRequiredError(err)) { | |
| // A 402 is the ACCOUNT's balance, not one model's (#1239: one broke | |
| // HuggingFace key answered three different models with 402 in a single | |
| // request, three hops spent rediscovering the same empty wallet). Bench | |
| // the key on every model of the platform for the credit window. Unlike a | |
| // suspension the platform itself stays in play: a sibling key is a | |
| // different wallet and may well have credits, so only THIS key is out. | |
| benchKeyAcrossPlatform(route, decision.durationMs, decision.source, state, 'is out of credits (402)'); | |
| } | |
| // Model-level failure benching: a model failing across keys (or repeatedly on | |
| // one key) must sink out of routing instead of being re-picked every request. | |
| noteModelFailure(route, now); | |
| // Model-level penalty only when no sibling key can still serve (#454). | |
| if (!hasOtherUsableKey(route.modelDbId, route.keyId, state.skipKeys)) { | |
| // Hard limit signals (429/402) carry the heavier demotion; ordinary | |
| // upstream failures (5xx/timeout/empty stream) get a lighter one so the | |
| // model sinks gradually instead of being banished on a single blip. | |
| if (isRateLimitSignal(err)) { | |
| recordRateLimitHit(route.modelDbId); | |
| } else { | |
| recordModelFailure(route.modelDbId); | |
| } | |
| } | |
| learnLimitFromError(route.modelDbId, err); | |
| return false; | |
| } | |
| // ββ Upstream 401 handling (key-fatal, not request-fatal) βββββββββββββββββββββ | |
| // A 401 means THIS key is bad, not this model or this request. The old behavior | |
| // (non-retryable β 502) stranded the provider's healthy sibling key and every | |
| // other provider in the chain, and left the bad key in rotation failing traffic | |
| // until the next 5-minute health cycle. Now the loop skips the key, benches the | |
| // model+key long enough to cover the health cycle, and kicks an immediate | |
| // targeted revalidation so a confirmed-bad key flips to status 'invalid' (and | |
| // out of routing) within seconds instead of minutes. | |
| export const AUTH_FAILURE_COOLDOWN_MS = 5 * 60 * 1000; | |
| // Dedupe window for the fire-and-forget revalidation: many concurrent requests | |
| // hitting the same bad key must not stampede the provider's validate endpoint. | |
| const REVALIDATION_DEDUPE_MS = 30_000; | |
| const lastRevalidation = new Map<number, number>(); | |
| function triggerKeyRevalidation(platform: string, keyId: number): void { | |
| const now = Date.now(); | |
| const last = lastRevalidation.get(keyId) ?? 0; | |
| if (now - last < REVALIDATION_DEDUPE_MS) return; | |
| lastRevalidation.set(keyId, now); | |
| console.warn(`[FallbackLoop] Upstream 401 from ${platform} key ${keyId}; revalidating it now instead of waiting for the health cycle`); | |
| void checkKeyHealth(keyId).catch(err => { | |
| console.error(`[FallbackLoop] Immediate revalidation of key ${keyId} failed:`, err?.message); | |
| }); | |
| } | |
| /** | |
| * Bookkeeping for an auth-fatal (401 / invalid key) attempt: skip the key for | |
| * this request, bench the model+key for the health-cycle window, and start an | |
| * immediate revalidation. Deliberately NO model penalty and NO limit-learning β | |
| * a bad key says nothing about the model's health. | |
| */ | |
| export function recordAuthFailure(route: RouteResult, state: FallbackState): void { | |
| state.skipKeys.add(`${route.platform}:${route.modelId}:${route.keyId}`); | |
| setCooldown(route.platform, route.modelId, route.keyId, AUTH_FAILURE_COOLDOWN_MS); | |
| triggerKeyRevalidation(route.platform, route.keyId); | |
| } | |
| /** | |
| * The success-side accounting every surface runs after a completed attempt: | |
| * count the request + its tokens against the model+key's rate-limit windows and | |
| * clear the model's 429 penalty. `rateLimitTokens` is whatever the surface metered | |
| * (the provider's usage.total_tokens for non-stream, an estimate for stream). | |
| */ | |
| export function recordUpstreamSuccess(route: RouteResult, rateLimitTokens: number, state?: FallbackState): void { | |
| // A tool-carrying request that was served proves two things (#1230): this | |
| // model does handle tools, and the request itself was well-formed, so the | |
| // models that answered it with a 400 earlier in this chain are the ones at | |
| // fault. Defer those for tool requests without waiting for more evidence. | |
| if (state?.wantsTools === true) { | |
| const winner = toolCapabilityKey(route.platform, route.modelId, route.endpointScope); | |
| clearToolRejections(winner); | |
| for (const tk of state.toolRejects ?? []) if (tk !== winner) benchForTools(tk); | |
| } | |
| recordRequest(route.platform, route.modelId, route.keyId); | |
| recordTokens(route.platform, route.modelId, route.keyId, rateLimitTokens); | |
| recordSuccess(route.modelDbId); | |
| // A served request proves the model+key can complete: the empty-completion | |
| // streak (#751) starts over. | |
| emptyCompletionStreaks.delete(`${route.platform}:${route.modelId}:${route.keyId}`); | |
| // A served request is the strongest evidence the route's edge is alive: | |
| // the truncated-stream streak (#1218) starts over too. | |
| truncationStreaks.delete(`${route.platform}:${route.modelId}:${route.keyId}`); | |
| // A served request is the strongest possible evidence the model works, so | |
| // clear any model-level failure streak that could bench it later. | |
| clearModelFailure(route); | |
| // A served request is the strongest possible evidence the key works, so clear | |
| // any stale 'error' status left by an earlier transport blip instead of waiting | |
| // for the next health pass to make the key routable again. | |
| markKeyHealthyFromRequest(route.keyId); | |
| } | |
| // ββ Attempt trail βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // One record per dispatched-and-failed attempt, so the final exhaustion error | |
| // can show the client WHAT was tried instead of only the last error. Key ids | |
| // are internal DB integers; the trail shows a per-request ordinal (key1, key2β¦) | |
| // instead, which is stable, readable, and leaks nothing. | |
| export type AttemptErrorClass = | |
| | 'auth' | |
| | 'out_of_credits' | |
| | 'daily_quota_exhausted' | |
| | 'model_not_found' | |
| | 'forbidden' | |
| | 'context_too_large' | |
| | 'provider_bad_request' | |
| | 'empty_completion' | |
| | 'format_ignored' | |
| | 'invalid_tool_arguments' | |
| | 'timeout' | |
| | 'rate_limited' | |
| | 'upstream_error' | |
| | 'error'; | |
| export interface AttemptRecord { | |
| platform: string; | |
| modelId: string; | |
| keyOrdinal: number; | |
| errorClass: AttemptErrorClass; | |
| } | |
| export function classifyAttemptError(err: any): AttemptErrorClass { | |
| if (isKeyAuthError(err)) return 'auth'; | |
| if (isPaymentRequiredError(err)) return 'out_of_credits'; | |
| if (isDailyQuotaExhaustedError(err)) return 'daily_quota_exhausted'; | |
| if (isModelNotFoundError(err)) return 'model_not_found'; | |
| if (isModelAccessForbiddenError(err)) return 'forbidden'; | |
| // Before the bad-request check: OpenAI-compat context errors arrive as | |
| // "API error 400: This model's maximum context length is β¦", which the | |
| // generic provider_bad_request rule would otherwise swallow. | |
| if (isContextTooLargeError(err)) return 'context_too_large'; | |
| // A DEGRADED-function 400 (NVIDIA NIM, #522) is provider health, not request | |
| // shape β keep it out of provider_bad_request so the trail reads honestly. | |
| if (isProviderDegradedError(err)) return 'upstream_error'; | |
| if (isProviderBadRequestError(err)) return 'provider_bad_request'; | |
| const msg = (err?.message ?? '').toLowerCase(); | |
| if (msg.includes('empty completion')) return 'empty_completion'; | |
| if (msg.includes('ignored response_format') || msg.includes('truncated json')) return 'format_ignored'; | |
| // Before the generic classes so the trail names the real cause rather than | |
| // booking a schema violation as a bare 'error'. | |
| if (msg.includes('invalid tool arguments')) return 'invalid_tool_arguments'; | |
| if (isTimeoutErrorText(msg)) return 'timeout'; | |
| if (msg.includes('429') || msg.includes('rate limit') || msg.includes('too many requests') || msg.includes('quota')) return 'rate_limited'; | |
| const status = typeof err?.status === 'number' ? err.status : 0; | |
| if (status >= 500 || msg.includes('500') || msg.includes('502') || msg.includes('503') || msg.includes('unavailable') || msg.includes('internal server error')) return 'upstream_error'; | |
| return 'error'; | |
| } | |
| const TRAIL_MAX_SHOWN = 10; | |
| const TRAIL_HEADER_MAX_LENGTH = 1024; | |
| // ββ Detailed failover trace header (opt-in) ββββββββββββββββββββββββββββββββββ | |
| // X-Fallback-Trail already tells a caller WHICH hops burned and WHY, but not | |
| // how long they cost. That is the part an agent cannot reconstruct: a request | |
| // answered in 40s reads identically whether one provider stalled for 39s or | |
| // four failed fast. The per-hop timings and the redacted provider message are | |
| // already collected for the request_attempts table; this exposes them on the | |
| // response so the caller sees them without a dashboard round trip. | |
| // | |
| // Off by default. It widens what an already-loopback-ish surface reveals β | |
| // hop timings plus provider error text β so it is opt-in like the discovery | |
| // aliases, not something a default install starts emitting. Precedence matches | |
| // the failover budget above: settings-table value, then env var, then off. | |
| export const EXPOSE_FALLBACK_DETAIL_SETTING = 'expose_fallback_detail_header'; | |
| // Ten hops of "platform/model keyN=class t=β¦+β¦ms msg=β¦" with a capped message | |
| // each. Kept well under the 8 KB total header budget that proxies commonly | |
| // enforce, since this rides alongside X-Fallback-Trail's own 1 KB. | |
| const DETAIL_HEADER_MAX_LENGTH = 2048; | |
| // summarizeAttemptError caps at 200; the header wants a tighter budget so ten | |
| // hops still fit. The full text remains in request_attempts either way. | |
| const DETAIL_SUMMARY_MAX_LENGTH = 120; | |
| export function isFallbackDetailHeaderEnabled(): boolean { | |
| let stored: string | undefined; | |
| try { | |
| stored = getSetting(EXPOSE_FALLBACK_DETAIL_SETTING); | |
| } catch { | |
| stored = undefined; // DB not ready β never throw on the proxy hot path | |
| } | |
| for (const raw of [stored, process.env.FALLBACK_DETAIL_HEADER]) { | |
| if (raw === undefined || raw.trim() === '') continue; | |
| const value = raw.trim().toLowerCase(); | |
| return value === '1' || value === 'true'; | |
| } | |
| return false; | |
| } | |
| /** | |
| * One `platform/model keyN=outcome t=<start>+<duration>ms msg=<summary>` segment | |
| * per hop, `; `-joined β the same leading shape as X-Fallback-Trail so the two | |
| * headers line up when read together. | |
| * | |
| * The summary is the already-redacted `errorSummary`, never `err.message`. Its | |
| * semicolons become commas because `; ` is the record separator; nothing else | |
| * is escaped, which keeps the value readable, and `safeHeaderValue` handles any | |
| * non-ASCII on the way out. | |
| */ | |
| export function formatAttemptDetail(records: AttemptTraceRecord[]): string { | |
| const shown = records.slice(0, TRAIL_MAX_SHOWN).map(r => { | |
| const parts = [ | |
| `${r.platform}/${r.modelId}`, | |
| `key${r.keyOrdinal}=${r.outcome}`, | |
| `t=${r.startOffsetMs}+${r.durationMs}ms`, | |
| ]; | |
| if (r.errorSummary) { | |
| parts.push(`msg=${r.errorSummary.slice(0, DETAIL_SUMMARY_MAX_LENGTH).replace(/;/g, ',')}`); | |
| } | |
| return parts.join(' '); | |
| }); | |
| const extra = records.length - shown.length; | |
| return shown.join('; ') + (extra > 0 ? `; +${extra} more` : ''); | |
| } | |
| export function formatAttemptTrail(attempts: AttemptRecord[]): string { | |
| const shown = attempts | |
| .slice(0, TRAIL_MAX_SHOWN) | |
| .map(a => `${a.platform}/${a.modelId} key${a.keyOrdinal}: ${a.errorClass}`); | |
| const extra = attempts.length - shown.length; | |
| return shown.join('; ') + (extra > 0 ? `; +${extra} more` : ''); | |
| } | |
| /** | |
| * Set the failover diagnostics headers every surface stamps on its responses: | |
| * X-Fallback-Attempts (how many hops failed before this response) and | |
| * X-Fallback-Trail (what each hop was and why it failed). Until now the trail | |
| * only reached clients inside exhaustion error MESSAGES β a request that | |
| * eventually succeeded gave no hint that it burned five hops first, which is | |
| * exactly the case an operator wants to notice. Values go through | |
| * safeHeaderValue so a non-ASCII or control-laden model id can neither inject | |
| * header lines nor make Node reject the response outright (#619). | |
| * | |
| * When EXPOSE_FALLBACK_DETAIL_SETTING is on, X-Fallback-Detail joins them with | |
| * per-hop timings and the redacted provider message. Every caller runs inside | |
| * the request's AsyncLocalStorage scope, so the trace is readable here without | |
| * threading it through all five surfaces. | |
| * | |
| * Note what the detail header can and cannot contain: at flush time the trace | |
| * holds exactly the hops that already FAILED, each with final timings. The hop | |
| * currently being served is recorded only after dispatch returns β after | |
| * res.json(), or after the whole stream has finished β so its duration is not | |
| * knowable while headers are still open, on either path. | |
| */ | |
| export function setFallbackHeaders( | |
| res: { setHeader(name: string, value: string): void }, | |
| failedAttempts: number, | |
| trail: AttemptRecord[] | undefined, | |
| ): void { | |
| if (failedAttempts > 0) res.setHeader('X-Fallback-Attempts', String(failedAttempts)); | |
| if (trail && trail.length > 0) { | |
| const value = trail | |
| .slice(0, TRAIL_MAX_SHOWN) | |
| .map(a => `${a.platform}/${a.modelId} key${a.keyOrdinal}=${a.errorClass}`) | |
| .join('; ') + (trail.length > TRAIL_MAX_SHOWN ? `; +${trail.length - TRAIL_MAX_SHOWN} more` : ''); | |
| // Ten hops of "platform/model keyN=class" outgrow the default cap, so the | |
| // trail gets its own budget. | |
| res.setHeader('X-Fallback-Trail', safeHeaderValue(value, TRAIL_HEADER_MAX_LENGTH)); | |
| } | |
| // Checked before the setting so the overwhelmingly common no-failover request | |
| // never pays for a settings read. | |
| const records = getRequestTrace()?.records; | |
| if (records && records.length > 0 && isFallbackDetailHeaderEnabled()) { | |
| res.setHeader('X-Fallback-Detail', safeHeaderValue(formatAttemptDetail(records), DETAIL_HEADER_MAX_LENGTH)); | |
| } | |
| } | |
| export interface ExhaustionBody { | |
| status: number; | |
| type: string; | |
| message: string; | |
| // Coarse class of the exhaustion, for surfaces that need to remap `type` to | |
| // their own wire vocabulary (the Anthropic route maps 'auth' β 'api_error', | |
| // 'unavailable' β 'overloaded_error', 'context_too_large' β | |
| // 'request_too_large', 'model_not_found' β 'not_found_error', 'upstream' β | |
| // 'api_error'). | |
| kind: 'auth' | 'bad_request' | 'rate_limit' | 'unavailable' | 'context_too_large' | 'model_not_found' | 'upstream'; | |
| // Machine-readable code for the OpenAI-compatible error object. | |
| code?: string; | |
| // 429 exhaustions only: epoch ms of the earliest moment any benched candidate | |
| // becomes available again (soonest cooldown expiry). Rendered in the error | |
| // body and as a Retry-After header (seconds, ceil) by setExhaustionHeaders. | |
| retryAtMs?: number; | |
| } | |
| /** | |
| * The OpenAI-compatible `error` object for an exhaustion body β shared by every | |
| * OpenAI-shaped surface so the wire shape (message/type/code/retryAtMs) cannot | |
| * drift between them. | |
| */ | |
| export function exhaustionErrorPayload(body: ExhaustionBody): { message: string; type: string; code?: string; retryAtMs?: number } { | |
| const payload: { message: string; type: string; code?: string; retryAtMs?: number } = { | |
| message: body.message, | |
| type: body.type, | |
| }; | |
| if (body.code) payload.code = body.code; | |
| if (body.retryAtMs != null) payload.retryAtMs = body.retryAtMs; | |
| return payload; | |
| } | |
| /** | |
| * Stamp the standard retry headers for an exhaustion body: a Retry-After of | |
| * ceil((retryAtMs - now) / 1000) seconds when the body carries a concrete | |
| * retry time. Callers must only invoke this before headers are flushed (i.e. | |
| * never on a committed SSE stream). | |
| */ | |
| export function setExhaustionHeaders( | |
| res: { setHeader(name: string, value: string): void }, | |
| body: ExhaustionBody, | |
| now = Date.now(), | |
| ): void { | |
| if (body.retryAtMs == null) return; | |
| res.setHeader('Retry-After', String(Math.max(0, Math.ceil((body.retryAtMs - now) / 1000)))); | |
| } | |
| export interface ExhaustionContext { | |
| attempts?: AttemptRecord[]; | |
| // True when the wall-clock retry budget stopped the loop before maxRetries. | |
| timedOut?: boolean; | |
| budgetMs?: number; | |
| // Set (to the failure count) when the circuit-breaker guardrail stopped the | |
| // loop; renders as a 503 instead of a rate-limit exhaustion. | |
| breakerFails?: number; | |
| } | |
| // Attempt classes that mean "this candidate is unavailable until a KNOWN time" | |
| // β a rate-limit window, a benched daily allocation, an out-of-credits key | |
| // (day bench), or a tier-forbidden model (day bench). When EVERY attempt is in | |
| // this family the pool recovers by itself, so the honest exhaustion is a 429 | |
| // with a concrete retry time. Anything else in the mix (a 5xx, a timeout, a | |
| // vanished model) means waiting is not a promise, so the mixed case renders a | |
| // 502 instead. | |
| const UNAVAILABLE_UNTIL_KNOWN_TIME: ReadonlySet<AttemptErrorClass> = new Set([ | |
| 'rate_limited', | |
| 'daily_quota_exhausted', | |
| 'out_of_credits', | |
| 'forbidden', | |
| ]); | |
| /** | |
| * The shared exhaustion response body β the single failure-kind β terminal- | |
| * status ladder every surface renders. Aggregated over the per-attempt failure | |
| * classes (most-specific first): | |
| * - Every attempt failed auth (401/invalid key) β 502 provider_error saying the | |
| * PROVIDER keys are bad β distinct from a rate-limit exhaustion, and never | |
| * 'authentication_error' (which would wrongly blame the CLIENT's key). | |
| * - Every attempt died on a context/prompt-too-large rejection β 413: no | |
| * candidate can fit this request; retrying cannot help, shrinking it can. | |
| * - Every attempt got a model-not-found/gone from its provider β 404: the | |
| * model has been removed upstream everywhere we route it. (No 410 β the | |
| * catalog keeps no removal tombstones, so "verifiably existed before" is | |
| * not determinable here.) | |
| * - A chain that died on a DEGRADED-function 400 (NVIDIA NIM, #522) β 503: | |
| * provider capacity, not a bad request. | |
| * - A request every routed provider rejected as invalid β 400 | |
| * invalid_request_error, not a misleading rate-limit exhaustion. | |
| * - Circuit-breaker stop β 503 (the pool looks unhealthy; retry later). | |
| * - Every attempt unavailable-until-known-time (rate limits, benched | |
| * quotas/credits/tiers) β 429 rate_limit_error with `retryAtMs` (soonest | |
| * cooldown expiry) for a matching Retry-After header. | |
| * - Mixed/other upstream failures (5xx, timeouts, transport errors) β 502 | |
| * provider_error: the UPSTREAMS failed. Never 500 β that status is | |
| * reserved for our own bugs. | |
| * All bodies carry the attempt trail (what was tried, per attempt) and, where | |
| * meaningful, the soonest-cooldown-reset hint. | |
| */ | |
| export function exhaustedRetryError(lastError: any, maxRetries?: number, ctx?: ExhaustionContext): ExhaustionBody { | |
| const safeLastError = sanitizeProviderErrorMessage(lastError?.message); | |
| const attempts = ctx?.attempts ?? []; | |
| const trail = attempts.length > 0 ? ` Attempt trail: ${formatAttemptTrail(attempts)}.` : ''; | |
| const budgetNote = ctx?.timedOut | |
| ? ` (stopped early: retry time budget ${Math.round((ctx.budgetMs ?? 0) / 1000)}s exceeded β one failover hop is always allowed, and past that the budget stops starting further retries and cancels an attempt still waiting on its first byte; raise FALLBACK_TIME_BUDGET_MS or the fallback_time_budget_ms setting to allow a longer failover chain)` | |
| : ''; | |
| const everyAttempt = (cls: AttemptErrorClass | ReadonlySet<AttemptErrorClass>): boolean => | |
| attempts.length > 0 && attempts.every(a => (cls instanceof Set ? cls.has(a.errorClass) : a.errorClass === cls)); | |
| if (everyAttempt('auth')) { | |
| return { | |
| kind: 'auth', | |
| status: 502, | |
| type: 'provider_error', | |
| code: 'provider_authentication_failed', | |
| message: `All ${attempts.length} attempted provider key(s) failed authentication${budgetNote}. ` + | |
| 'The configured upstream API key(s) look invalid or expired; they are being revalidated now and will be marked invalid automatically. ' + | |
| `Check the provider keys in the dashboard.${trail} Last error: ${safeLastError}`, | |
| }; | |
| } | |
| // Aggregate diagnoses before the lastError-shape ones below: when EVERY | |
| // attempt failed the same way, that unanimity is stronger evidence than the | |
| // shape of whichever error happened to come last. | |
| if (everyAttempt('context_too_large')) { | |
| return { | |
| kind: 'context_too_large', | |
| status: 413, | |
| type: 'invalid_request_error', | |
| code: 'context_length_exceeded', | |
| message: `The request is too large for every routed candidate: all ${attempts.length} attempt(s) were rejected as ` + | |
| `over the model's context/size limit${budgetNote}. Retrying will not help β reduce the prompt/history size or ` + | |
| `enable a larger-context model.${trail} Last error: ${safeLastError}`, | |
| }; | |
| } | |
| if (everyAttempt('model_not_found')) { | |
| return { | |
| kind: 'model_not_found', | |
| status: 404, | |
| type: 'invalid_request_error', | |
| code: 'model_not_found', | |
| message: `Every routed provider reports the model as not found or removed upstream (${attempts.length} attempt(s))` + | |
| `${budgetNote}. The catalog entry looks stale; pick another model or call /v1/models for the available list.` + | |
| `${trail} Last error: ${safeLastError}`, | |
| }; | |
| } | |
| // A chain that died on a DEGRADED-function 400 (NVIDIA NIM, #522) is a | |
| // provider-capacity outage, not a bad request: render 503 so clients retry | |
| // later instead of "fixing" a request that was never broken. | |
| if (isProviderDegradedError(lastError)) { | |
| return { | |
| kind: 'unavailable', | |
| status: 503, | |
| type: 'service_unavailable', | |
| code: 'provider_degraded', | |
| message: `The routed provider reported the model's hosted deployment as temporarily degraded${budgetNote}. ` + | |
| `This is a provider-side condition; retry later or route to another provider.${trail} Last error: ${safeLastError}`, | |
| }; | |
| } | |
| // 400 only when the trail agrees with the last error: EVERY attempt was a | |
| // provider-side request rejection (or the legacy no-trail shape). A mixed | |
| // trail β stale models, an empty wallet, a rate limit, and one bad-request | |
| // hop that happened to come last β is not evidence the caller's request is | |
| // invalid, and rendering it as one sent users debugging their own payload | |
| // (#1239: 5Γ provider_bad_request + 3Γ out_of_credits read as "All routed | |
| // providers rejected the request as invalid"). Those fall through to the | |
| // mixed 502 below, whose message names the class breakdown. | |
| if (isProviderBadRequestError(lastError) && (attempts.length === 0 || everyAttempt('provider_bad_request'))) { | |
| return { | |
| kind: 'bad_request', | |
| status: 400, | |
| type: 'invalid_request_error', | |
| code: 'provider_rejected_request', | |
| message: `All routed providers rejected the request as invalid${budgetNote}.${trail} Last error: ${safeLastError}`, | |
| }; | |
| } | |
| // Circuit-breaker guardrail stop. Checked after the aggregate and bad-request | |
| // diagnoses, which are more specific about WHY the pool is failing. | |
| if (ctx?.breakerFails) { | |
| const breakerEta = formatResetEta(getSoonestCooldownExpiry()); | |
| const breakerEtaNote = breakerEta ? ` Soonest cooldown reset ${breakerEta}.` : ''; | |
| return { | |
| kind: 'unavailable', | |
| status: 503, | |
| type: 'service_unavailable', | |
| code: 'upstream_unhealthy', | |
| message: `Failover stopped early by the circuit-breaker guardrail: ${ctx.breakerFails} consecutive upstream ` + | |
| `failure${ctx.breakerFails === 1 ? '' : 's'} (max_consecutive_upstream_fails). The enabled pool looks ` + | |
| `unhealthy right now, so the remaining candidates were skipped instead of burning quota on them.` + | |
| `${breakerEtaNote}${trail} Last error: ${safeLastError}`, | |
| }; | |
| } | |
| // 429 only when EVERY candidate is unavailable until a known time (or the | |
| // caller gave us no per-attempt classes to aggregate β the legacy shape). | |
| if (attempts.length === 0 || everyAttempt(UNAVAILABLE_UNTIL_KNOWN_TIME)) { | |
| const attemptCount = attempts.length > 0 ? attempts.length : maxRetries; | |
| const scope = attemptCount == null | |
| ? 'All models rate-limited' | |
| : `All models rate-limited after ${attemptCount} attempt${attemptCount === 1 ? '' : 's'}`; | |
| const retryAtMs = getSoonestCooldownExpiry() ?? undefined; | |
| const eta = formatResetEta(retryAtMs); | |
| const etaNote = eta ? ` Soonest cooldown reset ${eta}.` : ''; | |
| return { | |
| kind: 'rate_limit', | |
| status: 429, | |
| type: 'rate_limit_error', | |
| code: 'rate_limit_exceeded', | |
| ...(retryAtMs != null ? { retryAtMs } : {}), | |
| message: `${scope}${budgetNote}.${etaNote}${trail} Last error: ${safeLastError}`, | |
| }; | |
| } | |
| // Mixed/other upstream failures (5xx, timeouts, transport errors, or a mix of | |
| // those with rate limits): the upstream pool failed us, so say 502 β not 429 | |
| // (which would promise recovery-by-waiting the attempts don't support) and | |
| // not 500 (which would blame our own code). | |
| // | |
| // The breakdown names how many attempts fell in each class so a mixed trail | |
| // reads as what it is. When SOME hops were provider-side request rejections | |
| // the body must not promise the request was fine either β it says which | |
| // providers rejected it and leaves the verdict to the reader. | |
| const breakdown = formatClassBreakdown(attempts); | |
| const someBadRequest = attempts.some(a => a.errorClass === 'provider_bad_request'); | |
| const verdict = someBadRequest | |
| ? 'Most of these failures are provider-side (stale models, exhausted credits or quotas), but some providers ' + | |
| 'also rejected the request shape β check the attempt trail for which ones before changing your request.' | |
| : 'This is a provider-side failure, not a problem with your request; retry, or check provider status.'; | |
| return { | |
| kind: 'upstream', | |
| status: 502, | |
| type: 'provider_error', | |
| code: 'upstream_failed', | |
| message: `All ${attempts.length} routed attempt(s) failed with upstream provider errors${breakdown}${budgetNote}. ` + | |
| `${verdict}${trail} Last error: ${safeLastError}`, | |
| }; | |
| } | |
| /** " (provider_bad_request Γ5, out_of_credits Γ3)" β the per-class tally of a | |
| * mixed trail, most frequent first; empty when there is nothing to tally. */ | |
| function formatClassBreakdown(attempts: readonly AttemptRecord[]): string { | |
| if (attempts.length === 0) return ''; | |
| const counts = new Map<AttemptErrorClass, number>(); | |
| for (const a of attempts) counts.set(a.errorClass, (counts.get(a.errorClass) ?? 0) + 1); | |
| const parts = [...counts.entries()] | |
| .sort((a, b) => b[1] - a[1]) | |
| .map(([cls, n]) => `${cls} Γ${n}`); | |
| return ` (${parts.join(', ')})`; | |
| } | |
| // ββ Routing exhaustion (zero attempts ran) βββββββββββββββββββββββββββββββββββ | |
| // When routeRequest gives up before ANY upstream was tried, the only evidence | |
| // is its per-candidate diagnostics. Map them onto the same honest-terminal- | |
| // status taxonomy the attempt ladder uses: | |
| // - nothing configured at all (empty chain, or every candidate lacks a | |
| // provider/usable key) β 503: no amount of waiting or request-shrinking | |
| // helps; the operator must add keys. | |
| // - every candidate rejected the request as too big for its context/TPM | |
| // window β 413. | |
| // - at least one candidate is merely rate-limited/on cooldown (and the rest | |
| // are at worst unconfigured/too-small) β 429 with the soonest cooldown | |
| // expiry as retryAtMs. | |
| // - anything else (capability filters like vision/tools, mixed reasons) β | |
| // the router's status (429) with a generic routing_exhausted code. | |
| type RoutingDiagClass = 'config' | 'too_large' | 'time_bound' | 'monthly_budget' | 'other'; | |
| function classifyRoutingDiagLine(line: string): RoutingDiagClass { | |
| const l = line.toLowerCase(); | |
| // "< estimated" first: the tpm_limit-too-small line also contains 'tpm', | |
| // which would otherwise misread as a transient window. | |
| if (l.includes('< estimated')) return 'too_large'; | |
| const monthlyReasons = l.match(/key\(s\) β (.*)$/)?.[1]; | |
| if (monthlyReasons?.split(', ').every(reason => /^monthly-budget-cap:\d+$/.test(reason))) return 'monthly_budget'; | |
| if (/no provider registered|no enabled\+healthy key|no usable key|decrypt-error|no-resolved-provider|custom-key-mismatch/.test(l)) return 'config'; | |
| if (/cooldown|rpm|rpd|tpm|tpd|provider-daily-cap|provider-minute-cap|provider-daily-token-cap|key-concurrency|monthly-budget-cap/.test(l)) return 'time_bound'; | |
| return 'other'; | |
| } | |
| export function routingExhaustionBody(routeErr: any): ExhaustionBody { | |
| const diag: string[] = Array.isArray(routeErr?.diagnostics) ? routeErr.diagnostics : []; | |
| const message: string = routeErr?.message ?? 'No model available to route this request'; | |
| const classes = diag.map(classifyRoutingDiagLine); | |
| if (diag.length === 0 || classes.every(c => c === 'config')) { | |
| return { | |
| kind: 'unavailable', | |
| status: 503, | |
| type: 'service_unavailable', | |
| code: 'no_providers_configured', | |
| message: diag.length === 0 | |
| ? `No models are enabled/configured to serve this request. Add provider API keys and enable models in the dashboard. ${message}` | |
| : `No candidate model has a configured, usable provider key. Add provider API keys in the dashboard. ${message}`, | |
| }; | |
| } | |
| if (classes.every(c => c === 'too_large' || c === 'config') && classes.includes('too_large')) { | |
| return { | |
| kind: 'context_too_large', | |
| status: 413, | |
| type: 'invalid_request_error', | |
| code: 'context_length_exceeded', | |
| message: `The request is too large for every available candidate's context/token window. ` + | |
| `Reduce the prompt/history size or enable a larger-context model. ${message}`, | |
| }; | |
| } | |
| if (classes.includes('monthly_budget') && classes.every(c => c === 'monthly_budget' || c === 'config' || c === 'too_large')) { | |
| return { | |
| kind: 'rate_limit', status: 429, type: 'rate_limit_error', code: 'quota_exceeded', | |
| retryAtMs: Date.parse(nextMonthResetAt()), | |
| message: `Monthly key budget exhausted. Raise the cap or wait until the next UTC month. ${message}`, | |
| }; | |
| } | |
| if (classes.some(c => c === 'time_bound') && classes.every(c => c !== 'other')) { | |
| const retryAtMs = getSoonestCooldownExpiry() ?? undefined; | |
| return { | |
| kind: 'rate_limit', | |
| status: 429, | |
| type: 'rate_limit_error', | |
| code: 'rate_limit_exceeded', | |
| ...(retryAtMs != null ? { retryAtMs } : {}), | |
| message, | |
| }; | |
| } | |
| // Capability filters or mixed reasons: keep the router's verdict (429) but | |
| // stamp a machine-readable code so clients can tell it from a plain | |
| // rate-limit exhaustion. | |
| return { | |
| kind: 'rate_limit', | |
| status: typeof routeErr?.status === 'number' ? routeErr.status : 429, | |
| type: 'rate_limit_error', | |
| code: 'routing_exhausted', | |
| message, | |
| }; | |
| } | |
| /** Log the router's per-candidate disposition for a zero-attempt exhaustion. | |
| * Lives in the loop, not the surfaces: routing gave up before any upstream was | |
| * tried, so nothing else records WHY the pool was empty, and an opaque | |
| * routing_error is indistinguishable from a genuinely dry pool (issue _1). | |
| * Every surface used to be responsible for logging this itself and only | |
| * routes/proxy.ts ever did, so /v1/messages, /v1/responses and the inbound | |
| * wires logged nothing. The loop owns the format; surfaces only name | |
| * themselves via FallbackHooks.logIdentity. */ | |
| function logRoutingExhaustion(routeErr: any, identity?: FallbackHooks['logIdentity']): void { | |
| const disposition: string[] = Array.isArray(routeErr?.diagnostics) ? routeErr.diagnostics : []; | |
| const surface = identity?.surface ?? 'unidentified surface'; | |
| const req = identity?.requestId ? ` req=${identity.requestId.replace(/-/g, '').slice(0, 6)}` : ''; | |
| const requested = identity?.requestedModel ? ` requested=${identity.requestedModel}` : ''; | |
| console.warn( | |
| `[FallbackLoop] ${surface} routing exhausted (no upstream tried)${req}${requested} ` + | |
| `candidates=${disposition.length}` + | |
| (disposition.length ? `:\n ${disposition.join('\n ')}` : ''), | |
| ); | |
| } | |
| // What a surface's dispatch() returns to signal the response is finished and the | |
| // loop must stop: | |
| // 'done' β the attempt succeeded and the full response was sent. | |
| // 'committed' β a stream already flushed real bytes to the client, then hit a | |
| // mid-stream error the surface surfaced honestly; no failover is | |
| // possible, so stop without recording another retry. | |
| export type DispatchOutcome = 'done' | 'committed'; | |
| /** Per-attempt handles the loop hands to dispatch. */ | |
| export interface DispatchContext { | |
| /** | |
| * Cancel this attempt's time-budget hedge. Idempotent and always safe to | |
| * call, including when hedging is not armed at all. See FallbackHooks.dispatch. | |
| */ | |
| disarmHedge(): void; | |
| } | |
| // Per-request exhaustion metadata handed to the exhaustion hooks, so each | |
| // surface can stamp X-Fallback-Attempts on error responses (previously | |
| // success-only) without re-deriving the count. | |
| export interface ExhaustionInfo { | |
| attempts: AttemptRecord[]; | |
| timedOut: boolean; | |
| } | |
| export interface FallbackHooks { | |
| // Defaults to FALLBACK_MAX_RETRIES. | |
| maxRetries?: number; | |
| // Base wall-clock retry budget override, mostly for tests. Defaults to | |
| // getFallbackTimeBudgetMs() (setting β env β 45s; 0 disables). | |
| // Successful endpoint TTFB history can widen this floor. | |
| timeBudgetMs?: number; | |
| // Circuit-breaker threshold override, mostly for tests. Defaults to | |
| // getMaxConsecutiveUpstreamFails() (setting β env β 0 = disabled). | |
| breakerLimit?: number; | |
| // When provided, the loop records every failed attempt into THIS array (it | |
| // is the same array used for exhaustion bodies), so the surface can stamp | |
| // X-Fallback-Trail on successful responses too. | |
| attemptLog?: AttemptRecord[]; | |
| // Names this surface in the shared routing-exhaustion diagnostics line the | |
| // loop logs when route() gave up before any upstream was tried (see | |
| // logRoutingExhaustion). Absent = the line still fires, without identifying | |
| // the surface or the request. | |
| logIdentity?: { surface: string; requestId?: string; requestedModel?: string }; | |
| // Returns true once the client has hung up. Checked before STARTING each | |
| // retry: a chain nobody is waiting for must not keep burning provider | |
| // quota. Surfaces additionally thread a client-disconnect AbortSignal into | |
| // the provider options (CompletionOptions.signal), so the in-flight | |
| // attempt's fetch/body/stream is canceled the moment the client goes; the | |
| // resulting client-abort throw stops the loop without any failure | |
| // bookkeeping (see the isClientAbortError branch below). | |
| clientGone?: () => boolean; | |
| // Fallback-v2 hedging: when provided, the loop starts a per-attempt timer | |
| // (remaining wall-clock budget) and calls this to ABORT the in-flight | |
| // upstream instead of just refusing to start the next retry behind a | |
| // stalled attempt. The surface aborts its composed fetch signal with | |
| // newHedgeAbortError(); the loop renders timedOut exhaustion, and benches the | |
| // model+key only when that attempt ate the whole budget by itself (see the | |
| // isHedgeAbortError branch below). Absent = pre-v2 behavior. | |
| abortInFlight?: () => void; | |
| // Skip state; recordRetryableFailure / recordAuthFailure (called by the loop) | |
| // mutate it, and the surface's route() reads it to exclude failed keys/models. | |
| state: FallbackState; | |
| /** | |
| * Pick a route for this attempt. Reads state.skipKeys / state.skipModels / | |
| * state.skipPlatforms. | |
| * Throws the router's RouteError when the pool is exhausted before any | |
| * upstream is tried (caught by the loop β onRoutingExhausted). | |
| */ | |
| route(attempt: number): RouteResult; | |
| /** | |
| * Run one attempt against the chosen route. Return 'done' on success or | |
| * 'committed' when a stream already sent bytes and handled its own mid-stream | |
| * error. THROW a (possibly synthetic) provider error β an upstream HTTP error, | |
| * or an "empty completion" / "unparseable inline tool-call dialect" Error the | |
| * classifier already treats as retryable β to trigger failover; a | |
| * non-retryable throw becomes onFatal. A pre-commit failure MUST throw (not | |
| * return 'committed') so the loop can fail over invisibly. The loop enforces | |
| * this contract: any other return value is a programming error and fails | |
| * loudly instead of silently swallowing the request. | |
| * | |
| * `ctx.disarmHedge()` cancels the time-budget hedge for THIS attempt. Call it | |
| * the moment the attempt proves it is alive (first byte / headers flushed): | |
| * past that point the budget must not cancel it, because the answer is | |
| * already on its way and killing it would truncate a healthy response for no | |
| * failover benefit. Streaming surfaces are expected to call it; a | |
| * non-streaming attempt has nothing to disarm until it returns. | |
| */ | |
| dispatch(route: RouteResult, attempt: number, ctx: DispatchContext): Promise<DispatchOutcome>; | |
| /** Trace + log a per-attempt failure (per-surface scope + logRequest args). */ | |
| logFailure(route: RouteResult, err: any, attempt: number): void; | |
| /** Render a non-retryable provider error (per-surface body/status). `attempt` | |
| * is the failing attempt's index = the number of prior fallback hops. */ | |
| onFatal(route: RouteResult, err: any, attempt: number): void; | |
| /** | |
| * Render exhaustion when route() threw. `exhaustion` is always the shared | |
| * honest-status body: built from the attempt trail when at least one attempt | |
| * ran, or from routeErr's routing diagnostics (routingExhaustionBody) when | |
| * routing gave up before any upstream was tried. `lastError` is null in the | |
| * zero-attempt case; `routeErr` is passed for logging/diagnostics. | |
| */ | |
| onRoutingExhausted(lastError: any, routeErr: any, exhaustion: ExhaustionBody, info: ExhaustionInfo): void; | |
| /** Render exhaustion after the attempt cap or the time budget was hit. */ | |
| onExhausted(exhaustion: ExhaustionBody, info: ExhaustionInfo): void; | |
| } | |
| /** | |
| * The shared attempt loop. Owns iteration, the wall-clock retry budget, the | |
| * routeRequest-exhaustion path, the auth/retryable/fatal classification, the | |
| * per-failure bookkeeping (recordRetryableFailure / recordAuthFailure), the | |
| * attempt trail, and the final exhaustion body. Everything surface-specific β | |
| * request translation, stream framing, error-body shape, context handoff, group | |
| * routing β lives in the hooks. | |
| */ | |
| export async function runFallbackLoop(hooks: FallbackHooks): Promise<void> { | |
| // Durable per-attempt trace (P2 #15): every dispatched attempt β including | |
| // the successful final one β is recorded with timing and outcome, then | |
| // persisted as one insert batch into `request_attempts`, keyed to the | |
| // terminal `requests` row of this ladder. The trace rides AsyncLocalStorage | |
| // so logRequest() (called deep inside the surfaces' dispatch closures) can | |
| // report back the row ids it writes without any surface changing. The flush | |
| // runs after the loop returns β i.e. after the response is finished β so it | |
| // never sits on the client's latency path, and it is a no-op when no | |
| // `requests` row was written (a pure abort writes its own 'canceled' row, | |
| // so its trace persists too). | |
| const trace = newRequestTrace(); | |
| try { | |
| await runWithRequestTrace(trace, () => runFallbackLoopAttempts(hooks, trace)); | |
| } finally { | |
| persistRequestAttempts(trace); | |
| } | |
| } | |
| async function runFallbackLoopAttempts(hooks: FallbackHooks, trace: RequestTrace): Promise<void> { | |
| const maxRetries = hooks.maxRetries ?? FALLBACK_MAX_RETRIES; | |
| const baseBudgetMs = hooks.timeBudgetMs ?? getFallbackTimeBudgetMs(); | |
| const startedAt = Date.now(); | |
| const attempts: AttemptRecord[] = hooks.attemptLog ?? []; | |
| const keyOrdinals = new Map<string, number>(); | |
| const keyOrdinal = (route: RouteResult): number => { | |
| const key = `${route.platform}:${route.keyId}`; | |
| let ord = keyOrdinals.get(key); | |
| if (ord === undefined) { | |
| ord = keyOrdinals.size + 1; | |
| keyOrdinals.set(key, ord); | |
| } | |
| return ord; | |
| }; | |
| let lastError: any = null; | |
| // Circuit-breaker guardrail (default off): the Nth consecutive upstream | |
| // failure aborts the chain with a 503 instead of grinding the remaining | |
| // candidates of a pool that is failing across the board. Auth failures count | |
| // too β a wall of dead keys is exactly the "stop early" case. Within one | |
| // request every recorded failure is consecutive by construction (a success | |
| // ends the loop), so this is "max upstream failures per request". | |
| const breaker = newBreaker(hooks.breakerLimit); | |
| const stopIfBreakerTripped = (): boolean => { | |
| if (!recordBreakerFailure(breaker)) return false; | |
| // Tripped after the client already hung up: stop the chain, but there is | |
| // no socket to render an exhaustion body to (mirrors the loop-top | |
| // clientGone check). | |
| if (hooks.clientGone?.()) { | |
| console.log(`[FallbackLoop] breaker tripped after client disconnect β stopping without rendering (${attempts.length} failed attempt(s))`); | |
| return true; | |
| } | |
| hooks.onExhausted( | |
| exhaustedRetryError(lastError, maxRetries, { attempts, breakerFails: breaker.consecutive }), | |
| { attempts, timedOut: false }, | |
| ); | |
| return true; | |
| }; | |
| for (let attempt = 0; attempt < maxRetries; attempt++) { | |
| // Client disconnect: nobody is waiting for this chain anymore, so stop | |
| // burning provider quota on retries. Nothing to render β the socket is | |
| // gone β so return without calling any exhaustion hook. | |
| if (attempt > 0 && hooks.clientGone?.()) { | |
| console.log(`[FallbackLoop] client disconnected β stopping failover after ${attempts.length} failed attempt(s)`); | |
| return; | |
| } | |
| let route: RouteResult; | |
| try { | |
| route = hooks.route(attempt); | |
| } catch (routeErr) { | |
| // With no candidate to supply an endpoint-specific allowance, retain | |
| // the original timeout diagnosis when the base budget is already spent. | |
| if (attempt > 1 && baseBudgetMs > 0 && Date.now() - startedAt >= baseBudgetMs) { | |
| hooks.onExhausted( | |
| exhaustedRetryError(lastError, maxRetries, { attempts, timedOut: true, budgetMs: baseBudgetMs }), | |
| { attempts, timedOut: true }, | |
| ); | |
| return; | |
| } | |
| const exhaustion = lastError | |
| ? exhaustedRetryError(lastError, undefined, { attempts }) | |
| : routingExhaustionBody(routeErr); | |
| // Zero attempts ran: log the router's per-candidate disposition, the only | |
| // record of why the pool was empty. With prior attempts the trail in the | |
| // exhaustion body already explains the failure. | |
| if (!lastError) logRoutingExhaustion(routeErr, hooks.logIdentity); | |
| hooks.onRoutingExhausted(lastError, routeErr, exhaustion, { attempts, timedOut: false }); | |
| return; | |
| } | |
| let hedgeTimer: NodeJS.Timeout | undefined; | |
| try { | |
| // Select the endpoint before checking the budget: a slow endpoint may | |
| // still have time even after the base budget has expired. Recompute from | |
| // the base for every candidate so its allowance cannot leak to a faster | |
| // endpoint later in the ladder. The clock still starts at loop entry. | |
| const budgetMs = attempt > 1 | |
| ? getEndpointTimeBudgetMs(baseBudgetMs, route.platform, route.endpointScope) | |
| : baseBudgetMs; | |
| // Attempt 0 and the first retry remain exempt (#751). Routing reserves a | |
| // lease, so even a candidate rejected here must pass through finally. | |
| if (attempt > 1 && budgetMs > 0 && Date.now() - startedAt >= budgetMs) { | |
| hooks.onExhausted( | |
| exhaustedRetryError(lastError, maxRetries, { attempts, timedOut: true, budgetMs }), | |
| { attempts, timedOut: true }, | |
| ); | |
| return; | |
| } | |
| // Per-attempt trace record: pushed exactly once per dispatched attempt, on | |
| // whichever exit the attempt takes. startOffsetMs/durationMs bracket the | |
| // dispatch (for a successful stream, durationMs runs until the response | |
| // finished β that IS the attempt). When the attempt ended on an error, a | |
| // short REDACTED summary of it rides along β the outcome class alone loses | |
| // the provider's actual words, which is exactly what the dashboard's | |
| // drill-down needs to answer "why did this hop fail". | |
| const attemptStartedAt = Date.now(); | |
| const traceAttempt = (outcome: AttemptOutcome, err?: any): void => { | |
| trace.records.push({ | |
| ordinal: trace.records.length, | |
| platform: route.platform, | |
| modelId: route.modelId, | |
| keyOrdinal: keyOrdinal(route), | |
| keyLabel: route.keyLabel ?? null, | |
| outcome, | |
| startOffsetMs: attemptStartedAt - startedAt, | |
| durationMs: Date.now() - attemptStartedAt, | |
| errorSummary: err != null ? summarizeAttemptError(err?.message) : null, | |
| }); | |
| }; | |
| // Everything from here to the end of the iteration runs inside a finally that | |
| // frees the route's in-flight lease. Every exit β success, auth rotation, | |
| // retryable continue, fatal, breaker trip, contract violation β passes through | |
| // it, so no path can leak a lease and leave the key's concurrency budget short. | |
| // Success accounting happens inside dispatch, so the persisted counters are | |
| // already written by the time the provisional lease goes away. | |
| // Fallback-v2 hedging: arm a timer for the remaining wall-clock budget so a | |
| // STALLED attempt is aborted mid-flight (abortInFlight) instead of only | |
| // refusing to start the next retry behind it. Mirrors the loop-top budget | |
| // check β attempt 0 and the first retry always run (#751). | |
| // | |
| // The timer only covers the silent window. dispatch calls ctx.disarmHedge() | |
| // as soon as the attempt proves it is alive (first byte / headers flushed), | |
| // because past that point cancelling would truncate a healthy response and | |
| // buy nothing: a committed stream can no longer fail over anyway. Slow is | |
| // not the same as stalled, and only stalled is worth killing. | |
| const disarmHedge = () => { | |
| if (hedgeTimer) { | |
| clearTimeout(hedgeTimer); | |
| hedgeTimer = undefined; | |
| } | |
| }; | |
| if (attempt > 1 && budgetMs > 0 && hooks.abortInFlight) { | |
| const remaining = budgetMs - (Date.now() - startedAt); | |
| if (remaining > 0) { | |
| hedgeTimer = setTimeout(() => { | |
| console.log(`[FallbackLoop] retry time budget (${budgetMs}ms) expired with no first byte on ${route.platform}/${route.modelId} β aborting stalled upstream`); | |
| hooks.abortInFlight?.(); | |
| }, remaining); | |
| } | |
| } | |
| let outcome: DispatchOutcome; | |
| try { | |
| // #590 (per-key proxy): if THIS key carries its own proxy URL, route the | |
| // attempt through it (withKeyProxy β proxyFetch reads the ALS store); | |
| // otherwise the global proxy / direct path applies as before. The URL | |
| // arrives already decrypted on the route (services/router.ts), so an | |
| // attempt costs nothing extra β no query, no decrypt. | |
| outcome = await withKeyProxy(route.proxyUrl, () => hooks.dispatch(route, attempt, { disarmHedge })); | |
| } catch (err: any) { | |
| // Client-caused abort: the composed fetch signal fired because OUR | |
| // client hung up mid-attempt (see newClientAbortError). Not a | |
| // provider-health signal β no cooldown, no penalty, no failure stats, | |
| // no logFailure 'error' row β and no further attempts either: nobody is | |
| // waiting, and there is no socket to render anything to. The finally | |
| // below still frees the in-flight lease. | |
| if (isClientAbortError(err)) { | |
| const elapsedMs = Date.now() - startedAt; | |
| console.log(`[FallbackLoop] client disconnected mid-attempt on ${route.platform}/${route.modelId} β upstream canceled, stopping without benching`); | |
| // Visibility row (#752): a pure abort used to leave NOTHING in the | |
| // requests table, so the dashboard showed no trace of the request. | |
| // Status 'canceled' is neither success nor failure β every stats/ | |
| // scoring query excludes it β and this row is also the parent the | |
| // attempt-trace batch below keys to. | |
| logRequest(route.platform, route.modelId, route.keyId, 'canceled', 0, 0, elapsedMs, | |
| `client disconnected after ${(elapsedMs / 1000).toFixed(1)}s; upstream request canceled`); | |
| traceAttempt('client_abort'); | |
| return; | |
| } | |
| // Time-budget hedge abort: the wall-clock retry budget expired while this | |
| // attempt was still in flight, and the surface aborted the composed fetch | |
| // signal (see newHedgeAbortError). The budget is spent either way, so we | |
| // render timedOut exhaustion exactly like the loop-top budget check does. | |
| // | |
| // Whether it also counts as a provider-health signal depends on how much | |
| // of the silence belongs to THIS attempt. Sharing a budget that earlier | |
| // attempts already mostly spent says nothing about this route β it was | |
| // simply last in line, and cancelling it is a scheduling accident. But an | |
| // attempt that stayed silent for most of the operator's whole declared | |
| // patience on its own produced no first byte over that entire window, | |
| // and that is a stalled upstream. Left unbenched it stays at the head of the route | |
| // order and re-stalls every subsequent request, burning the full budget | |
| // each time and starving the healthy routes queued behind it β observed | |
| // in production as one dead free-tier route 45s-ing every request for | |
| // hours. Benching costs a short transient cooldown (a timeout is not a | |
| // rate-limit signal, so cooldownDecisionForError keeps it light) plus the | |
| // gradual model-level sink, both of which decay on their own if the route | |
| // recovers. | |
| if (isHedgeAbortError(err)) { | |
| const elapsedMs = Date.now() - startedAt; | |
| const attemptElapsedMs = Date.now() - attemptStartedAt; | |
| const ownedWholeBudget = budgetMs > 0 | |
| && attemptElapsedMs >= budgetMs * HEDGE_BENCH_MIN_SILENT_FRACTION; | |
| if (ownedWholeBudget) { | |
| hooks.logFailure(route, err, attempt); | |
| recordRetryableFailure(route, err, hooks.state); | |
| } | |
| console.log(`[FallbackLoop] retry time budget expired mid-attempt on ${route.platform}/${route.modelId} after ${(elapsedMs / 1000).toFixed(1)}s β rendering timedOut exhaustion ${ownedWholeBudget ? `and benching the route (silent for the whole ${budgetMs}ms budget)` : 'without benching'}`); | |
| hooks.onExhausted( | |
| exhaustedRetryError(lastError, maxRetries, { attempts, timedOut: true, budgetMs }), | |
| { attempts, timedOut: true }, | |
| ); | |
| traceAttempt('timeout', err); | |
| return; | |
| } | |
| hooks.logFailure(route, err, attempt); | |
| if (isKeyAuthError(err)) { | |
| // KEY-fatal, not request-fatal: rotate past the bad key and revalidate | |
| // it immediately instead of 502-ing while healthy routes sit idle. | |
| recordAuthFailure(route, hooks.state); | |
| attempts.push({ platform: route.platform, modelId: route.modelId, keyOrdinal: keyOrdinal(route), errorClass: 'auth' }); | |
| traceAttempt('auth', err); | |
| lastError = err; | |
| if (stopIfBreakerTripped()) return; | |
| continue; | |
| } | |
| if (isRetryableError(err)) { | |
| const exempt = recordRetryableFailure(route, err, hooks.state); | |
| // An in-band provider error that arrives only after the attempt has | |
| // silently consumed most of the operator's whole budget (#1218 Gap 3: | |
| // nvidia ran 140.7s before surfacing "Service temporarily overloaded", | |
| // leaving scraps for the next hop) behaved like a stall for its entire | |
| // window β the hedge-abort bench would have fired had the abort landed | |
| // first. Give the late error the same treatment the hedge abort gets: | |
| // bench the route so the ladder's next hop keeps a usable budget, | |
| // instead of re-stalling on this route every request. Errors that | |
| // arrive EARLY (the common Groq tool_use_failed shape) stay unbenced β | |
| // a fast verdict costs the ladder nothing. | |
| const errStr = err?.message ?? ''; | |
| if ( | |
| budgetMs > 0 | |
| && typeof errStr === 'string' && errStr.includes('in-band provider error') | |
| && Date.now() - attemptStartedAt >= budgetMs * HEDGE_BENCH_MIN_SILENT_FRACTION | |
| ) { | |
| setCooldown(route.platform, route.modelId, route.keyId, TRUNCATION_BENCH_MS, 'heuristic'); | |
| console.warn(`[FallbackLoop] ${route.platform}/${route.modelId}: in-band provider error after ${((Date.now() - attemptStartedAt) / 1000).toFixed(1)}s silent β benching the route ${Math.round(TRUNCATION_BENCH_MS / 1000)}s (#1218 Gap 3)`); | |
| } | |
| const errorClass = classifyAttemptError(err); | |
| attempts.push({ platform: route.platform, modelId: route.modelId, keyOrdinal: keyOrdinal(route), errorClass }); | |
| traceAttempt(errorClass, err); | |
| lastError = err; | |
| // A still-exempt skipBench failure (format ignored, hidden-reasoning | |
| // truncation under the streak limit) is model behavior, not provider | |
| // health β recordRetryableFailure skipped the cooldown/penalty for it, | |
| // and it must not count toward the "pool looks unhealthy" breaker | |
| // either: three prose answers to a json_schema request say nothing | |
| // about whether candidate four is up. Once the empty-completion streak | |
| // lifts the exemption (#751), the failure counts everywhere. | |
| if (!exempt && stopIfBreakerTripped()) return; | |
| continue; | |
| } | |
| traceAttempt(classifyAttemptError(err), err); | |
| hooks.onFatal(route, err, attempt); | |
| return; | |
| } | |
| // Enforce the dispatch contract: 'done'/'committed' mean the response is | |
| // finished. Anything else (a stray `return` in an adapter) would silently | |
| // swallow the request, so fail loudly. Deliberately OUTSIDE the try/catch: | |
| // the violation message embeds route.modelId, and a model id containing a | |
| // digit run like "2503" would match a retryable-error substring and make | |
| // the loop re-dispatch the buggy adapter until exhaustion. Routing straight | |
| // to onFatal renders an immediate 502 with no retryability classification. | |
| if (outcome !== 'done' && outcome !== 'committed') { | |
| const violation = new Error( | |
| `fallback-loop dispatch contract violation on ${route.platform}/${route.modelId}: ` + | |
| `expected 'done' or 'committed', got ${JSON.stringify(outcome)}`, | |
| ); | |
| console.error('[FallbackLoop]', violation.message); | |
| hooks.logFailure(route, violation, attempt); | |
| traceAttempt('error', violation); | |
| hooks.onFatal(route, violation, attempt); | |
| return; | |
| } | |
| // Terminal attempt: 'done' produced the response; 'committed' means the | |
| // stream had already flushed bytes when the attempt ended (mid-stream | |
| // error or pre-commit disconnect) β the parent row carries the specifics. | |
| traceAttempt(outcome === 'done' ? 'ok' : 'committed'); | |
| return; | |
| } finally { | |
| if (hedgeTimer) clearTimeout(hedgeTimer); | |
| route.release?.(); | |
| } | |
| } | |
| hooks.onExhausted( | |
| exhaustedRetryError(lastError, maxRetries, { attempts }), | |
| { attempts, timedOut: false }, | |
| ); | |
| } | |