Spaces:
Runtime error
Runtime error
| import { DEFAULT_API_LIMITS, PROVIDER_PROFILES } from "@omniroute/open-sse/config/constants"; | |
| import { resolveFeatureFlag } from "@/shared/utils/featureFlags"; | |
| type JsonRecord = Record<string, unknown>; | |
| type AuthCategory = "oauth" | "apikey"; | |
| export interface RequestQueueSettings { | |
| autoEnableApiKeyProviders: boolean; | |
| requestsPerMinute: number; | |
| minTimeBetweenRequestsMs: number; | |
| concurrentRequests: number; | |
| maxWaitMs: number; | |
| } | |
| export interface ConnectionCooldownProfileSettings { | |
| baseCooldownMs: number; | |
| useUpstreamRetryHints: boolean; | |
| /** | |
| * Issue #2100 follow-up: opt-in toggle for upstream 429 hint trust at the | |
| * circuit-breaker cooldown layer (independent of `useUpstreamRetryHints` | |
| * which controls retry scheduling). | |
| * | |
| * Stored shape is intentionally optional / `boolean | undefined`: when | |
| * unset, the per-provider default from `providerHints.ts` applies. | |
| * Normalize/merge MUST preserve `undefined` — do not coerce via | |
| * `toBoolean(value, fallback)`. | |
| */ | |
| useUpstream429BreakerHints?: boolean; | |
| maxBackoffSteps: number; | |
| } | |
| export interface ProviderBreakerProfileSettings { | |
| failureThreshold: number; | |
| degradationThreshold: number; | |
| resetTimeoutMs: number; | |
| } | |
| export interface WaitForCooldownSettings { | |
| enabled: boolean; | |
| maxRetries: number; | |
| maxRetryWaitSec: number; | |
| maxRetryWaitMs: number; | |
| } | |
| /** | |
| * Quota-share combo cooldown-aware retry (Variante A). A quota-share (`qtSd/…`) | |
| * combo that would crystallize a 429 `model_cooldown` for a SHORT transient | |
| * cooldown waits it out and re-dispatches instead. Guards (gating + the | |
| * `quota_exhausted`/auth/not-found exclusions) live in | |
| * open-sse/services/combo/comboCooldownRetry.ts; `maxWaitMs`/`maxAttempts`/ | |
| * `budgetMs` bound a single wait, the retry cycles, and the total wait time. | |
| */ | |
| export interface ComboCooldownWaitSettings { | |
| enabled: boolean; | |
| maxWaitMs: number; | |
| maxAttempts: number; | |
| budgetMs: number; | |
| } | |
| /** | |
| * Per-connection concurrency limit for quota-share (`qtSd/…`) combos (FASE 2.1). | |
| * The quota-share gating in selectQuotaShareTarget is fail-open and cannot | |
| * hard-limit a single-connection pool, so concurrent requests to one | |
| * subscription account can still flood it (→ 429 + cooldown). When a connection | |
| * declares a positive `max_concurrent` ceiling, this layer serializes concurrent | |
| * requests to that account through a per-connection semaphore (excess requests | |
| * wait in the queue instead of flooding). Kill-switch only: the cap itself comes | |
| * from each connection's `max_concurrent`. Wiring lives in | |
| * open-sse/services/combo/quotaShareConcurrency.ts. | |
| */ | |
| export interface QuotaShareConcurrencyLimitSettings { | |
| enabled: boolean; | |
| } | |
| export interface ProviderCooldownSettings { | |
| /** | |
| * Minimum cooldown (ms) before a failed provider/connection can be retried. | |
| * This prevents subsequent requests from immediately re-walking failing providers. | |
| * Scaled exponentially with failure count: minRetryCooldownMs * 2^(failures-1). | |
| * Default: 5000 (5 seconds). | |
| */ | |
| minRetryCooldownMs: number; | |
| /** | |
| * Maximum cooldown (ms) before a failed provider/connection is retried regardless. | |
| * Hard cap to prevent providers from being skipped indefinitely. | |
| * Default: 300000 (5 minutes). | |
| */ | |
| maxRetryCooldownMs: number; | |
| /** | |
| * Enable/disable global provider cooldown tracking. | |
| * When disabled, only per-request cooldown state is used. | |
| * Default: true. | |
| */ | |
| enabled: boolean; | |
| } | |
| export interface QuotaPreflightSettings { | |
| /** | |
| * Master switch for the auto-routing quota cutoff (buildAutoCandidates). When | |
| * disabled (default), candidates are NOT dropped for low quota before scoring — | |
| * the soft quota penalty + connection cooldown still apply, so behavior is | |
| * unchanged. Opt-in because the hard cutoff interacts with the auto-routing | |
| * scorer and must be validated per deployment. Default: false. | |
| */ | |
| enabled: boolean; | |
| /** | |
| * Global minimum-remaining cutoff (percent, 0-100). A connection is skipped | |
| * when its remaining quota drops to this value or below. Matches the | |
| * dashboard's quota bars (which show REMAINING %, not used %), so the | |
| * number means the same thing in both places. Default: 2 (stop at 2% | |
| * remaining = 98% used). | |
| */ | |
| defaultThresholdPercent: number; | |
| /** | |
| * Global warn threshold (percent, 0-100 remaining %). Fires when remaining | |
| * quota drops to this value or below. Must be HIGHER than the cutoff so | |
| * warnings appear before the block point. Default: 20 (warn at 20% | |
| * remaining = 80% used). | |
| */ | |
| warnThresholdPercent: number; | |
| /** | |
| * Per-(provider, window) defaults for providers that expose multiple quota | |
| * windows (e.g. Codex's session + weekly). Values are minimum-remaining % | |
| * cutoffs. Resolution order, low-to-high precedence: | |
| * defaultThresholdPercent | |
| * → providerWindowDefaults[provider][window] | |
| * → connection.quotaWindowThresholds[window] | |
| */ | |
| providerWindowDefaults: Record<string, Record<string, number>>; | |
| } | |
| export interface StreamRecoverySettings { | |
| /** | |
| * Opt-in transparent recovery of truncated upstream streams (free-claude-code port). | |
| * When enabled, the opening SSE window is briefly held (see STREAM_RECOVERY in | |
| * open-sse/config/constants.ts) so an early cutoff can be retried before any byte | |
| * reaches the client. OFF by default because holding the window adds up to | |
| * STREAM_RECOVERY.HOLDBACK_MS of time-to-first-token latency on every stream. | |
| * Default seeds from the STREAM_RECOVERY_ENABLED feature flag / env var. | |
| */ | |
| enabled: boolean; | |
| /** | |
| * Opt-in mid-stream continuation (Fase 4.4): when an upstream stream truncates AFTER | |
| * bytes already reached the client, re-request with the partial text as an assistant | |
| * prefill and stitch the missing suffix (plain-text OpenAI-compatible streams only; | |
| * never with a tool call in flight). OFF by default because the recovered tail arrives | |
| * as one burst rather than token-by-token. Default seeds from the | |
| * STREAM_RECOVERY_MIDSTREAM_ENABLED feature flag / env var. | |
| */ | |
| continueMidStream: boolean; | |
| } | |
| export interface ResilienceSettings { | |
| requestQueue: RequestQueueSettings; | |
| connectionCooldown: Record<AuthCategory, ConnectionCooldownProfileSettings>; | |
| providerBreaker: Record<AuthCategory, ProviderBreakerProfileSettings>; | |
| waitForCooldown: WaitForCooldownSettings; | |
| comboCooldownWait: ComboCooldownWaitSettings; | |
| quotaShareConcurrencyLimit: QuotaShareConcurrencyLimitSettings; | |
| providerCooldown: ProviderCooldownSettings; | |
| quotaPreflight: QuotaPreflightSettings; | |
| streamRecovery: StreamRecoverySettings; | |
| } | |
| export interface ResilienceSettingsPatch { | |
| requestQueue?: Partial<RequestQueueSettings>; | |
| connectionCooldown?: Partial<Record<AuthCategory, Partial<ConnectionCooldownProfileSettings>>>; | |
| providerBreaker?: Partial<Record<AuthCategory, Partial<ProviderBreakerProfileSettings>>>; | |
| waitForCooldown?: Partial<WaitForCooldownSettings>; | |
| comboCooldownWait?: Partial<ComboCooldownWaitSettings>; | |
| quotaShareConcurrencyLimit?: Partial<QuotaShareConcurrencyLimitSettings>; | |
| providerCooldown?: Partial<ProviderCooldownSettings>; | |
| quotaPreflight?: Partial<QuotaPreflightSettings>; | |
| streamRecovery?: Partial<StreamRecoverySettings>; | |
| } | |
| function asRecord(value: unknown): JsonRecord { | |
| return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; | |
| } | |
| function toInteger( | |
| value: unknown, | |
| fallback: number, | |
| options: { min?: number; max?: number } = {} | |
| ): number { | |
| const min = options.min ?? 0; | |
| const max = options.max ?? Number.MAX_SAFE_INTEGER; | |
| const parsed = | |
| typeof value === "number" | |
| ? value | |
| : typeof value === "string" && value.trim().length > 0 | |
| ? Number(value) | |
| : Number.NaN; | |
| if (!Number.isFinite(parsed)) { | |
| return fallback; | |
| } | |
| return Math.min(max, Math.max(min, Math.trunc(parsed))); | |
| } | |
| function toBoolean(value: unknown, fallback: boolean): boolean { | |
| return typeof value === "boolean" ? value : fallback; | |
| } | |
| function parseFeatureFlagBoolean(value: string, fallback: boolean): boolean { | |
| const normalized = value.trim().toLowerCase(); | |
| if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { | |
| return true; | |
| } | |
| if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { | |
| return false; | |
| } | |
| return fallback; | |
| } | |
| function resolveBooleanFeatureFlag(key: string, fallback: boolean): boolean { | |
| try { | |
| return parseFeatureFlagBoolean(resolveFeatureFlag(key), fallback); | |
| } catch (error) { | |
| const envValue = process.env[key]; | |
| if (typeof envValue === "string" && envValue.trim() !== "") { | |
| return parseFeatureFlagBoolean(envValue, fallback); | |
| } | |
| console.error( | |
| `[resilience] Failed to resolve ${key}, falling back to ${String(fallback)}:`, | |
| error instanceof Error ? error.message : error | |
| ); | |
| return fallback; | |
| } | |
| } | |
| function resolveStreamRecoveryDefaults(): StreamRecoverySettings { | |
| return { | |
| enabled: resolveBooleanFeatureFlag("STREAM_RECOVERY_ENABLED", false), | |
| continueMidStream: resolveBooleanFeatureFlag("STREAM_RECOVERY_MIDSTREAM_ENABLED", false), | |
| }; | |
| } | |
| export const DEFAULT_REQUEST_QUEUE_MAX_WAIT_MS = (() => { | |
| const parsed = Number(process.env.RATE_LIMIT_MAX_WAIT_MS || "120000"); | |
| return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 120000; | |
| })(); | |
| export const DEFAULT_RESILIENCE_SETTINGS: ResilienceSettings = { | |
| requestQueue: { | |
| autoEnableApiKeyProviders: true, | |
| requestsPerMinute: DEFAULT_API_LIMITS.requestsPerMinute, | |
| minTimeBetweenRequestsMs: DEFAULT_API_LIMITS.minTimeBetweenRequests, | |
| concurrentRequests: DEFAULT_API_LIMITS.concurrentRequests, | |
| maxWaitMs: DEFAULT_REQUEST_QUEUE_MAX_WAIT_MS, | |
| }, | |
| connectionCooldown: { | |
| oauth: { | |
| baseCooldownMs: PROVIDER_PROFILES.oauth.transientCooldown, | |
| useUpstreamRetryHints: PROVIDER_PROFILES.oauth.rateLimitCooldown === 0, | |
| maxBackoffSteps: PROVIDER_PROFILES.oauth.maxBackoffLevel, | |
| }, | |
| apikey: { | |
| baseCooldownMs: PROVIDER_PROFILES.apikey.transientCooldown, | |
| useUpstreamRetryHints: PROVIDER_PROFILES.apikey.rateLimitCooldown === 0, | |
| maxBackoffSteps: PROVIDER_PROFILES.apikey.maxBackoffLevel, | |
| }, | |
| }, | |
| providerBreaker: { | |
| oauth: { | |
| failureThreshold: PROVIDER_PROFILES.oauth.circuitBreakerThreshold, | |
| degradationThreshold: PROVIDER_PROFILES.oauth.degradationThreshold, | |
| resetTimeoutMs: PROVIDER_PROFILES.oauth.circuitBreakerReset, | |
| }, | |
| apikey: { | |
| failureThreshold: PROVIDER_PROFILES.apikey.circuitBreakerThreshold, | |
| degradationThreshold: PROVIDER_PROFILES.apikey.degradationThreshold, | |
| resetTimeoutMs: PROVIDER_PROFILES.apikey.circuitBreakerReset, | |
| }, | |
| }, | |
| waitForCooldown: { | |
| enabled: true, | |
| maxRetries: 3, | |
| maxRetryWaitSec: 30, | |
| maxRetryWaitMs: 30000, | |
| }, | |
| // Conservative defaults: wait at most 5s for a single short transient | |
| // cooldown, at most 2 redispatch cycles, never more than 8s total. Active only | |
| // for quota-share combos and only for transient (non quota_exhausted) reasons. | |
| comboCooldownWait: { | |
| enabled: true, | |
| maxWaitMs: 5000, | |
| maxAttempts: 2, | |
| budgetMs: 8000, | |
| }, | |
| // FASE 2.1: serialize concurrent quota-share requests per connection when the | |
| // connection sets a max_concurrent cap, so a subscription account is not | |
| // flooded past its concurrency ceiling. Kill-switch only (default on); the cap | |
| // comes from each connection's max_concurrent. | |
| quotaShareConcurrencyLimit: { | |
| enabled: true, | |
| }, | |
| providerCooldown: { | |
| minRetryCooldownMs: Number(process.env.PROVIDER_COOLDOWN_MIN_MS || "5000"), | |
| maxRetryCooldownMs: Number(process.env.PROVIDER_COOLDOWN_MAX_MS || "300000"), | |
| // Opt-in (default OFF): this global cross-request cooldown overlaps the | |
| // existing Connection Cooldown / Provider Circuit Breaker layers, so it is | |
| // disabled by default and must be explicitly enabled by the operator until | |
| // its interaction with those layers is validated in production. | |
| enabled: ["true", "1", "on"].includes( | |
| (process.env.PROVIDER_COOLDOWN_ENABLED || "").trim().toLowerCase() | |
| ), | |
| }, | |
| quotaPreflight: { | |
| // Opt-in (default OFF): the auto-routing hard cutoff drops low-quota candidates | |
| // before scoring, overlapping the existing soft quota penalty + connection | |
| // cooldown, so it must be explicitly enabled by the operator until its | |
| // interaction with the scorer is validated in production. | |
| enabled: ["true", "1", "on"].includes( | |
| (process.env.QUOTA_PREFLIGHT_CUTOFF_ENABLED || "").trim().toLowerCase() | |
| ), | |
| // Remaining-% semantics. 2 = "stop when only 2% remaining" (= 98% used). | |
| // Uniform across all providers and windows; operators set per-window | |
| // overrides per connection via the Cutoff modal in Dashboard › Limits, | |
| // or per-(provider, window) globally via the providerWindowDefaults map | |
| // below (no factory seeds — keep behavior consistent across providers). | |
| defaultThresholdPercent: 2, | |
| warnThresholdPercent: 20, | |
| providerWindowDefaults: {}, | |
| }, | |
| streamRecovery: { | |
| // Opt-in (default OFF): the holdback that powers transparent early-retry adds | |
| // up to STREAM_RECOVERY.HOLDBACK_MS of time-to-first-token latency on every | |
| // streaming request, so it must be explicitly enabled by the operator. | |
| enabled: ["true", "1", "on"].includes( | |
| (process.env.STREAM_RECOVERY_ENABLED || "").trim().toLowerCase() | |
| ), | |
| // Opt-in (default OFF): mid-stream continuation re-requests after a post-commit cut. | |
| continueMidStream: ["true", "1", "on"].includes( | |
| (process.env.STREAM_RECOVERY_MIDSTREAM_ENABLED || "").trim().toLowerCase() | |
| ), | |
| }, | |
| }; | |
| function normalizeRequestQueueSettings( | |
| next: unknown, | |
| fallback: RequestQueueSettings | |
| ): RequestQueueSettings { | |
| const record = asRecord(next); | |
| const requestsPerMinute = toInteger(record.requestsPerMinute, fallback.requestsPerMinute, { | |
| min: 1, | |
| max: 1_000_000, | |
| }); | |
| const minTimeBetweenRequestsMs = toInteger( | |
| record.minTimeBetweenRequestsMs, | |
| fallback.minTimeBetweenRequestsMs, | |
| { min: 0, max: 60 * 60 * 1000 } | |
| ); | |
| const concurrentRequests = toInteger(record.concurrentRequests, fallback.concurrentRequests, { | |
| min: 1, | |
| max: 10_000, | |
| }); | |
| const maxWaitMs = toInteger(record.maxWaitMs, fallback.maxWaitMs, { | |
| min: 1, | |
| max: 24 * 60 * 60 * 1000, | |
| }); | |
| return { | |
| autoEnableApiKeyProviders: toBoolean( | |
| record.autoEnableApiKeyProviders, | |
| fallback.autoEnableApiKeyProviders | |
| ), | |
| requestsPerMinute, | |
| minTimeBetweenRequestsMs, | |
| concurrentRequests, | |
| maxWaitMs, | |
| }; | |
| } | |
| function normalizeConnectionCooldownProfile( | |
| next: unknown, | |
| fallback: ConnectionCooldownProfileSettings | |
| ): ConnectionCooldownProfileSettings { | |
| const record = asRecord(next); | |
| // useUpstream429BreakerHints uses a 3-state input contract: | |
| // - boolean → user override, store as-is | |
| // - null → explicit unset sentinel, drop key so the per-provider | |
| // default in `providerHints.ts` resolves at runtime | |
| // - omitted → leave existing fallback value unchanged (partial-merge) | |
| // Never coerce via `toBoolean(value, fallback)` because that would | |
| // collapse the unset state. | |
| const hasHintsKey = Object.prototype.hasOwnProperty.call(record, "useUpstream429BreakerHints"); | |
| const rawHints = record.useUpstream429BreakerHints; | |
| let useUpstream429BreakerHints: boolean | undefined; | |
| if (!hasHintsKey) { | |
| useUpstream429BreakerHints = fallback.useUpstream429BreakerHints; | |
| } else if (rawHints === null) { | |
| useUpstream429BreakerHints = undefined; | |
| } else if (typeof rawHints === "boolean") { | |
| useUpstream429BreakerHints = rawHints; | |
| } else { | |
| useUpstream429BreakerHints = fallback.useUpstream429BreakerHints; | |
| } | |
| const out: ConnectionCooldownProfileSettings = { | |
| baseCooldownMs: toInteger(record.baseCooldownMs, fallback.baseCooldownMs, { | |
| min: 0, | |
| max: 24 * 60 * 60 * 1000, | |
| }), | |
| useUpstreamRetryHints: toBoolean(record.useUpstreamRetryHints, fallback.useUpstreamRetryHints), | |
| maxBackoffSteps: toInteger(record.maxBackoffSteps, fallback.maxBackoffSteps, { | |
| min: 0, | |
| max: 32, | |
| }), | |
| }; | |
| // Only attach the key when defined — preserves omission across round-trips. | |
| if (useUpstream429BreakerHints !== undefined) { | |
| out.useUpstream429BreakerHints = useUpstream429BreakerHints; | |
| } | |
| return out; | |
| } | |
| function normalizeLegacyConnectionCooldownProfile( | |
| next: unknown, | |
| fallback: ConnectionCooldownProfileSettings | |
| ): ConnectionCooldownProfileSettings { | |
| const record = asRecord(next); | |
| const transientCooldown = toInteger(record.transientCooldown, fallback.baseCooldownMs, { | |
| min: 0, | |
| max: 24 * 60 * 60 * 1000, | |
| }); | |
| const legacyRateLimitCooldown = toInteger(record.rateLimitCooldown, transientCooldown, { | |
| min: 0, | |
| max: 24 * 60 * 60 * 1000, | |
| }); | |
| const useUpstreamRetryHints = | |
| typeof record.rateLimitCooldown === "number" | |
| ? record.rateLimitCooldown === 0 | |
| : fallback.useUpstreamRetryHints; | |
| return { | |
| baseCooldownMs: useUpstreamRetryHints | |
| ? transientCooldown | |
| : Math.max(transientCooldown, legacyRateLimitCooldown), | |
| useUpstreamRetryHints, | |
| maxBackoffSteps: toInteger(record.maxBackoffLevel, fallback.maxBackoffSteps, { | |
| min: 0, | |
| max: 32, | |
| }), | |
| }; | |
| } | |
| function normalizeProviderBreakerProfile( | |
| next: unknown, | |
| fallback: ProviderBreakerProfileSettings | |
| ): ProviderBreakerProfileSettings { | |
| const record = asRecord(next); | |
| const failureThreshold = toInteger(record.failureThreshold, fallback.failureThreshold, { | |
| min: 1, | |
| max: 1000, | |
| }); | |
| const degradationThreshold = Math.min( | |
| toInteger(record.degradationThreshold, fallback.degradationThreshold, { | |
| min: 1, | |
| max: 1000, | |
| }), | |
| failureThreshold <= 1 ? 1 : failureThreshold - 1 | |
| ); | |
| return { | |
| failureThreshold, | |
| degradationThreshold, | |
| resetTimeoutMs: toInteger(record.resetTimeoutMs, fallback.resetTimeoutMs, { | |
| min: 1000, | |
| max: 24 * 60 * 60 * 1000, | |
| }), | |
| }; | |
| } | |
| function normalizeProviderWindowDefaults( | |
| next: unknown, | |
| fallback: Record<string, Record<string, number>> | |
| ): Record<string, Record<string, number>> { | |
| // Accept either an explicit object or fall back. Drop providers/windows | |
| // whose values are not a valid 0-100 integer so a malformed setting can't | |
| // accidentally disable cutoffs entirely. | |
| const rawProviders = asRecord(next ?? fallback); | |
| const out: Record<string, Record<string, number>> = {}; | |
| for (const [provider, windows] of Object.entries(rawProviders)) { | |
| if (!provider || typeof windows !== "object" || windows === null) continue; | |
| const windowMap: Record<string, number> = {}; | |
| for (const [windowName, percent] of Object.entries(windows as Record<string, unknown>)) { | |
| if (!windowName) continue; | |
| const parsed = | |
| typeof percent === "number" | |
| ? percent | |
| : typeof percent === "string" && percent.trim() !== "" | |
| ? Number(percent) | |
| : NaN; | |
| if (Number.isFinite(parsed)) { | |
| const clamped = Math.min(100, Math.max(0, Math.trunc(parsed))); | |
| windowMap[windowName] = clamped; | |
| } | |
| } | |
| if (Object.keys(windowMap).length > 0) { | |
| out[provider] = windowMap; | |
| } | |
| } | |
| return out; | |
| } | |
| function normalizeQuotaPreflightSettings( | |
| next: unknown, | |
| fallback: QuotaPreflightSettings | |
| ): QuotaPreflightSettings { | |
| const record = asRecord(next); | |
| // Remaining-% semantics: cutoff is the lowest acceptable remaining %, warn | |
| // is the higher "you're getting close" remaining %. So warn MUST be greater | |
| // than cutoff — otherwise the warn log would only fire after the request | |
| // is already blocked. | |
| const defaultThresholdPercent = toInteger( | |
| record.defaultThresholdPercent, | |
| fallback.defaultThresholdPercent, | |
| { min: 0, max: 99 } | |
| ); | |
| const warnRaw = toInteger(record.warnThresholdPercent, fallback.warnThresholdPercent, { | |
| min: 0, | |
| max: 100, | |
| }); | |
| const warnThresholdPercent = | |
| warnRaw <= defaultThresholdPercent ? Math.min(100, defaultThresholdPercent + 1) : warnRaw; | |
| const providerWindowDefaults = normalizeProviderWindowDefaults( | |
| record.providerWindowDefaults, | |
| fallback.providerWindowDefaults | |
| ); | |
| const enabled = typeof record.enabled === "boolean" ? record.enabled : fallback.enabled; | |
| return { enabled, defaultThresholdPercent, warnThresholdPercent, providerWindowDefaults }; | |
| } | |
| function normalizeWaitForCooldownSettings( | |
| next: unknown, | |
| fallback: WaitForCooldownSettings | |
| ): WaitForCooldownSettings { | |
| const record = asRecord(next); | |
| const maxRetryWaitSec = toInteger(record.maxRetryWaitSec, fallback.maxRetryWaitSec, { | |
| min: 0, | |
| max: 300, | |
| }); | |
| const maxRetries = toInteger(record.maxRetries, fallback.maxRetries, { min: 0, max: 10 }); | |
| const enabled = | |
| toBoolean(record.enabled, fallback.enabled) && maxRetries > 0 && maxRetryWaitSec > 0; | |
| return { | |
| enabled, | |
| maxRetries, | |
| maxRetryWaitSec, | |
| maxRetryWaitMs: maxRetryWaitSec * 1000, | |
| }; | |
| } | |
| function normalizeComboCooldownWaitSettings( | |
| next: unknown, | |
| fallback: ComboCooldownWaitSettings | |
| ): ComboCooldownWaitSettings { | |
| const record = asRecord(next); | |
| // Hard ceiling of 30s on a single wait — this layer only ever exists for | |
| // SHORT transient cooldowns; anything longer should fall through to the | |
| // existing 429 crystallization (and the cross-request cooldown layers). | |
| const maxWaitMs = toInteger(record.maxWaitMs, fallback.maxWaitMs, { min: 0, max: 30000 }); | |
| const maxAttempts = toInteger(record.maxAttempts, fallback.maxAttempts, { min: 0, max: 10 }); | |
| // Budget can never be smaller than a single wait, otherwise no wait could | |
| // ever fire; floor it at maxWaitMs. | |
| const budgetMs = toInteger(record.budgetMs, fallback.budgetMs, { | |
| min: maxWaitMs, | |
| max: 5 * 60 * 1000, | |
| }); | |
| const enabled = toBoolean(record.enabled, fallback.enabled) && maxWaitMs > 0 && maxAttempts > 0; | |
| return { enabled, maxWaitMs, maxAttempts, budgetMs }; | |
| } | |
| function normalizeQuotaShareConcurrencyLimitSettings( | |
| next: unknown, | |
| fallback: QuotaShareConcurrencyLimitSettings | |
| ): QuotaShareConcurrencyLimitSettings { | |
| const record = asRecord(next); | |
| return { enabled: toBoolean(record.enabled, fallback.enabled) }; | |
| } | |
| function normalizeProviderCooldownSettings( | |
| next: unknown, | |
| fallback: ProviderCooldownSettings | |
| ): ProviderCooldownSettings { | |
| const record = asRecord(next); | |
| const enabled = toBoolean(record.enabled, fallback.enabled); | |
| const minRetryCooldownMs = toInteger(record.minRetryCooldownMs, fallback.minRetryCooldownMs, { | |
| min: 0, | |
| max: 60 * 60 * 1000, | |
| }); | |
| const maxRetryCooldownMs = toInteger(record.maxRetryCooldownMs, fallback.maxRetryCooldownMs, { | |
| min: minRetryCooldownMs, | |
| max: 24 * 60 * 60 * 1000, | |
| }); | |
| return { enabled, minRetryCooldownMs, maxRetryCooldownMs }; | |
| } | |
| function normalizeStreamRecoverySettings( | |
| next: unknown, | |
| fallback: StreamRecoverySettings | |
| ): StreamRecoverySettings { | |
| const record = asRecord(next); | |
| return { | |
| enabled: toBoolean(record.enabled, fallback.enabled), | |
| continueMidStream: toBoolean(record.continueMidStream, fallback.continueMidStream), | |
| }; | |
| } | |
| function buildLegacyFallback(settings: JsonRecord): ResilienceSettings { | |
| const profiles = asRecord(settings.providerProfiles); | |
| const defaults = asRecord(settings.rateLimitDefaults); | |
| const streamRecoveryDefaults = resolveStreamRecoveryDefaults(); | |
| const oauthLegacy = asRecord(profiles.oauth); | |
| const apikeyLegacy = asRecord(profiles.apikey); | |
| const waitMaxRetrySec = toInteger( | |
| settings.maxRetryIntervalSec, | |
| DEFAULT_RESILIENCE_SETTINGS.waitForCooldown.maxRetryWaitSec, | |
| { min: 0, max: 300 } | |
| ); | |
| const waitMaxRetries = toInteger( | |
| settings.requestRetry, | |
| DEFAULT_RESILIENCE_SETTINGS.waitForCooldown.maxRetries, | |
| { min: 0, max: 10 } | |
| ); | |
| return { | |
| requestQueue: { | |
| autoEnableApiKeyProviders: DEFAULT_RESILIENCE_SETTINGS.requestQueue.autoEnableApiKeyProviders, | |
| requestsPerMinute: toInteger( | |
| defaults.requestsPerMinute, | |
| DEFAULT_RESILIENCE_SETTINGS.requestQueue.requestsPerMinute, | |
| { min: 1, max: 1_000_000 } | |
| ), | |
| minTimeBetweenRequestsMs: toInteger( | |
| defaults.minTimeBetweenRequests, | |
| DEFAULT_RESILIENCE_SETTINGS.requestQueue.minTimeBetweenRequestsMs, | |
| { min: 0, max: 60 * 60 * 1000 } | |
| ), | |
| concurrentRequests: toInteger( | |
| defaults.concurrentRequests, | |
| DEFAULT_RESILIENCE_SETTINGS.requestQueue.concurrentRequests, | |
| { min: 1, max: 10_000 } | |
| ), | |
| maxWaitMs: DEFAULT_RESILIENCE_SETTINGS.requestQueue.maxWaitMs, | |
| }, | |
| connectionCooldown: { | |
| oauth: normalizeLegacyConnectionCooldownProfile( | |
| oauthLegacy, | |
| DEFAULT_RESILIENCE_SETTINGS.connectionCooldown.oauth | |
| ), | |
| apikey: normalizeLegacyConnectionCooldownProfile( | |
| apikeyLegacy, | |
| DEFAULT_RESILIENCE_SETTINGS.connectionCooldown.apikey | |
| ), | |
| }, | |
| providerBreaker: { | |
| oauth: { | |
| failureThreshold: toInteger( | |
| oauthLegacy.circuitBreakerThreshold, | |
| DEFAULT_RESILIENCE_SETTINGS.providerBreaker.oauth.failureThreshold, | |
| { min: 1, max: 1000 } | |
| ), | |
| degradationThreshold: | |
| DEFAULT_RESILIENCE_SETTINGS.providerBreaker.oauth.degradationThreshold, | |
| resetTimeoutMs: toInteger( | |
| oauthLegacy.circuitBreakerReset, | |
| DEFAULT_RESILIENCE_SETTINGS.providerBreaker.oauth.resetTimeoutMs, | |
| { min: 1000, max: 24 * 60 * 60 * 1000 } | |
| ), | |
| }, | |
| apikey: { | |
| failureThreshold: toInteger( | |
| apikeyLegacy.circuitBreakerThreshold, | |
| DEFAULT_RESILIENCE_SETTINGS.providerBreaker.apikey.failureThreshold, | |
| { min: 1, max: 1000 } | |
| ), | |
| degradationThreshold: | |
| DEFAULT_RESILIENCE_SETTINGS.providerBreaker.apikey.degradationThreshold, | |
| resetTimeoutMs: toInteger( | |
| apikeyLegacy.circuitBreakerReset, | |
| DEFAULT_RESILIENCE_SETTINGS.providerBreaker.apikey.resetTimeoutMs, | |
| { min: 1000, max: 24 * 60 * 60 * 1000 } | |
| ), | |
| }, | |
| }, | |
| waitForCooldown: { | |
| enabled: waitMaxRetries > 0 && waitMaxRetrySec > 0, | |
| maxRetries: waitMaxRetries, | |
| maxRetryWaitSec: waitMaxRetrySec, | |
| maxRetryWaitMs: waitMaxRetrySec * 1000, | |
| }, | |
| comboCooldownWait: DEFAULT_RESILIENCE_SETTINGS.comboCooldownWait, | |
| quotaShareConcurrencyLimit: DEFAULT_RESILIENCE_SETTINGS.quotaShareConcurrencyLimit, | |
| providerCooldown: DEFAULT_RESILIENCE_SETTINGS.providerCooldown, | |
| quotaPreflight: DEFAULT_RESILIENCE_SETTINGS.quotaPreflight, | |
| streamRecovery: streamRecoveryDefaults, | |
| }; | |
| } | |
| export function resolveResilienceSettings( | |
| settings: Record<string, unknown> | null | undefined | |
| ): ResilienceSettings { | |
| const record = asRecord(settings); | |
| const current = asRecord(record.resilienceSettings); | |
| const fallback = buildLegacyFallback(record); | |
| return { | |
| requestQueue: normalizeRequestQueueSettings(current.requestQueue, fallback.requestQueue), | |
| connectionCooldown: { | |
| oauth: normalizeConnectionCooldownProfile( | |
| asRecord(current.connectionCooldown).oauth, | |
| fallback.connectionCooldown.oauth | |
| ), | |
| apikey: normalizeConnectionCooldownProfile( | |
| asRecord(current.connectionCooldown).apikey, | |
| fallback.connectionCooldown.apikey | |
| ), | |
| }, | |
| providerBreaker: { | |
| oauth: normalizeProviderBreakerProfile( | |
| asRecord(current.providerBreaker).oauth, | |
| fallback.providerBreaker.oauth | |
| ), | |
| apikey: normalizeProviderBreakerProfile( | |
| asRecord(current.providerBreaker).apikey, | |
| fallback.providerBreaker.apikey | |
| ), | |
| }, | |
| waitForCooldown: normalizeWaitForCooldownSettings( | |
| current.waitForCooldown, | |
| fallback.waitForCooldown | |
| ), | |
| comboCooldownWait: normalizeComboCooldownWaitSettings( | |
| current.comboCooldownWait, | |
| fallback.comboCooldownWait | |
| ), | |
| quotaShareConcurrencyLimit: normalizeQuotaShareConcurrencyLimitSettings( | |
| current.quotaShareConcurrencyLimit, | |
| fallback.quotaShareConcurrencyLimit | |
| ), | |
| providerCooldown: normalizeProviderCooldownSettings( | |
| current.providerCooldown, | |
| fallback.providerCooldown | |
| ), | |
| quotaPreflight: normalizeQuotaPreflightSettings( | |
| current.quotaPreflight, | |
| fallback.quotaPreflight | |
| ), | |
| streamRecovery: normalizeStreamRecoverySettings( | |
| current.streamRecovery, | |
| fallback.streamRecovery | |
| ), | |
| }; | |
| } | |
| export function mergeResilienceSettings( | |
| current: ResilienceSettings, | |
| updates: ResilienceSettingsPatch | |
| ): ResilienceSettings { | |
| return { | |
| requestQueue: normalizeRequestQueueSettings(updates.requestQueue, current.requestQueue), | |
| connectionCooldown: { | |
| oauth: normalizeConnectionCooldownProfile( | |
| updates.connectionCooldown?.oauth, | |
| current.connectionCooldown.oauth | |
| ), | |
| apikey: normalizeConnectionCooldownProfile( | |
| updates.connectionCooldown?.apikey, | |
| current.connectionCooldown.apikey | |
| ), | |
| }, | |
| providerBreaker: { | |
| oauth: normalizeProviderBreakerProfile( | |
| updates.providerBreaker?.oauth, | |
| current.providerBreaker.oauth | |
| ), | |
| apikey: normalizeProviderBreakerProfile( | |
| updates.providerBreaker?.apikey, | |
| current.providerBreaker.apikey | |
| ), | |
| }, | |
| waitForCooldown: normalizeWaitForCooldownSettings( | |
| updates.waitForCooldown, | |
| current.waitForCooldown | |
| ), | |
| comboCooldownWait: normalizeComboCooldownWaitSettings( | |
| updates.comboCooldownWait, | |
| current.comboCooldownWait | |
| ), | |
| quotaShareConcurrencyLimit: normalizeQuotaShareConcurrencyLimitSettings( | |
| updates.quotaShareConcurrencyLimit, | |
| current.quotaShareConcurrencyLimit | |
| ), | |
| providerCooldown: normalizeProviderCooldownSettings( | |
| updates.providerCooldown, | |
| current.providerCooldown | |
| ), | |
| quotaPreflight: normalizeQuotaPreflightSettings(updates.quotaPreflight, current.quotaPreflight), | |
| streamRecovery: normalizeStreamRecoverySettings(updates.streamRecovery, current.streamRecovery), | |
| }; | |
| } | |
| export function buildLegacyResilienceCompat(settings: ResilienceSettings) { | |
| return { | |
| profiles: { | |
| oauth: { | |
| transientCooldown: settings.connectionCooldown.oauth.baseCooldownMs, | |
| rateLimitCooldown: settings.connectionCooldown.oauth.useUpstreamRetryHints | |
| ? 0 | |
| : settings.connectionCooldown.oauth.baseCooldownMs, | |
| maxBackoffLevel: settings.connectionCooldown.oauth.maxBackoffSteps, | |
| circuitBreakerThreshold: settings.providerBreaker.oauth.failureThreshold, | |
| degradationThreshold: settings.providerBreaker.oauth.degradationThreshold, | |
| circuitBreakerReset: settings.providerBreaker.oauth.resetTimeoutMs, | |
| }, | |
| apikey: { | |
| transientCooldown: settings.connectionCooldown.apikey.baseCooldownMs, | |
| rateLimitCooldown: settings.connectionCooldown.apikey.useUpstreamRetryHints | |
| ? 0 | |
| : settings.connectionCooldown.apikey.baseCooldownMs, | |
| maxBackoffLevel: settings.connectionCooldown.apikey.maxBackoffSteps, | |
| circuitBreakerThreshold: settings.providerBreaker.apikey.failureThreshold, | |
| degradationThreshold: settings.providerBreaker.apikey.degradationThreshold, | |
| circuitBreakerReset: settings.providerBreaker.apikey.resetTimeoutMs, | |
| }, | |
| }, | |
| defaults: { | |
| requestsPerMinute: settings.requestQueue.requestsPerMinute, | |
| minTimeBetweenRequests: settings.requestQueue.minTimeBetweenRequestsMs, | |
| concurrentRequests: settings.requestQueue.concurrentRequests, | |
| }, | |
| }; | |
| } | |