mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 03:42:21 +03:00
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437, ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes. Guardar os contratos com testes ANTES de levantar o bloco (`05059880` no irmão, e o `287658f5` marcando os `executeTargetGates` como lift-as-is) é o que torna um refactor deste tamanho auditável. Sem essa ordem, um extract de 3 mil linhas é indistinguível de uma reescrita. Revalidei depois do merge da base: `combo-attempt-loop`, `execute-target-attempt`, `execute-target-gates` e `combo-loop-safety-timer-leak-11804` — 20/20 — mais typecheck:core limpo e o cap de arquivo OK. O #12811 entra na sequência logo em seguida, com os três commits do round-robin sobre este.
1205 lines
52 KiB
TypeScript
1205 lines
52 KiB
TypeScript
/**
|
||
* Retry loop for a combo target that already passed pre-dispatch gates.
|
||
* Lift-as-is from combo.ts:1533–2616. Classify helpers live in
|
||
* executeTargetClassify.ts. Pin/LKGP side effects go through deps.
|
||
*
|
||
* @internal — not part of the public combo.ts barrel.
|
||
*/
|
||
import {
|
||
checkFallbackError,
|
||
classifyLockoutReason,
|
||
decayModelFailureCount,
|
||
hasPerModelQuota,
|
||
isModelLocked,
|
||
lockModelIfPerModelQuota,
|
||
recordModelLockoutFailure,
|
||
recordProviderFailure,
|
||
recordProviderSuccess,
|
||
retryHintBypassesMaxCooldownMs,
|
||
selectLockoutCooldownMs,
|
||
} from "../accountFallback.ts";
|
||
import { errorResponse, errorResponseWithComboDiagnostics } from "../../utils/error.ts";
|
||
import { recordComboFailure, clearComboFailureTracking } from "./failureTracker.ts";
|
||
import { buildRecoveryHint } from "./pinRecovery.ts";
|
||
import { formatExhaustedConnectionKey } from "./comboDiagFormat.ts";
|
||
import { recordComboRequest, getComboMetrics } from "../comboMetrics.ts";
|
||
import {
|
||
expandComboSystemPromptIfPresent,
|
||
resolveTargetFingerprint,
|
||
} from "../comboAgentMiddleware.ts";
|
||
import {
|
||
maybeGenerateHandoff,
|
||
maybeGenerateUniversalHandoff,
|
||
injectUniversalHandoffBody,
|
||
SKIP_UNIVERSAL_HANDOFF_FLAG,
|
||
DEFAULT_UNIVERSAL_HANDOFF_CONFIG,
|
||
type MessageLike,
|
||
} from "../contextHandoff.ts";
|
||
import {
|
||
recordSessionModelUsage,
|
||
getLastSessionModel,
|
||
getHandoff,
|
||
} from "../../../src/lib/db/contextHandoffs.ts";
|
||
import { resolveModelLockoutSettings } from "../../../src/lib/resilience/modelLockoutSettings";
|
||
import { fetchCodexQuota } from "../codexQuotaFetcher.ts";
|
||
import { emit } from "../../../src/lib/events/eventBus";
|
||
import { notifyWebhookEvent } from "../../../src/lib/webhookDispatcher";
|
||
import { getSessionConnection } from "../sessionManager.ts";
|
||
import { recordStickyBinding } from "./sessionStickiness.ts";
|
||
import { recordStickyWeightedSuccess } from "./rrState.ts";
|
||
import { resolveReasoningBufferedMaxTokens, toPositiveInteger } from "../reasoningTokenBuffer.ts";
|
||
import { parseModel } from "../model.ts";
|
||
import type { ProviderProfile } from "../accountFallback.ts";
|
||
import {
|
||
MAX_FALLBACK_WAIT_MS,
|
||
clampGlobalAttempts,
|
||
shouldSkipForPredictedTtft,
|
||
shouldRecordProviderBreakerFailure,
|
||
isComboRequestScopedFailure as isScopedFailure,
|
||
isStreamReadinessFailureErrorBody,
|
||
isStreamEarlyEofErrorBody,
|
||
isTokenLimitBreachErrorBody,
|
||
isLocalQueueCapacityErrorBody,
|
||
toRecordedTarget,
|
||
resolveDelayMs,
|
||
resolvePersistedConnectionCooldownSkipReason,
|
||
isModelScoped400,
|
||
} from "./comboPredicates.ts";
|
||
import { applyComboTargetExhaustion } from "./targetExhaustion.ts";
|
||
import { pinNativeCodexTurn } from "./nativeCodexTurnPin.ts";
|
||
import { recordComboDecision } from "./decisionTrace.ts";
|
||
import { recordProviderCooldown } from "../providerCooldownTracker.ts";
|
||
import {
|
||
validateResponseQuality,
|
||
releaseQualityClone,
|
||
releaseRejectedQualityResponse,
|
||
} from "./validateQuality.ts";
|
||
import {
|
||
isQuotaExhaustionResponse,
|
||
recordQuotaExhaustionClassification,
|
||
} from "./quotaExhaustion.ts";
|
||
import { classifyComboOutcome, redactConnectionLabel } from "./comboErrorAggregation.ts";
|
||
import { readConnectionForCooldownGate } from "./executeTargetGates.ts";
|
||
import {
|
||
remainderIsHomogeneous,
|
||
shouldAbortOnInputBoundFailure,
|
||
shouldSurfaceBodySpecific400,
|
||
} from "./executeTargetClassify.ts";
|
||
import type { CompressionMode } from "../compression/types.ts";
|
||
import type { AttemptLoopDeps, AttemptLoopState, ExecuteTargetResult } from "./attemptLoopTypes.ts";
|
||
import type { ComboDiagnostics } from "../../utils/error.ts";
|
||
import type { ComboErrorBody, ComboRetryAfter, ResolvedComboTarget } from "./types.ts";
|
||
import type { ResponseValidationConfig } from "./responseValidation.ts";
|
||
|
||
export async function executeTargetAttempt(opts: {
|
||
index: number;
|
||
state: AttemptLoopState;
|
||
deps: AttemptLoopDeps;
|
||
targetForAttempt: ResolvedComboTarget;
|
||
profile: unknown;
|
||
protectedPriorityTarget: boolean;
|
||
}): Promise<ExecuteTargetResult> {
|
||
const { index: i, state, deps, targetForAttempt, protectedPriorityTarget } = opts;
|
||
const profile = opts.profile as ProviderProfile | undefined;
|
||
const target = state.orderedTargets[i];
|
||
const modelStr = target.modelStr;
|
||
const rawModel = parseModel(modelStr).model || modelStr;
|
||
const provider = target.provider;
|
||
const allowRateLimitedConnection =
|
||
Boolean(provider && provider !== "unknown") &&
|
||
state.transientRateLimitedProviders.has(provider);
|
||
const maxGlobalAttempts = clampGlobalAttempts(deps.config.maxGlobalAttempts);
|
||
const retryDelayMs = resolveDelayMs(deps.config.retryDelayMs, 2000);
|
||
const fallbackDelayMs = resolveDelayMs(deps.config.fallbackDelayMs, 0);
|
||
const universalHandoffConfig = deps.universalHandoffConfig ?? DEFAULT_UNIVERSAL_HANDOFF_CONFIG;
|
||
|
||
const stopProtectedPriorityTarget = (message: string) => {
|
||
state.observeFailure(false, target.executionKey);
|
||
deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO");
|
||
return protectedPriorityTarget
|
||
? { ok: false as const, response: errorResponse(503, message) }
|
||
: null;
|
||
};
|
||
|
||
const buildComboDiag = (
|
||
terminalReason: string,
|
||
retryAfterSeconds?: number
|
||
): ComboDiagnostics => ({
|
||
poolSize: state.orderedTargets.length,
|
||
attempted: state.recordedAttempts,
|
||
excluded: [
|
||
...[...state.exhaustedProviders].map((p) => ({ provider: p, reason: "exhausted" })),
|
||
...[...state.exhaustedConnections].map((c) => formatExhaustedConnectionKey(String(c))),
|
||
],
|
||
attemptOrder: state.comboAttemptOrder,
|
||
terminalReason,
|
||
recovery: buildRecoveryHint(terminalReason, retryAfterSeconds),
|
||
});
|
||
|
||
// Retry loop for transient errors
|
||
for (let retry = 0; retry <= deps.maxRetries; retry++) {
|
||
// Fix #1681: Bail out immediately if the client has disconnected
|
||
if (deps.signal?.aborted) {
|
||
deps.log.info("COMBO", `Client disconnected — aborting combo loop before model ${modelStr}`);
|
||
return { ok: false, response: errorResponse(499, "Client disconnected") };
|
||
}
|
||
state.globalAttempts++;
|
||
if (state.globalAttempts > maxGlobalAttempts) {
|
||
deps.log.warn(
|
||
"COMBO",
|
||
`Maximum combo attempts (${maxGlobalAttempts}) exceeded across all targets and fallbacks. Terminating loop to prevent runaway background requests.`
|
||
);
|
||
// Actionable failure instead of an opaque 503 when every candidate
|
||
// failed the same recoverable way. If the dominant cause was reasoning
|
||
// models exhausting a too-small max_tokens budget (no content output),
|
||
// retrying other models can't help — tell the caller to raise max_tokens.
|
||
// Silent-stop fix: bump the consecutive-failure counter for this session-combo pair
|
||
// so the pin gets cleared on the 3rd attempt (recovery.next_step tells the client).
|
||
const reasoningExhausted = /reasoning consumed \d+\/\d+ tokens/.test(state.lastError || "");
|
||
const failureReason = reasoningExhausted
|
||
? "reasoning_budget_exhausted"
|
||
: "max_attempts_exceeded";
|
||
recordComboFailure(deps.effectiveSessionId, deps.combo.name);
|
||
return {
|
||
ok: false,
|
||
response: errorResponseWithComboDiagnostics(
|
||
503,
|
||
reasoningExhausted
|
||
? "All combo candidates exhausted their token budget on reasoning without producing content. Increase max_tokens — reasoning models need a larger budget to emit content."
|
||
: "Maximum combo retry limit reached",
|
||
buildComboDiag(failureReason)
|
||
),
|
||
};
|
||
}
|
||
// Predictive TTFT Circuit Breaker (skip slow models)
|
||
if (
|
||
deps.config.zeroLatencyOptimizationsEnabled === true &&
|
||
deps.config.predictiveTtftMs &&
|
||
deps.config.predictiveTtftMs > 0 &&
|
||
retry === 0
|
||
) {
|
||
const cMetrics = getComboMetrics(deps.combo.name);
|
||
if (cMetrics) {
|
||
const targetKey = state.orderedTargets[i].executionKey || modelStr;
|
||
const m = cMetrics.byTarget[targetKey] || cMetrics.byModel[modelStr];
|
||
if (shouldSkipForPredictedTtft(m, deps.config.predictiveTtftMs)) {
|
||
deps.log.warn(
|
||
"COMBO",
|
||
`Predictive TTFT Circuit Breaker: skipping ${modelStr} (avg ${m.avgLatencyMs}ms > max ${deps.config.predictiveTtftMs}ms)`
|
||
);
|
||
recordComboDecision(deps.traceInvocationId, {
|
||
step: target.executionKey,
|
||
target: modelStr,
|
||
decision: "skipped_before_dispatch",
|
||
reason: "predictive_ttft",
|
||
});
|
||
return stopProtectedPriorityTarget(`Predictive latency check rejected ${modelStr}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (retry > 0) {
|
||
deps.log.info(
|
||
"COMBO",
|
||
`Retrying ${modelStr} in ${retryDelayMs}ms (attempt ${retry + 1}/${deps.maxRetries + 1})`
|
||
);
|
||
await new Promise((resolve) => {
|
||
const timer = setTimeout(resolve, retryDelayMs);
|
||
deps.signal?.addEventListener(
|
||
"abort",
|
||
() => {
|
||
clearTimeout(timer);
|
||
resolve(undefined);
|
||
},
|
||
{ once: true }
|
||
);
|
||
});
|
||
if (deps.signal?.aborted) {
|
||
deps.log.info("COMBO", `Client disconnected during retry delay — aborting`);
|
||
return { ok: false, response: errorResponse(499, "Client disconnected") };
|
||
}
|
||
|
||
// Retry re-check: a sibling attempt (or attempt 1) may have persisted
|
||
// a quota cooldown while this attempt was sleeping out its retry delay
|
||
// ("Trying model 1/7: zai/glm-5.3 (retry 1)" after "already marked
|
||
// unavailable until …"). Reads fresh, not cached: see readConnectionForCooldownGate.
|
||
const persistedRetrySkip = await resolvePersistedConnectionCooldownSkipReason(
|
||
target,
|
||
(id) => readConnectionForCooldownGate(id, true),
|
||
allowRateLimitedConnection
|
||
);
|
||
if (persistedRetrySkip) {
|
||
deps.log.info("COMBO", persistedRetrySkip);
|
||
if (i > 0) state.fallbackCount++;
|
||
return null;
|
||
}
|
||
}
|
||
|
||
deps.log.info(
|
||
"COMBO",
|
||
`Trying model ${i + 1}/${state.orderedTargets.length}: ${modelStr}${retry > 0 ? ` (retry ${retry})` : ""}`
|
||
);
|
||
emit("combo.target.attempt", {
|
||
comboName: deps.combo.name,
|
||
targetIndex: i,
|
||
provider,
|
||
model: modelStr,
|
||
timestamp: Date.now(),
|
||
strategy: deps.strategy,
|
||
});
|
||
// QA P0 diagnostics: capture the attempt order (provider/model ids only).
|
||
state.comboAttemptOrder.push({ provider: provider ?? "unknown", model: modelStr });
|
||
|
||
// Copy-on-write, not a deep clone (#7847 — 9.53 MiB at 3 targets). Writes here are
|
||
// top-level scalars. Invariant: tests/unit/combo-attempt-body-isolation-7847.test.ts.
|
||
let attemptBody = { ...(deps.body as Record<string, unknown>) } as typeof deps.body;
|
||
|
||
// Proactive Context Compression for fallbacks (Zero-Latency optimization)
|
||
if (
|
||
deps.config.zeroLatencyOptimizationsEnabled === true &&
|
||
i > 0 &&
|
||
deps.config.fallbackCompressionMode &&
|
||
deps.config.fallbackCompressionMode !== "off"
|
||
) {
|
||
const { estimateTokens } = await import("../contextManager.ts");
|
||
// #7847: object, not JSON.stringify — the string branch mis-counts inline images.
|
||
const estimatedTokens = estimateTokens(attemptBody);
|
||
if (estimatedTokens > (deps.config.fallbackCompressionThreshold ?? 1000)) {
|
||
const { applyCompression } = await import("../compression/strategySelector.ts");
|
||
const compressionResult = applyCompression(
|
||
attemptBody,
|
||
deps.config.fallbackCompressionMode as CompressionMode,
|
||
// Opt into the TV1 bail-out so a throwing fallback engine is SKIPPED rather than
|
||
// propagating out of executeTarget and being swallowed as a "Speculative task
|
||
// error" (which silently drops this combo target). minGainPercent:0 keeps the
|
||
// advance behavior identical to the default path — this only adds skip-on-throw.
|
||
{ model: modelStr, bailout: { enabled: true, minGainPercent: 0 } }
|
||
);
|
||
if (compressionResult.compressed) {
|
||
deps.log.info(
|
||
"COMBO",
|
||
`Proactive fallback compression applied (${deps.config.fallbackCompressionMode}): ${estimatedTokens} -> ${compressionResult.stats?.compressedTokens} tokens`
|
||
);
|
||
attemptBody = compressionResult.body;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Universal handoff: inject existing handoff if model changed. i === 0
|
||
// only: a fallback target (i > 0) serves the SAME client request the
|
||
// failed primary target would have served, with the original messages
|
||
// already intact -- there's nothing to hand off, since the client never
|
||
// saw the earlier target fail. Injecting a handoff note there replaces
|
||
// real context with a context-free note, which weaker fallback models
|
||
// have been observed treating as license to fabricate content instead
|
||
// of just answering the actual request (#12227 follow-up).
|
||
if (
|
||
i === 0 &&
|
||
universalHandoffConfig.enabled &&
|
||
deps.relayOptions?.sessionId &&
|
||
!(deps.body as Record<string, unknown>)?.[SKIP_UNIVERSAL_HANDOFF_FLAG]
|
||
) {
|
||
const lastModel = getLastSessionModel(deps.relayOptions.sessionId, deps.combo.name);
|
||
if (lastModel && lastModel !== modelStr) {
|
||
const existingHandoff = getHandoff(deps.relayOptions.sessionId, deps.combo.name);
|
||
attemptBody = injectUniversalHandoffBody(
|
||
attemptBody, // Use the cloned body to maintain isolation
|
||
lastModel,
|
||
modelStr,
|
||
`Model routing: ${lastModel} → ${modelStr}`,
|
||
existingHandoff,
|
||
universalHandoffConfig.relayMode
|
||
);
|
||
}
|
||
}
|
||
|
||
// Issue #3587: Reasoning models can spend the whole output budget on
|
||
// reasoning. Only add headroom when the complete buffer fits inside the
|
||
// model's known output cap; otherwise preserve the client's explicit limit.
|
||
{
|
||
const bodyRecord = attemptBody as Record<string, unknown>;
|
||
const currentMaxTokens = toPositiveInteger(bodyRecord.max_tokens);
|
||
const bufferedMaxTokens = resolveReasoningBufferedMaxTokens(modelStr, bodyRecord.max_tokens, {
|
||
enabled: deps.reasoningTokenBufferEnabled !== false,
|
||
});
|
||
if (currentMaxTokens !== null && bufferedMaxTokens !== null) {
|
||
bodyRecord.max_tokens = bufferedMaxTokens;
|
||
if (bufferedMaxTokens !== currentMaxTokens) {
|
||
deps.log.info(
|
||
"COMBO",
|
||
`Reasoning model ${modelStr}: adjusted max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// #5501: server-side template expansion for the combo system_message —
|
||
// resolved per-target, scoped to combo-injected content only (never
|
||
// client-owned system messages). Gate: a non-empty combo system_message.
|
||
attemptBody = expandComboSystemPromptIfPresent(attemptBody, deps.combo, {
|
||
modelId: modelStr,
|
||
providerId: provider !== "unknown" ? provider : "",
|
||
account:
|
||
typeof target.label === "string" && target.label.trim().length > 0
|
||
? target.label.trim()
|
||
: "",
|
||
fingerprint: resolveTargetFingerprint(target) ?? "",
|
||
});
|
||
// #10681: record dispatch once per target (retries keep the first decision).
|
||
if (!state.dispatchedTargets.has(target.executionKey)) {
|
||
state.dispatchedTargets.add(target.executionKey);
|
||
recordComboDecision(deps.traceInvocationId, {
|
||
step: target.executionKey,
|
||
target: modelStr,
|
||
decision: "dispatched",
|
||
});
|
||
}
|
||
const result = await deps.handleSingleModelWithTimeout(attemptBody, modelStr, {
|
||
...targetForAttempt,
|
||
effectiveComboStrategy: deps.strategy,
|
||
failoverBeforeRetry: deps.config.failoverBeforeRetry,
|
||
});
|
||
|
||
// Success — validate response quality before returning
|
||
if (result.ok) {
|
||
const selectedConnectionId =
|
||
result.headers?.get("X-OmniRoute-Selected-Connection-Id") ||
|
||
result.headers?.get("x-omniroute-selected-connection-id") ||
|
||
undefined;
|
||
const effectiveConnectionId = selectedConnectionId || target.connectionId || "";
|
||
|
||
// Clone BEFORE quality check — validateResponseQuality reads the body
|
||
// via getReader() which locks the stream. The clone's body is consumed
|
||
// by the quality check; the original stays unlocked for piping.
|
||
let qualityClone: Response;
|
||
try {
|
||
qualityClone = result.clone();
|
||
} catch {
|
||
qualityClone = result;
|
||
}
|
||
const quality = await validateResponseQuality(
|
||
qualityClone,
|
||
deps.clientRequestedStream,
|
||
deps.log,
|
||
deps.config.responseValidation as ResponseValidationConfig | null | undefined
|
||
);
|
||
releaseQualityClone(qualityClone, result, quality);
|
||
if (!quality.valid) {
|
||
releaseRejectedQualityResponse(qualityClone, result);
|
||
deps.log.warn(
|
||
"COMBO",
|
||
`Model ${modelStr} returned 200 but failed quality check: ${quality.reason}`
|
||
);
|
||
// #6692: a quality-rejected 200 never marks the connection row
|
||
// unhealthy, so the sticky pin's lazy headroom recheck would never
|
||
// catch it either — release it here, on the failing response.
|
||
deps.releaseStickyPinOnFailure(deps.sticky.messageHash, effectiveConnectionId);
|
||
recordComboRequest(deps.combo.name, modelStr, {
|
||
success: false,
|
||
latencyMs: Date.now() - deps.startTime,
|
||
fallbackCount: state.fallbackCount,
|
||
strategy: deps.strategy,
|
||
target: toRecordedTarget(target),
|
||
});
|
||
state.recordedAttempts++;
|
||
// Fix #1707: Set terminal state so the fallback doesn't emit
|
||
// misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality.
|
||
state.lastError = `Upstream response failed quality validation: ${quality.reason}`;
|
||
state.lastStatus = 502;
|
||
// #10314: record quality failures as a FIRST-CLASS per-target outcome
|
||
// so a quality reason is never silently dropped from the aggregated
|
||
// terminal message when a later sibling overwrites lastError.
|
||
state.comboErrors.push({
|
||
model: modelStr,
|
||
status: 502,
|
||
error: quality.reason || "upstream response failed quality validation",
|
||
kind: "quality",
|
||
});
|
||
if (i > 0) state.fallbackCount++;
|
||
if (provider && rawModel) {
|
||
const mlSettings = resolveModelLockoutSettings(deps.settings);
|
||
if (mlSettings.enabled && mlSettings.errorCodes.includes(502)) {
|
||
recordModelLockoutFailure(
|
||
provider,
|
||
target.connectionId || "",
|
||
rawModel,
|
||
"quality_failure",
|
||
502,
|
||
mlSettings.baseCooldownMs,
|
||
profile,
|
||
{
|
||
exactCooldownMs: mlSettings.useExponentialBackoff ? 0 : mlSettings.baseCooldownMs,
|
||
maxCooldownMs: mlSettings.maxCooldownMs,
|
||
}
|
||
);
|
||
}
|
||
}
|
||
emit("combo.target.failed", {
|
||
comboName: deps.combo.name,
|
||
targetIndex: i,
|
||
provider,
|
||
model: modelStr,
|
||
error: `Quality: ${quality.reason}`,
|
||
latencyMs: Date.now() - deps.startTime,
|
||
});
|
||
state.observeFailure(false, target.executionKey);
|
||
return protectedPriorityTarget
|
||
? {
|
||
ok: false,
|
||
response: errorResponse(502, "Upstream response failed quality validation"),
|
||
}
|
||
: null;
|
||
}
|
||
|
||
if (Boolean(deps.clientManagedResponsesContext) && effectiveConnectionId) {
|
||
pinNativeCodexTurn({
|
||
body: deps.body,
|
||
comboName: deps.combo.name,
|
||
target,
|
||
connectionId: effectiveConnectionId,
|
||
});
|
||
}
|
||
|
||
// Success decay: a healthy response walks the model's lockout failure
|
||
// count back down (and eventually clears an expired lockout entirely).
|
||
if (provider && rawModel) {
|
||
const dcResult = decayModelFailureCount(provider, effectiveConnectionId, rawModel);
|
||
if (dcResult.cleared) {
|
||
deps.log.info("COMBO", `Model ${modelStr} fully recovered — lockout cleared`);
|
||
} else if (dcResult.newFailureCount > 0) {
|
||
deps.log.debug(
|
||
"COMBO",
|
||
`Model ${modelStr} decayed to failureCount=${dcResult.newFailureCount}`
|
||
);
|
||
}
|
||
}
|
||
|
||
const latencyMs = Date.now() - deps.startTime;
|
||
emit("combo.target.succeeded", {
|
||
comboName: deps.combo.name,
|
||
targetIndex: i,
|
||
provider,
|
||
model: modelStr,
|
||
latencyMs,
|
||
});
|
||
deps.log.info(
|
||
"COMBO",
|
||
`Model ${modelStr} succeeded (${latencyMs}ms, ${state.fallbackCount} fallbacks)`
|
||
);
|
||
recordComboRequest(deps.combo.name, modelStr, {
|
||
success: true,
|
||
latencyMs,
|
||
fallbackCount: state.fallbackCount,
|
||
strategy: deps.strategy,
|
||
target: toRecordedTarget(target),
|
||
});
|
||
state.recordedAttempts++;
|
||
|
||
// Reset cooldown on success
|
||
if (provider && provider !== "unknown") {
|
||
recordProviderSuccess(provider, effectiveConnectionId || undefined);
|
||
}
|
||
if (deps.strategy === "weighted" && (deps.stickyWeightedLimit ?? 0) > 1) {
|
||
const stickySuccessKey = deps.getWeightedStepKeyForTarget?.(target);
|
||
if (stickySuccessKey) {
|
||
recordStickyWeightedSuccess(
|
||
deps.combo.name,
|
||
stickySuccessKey,
|
||
deps.stickyWeightedLimit ?? 0
|
||
);
|
||
}
|
||
}
|
||
// Webhook fan-out: best-effort, never blocks the response stream.
|
||
notifyWebhookEvent("request.completed", {
|
||
combo: deps.combo.name,
|
||
provider,
|
||
model: modelStr,
|
||
account:
|
||
typeof target.label === "string" && target.label.trim().length > 0
|
||
? target.label.trim()
|
||
: "",
|
||
accountId: effectiveConnectionId ?? "",
|
||
latencyMs,
|
||
fallbackCount: state.fallbackCount,
|
||
});
|
||
|
||
// Silent-stop fix: reset the consecutive-failure counter for this session-combo pair
|
||
// on every successful dispatch so a transient recovery doesn't get "credited" against
|
||
// the threshold the user already paid through to clear the stale pin.
|
||
if (deps.effectiveSessionId) {
|
||
clearComboFailureTracking(deps.effectiveSessionId, deps.combo.name);
|
||
}
|
||
// Context cache pinning: record model usage for session-based pinning
|
||
// (independent of universal handoff — always fires when context_cache_protection is on)
|
||
// #3825: write under the SAME effectiveSessionId used by the read site so a
|
||
// sessionless conversation re-pins to this model on its next turn.
|
||
if (
|
||
deps.combo.context_cache_protection &&
|
||
deps.effectiveSessionId &&
|
||
!(deps.body as Record<string, unknown>)?.[SKIP_UNIVERSAL_HANDOFF_FLAG]
|
||
) {
|
||
recordSessionModelUsage(
|
||
deps.effectiveSessionId,
|
||
deps.combo.name,
|
||
modelStr,
|
||
provider,
|
||
target.connectionId ?? undefined
|
||
);
|
||
}
|
||
|
||
// Universal handoff: record model usage for session
|
||
if (
|
||
universalHandoffConfig.enabled &&
|
||
deps.relayOptions?.sessionId &&
|
||
!(deps.body as Record<string, unknown>)?.[SKIP_UNIVERSAL_HANDOFF_FLAG]
|
||
) {
|
||
const prevModel = getLastSessionModel(deps.relayOptions.sessionId, deps.combo.name);
|
||
recordSessionModelUsage(
|
||
deps.relayOptions.sessionId,
|
||
deps.combo.name,
|
||
modelStr,
|
||
provider,
|
||
target.connectionId ?? undefined
|
||
);
|
||
// i === 0 only: a same-request fallback target (i > 0) never
|
||
// needs a summary generated for it -- see the injection-site
|
||
// comment above. recordSessionModelUsage above stays
|
||
// unconditional regardless of i: it must reflect whichever
|
||
// model actually served THIS response, since the next
|
||
// request's i === 0 comparison depends on that being
|
||
// accurate even when this response came from a fallback.
|
||
if (i === 0 && prevModel && prevModel !== modelStr) {
|
||
const handoffSourceMessages =
|
||
Array.isArray(deps.body?.messages) && deps.body.messages.length > 0
|
||
? deps.body.messages
|
||
: Array.isArray(deps.body?.input)
|
||
? deps.body.input
|
||
: [];
|
||
|
||
maybeGenerateUniversalHandoff({
|
||
sessionId: deps.relayOptions.sessionId,
|
||
comboName: deps.combo.name,
|
||
messages: handoffSourceMessages as MessageLike[],
|
||
prevModel,
|
||
currModel: modelStr,
|
||
universalConfig: universalHandoffConfig,
|
||
handleSingleModel: deps.handleSingleModelWithTimeout,
|
||
});
|
||
}
|
||
|
||
recordSessionModelUsage(
|
||
deps.relayOptions.sessionId,
|
||
deps.combo.name,
|
||
modelStr,
|
||
provider,
|
||
target.connectionId ?? undefined
|
||
);
|
||
}
|
||
// Context-relay intentionally splits responsibilities:
|
||
// combo.ts decides whether a successful turn should generate a handoff,
|
||
// while chat.ts injects the handoff after the real connectionId is resolved.
|
||
if (
|
||
deps.strategy === "context-relay" &&
|
||
deps.relayOptions?.sessionId &&
|
||
deps.relayConfig &&
|
||
(deps.relayConfig.handoffProviders ?? []).includes(provider) &&
|
||
provider === "codex"
|
||
) {
|
||
const connectionId = getSessionConnection(deps.relayOptions.sessionId);
|
||
if (connectionId) {
|
||
const quotaInfo = await fetchCodexQuota(connectionId).catch(() => null);
|
||
if (quotaInfo) {
|
||
const resetCandidates = [
|
||
quotaInfo.windows?.session?.resetAt,
|
||
quotaInfo.windows?.weekly?.resetAt,
|
||
quotaInfo.resetAt,
|
||
]
|
||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||
.sort((a, b) => a.localeCompare(b));
|
||
const handoffSourceMessages =
|
||
Array.isArray(deps.body?.messages) && deps.body.messages.length > 0
|
||
? deps.body.messages
|
||
: Array.isArray(deps.body?.input)
|
||
? deps.body.input
|
||
: [];
|
||
|
||
maybeGenerateHandoff({
|
||
sessionId: deps.relayOptions.sessionId,
|
||
comboName: deps.combo.name,
|
||
connectionId,
|
||
percentUsed: quotaInfo.percentUsed,
|
||
messages: handoffSourceMessages,
|
||
model: modelStr,
|
||
expiresAt: resetCandidates[0] || null,
|
||
config: deps.relayConfig,
|
||
handleSingleModel: deps.handleSingleModelWithTimeout,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if (deps.sticky.messageHash && target.connectionId)
|
||
recordStickyBinding(deps.sticky.messageHash, target.connectionId); // LKGP (#919):
|
||
if (provider) {
|
||
const connId = effectiveConnectionId || undefined;
|
||
void (async () => {
|
||
try {
|
||
const { setLKGP } = await import("@/lib/db/settings");
|
||
await Promise.all([
|
||
setLKGP(deps.combo.name, target.executionKey, provider, connId),
|
||
setLKGP(deps.combo.name, deps.combo.id || deps.combo.name, provider, connId),
|
||
]);
|
||
} catch (err) {
|
||
deps.log.warn(
|
||
"COMBO",
|
||
"Failed to record Last Known Good Provider. This is non-fatal.",
|
||
{
|
||
err,
|
||
}
|
||
);
|
||
}
|
||
})();
|
||
}
|
||
|
||
return { ok: true, response: result };
|
||
}
|
||
|
||
// Extract error info from response
|
||
let errorText = result.statusText || "";
|
||
let errorBody: ComboErrorBody = null;
|
||
let retryAfter: ComboRetryAfter | null = null;
|
||
try {
|
||
const cloned = result.clone();
|
||
try {
|
||
const text = await cloned.text();
|
||
if (text) {
|
||
errorText = text.substring(0, 500);
|
||
errorBody = JSON.parse(text);
|
||
const parsedError = errorBody?.error;
|
||
errorText =
|
||
(typeof parsedError === "object" && parsedError?.message) ||
|
||
(typeof parsedError === "string" ? parsedError : null) ||
|
||
errorBody?.message ||
|
||
errorText;
|
||
// Live incident (log id 1784457764961-73 follow-up): the pre-dispatch
|
||
// "all credentials cooling down" rejection (buildModelCooldownBody /
|
||
// handleNoCredentials in src/sse/handlers/chatHelpers.ts) nests its
|
||
// retry hint as error.retry_after (ISO string) / error.reset_seconds
|
||
// (seconds), not the top-level `retryAfter` every other 429 shape
|
||
// uses. Without this fallback, lastStatus gets recorded (fixed above)
|
||
// but earliestRetryAfter stays null, so the final check falls through
|
||
// to the generic "all combo models unavailable" error instead of ever
|
||
// reaching the cooldown-wait decision — same class of bug, different
|
||
// response shape.
|
||
const nestedRetryAfter =
|
||
typeof parsedError === "object" ? (parsedError?.retry_after ?? null) : null;
|
||
const nestedResetSeconds =
|
||
typeof parsedError === "object" ? (parsedError?.reset_seconds ?? null) : null;
|
||
retryAfter =
|
||
errorBody?.retryAfter ||
|
||
nestedRetryAfter ||
|
||
(typeof nestedResetSeconds === "number" && nestedResetSeconds > 0
|
||
? new Date(Date.now() + nestedResetSeconds * 1000).toISOString()
|
||
: null);
|
||
}
|
||
} catch {
|
||
/* Clone parse failed */
|
||
}
|
||
} catch {
|
||
/* Clone failed */
|
||
}
|
||
|
||
// Track earliest retryAfter
|
||
if (
|
||
retryAfter &&
|
||
(!state.earliestRetryAfter || new Date(retryAfter) < new Date(state.earliestRetryAfter))
|
||
) {
|
||
state.earliestRetryAfter = retryAfter;
|
||
}
|
||
|
||
// Normalize error text
|
||
if (typeof errorText !== "string") {
|
||
try {
|
||
errorText = JSON.stringify(errorText);
|
||
} catch {
|
||
errorText = String(errorText);
|
||
}
|
||
}
|
||
|
||
const isStreamReadinessFailure =
|
||
(result.status === 502 || result.status === 504) &&
|
||
isStreamReadinessFailureErrorBody(errorBody);
|
||
// An early EOF is an upstream failure, not a readiness probe — the breaker must
|
||
// see it even though the transient-retry path below treats both codes alike.
|
||
const isStreamEarlyEof =
|
||
(result.status === 502 || result.status === 504) && isStreamEarlyEofErrorBody(errorBody);
|
||
|
||
// FIX 5: a local per-API-key token-limit 429 must not cool shared accounts.
|
||
const isTokenLimitBreach = result.status === 429 && isTokenLimitBreachErrorBody(errorBody);
|
||
const isLocalQueueCapacity = isLocalQueueCapacityErrorBody(errorBody);
|
||
|
||
// Fix #1681: Status 499 means client disconnected — stop combo loop immediately.
|
||
// There is no point trying fallback models when nobody is listening.
|
||
if (result.status === 499) {
|
||
deps.log.info("COMBO", `Client disconnected (499) during ${modelStr} — stopping combo loop`);
|
||
recordComboRequest(deps.combo.name, modelStr, {
|
||
success: false,
|
||
latencyMs: Date.now() - deps.startTime,
|
||
fallbackCount: state.fallbackCount,
|
||
strategy: deps.strategy,
|
||
target: toRecordedTarget(target),
|
||
});
|
||
state.recordedAttempts++;
|
||
// executeTarget must return the {ok,response} contract — a raw Response
|
||
// here makes the speculative loop's res.ok/res.response checks both miss,
|
||
// so the combo would wrongly fall through to the next model after a 499.
|
||
return { ok: false, response: result };
|
||
}
|
||
if (isLocalQueueCapacity) {
|
||
deps.log.info(
|
||
"COMBO",
|
||
`Local rate-limit queue capacity reached for ${modelStr} — returning without upstream fallback`
|
||
);
|
||
recordComboRequest(deps.combo.name, modelStr, {
|
||
success: false,
|
||
latencyMs: Date.now() - deps.startTime,
|
||
fallbackCount: state.fallbackCount,
|
||
strategy: deps.strategy,
|
||
target: toRecordedTarget(target),
|
||
});
|
||
state.recordedAttempts++;
|
||
if (i > 0) state.fallbackCount++;
|
||
return { ok: false, response: result };
|
||
}
|
||
|
||
// Combo fallback is target-level orchestration: a non-ok target response is
|
||
// treated as local to that target and the combo continues to the next target.
|
||
// Error classification is retained only for retry/cooldown pacing; it must
|
||
// not decide whether fallback happens, including for generic 400 responses.
|
||
const rawError = errorBody?.error;
|
||
const structuredError =
|
||
rawError && typeof rawError === "object"
|
||
? {
|
||
// Upstream JSON may carry a numeric `code`/`type` (e.g. {"code":40001}).
|
||
// Coerce to string if present instead of discarding, so downstream string
|
||
// ops (.toLowerCase, .startsWith) can run safely without type crashes.
|
||
code:
|
||
(rawError as Record<string, unknown>).code !== undefined &&
|
||
(rawError as Record<string, unknown>).code !== null
|
||
? String((rawError as Record<string, unknown>).code)
|
||
: undefined,
|
||
type:
|
||
(rawError as Record<string, unknown>).type !== undefined &&
|
||
(rawError as Record<string, unknown>).type !== null
|
||
? String((rawError as Record<string, unknown>).type)
|
||
: undefined,
|
||
}
|
||
: undefined;
|
||
const scopedFailure = isScopedFailure(result, errorText, structuredError);
|
||
|
||
// #8375: input-bound request-scoped failures (context_length_exceeded) are
|
||
// deterministic for the same input — retrying on other accounts of the same
|
||
// model will fail identically. Short-circuit the combo immediately with the
|
||
// original error instead of burning MAX_GLOBAL_ATTEMPTS.
|
||
// Scoped to homogeneous remainders only: a heterogeneous combo (#6637) may
|
||
// have a later target with a different, larger context window that would
|
||
// NOT reject the same input — isContextOverflow400 below exists precisely to
|
||
// let that case fall through, so only short-circuit when every remaining
|
||
// target is the same model (the "retrying will fail identically" premise
|
||
// only holds within a homogeneous same-model pool).
|
||
const remainderHomogeneous = remainderIsHomogeneous(state.orderedTargets, i, modelStr);
|
||
const isInputBoundFailure = shouldAbortOnInputBoundFailure({
|
||
structuredError,
|
||
remainderIsHomogeneous: remainderHomogeneous,
|
||
});
|
||
if (isInputBoundFailure) {
|
||
deps.log.warn(
|
||
"COMBO",
|
||
`Input-bound request failure from ${modelStr} — aborting combo (same input will fail identically on every account)`
|
||
);
|
||
recordComboRequest(deps.combo.name, modelStr, {
|
||
success: false,
|
||
latencyMs: Date.now() - deps.startTime,
|
||
fallbackCount: state.fallbackCount,
|
||
strategy: deps.strategy,
|
||
target: toRecordedTarget(target),
|
||
});
|
||
state.recordedAttempts++;
|
||
if (i > 0) state.fallbackCount++;
|
||
return { ok: false, response: result };
|
||
}
|
||
const fallbackResult = checkFallbackError(
|
||
result.status,
|
||
errorText,
|
||
0,
|
||
protectedPriorityTarget ? rawModel : null,
|
||
provider,
|
||
result.headers,
|
||
profile,
|
||
structuredError
|
||
);
|
||
const { cooldownMs } = fallbackResult;
|
||
// #6863: a parsed upstream quota reset (e.g. Antigravity "Resets in 92h27m28s")
|
||
// arrives in `quotaResetHintMs` — it bypasses the operator-gated
|
||
// `useUpstreamRetryHints` connection-cooldown setting. Mirror the
|
||
// single-model path (src/sse/services/auth.ts): when the retry hint was
|
||
// already honored, `cooldownMs` IS the upstream value; otherwise prefer the
|
||
// parsed quota reset — even when it is SHORTER than the fallback cooldown
|
||
// (e.g. subscription-quota 1h default vs a real "resets in 10m").
|
||
// `selectLockoutCooldownMs` still ignores hints at/below the base cooldown,
|
||
// so absent/tiny hints keep the #1308 exponential-backoff behavior.
|
||
const lockoutHintMs =
|
||
fallbackResult.usedUpstreamRetryHint === true
|
||
? cooldownMs
|
||
: (fallbackResult.quotaResetHintMs ?? 0);
|
||
// Only a transport header or google.rpc.RetryInfo is authoritative enough
|
||
// to bypass maxCooldownMs. Prose and generic JSON remain useful exact hints,
|
||
// but the operator cap still bounds them.
|
||
const lockoutHintVerified = retryHintBypassesMaxCooldownMs(fallbackResult.retryHintSource);
|
||
const selectedConnectionId =
|
||
result.headers?.get("X-OmniRoute-Selected-Connection-Id") ||
|
||
result.headers?.get("x-omniroute-selected-connection-id") ||
|
||
undefined;
|
||
const targetWithConnection = selectedConnectionId
|
||
? { ...target, connectionId: selectedConnectionId }
|
||
: target;
|
||
|
||
// #1731 / #1731v2: classify the upstream error and update the exhaustion sets
|
||
// (shared with handleRoundRobinCombo). Returns whether the provider is fully exhausted.
|
||
const providerExhausted = applyComboTargetExhaustion(targetWithConnection, {
|
||
result,
|
||
fallbackResult,
|
||
errorText,
|
||
rawModel,
|
||
isTokenLimitBreach,
|
||
allAccountsRateLimited: false,
|
||
requestScopedFailure: scopedFailure,
|
||
sets: {
|
||
exhaustedProviders: state.exhaustedProviders,
|
||
exhaustedConnections: state.exhaustedConnections,
|
||
transientRateLimitedProviders: state.transientRateLimitedProviders,
|
||
},
|
||
log: deps.log,
|
||
tag: "COMBO",
|
||
exhaustedLogLevel: "info",
|
||
structuredError,
|
||
});
|
||
// #6692: this connection was just classified as provider/connection-level
|
||
// exhausted — if it's the currently sticky-bound one, release the pin now
|
||
// rather than waiting for the next turn's lazy headroom/status recheck.
|
||
deps.releaseStickyPinOnFailure(deps.sticky.messageHash, targetWithConnection.connectionId);
|
||
if (
|
||
providerExhausted ||
|
||
state.exhaustedConnections.has(`${provider}:${targetWithConnection.connectionId}`) ||
|
||
(provider && state.exhaustedProviders.has(provider))
|
||
) {
|
||
deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO");
|
||
}
|
||
|
||
// #2101: Prevent infinite fallback loops with 400 Bad Request errors that are genuinely
|
||
// body-specific (malformed JSON, bad format, missing required fields).
|
||
// These should NOT stop the combo:
|
||
// - Context overflow: different models have different context windows
|
||
// - Max_tokens / param errors: different models have different output limits
|
||
// - Model access denied / "not supported": different providers serve different
|
||
// model sets — keep the model in the combo and try the next target (#5249).
|
||
// Wrapper words like "invalid" / "bad request" still stop only when the text is
|
||
// NOT model-scoped (e.g. "invalid message format").
|
||
if (
|
||
shouldSurfaceBodySpecific400({
|
||
status: result.status,
|
||
errorText,
|
||
shouldFallback: fallbackResult.shouldFallback,
|
||
})
|
||
) {
|
||
deps.log.warn(
|
||
"COMBO",
|
||
`400 Bad Request with body-specific error detected on ${modelStr} — skipping fallback to other targets to prevent infinite loop`
|
||
);
|
||
// Record the failure and break to avoid trying other targets with the same bad request
|
||
recordComboRequest(deps.combo.name, modelStr, {
|
||
success: false,
|
||
latencyMs: Date.now() - deps.startTime,
|
||
fallbackCount: state.fallbackCount,
|
||
strategy: deps.strategy,
|
||
target: toRecordedTarget(target),
|
||
});
|
||
state.recordedAttempts++;
|
||
state.lastError = errorText || String(result.status);
|
||
state.comboErrors.push({
|
||
model: modelStr,
|
||
status: result.status,
|
||
error: errorText || String(result.status),
|
||
kind: classifyComboOutcome(result.status, errorText),
|
||
});
|
||
state.lastStatus = result.status;
|
||
if (i > 0) state.fallbackCount++;
|
||
deps.log.warn("COMBO", `Model ${modelStr} failed with body-specific error, stopping combo`);
|
||
deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO");
|
||
// #4279: surface the 400 via the {ok,response} contract so the OUTER
|
||
// target loop resolves the combo and stops. A bare `break` here only
|
||
// exits the inner retry loop; executeTarget then returns null, which
|
||
// the outer loop treats as "this target produced nothing" and advances
|
||
// to the next model — so the guard failed to stop fallback and a combo
|
||
// of N body-rejecting targets tried all N. Mirrors the 499 path above.
|
||
return { ok: false, response: result };
|
||
}
|
||
|
||
// A model-scoped 400 ("The requested model is not supported" / "not
|
||
// available for integrator") is permanent for THIS connection — the
|
||
// account/integration will not gain support for the model mid-session.
|
||
// Combo still advances to the next target immediately (unchanged,
|
||
// preserves #5249's cross-provider fallback), but without a lockout
|
||
// here the SAME dead model gets retried on every future, separate
|
||
// request forever (observed: every auto-combo request wasted several
|
||
// upstream 400s on the same GitHub models, all day). isModelLocked()
|
||
// is checked before dispatch (see the pre-check above this loop), so
|
||
// this lockout is honored on the next request.
|
||
if (result.status === 400 && isModelScoped400(errorText) && provider && rawModel) {
|
||
lockModelIfPerModelQuota(
|
||
provider,
|
||
targetWithConnection.connectionId || "",
|
||
rawModel,
|
||
"model_capacity",
|
||
60 * 60 * 1000 // 1h
|
||
);
|
||
}
|
||
|
||
// Trigger shared provider circuit breaker for 5xx errors and connection failures. If the
|
||
// next target is on the same provider, don't mark it failed (a different model may still
|
||
// succeed) — #8376: EXCEPT a proxy-unreachable failure, which poisons every model alike.
|
||
// G-02: when fallbackResult.skipProviderBreaker is set (embedded service supervisor outage
|
||
// signalled via X-Omni-Fallback-Hint: connection_cooldown) apply cooldown only — never trip.
|
||
const nextTarget = state.orderedTargets[i + 1];
|
||
const sameProviderNext =
|
||
typeof nextTarget?.provider === "string" && nextTarget.provider === provider;
|
||
if (
|
||
shouldRecordProviderBreakerFailure({
|
||
isStreamReadinessFailure,
|
||
isStreamEarlyEof,
|
||
status: result.status,
|
||
sameProviderNext,
|
||
skipProviderBreaker: fallbackResult.skipProviderBreaker,
|
||
requestScopedFailure: scopedFailure,
|
||
error: errorText,
|
||
isProxyUnreachable: structuredError?.code === "proxy_unreachable",
|
||
})
|
||
) {
|
||
const isQueueTimeout =
|
||
errorText.includes("RATE_LIMIT_QUEUE_TIMEOUT") ||
|
||
errorText.includes("RATE_LIMIT_QUEUE_WEDGED");
|
||
recordProviderFailure(provider, deps.log, targetWithConnection.connectionId, profile, {
|
||
isQueueTimeout,
|
||
isNetworkError: structuredError?.code === "proxy_unreachable",
|
||
});
|
||
}
|
||
|
||
const quotaExhausted = await isQuotaExhaustionResponse(result, provider, rawModel, profile);
|
||
recordQuotaExhaustionClassification(result, quotaExhausted);
|
||
state.observeFailure(quotaExhausted, target.executionKey);
|
||
|
||
// Check if this is a transient error worth retrying on same model.
|
||
// A token-limit 429 is terminal for the client — never retry it.
|
||
const isTransient =
|
||
!isStreamReadinessFailure &&
|
||
!isTokenLimitBreach &&
|
||
!scopedFailure &&
|
||
[408, 429, 500, 502, 503, 504].includes(result.status);
|
||
// failoverBeforeRetry means what it says: prefer the next sibling
|
||
// target over hammering this one again. Without this check, a
|
||
// transient error always re-hit the SAME model up to maxRetries
|
||
// times regardless of the setting — config.failoverBeforeRetry was
|
||
// threaded through to skipUpstreamRetry (a different, lower-level
|
||
// retry mechanism) but never consulted here, so a rate-limited
|
||
// model got maxRetries+1 back-to-back attempts on itself before
|
||
// this loop's own fallback-to-next-target ever ran (#2417). Only
|
||
// skip the same-model retry when `nextTarget` (computed above)
|
||
// actually gives us somewhere to fail over to — with no sibling
|
||
// left, skipping just burns the last attempt for nothing.
|
||
//
|
||
// #10217 round-4 fix: this guard reads `failoverBeforeRetryExplicit`
|
||
// (opt-in only), NOT `config.failoverBeforeRetry` — that field
|
||
// defaults to true for the separate skipUpstreamRetry mechanism
|
||
// (see DEFAULT_COMBO_CONFIG comment in comboConfig.ts) and reading
|
||
// it here would silently skip the same-model retry for every combo,
|
||
// not just ones that explicitly opted in.
|
||
if (
|
||
retry < deps.maxRetries &&
|
||
isTransient &&
|
||
!providerExhausted &&
|
||
(!deps.config.failoverBeforeRetryExplicit || !nextTarget)
|
||
) {
|
||
if (
|
||
!protectedPriorityTarget &&
|
||
provider &&
|
||
rawModel &&
|
||
isModelLocked(provider, targetWithConnection.connectionId || "", rawModel)
|
||
) {
|
||
deps.log.info("COMBO", `Skipping retry for ${modelStr} — model lockout active`);
|
||
// Live incident (log id 1784457764961-73): earliestRetryAfter is already
|
||
// captured above from THIS dispatch's own response, but lastStatus was
|
||
// never recorded on this bail-out path — so once every target in the set
|
||
// hit an existing lockout, lastStatus stayed null and the final `if
|
||
// (!lastStatus)` check crystallized an immediate ALL_ACCOUNTS_INACTIVE 503
|
||
// instead of ever reaching the `if (earliestRetryAfter)` cooldown-wait
|
||
// decision below, even though a real 429 with a short (~1min) retry-after
|
||
// was just observed. Recording it here mirrors the "done retrying" path.
|
||
state.lastError = errorText || String(result.status);
|
||
state.lastStatus = result.status;
|
||
if (i > 0) state.fallbackCount++;
|
||
return null;
|
||
}
|
||
// Record model lockout immediately on the first transient failure —
|
||
// once the model is cooling down, retrying it would waste an upstream
|
||
// call and extend the cooldown via exponential backoff.
|
||
let lockoutRecorded = false;
|
||
if (!protectedPriorityTarget && provider && rawModel && retry === 0 && !scopedFailure) {
|
||
const mlSettings = resolveModelLockoutSettings(deps.settings);
|
||
if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) {
|
||
recordModelLockoutFailure(
|
||
provider,
|
||
targetWithConnection.connectionId || "",
|
||
rawModel,
|
||
classifyLockoutReason(result.status),
|
||
result.status,
|
||
mlSettings.baseCooldownMs,
|
||
profile,
|
||
{
|
||
// #1308/#6863: honor a long upstream reset (e.g. "Resets in 160h") over
|
||
// the short base cooldown / exponential backoff when present. #7940's
|
||
// maxCooldownMs cap only applies to synthetic values — a verified
|
||
// upstream reset (lockoutHintVerified) bypasses it.
|
||
exactCooldownMs: selectLockoutCooldownMs(lockoutHintMs, mlSettings),
|
||
maxCooldownMs: mlSettings.maxCooldownMs,
|
||
// Preserve authoritative structured/header resets; clamp body prose.
|
||
exactCooldownIsUpstreamReset: lockoutHintVerified,
|
||
}
|
||
);
|
||
lockoutRecorded = true;
|
||
}
|
||
}
|
||
if (lockoutRecorded) {
|
||
deps.log.info("COMBO", `Skipping retry for ${modelStr} — model lockout active`);
|
||
// Same fix as the already-locked branch above — this is the
|
||
// first-failure lockout path, so lastStatus needs recording here too.
|
||
state.lastError = errorText || String(result.status);
|
||
state.lastStatus = result.status;
|
||
if (i > 0) state.fallbackCount++;
|
||
return null;
|
||
}
|
||
continue; // Retry same model (transient error, no lockout recorded)
|
||
}
|
||
|
||
// Done retrying this model
|
||
const protectedTargetTrust = state.targetFailureTrust.get(target.executionKey);
|
||
if (
|
||
protectedPriorityTarget &&
|
||
(!protectedTargetTrust?.observedFailure || !protectedTargetTrust.allObservedFailuresQuota)
|
||
) {
|
||
recordComboRequest(deps.combo.name, modelStr, {
|
||
success: false,
|
||
latencyMs: Date.now() - deps.startTime,
|
||
fallbackCount: state.fallbackCount,
|
||
strategy: deps.strategy,
|
||
target: toRecordedTarget(target),
|
||
});
|
||
state.recordedAttempts++;
|
||
return { ok: false, response: result };
|
||
}
|
||
recordComboRequest(deps.combo.name, modelStr, {
|
||
success: false,
|
||
latencyMs: Date.now() - deps.startTime,
|
||
fallbackCount: state.fallbackCount,
|
||
strategy: deps.strategy,
|
||
target: toRecordedTarget(target),
|
||
});
|
||
// LKGP (#919) mirror of the success-path set below: a just-failed target
|
||
// must not keep re-pinning itself as the "last known good" choice for the
|
||
// *next* separate request. Circuit breaker / model lockout deliberately
|
||
// don't react to request-scoped failure classes (see scopedFailure below),
|
||
// so nothing else clears this stale pin.
|
||
deps.clearStaleLKGP(deps.combo.name, target.executionKey, deps.combo.id, deps.log, "COMBO");
|
||
state.recordedAttempts++;
|
||
state.lastError = errorText || String(result.status);
|
||
state.comboErrors.push({
|
||
model: modelStr,
|
||
status: result.status,
|
||
error: errorText || String(result.status),
|
||
kind: classifyComboOutcome(result.status, errorText),
|
||
});
|
||
state.lastStatus = result.status;
|
||
if (i > 0) state.fallbackCount++;
|
||
// Wire combo failures into the resilience dashboard (model-level lockout)
|
||
// alongside the provider-level cooldown below — they govern different scopes.
|
||
if (provider && rawModel && !scopedFailure) {
|
||
const mlSettings = resolveModelLockoutSettings(deps.settings);
|
||
if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) {
|
||
recordModelLockoutFailure(
|
||
provider,
|
||
targetWithConnection.connectionId || "",
|
||
rawModel,
|
||
classifyLockoutReason(result.status),
|
||
result.status,
|
||
mlSettings.baseCooldownMs,
|
||
profile,
|
||
{
|
||
// #1308/#6863: honor a long upstream reset over base/exponential cooldown.
|
||
// #7940's maxCooldownMs cap only applies to synthetic values — a verified
|
||
// upstream reset (lockoutHintVerified) bypasses it.
|
||
exactCooldownMs: selectLockoutCooldownMs(lockoutHintMs, mlSettings),
|
||
maxCooldownMs: mlSettings.maxCooldownMs,
|
||
// Preserve authoritative structured/header resets; clamp body prose.
|
||
exactCooldownIsUpstreamReset: lockoutHintVerified,
|
||
}
|
||
);
|
||
}
|
||
}
|
||
deps.log.warn("COMBO", `Model ${modelStr} failed, trying next`, {
|
||
status: result.status,
|
||
errorBody: redactConnectionLabel(errorText),
|
||
});
|
||
|
||
// #5976: per-model-quota providers (Gemini, GitHub, etc.) multiplex models
|
||
// behind one connection. A model-level 500 or 429 (RPM) must NOT cool down
|
||
// the entire provider — sibling models may still succeed. Skip cooldown
|
||
// recording for these providers on 500/429 errors so the next target can try.
|
||
if (
|
||
deps.resilienceSettings.providerCooldown.enabled &&
|
||
provider &&
|
||
provider !== "unknown" &&
|
||
!scopedFailure &&
|
||
!((result.status === 500 || result.status === 429) && hasPerModelQuota(provider, rawModel))
|
||
) {
|
||
recordProviderCooldown(
|
||
provider,
|
||
targetWithConnection.connectionId ?? undefined,
|
||
deps.resilienceSettings
|
||
);
|
||
}
|
||
|
||
const fallbackWaitMs =
|
||
fallbackDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS
|
||
? Math.min(cooldownMs, fallbackDelayMs)
|
||
: 0;
|
||
if ([502, 503, 504].includes(result.status) && fallbackWaitMs > 0) {
|
||
deps.log.debug?.("COMBO", `Waiting ${fallbackWaitMs}ms before fallback to next model`);
|
||
await new Promise((resolve) => {
|
||
const timer = setTimeout(resolve, fallbackWaitMs);
|
||
deps.signal?.addEventListener(
|
||
"abort",
|
||
() => {
|
||
clearTimeout(timer);
|
||
resolve(undefined);
|
||
},
|
||
{ once: true }
|
||
);
|
||
});
|
||
if (deps.signal?.aborted) {
|
||
deps.log.info("COMBO", `Client disconnected during fallback wait — aborting`);
|
||
return { ok: false, response: errorResponse(499, "Client disconnected") };
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
return null;
|
||
}
|