ai_api / src /lib /resilience /settings.ts
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
31.7 kB
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,
},
};
}