Files
OmniRoute/open-sse/services/combo/comboPredicates.ts
Dizzle 9f0d54a48f fix(combo): parse numeric-epoch rateLimitedUntil in hasFutureRateLimitUntil (#13141)
Correct and well-traced: `rate_limited_until` is TEXT but one write path stores a bare epoch that SQLite coerces to `"1781696905131.0"`, which `new Date()` alone reads as `NaN` — so a still-cooling connection looked available and combo fed it traffic that could only come back 429. Routing both readers through the existing tolerant normalizer is the minimal fix, and keeping unreadable values fail-open is the right default. The #3954/#3995 lineage explains exactly why this function never inherited the normalization.

This PR also carries the batch's file-size rebaseline, since it merges first and the ceiling has to cover every intermediate state.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the rest of this batch — zero conflicts between them.

- `typecheck:core` clean; `check:changelog-integrity` OK
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 86 focused assertions green across the batch's 10 unit test files, plus 16/16 on the v1 plugin option schema and 16/16 on the v2 option tests
- `check-file-size` rebaselined for this batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_maxmad_opencode`, landed on #13141). `open-sse/utils/stream.ts` was deliberately left frozen: it is already 3115 > 3098 on the pure tip with zero contribution from this batch.

⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and the `stream.ts` freeze above). None of them touch these diffs.

Thanks @maxmad64bis.
2026-09-11 13:51:13 -03:00

644 lines
30 KiB
TypeScript

/**
* Pure combo predicates + tuning constants extracted from combo.ts.
*
* Side-effect-free helpers and the combo-loop tuning constants moved out of the
* combo.ts god-file (Quality Gate v2 / Fase 9). Logic unchanged; the public
* predicates are re-exported from combo.ts for backward compatibility.
*/
import { EXECUTOR_CONTRACT_VIOLATION_CODE } from "../../config/constants.ts";
import { remainingPercentFromQuotaWindows } from "../antigravityQuotaFamily.ts";
import { errorResponse } from "../../utils/error.ts";
import { parseModel } from "../model.ts";
import { isSelfInflictedUpstreamTimeout } from "../../handlers/chatCore/cooldownClassification.ts";
import {
isLocalStreamLifecycleError,
isLocalExecutionError,
isModelCapacityOverloadError,
} from "@/shared/utils/circuitBreaker";
import {
CONTEXT_OVERFLOW_PATTERNS,
MODEL_ACCESS_DENIED_PATTERNS,
cooldownUntilMs,
} from "../accountFallback.ts";
import { isResourceNotFoundResponse } from "../errorClassifier.ts";
import { getTrustedLocalRateLimitResponse } from "../rateLimitManager/errors.ts";
import type { ResolvedComboTarget } from "./types.ts";
// Status codes that should mark round-robin target semaphores as cooling down.
export const TRANSIENT_FOR_SEMAPHORE = [429, 502, 503, 504];
// G1 (silent-stop fix): hard ceiling for the combo target loop when the operator
// left comboTimeoutMs at 0 ("unlimited"). Without this, a hung upstream (per-model
// timeout disabled) would freeze the request forever with no response. 10 minutes
// is a generous bound for legitimate long-running fallback cascades.
export const COMBO_LOOP_SAFETY_TIMEOUT_MS = 10 * 60 * 1000;
// G1: after the safety timer fires, wait this long for in-flight targets to land
// their per-model errors into comboErrors (so the 504 carries the same "tried:"
// summary as the regular timeout path) before returning the safety response.
export const COMBO_SAFETY_DRAIN_MS = 2000;
// Patterns that signal all accounts for a provider are rate-limited / exhausted.
// Used to detect 503 responses from handleNoCredentials so combo can fallback.
export const ALL_ACCOUNTS_RATE_LIMITED_PATTERNS = [
/unavailable/i,
/service temporarily unavailable/i,
];
export function isAllAccountsRateLimitedResponse(
status: number,
contentType: string | null,
errorText: string
): boolean {
if (status !== 503) return false;
if (!contentType?.includes("application/json")) return false;
return ALL_ACCOUNTS_RATE_LIMITED_PATTERNS.some((p) => p.test(errorText));
}
// #1731v2 guard: a provider circuit-breaker-open response (503 + `X-OmniRoute-Provider-Breaker`
// header / `provider_circuit_open` error code, see providerCircuitOpenResponse) is an OmniRoute
// resilience signal, NOT a per-connection upstream failure. It must keep being treated as an
// ordinary target failure (try the next target, including same-provider ones) — so it must NOT
// poison exhaustedConnections/exhaustedProviders, otherwise remaining same-provider targets get
// wrongly skipped while the breaker is open.
export function isProviderCircuitOpenResult(
result: { headers?: Headers | null; status?: number },
errorText: string
): boolean {
const breakerHeader = result.headers?.get?.("x-omniroute-provider-breaker");
if (typeof breakerHeader === "string" && breakerHeader.toLowerCase() === "open") return true;
return /provider_circuit_open/i.test(errorText);
}
/**
* Skip reason for a combo target already known-exhausted THIS request, or null if it is not.
*
* De-duplicates the byte-identical #1731 / #1731v2 pre-dispatch skip checks that BOTH combo
* dispatchers run per target (handleComboChat's speculative loop and handleRoundRobinCombo's
* rotation): a target whose `provider:connectionId` pair already had a connection-level error
* (`exhaustedConnections`), or whose provider already signaled full quota exhaustion
* (`exhaustedProviders`), is skipped for the rest of the request. Returns the log message the
* caller emits with its OWN tag ("COMBO" / "COMBO-RR"); each caller keeps its own control flow
* (return null vs continue) and its own fallbackCount bookkeeping.
*/
export function getExhaustedTargetSkipReason(
target: ResolvedComboTarget,
exhaustedProviders: ReadonlySet<string>,
exhaustedConnections: ReadonlySet<string>
): string | null {
const { provider, modelStr, connectionId } = target;
// #1731v2: skip targets whose provider:connection pair had a connection-level error.
if (provider && connectionId) {
if (exhaustedConnections.has(`${provider}:${connectionId}`)) {
return `Skipping ${modelStr} — connection ${connectionId} for provider ${provider} had connection error (#1731v2)`;
}
}
// #1731: skip targets from a provider that already signaled full quota exhaustion this request.
if (provider && exhaustedProviders.has(provider)) {
return `Skipping ${modelStr} — provider ${provider} marked exhausted this request (#1731)`;
}
return null;
}
export const MAX_COMBO_DEPTH = 3;
// Absolute safety ceiling for operator-configured nesting depth. config.maxComboDepth
// can raise the default (3) up to this cap, or lower it, but never above — runaway
// nested-combo expansion is a real DoS/perf risk.
export const MAX_COMBO_DEPTH_HARD_CAP = 10;
export const MAX_FALLBACK_WAIT_MS = 5000;
export const MAX_GLOBAL_ATTEMPTS = 30;
// Absolute safety ceiling for the operator-configured shared attempt budget
// (#11134). config.maxGlobalAttempts can raise the default (30) or lower it,
// but never above this cap — an unbounded attempt budget is the same runaway
// background-request DoS risk that motivated MAX_COMBO_DEPTH_HARD_CAP.
export const MAX_GLOBAL_ATTEMPTS_HARD_CAP = 200;
/**
* Clamp an operator-configured combo nesting depth (config.maxComboDepth) to a
* safe integer in [1, MAX_COMBO_DEPTH_HARD_CAP]. Anything non-numeric, < 1, or
* NaN falls back to the default MAX_COMBO_DEPTH so a bad config never disables
* nesting or blows past the safety ceiling.
*/
export function clampComboDepth(value: unknown): number {
const n = Math.floor(Number(value));
if (!Number.isFinite(n) || n < 1) return MAX_COMBO_DEPTH;
return Math.min(n, MAX_COMBO_DEPTH_HARD_CAP);
}
/**
* Clamp an operator-configured shared per-request attempt budget
* (config.maxGlobalAttempts) to a safe integer in
* [1, MAX_GLOBAL_ATTEMPTS_HARD_CAP] (#11134). Mirrors clampComboDepth: anything
* non-numeric, < 1, NaN or Infinity falls back to the default
* MAX_GLOBAL_ATTEMPTS so a bad config can never disable the budget (runaway
* retries against a dead pool) nor blow past the safety ceiling.
*/
export function clampGlobalAttempts(value: unknown): number {
const n = Math.floor(Number(value));
if (!Number.isFinite(n) || n < 1) return MAX_GLOBAL_ATTEMPTS;
return Math.min(n, MAX_GLOBAL_ATTEMPTS_HARD_CAP);
}
/** Minimum recorded requests before the predictive-TTFT breaker trusts the average. */
export const PREDICTIVE_TTFT_MIN_SAMPLES = 5;
/**
* Predictive-TTFT circuit-breaker decision: skip a target whose recent average
* latency — measured over a statistically meaningful sample — exceeds the
* configured ceiling, so the combo fails over before paying a slow first byte.
* Returns false when disabled (ceiling <= 0), when there is no metric, or when
* the sample is too small to trust.
*/
export function shouldSkipForPredictedTtft(
metric: { requests?: number; avgLatencyMs?: number } | null | undefined,
predictiveTtftMs: number
): boolean {
if (!metric || !(predictiveTtftMs > 0)) return false;
return (
(metric.requests ?? 0) >= PREDICTIVE_TTFT_MIN_SAMPLES &&
(metric.avgLatencyMs ?? 0) > predictiveTtftMs
);
}
/**
* Whole-provider circuit-breaker failure statuses for the combo path. Kept byte-identical
* to the single-model path's `PROVIDER_BREAKER_FAILURE_STATUSES` (src/sse/handlers/chat.ts:206)
* — the source of truth. 429 is deliberately EXCLUDED: a plain rate-limit must not open the
* whole-provider breaker (it's connection-cooldown / model-lockout scope). Defined locally
* rather than imported to avoid a cross-layer (open-sse → src/sse) import cycle.
*/
const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]);
/**
* Decide whether a failed combo target should record a whole-provider circuit-breaker
* failure (#1731 / #2743 gap-d). This is the consumer side of `skipProviderBreaker`:
*
* - Stream-readiness failures (pre-flight zombie/ping probes) never count as provider
* failures — they are a connection-readiness signal, not an upstream outage. EXCEPT a
* STREAM_EARLY_EOF (`isStreamEarlyEof`): there the upstream returned HTTP 200, opened the
* SSE stream and then hung up without a single non-ping event, which is a genuine upstream
* failure. Excluding it made a provider-wide outage invisible to the breaker — see the
* STREAM_EARLY_EOF section of RESILIENCE_GUIDE.md.
* - Only whole-provider failure statuses (408/500/502/503/504) count. A plain rate-limit
* 429 is deliberately EXCLUDED — it belongs to connection cooldown / model lockout scope
* (a genuine quota/token-limit 429 is handled there), NOT the whole-provider breaker. This
* mirrors the single-model path's `PROVIDER_BREAKER_FAILURE_STATUSES` (src/sse/handlers/
* chat.ts:206) — the source of truth — and the documented RESILIENCE_GUIDE policy. NOTE:
* this intentionally differs from `isProviderFailureCode` (accountFallback.ts), which
* INCLUDES 429 for connection-cooldown purposes and must not be changed here.
* - When the next combo target is on the SAME provider, don't trip the provider breaker:
* a different model on that provider may still succeed. #8376: EXCEPT when the failure
* itself is a transport-level "proxy unreachable" event (`isProxyUnreachable`) — a dead
* upstream proxy poisons every account on that provider identically, so a different
* model on the same provider will fail the exact same way. Without this override a
* homogeneous same-provider combo pool never trips the breaker and instead burns every
* attempt against the same dead proxy until it hits the 503 max-retry limit.
* - G-02 / #2743: when the fallback result carries `skipProviderBreaker` (an embedded
* service supervisor outage signalled via `X-Omni-Fallback-Hint: connection_cooldown`)
* apply connection cooldown ONLY — never trip the whole-provider breaker.
*
* #7907/#7908: also skip the breaker trip when the failure is a local stream lifecycle
* event (client-side abort — `request_signal_aborted`, "Client disconnected: ...", or an
* AbortError with no upstream status, which defaults to 502). Otherwise a client abort mid
* combo-target-loop still trips the whole-provider breaker exactly like a genuine upstream
* failure would, undermining the same #4602 policy `shouldSkipConnDisable()` already applies
* to connection-level cooldown.
*
* Pure predicate so the breaker decision is unit-testable without the full combo harness.
*/
export function shouldRecordProviderBreakerFailure(args: {
isStreamReadinessFailure: boolean;
/** True when the failure is specifically a STREAM_EARLY_EOF (upstream hung up after
* HTTP 200). Overrides the `isStreamReadinessFailure` exemption only; every other
* AND-term below still gates the trip. */
isStreamEarlyEof?: boolean;
status: number;
sameProviderNext: boolean;
skipProviderBreaker?: boolean;
requestScopedFailure?: boolean;
error?: unknown;
/** #8376: transport-level "proxy unreachable" signal — overrides the `sameProviderNext`
* exemption only; every other AND-term still gates the trip. */
isProxyUnreachable?: boolean;
}): boolean {
return (
(!args.isStreamReadinessFailure || args.isStreamEarlyEof === true) &&
// Overloaded 502 (STREAM_EARLY_EOF wrapping "Overloaded") must not trip
// the whole-provider breaker. The status=529 check is defense in depth:
// 529 is not in PROVIDER_BREAKER_FAILURE_STATUSES today, but a later
// addition of 529 to that set must still stay off the breaker.
!isModelCapacityOverloadError(args.error) &&
!isModelCapacityOverloadError(args.status) &&
PROVIDER_BREAKER_FAILURE_STATUSES.has(args.status) &&
(!args.sameProviderNext || args.isProxyUnreachable === true) &&
!args.skipProviderBreaker &&
!args.requestScopedFailure &&
!isLocalStreamLifecycleError(args.error) &&
!isLocalExecutionError(args.error)
);
}
const REQUEST_SCOPED_UPSTREAM_ERROR_CODES: Record<string, true> = {
context_length_exceeded: true,
upstream_empty_response: true,
upstream_response_failed: true,
// Local combo per-target timer (targetTimeoutRunner) — not a connection health signal.
combo_target_timeout: true,
// Local limiter queue-capacity codes — not a provider/connection health signal.
rate_limit_queue_timeout: true,
rate_limit_queue_full: true,
rate_limit_queue_wedged: true,
// #10360: our own executor-result contract violation. An internal defect, not
// a provider/account fault — it must never cool a connection or trip a breaker.
[EXECUTOR_CONTRACT_VIOLATION_CODE]: true,
};
/** Request/model-specific failures must not poison provider-wide resilience state. */
export function isRequestScopedUpstreamFailure(error?: {
code?: string | null;
type?: string | null;
}): boolean {
const code = typeof error?.code === "string" ? error.code.toLowerCase() : "";
const type = typeof error?.type === "string" ? error.type.toLowerCase() : "";
return (
REQUEST_SCOPED_UPSTREAM_ERROR_CODES[code] === true ||
type === "context_length_exceeded" ||
type === "local_queue_capacity"
);
}
/** Request-scoped classification that also has access to the HTTP body. */
export function isComboRequestScopedFailure(
response: Response,
errorText: string,
error?: { code?: string | null; type?: string | null }
): boolean {
return (
getTrustedLocalRateLimitResponse(response) !== null ||
isRequestScopedUpstreamFailure(error) ||
(response.status === 404 && isResourceNotFoundResponse(errorText))
);
}
const INPUT_BOUND_ERROR_CODES = new Set(["context_length_exceeded", "context_window_exceeded"]);
/**
* #8375: Whether an upstream error is input-bound — i.e. determined solely by the
* request content, not by the provider/account state. A context_length_exceeded
* for a 159K-token input will fail on every account of that same model, so the
* combo loop should propagate the error immediately instead of retrying.
*/
export function isInputBoundRequestFailure(error?: {
code?: string | null;
type?: string | null;
}): boolean {
const code = typeof error?.code === "string" ? error.code.toLowerCase() : "";
const type = typeof error?.type === "string" ? error.type.toLowerCase() : "";
return INPUT_BOUND_ERROR_CODES.has(code) || type === "context_length_exceeded";
}
/**
* #7177: whether handleSingleModelChat should skip the connection-level cooldown
* (markAccountUnavailable) for a failed attempt — client disconnects, a 401 when the
* connection has extra keys to rotate through, a known request-scoped upstream failure
* (e.g. context overflow — not a connection health signal), a plugin refusing the
* request (our own policy, not a provider fault — see below), or our own
* self-inflicted timeout all mean the connection itself is healthy and should not be
* cooled down.
*
* A plugin block (`plugin_block`) is our own policy decision, not the provider
* rejecting us. Banning the account here would let a working security plugin destroy
* the connection it protects — one block would ban the provider for every later
* request, valid ones included — and would trigger a pointless retry loop across other
* accounts the plugin would refuse identically.
*/
export function shouldSkipConnDisable(
result: {
status: number;
response?: Response;
errorCode?: string | null;
errorType?: string | null;
error?: unknown;
},
is401: boolean,
hasExtraKeys: boolean,
provider: string
): boolean {
return (
result.status === 499 ||
result.errorCode === "client_disconnected" ||
result.errorType === "client_disconnected" ||
// Client abort surfaced as a bare error (no statusCode → defaults to 502):
// a local lifecycle event, not a provider failure (#4602 policy).
isLocalStreamLifecycleError(result.error) ||
isLocalExecutionError(result.error) ||
(result.response ? getTrustedLocalRateLimitResponse(result.response) !== null : false) ||
result.errorCode === "plugin_block" ||
result.errorType === "plugin_block" ||
(is401 && hasExtraKeys) ||
isRequestScopedUpstreamFailure({ code: result.errorCode, type: result.errorType }) ||
isSelfInflictedUpstreamTimeout(result.status, result.errorType, provider)
);
}
export function resolveDelayMs(value: unknown, fallback: number): number {
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || numericValue < 0) return fallback;
return numericValue;
}
/**
* Resolve the effective semaphore `maxConcurrency` for a round-robin combo
* target from its connection's per-account concurrency cap.
*
* `cap` is the connection's `maxConcurrent` (provider_connections.max_concurrent).
* A positive cap is honored (floored to a whole slot count); null / undefined /
* <= 0 / non-finite all mean "no per-connection limit" and fall back to the
* combo-level concurrency. This keeps subscription accounts with a tiny
* concurrency ceiling (e.g. GLM/MiniMax ≈ 1) from being flooded.
*/
export function effectiveMaxConcurrency(cap: number | null | undefined, fallback: number): number {
if (typeof cap === "number" && Number.isFinite(cap) && cap > 0) {
return Math.floor(cap);
}
return fallback;
}
export function comboModelNotFoundResponse(message: string) {
return errorResponse(404, message);
}
export function getTargetProvider(modelStr: string, providerId?: string | null): string {
const parsed = parseModel(modelStr);
return providerId || parsed.provider || parsed.providerAlias || "unknown";
}
export function isStreamReadinessFailureErrorBody(errorBody: unknown): boolean {
if (!errorBody || typeof errorBody !== "object") return false;
const error = (errorBody as Record<string, unknown>).error;
if (!error || typeof error !== "object") return false;
const code = (error as Record<string, unknown>).code;
return code === "STREAM_READINESS_TIMEOUT" || code === "STREAM_EARLY_EOF";
}
/**
* A STREAM_EARLY_EOF specifically: the upstream accepted the request (HTTP 200), opened the
* SSE stream, then closed it before emitting a single non-ping event.
*
* This is deliberately NOT the same signal as STREAM_READINESS_TIMEOUT. The readiness probe
* is a pre-flight liveness check on a connection we have not committed to yet, so failing it
* says "this connection looks stale", not "this provider is failing". An early EOF is the
* opposite: the provider took the request and then failed to serve it, which is an upstream
* failure by any reasonable definition.
*
* `isStreamReadinessFailureErrorBody` still covers both codes because the transient-retry and
* semaphore-cooldown paths in combo.ts want identical treatment for both. Only the
* whole-provider circuit breaker needs to tell them apart — see
* `shouldRecordProviderBreakerFailure`.
*/
export function isStreamEarlyEofErrorBody(errorBody: unknown): boolean {
if (!errorBody || typeof errorBody !== "object") return false;
const error = (errorBody as Record<string, unknown>).error;
if (!error || typeof error !== "object") return false;
return (error as Record<string, unknown>).code === "STREAM_EARLY_EOF";
}
/**
* A local per-API-key token-limit breach surfaces as a 429 tagged with
* errorCode "TOKEN_LIMIT_EXCEEDED" (see chatCore.ts Tier 2 early return). This
* is NOT an upstream rate limit, so the combo loop must not cool the shared
* account/provider, must not add it to transientRateLimitedProviders, and must
* not retry it transiently — it propagates to the client as a terminal 429.
*/
export function isTokenLimitBreachErrorBody(errorBody: unknown): boolean {
if (!errorBody || typeof errorBody !== "object") return false;
const error = (errorBody as Record<string, unknown>).error;
if (!error || typeof error !== "object") return false;
return (error as Record<string, unknown>).code === "TOKEN_LIMIT_EXCEEDED";
}
/** Local limiter capacity is not an upstream/provider failure and must not cascade. */
export function isLocalQueueCapacityErrorBody(errorBody: unknown): boolean {
if (!errorBody || typeof errorBody !== "object") return false;
const error = (errorBody as Record<string, unknown>).error;
if (!error || typeof error !== "object") return false;
const code = String((error as Record<string, unknown>).code || "").toUpperCase();
const type = String((error as Record<string, unknown>).type || "").toLowerCase();
return (
code === "RATE_LIMIT_QUEUE_TIMEOUT" ||
code === "RATE_LIMIT_QUEUE_FULL" ||
code === "RATE_LIMIT_QUEUE_WEDGED" ||
type === "local_queue_capacity"
);
}
export function toRecordedTarget(target: ResolvedComboTarget) {
return {
executionKey: target.executionKey,
stepId: target.stepId,
provider: target.provider,
providerId: target.providerId,
connectionId: target.connectionId,
label: target.label,
};
}
export function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 100;
return Math.max(0, Math.min(100, value));
}
export function quotaRemainingPercentFromQuota(
quota: unknown,
scope?: { provider?: string | null; requestedModel?: string | null }
): number {
if (!quota || typeof quota !== "object") return 100;
const record = quota as Record<string, unknown>;
const windows = record.windows;
if (windows && typeof windows === "object" && !Array.isArray(windows)) {
const fromWindows = remainingPercentFromQuotaWindows(windows as Record<string, unknown>, scope);
if (fromWindows !== null) return fromWindows;
}
if (record.limitReached === true) return 0;
const percentUsed = Number(record.percentUsed);
if (Number.isFinite(percentUsed)) return clampPercent((1 - percentUsed) * 100);
return 100;
}
export const QUOTA_BLOCKING_CONNECTION_STATUSES = new Set([
"banned",
"credits_exhausted",
"deactivated",
"expired",
"rate_limited",
]);
export function normalizeConnectionStatus(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
export function hasFutureRateLimitUntil(value: unknown): boolean {
if (value == null || value === "") return false;
if (typeof value !== "string" && typeof value !== "number" && !(value instanceof Date))
return false;
const time = cooldownUntilMs(value);
return Number.isFinite(time) && time > Date.now();
}
/**
* #12168: mirrors ERROR_LABEL_GRACE_MS in src/lib/quota/connectionRecovery.ts — a
* bare status label with no cooldown timestamp is only trusted while the failure
* that wrote it is recent. Kept in sync with that constant deliberately: both
* answer the same question ("is this label still meaningful?") and they must not
* disagree, or a connection the recovery job considers healthy would still be
* pre-skipped by combo dispatch.
*/
const UNAVAILABLE_LABEL_GRACE_MS = 60 * 1000;
/**
* True when a bare `unavailable` label should still be honoured: the recorded
* failure is inside the grace window. A missing/unparseable lastErrorAt is
* treated as stale (not blocking) — an unbounded skip is exactly the failure
* mode #12168 reported, and one extra upstream attempt is far cheaper than a
* permanently dark connection pool.
*/
export function isWithinUnavailableGrace(lastErrorAt: unknown): boolean {
if (lastErrorAt == null || lastErrorAt === "") return false;
const time = new Date(String(lastErrorAt)).getTime();
if (!Number.isFinite(time)) return false;
return Date.now() - time < UNAVAILABLE_LABEL_GRACE_MS;
}
export function getConnectionStatusQuotaCutoffReason(
connection: Record<string, unknown> | undefined
): string | undefined {
if (!connection) return undefined;
const status = normalizeConnectionStatus(connection.testStatus);
if (QUOTA_BLOCKING_CONNECTION_STATUSES.has(status)) return status;
if (status === "unavailable" && hasFutureRateLimitUntil(connection.rateLimitedUntil)) {
return "rate_limited";
}
return undefined;
}
/**
* Pre-dispatch skip for a combo target whose connection is already on a
* persisted cooldown. Combo previously only learned that from AUTH after a
* real upstream call, so a burst could burn max_concurrent slots against a
* connection that SQLite already marked unavailable until a future reset.
*
* Honours a future rateLimitedUntil regardless of testStatus, the terminal
* statuses that must never be dispatched, and a bare `unavailable` status even
* when no timestamp was written alongside it.
*/
export function getPersistedConnectionCooldownSkipReason(
target: { modelStr: string; connectionId?: string | null },
connection: Record<string, unknown> | null | undefined,
allowRateLimitedConnection = false
): string | null {
if (allowRateLimitedConnection) return null;
if (!target.connectionId || !connection) return null;
if (hasFutureRateLimitUntil(connection.rateLimitedUntil)) {
return `Skipping ${target.modelStr} — connection ${target.connectionId} has persisted cooldown until ${String(connection.rateLimitedUntil)}`;
}
const status = normalizeConnectionStatus(connection.testStatus);
if (QUOTA_BLOCKING_CONNECTION_STATUSES.has(status)) {
return `Skipping ${target.modelStr} — connection ${target.connectionId} status=${status}`;
}
// `unavailable` with no rateLimitedUntil still means AUTH took this connection out
// of rotation — markAccountUnavailable() writes the status before, and sometimes
// without, a timestamp ("Using zai account …" then a real upstream 429). Without
// this branch a burst still dispatched against a connection AUTH had already retired.
//
// #12168: but the skip must be BOUNDED. The original version returned here for any
// `unavailable` row, which is the raw-label anti-pattern AGENTS.md warns about — the
// resilience layers are supposed to recover lazily. Its stated justification
// ("clearAccountError() resets the status on first success") does not hold on this
// path: this gate runs BEFORE dispatch, so it prevents the very successful request
// that would call clearAccountError(). A connection left with a stale `unavailable`
// label and no timestamp could therefore never dispatch again, and the out-of-band
// recovery job cannot rescue it either — hasElapsedCooldown() there requires a
// rateLimitedUntil to be present. Result: a whole combo pool could report
// ALL_TARGETS_SKIPPED with zero upstream attempts, forever.
//
// Bound it the same way src/lib/quota/connectionRecovery.ts bounds a bare error
// label: honour the skip only while the failure is recent (lastErrorAt within the
// grace window). Past that, treat the label as stale and let the request through —
// one real attempt then either succeeds (clearing the status) or re-arms the
// cooldown with a fresh timestamp.
if (status === "unavailable" && isWithinUnavailableGrace(connection.lastErrorAt)) {
return `Skipping ${target.modelStr} — connection ${target.connectionId} status=unavailable`;
}
return null;
}
/**
* Async wrapper around `getPersistedConnectionCooldownSkipReason` for the combo
* dispatchers, which must re-check the persisted cooldown before EVERY upstream
* attempt — not just once before the retry loop.
*
* The retry path is exactly where the stale-read risk lives: a sibling request in
* the same burst can write `rate_limited_until` while this attempt is sleeping out
* its retry delay, so the caller passes a cache-bypassing fetcher for retry > 0
* (the readCache TTL is 5s, long enough to serve a "no cooldown" snapshot written
* before the 429 landed).
*
* Kept dependency-free — the fetcher is injected, so this module stays pure and
* unit-testable without a DB.
*/
export async function resolvePersistedConnectionCooldownSkipReason(
target: { modelStr: string; connectionId?: string | null },
fetchConnection: (id: string) => Promise<Record<string, unknown> | null | undefined>,
allowRateLimitedConnection = false
): Promise<string | null> {
if (allowRateLimitedConnection) return null;
if (!target.connectionId) return null;
let connection: Record<string, unknown> | null | undefined;
try {
connection = await fetchConnection(target.connectionId);
} catch {
// A DB read failure must never block dispatch — fall through to the upstream call.
return null;
}
return getPersistedConnectionCooldownSkipReason(target, connection, allowRateLimitedConnection);
}
/** @param {string} errorText */
export function isContextOverflow400(errorText: string | null | undefined): boolean {
const text = String(errorText || "");
if (!text) return false;
return (
/\bcontext.*(?:length_exceeded|too long|overflow|exceeded|window|limit)\b/i.test(text) ||
/exceeds.*context/i.test(text) ||
/your input exceeds/i.test(text) ||
CONTEXT_OVERFLOW_PATTERNS.some((p) => p.test(text))
);
}
/** @param {string} errorText */
export function isParamValidation400(errorText: string | null | undefined): boolean {
const text = String(errorText || "");
if (!text) return false;
return (
/\bmax_tokens\b.*(?:illegal|must|range|invalid)/i.test(text) ||
/\bparameter is illegal\b/i.test(text) ||
/\bis illegal.*range\b/i.test(text)
);
}
/**
* #5249 / #2101: model-scoped 400s must NEVER stop the combo.
*/
export function isModelScoped400(errorText: string | null | undefined): boolean {
const text = String(errorText || "");
if (!text) return false;
if (MODEL_ACCESS_DENIED_PATTERNS.some((p) => p.test(text))) return true;
return (
/\bmodel\b[\s\S]{0,80}?\b(?:not\s+supported|unsupported|unknown|unavailable)\b/i.test(text) ||
/\b(?:not\s+supported|unsupported|unknown)\b[\s\S]{0,80}?\bmodel\b/i.test(text) ||
/\bunsupported_api_for_model\b/i.test(text) ||
/\bdoes\s+not\s+support\s+(?:the\s+)?responses\s+api\b/i.test(text)
);
}