mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 19:52:50 +03:00
* fix(ci): clear base-reds on release/v3.8.50 (round 3) - CHANGELOG.md: restore the top [Unreleased] section dropped by the #10189 reconcile (docs-sync gate: first section must be Unreleased) - env-doc-sync: document CONDUCTOR_ORCHESTRATOR_TOKEN + CONDUCTOR_SPOKESPERSON_URL in .env.example/ENVIRONMENT.md; allowlist the CI-only GITHUB_STEP_SUMMARY and TS7_BASE_REF (ts7 ratchet signals); drop a stray merge artifact line - providers: restore the audited chatanywhere metadata entry that base-reds round 2 dropped together with its duplicate — the provider was half-wired (registry+endpoint without APIKEY metadata), which is what the wave3 test catches; re-pin providers-constants-split at the measured 228 - docs counts: 338 -> 339 (today's +2 void-ai/helixmind, -1 Puter) via gen:provider-reference + README/AGENTS/llm.txt/package.json/diagrams/i18n mirrors - file-size ratchet: annotated rebaseline for the two pre-existing drifts (ModelSelectModal 1138, gateways 1250) following the 2026-08-11 precedent Refs #9985 * fix(ci): base-reds round 3b — stale sibling tests + mode-pack weight contract - check-docs-counts-sync.test.ts: drop the imports/subtests of the four helpers #10196 removed from the gate script (readMcpFactsFromSource, listLocalizedDocs, makeRequiredCountsValidator, checkFreeTierInventory) — the new-API tests that #10196 added stay; the file now loads again under the node runner - quota-connection-recovery.test.ts: convert from vitest APIs to node:test — the file lives in tests/unit/*.test.ts (node-runner glob) and the vitest runtime crashes when imported outside vitest, killing the whole shard entry - modePacks.ts: re-normalize all six mode packs to sum 1.0 — #8940 added sessionAvailability: 0.05 to every pack without rebalancing (1.05 total); ratios preserved exactly (÷1.05), so post-normalizeScoringWeights behavior is unchanged; restores the declared sum-to-1.0 contract the 4235 test pins Refs #9985 * fix(ci): base-reds round 3c — vitest siblings, weights default, secrets FP, mutation tap - DistributeProxiesButton.test.tsx: wrap renders in NextIntlClientProvider — #9245 localized the component (useTranslations) and left the test without the intl context, failing all 14 cases - scoring.ts: re-normalize DEFAULT_WEIGHTS to sum 1.0 (same #8940 class as the mode packs — sessionAvailability added without rebalancing; ratios preserved) - .gitleaks.toml: generalize the kimi sponsor-banner localStorage-key allowlist to -v\d+ — #10200 bumped v1→v2 and the stale regex regressed the secrets ratchet with a false positive - stryker.conf.json: register 6 covering unit tests in tap.testFiles (4 modules) so their mutant kills count — unblocks check:mutation-test-coverage --strict Refs #9985 * fix(ci): base-reds round 3d — inspector factor gap, stale registry/gap tests, i18n key sync - comboScoringInspector: add cacheAffinity/sessionAvailability/connectionDensity to FACTOR_KEYS + the factor-key type — calculateScore() weighs them but the breakdown omitted them, so the explained contributions never summed to the reported score (inspector bug, red on the pure tip) - combo-scoring-inspector.test: make the explicit-weights override sum-neutral (±0.05 shift) so it stays valid for any DEFAULT_WEIGHTS values — the hardcoded override only summed to 1.0 against the pre-#8940 defaults, which is also why explicit weights silently fell back to 'default' on the tip - unorouter-registry.test: align to the canonical .com host (api.unorouter.ai 301-redirects there, verified live) and to wave4's live model discovery (passthrough, no static seed) — the .ai/auto-model expectations were stale - check-migration-numbering.test: 147 left KNOWN_GAPS when 147_api_keys_model_access_mode.sql landed — assert absent (same as 143) - i18n: sync-ui pass — 35,914 missing UI keys stamped as __MISSING__ placeholders across 42 locales (mechanical; greens the pt-BR key-presence integrity test; coverage pct unchanged by design — translation is a separate workstream) Refs #9985 * fix(ci): base-reds round 3e — 2 real defects + 14 stale sibling tests (waves A-E) Real defects fixed: - src/lib/db/apiKeys.ts: #9313's empty-allowlist early return bypassed the group permission check, silently disabling group deny rules (#8817) for every key without a per-key allowlist; fall-through restored, restricted+[] deny-all kept - open-sse/utils/proxyFetch.ts: #10032 re-appended the raw transport error to the propagated message, reintroducing the proxy user:password leak #9837 closed; new redactProxyDetailsInMessage() keeps the reason, redacts URL/credentials - .github/workflows/quality.yml: #10134 added the TS7 ratchet as a separate blocking step AFTER the aggregated gates — the exact #8542 masking mechanism; folded into the non-fail-fast loop (still blocking, still PR-only) ⚠️ CI edit, gate-strengthening — explicit owner sign-off requested on the PR - src/i18n/messages/ko.json: 3 machine-mistranslation regressions caught by the #8244 glossary checker (장애인→비활성화됨, 양말5://→socks5://, 비클로드→Claude가 아닌) Stale sibling tests aligned to deliberately-moved contracts (each cites its mover): request-log-detail-layout + -stream (#9245 intl provider), repro-8542 pin update, quality-rail-gate-membership (#10134 shape), agentSkills-routes 45→46 (#9058), cloudflare-ai-catalog-8717 (#8804 supersedes #8808), executor-xai (#9994), vision-bridge-claude-wire (#9463 minimax→openai), sse-auth forced-pin (#8893), tls-proxy-context (strengthened leak guards), rate-limit-local-error-classification (#9164/#9342), minimax-thinking-signature (#9463), codebuddy-cn (#9723 +1 test), github-copilot-custom-model (#9050), providers-g4f-batch3 (#9584), synced-capability-warmup (#9199, stricter), sidebar-tools-group (#8221), oauth-modal-grok-cli-paste (#9245); agentSkills/catalog.ts comment 45→46; file-size rebaseline for proxyFetch (+19, annotated) Refs #9985 * fix(ci): base-reds round 3f — waves F-J: 9 more real defects + stale sibling sweep Real production defects fixed (all red on the pure tip, each with its origin): - routeGuard.ts: #8949 accidentally DELETED the /api/providers/[id]/login local-only pattern — the route spawns a browser, so the loopback gate for a process-spawning route was gone (Hard Rules #15/#17); restored (314 guard tests green) - agentSkills generator: #9058's category dispatch gave the config category an empty body, wiping skills/config-codex-cli/SKILL.md at the #10131 sync; fixed + SKILL.md regenerated via the official generator - imageRegistry: #9982 broke same-provider bare aliasing (antigravity preview id sent upstream unresolved); new resolveSameProviderBareAlias() keeps the fal cross-provider fix intact - imageRegistry: #9982's prefix strip handed the bare nano-banana ids to fal-ai, violating the pinned 2026-07-31 operator decision (adobe-firefly owns them); fal entries made prefix-only (dispatch already re-prefixes) - mediaGeneration/fal.ts: the missing-credential 401 guard was lost when #10198 deleted the superseded falHandler — tests were hitting the live network - bottleneckPatch/rateLimitManager: #9041's merge clobbered #9604, resurrecting the Bottleneck v2.19.5 heartbeat bug (reservoir never refills); patched the library defect at the root and re-aligned chat-rate-limit-body-lock to the working reservoir contract - processSupervisor.mjs: #9761 regressed the Node spawn to bare "node" (the #9156 launchd bug) and dropped #9209's ipv4first args; both restored - openai-responses/pureHelpers: #9423's Agent null-sentinel was unreachable on the schemaless JSON-string path; gate extended - i18n en.json: #8222's regen reverted the #9976 unclosed-tag fix and #8559's combo-cooldown copy; #9038 shipped 40 t() calls with no messages (runtime MISSING_MESSAGE); all restored/added + official sync-ui stamps, and vi's zero-marker policy re-established via the sanctioned translation backend Stale sibling tests aligned (movers cited inline): chat-helpers (#9447), executor-antigravity (#9351), video-fal-grok (#9982), visionBridge (#9759), web-session-credentials (#8974), production-build-module-integrity (positive anchor added), agentSkills-generator/skillManifestsLint/skills-injection/ agentSkillTools-mcp/listCapabilities-a2a (#9058), memory-settings (#10010), model-catalog-policy-invalidation (#8906), model-alias-seed (#9485), reactive-context-compaction (#8949), combo-provider-wildcard (broken upsert helper), oauth-google-loopback (43-locale resurrected-key removal) Validation: 501/501 across the 47 touched test files; typecheck:core, lint, file-size, docs-sync all green. Refs #9985 * fix(ci): base-reds round 3g — wave K/L: 4 more real defects + stale alignments Real defects: - base/reasoningEffort.ts: the stale duplicate cherry-pick #9612 re-added the codex minimal→low rewrite that #9883 had deliberately removed (OMP minimal passthrough); block removed again - cursorImages.ts: #9840 wired prepareCursorImageForWire (sharp re-encode, fail-closed) into the SHARED resolveCursorImages, breaking zai-web and conol-web image uploads (HTTP 400 'undecodable'); new prepareForWire opt-out, Cursor default path unchanged (8 cursor suites green) - modelCapabilities/snapshot: catalog prepare still issued 323 per-model reads of model_context_overrides + max_input_tokens overrides, violating #9199's bulk-load contract; both now resolve from the snapshot single pass - v1-models-discovery-conformance: re-pinned to the bounded 30s SWR window (#9199/#10198) — the old 'stale-first regardless of age' contract is gone Stale tests aligned (movers cited inline): codex-tools-strict-default (#9828 redundant-oneOf strip), devin-providers (#9245 i18n), db-migrationrunner- constants-split (147→151 renumber #8228), gitlab-duo-oauth-setup (#9245), chatcore-extracted-modules (#9161 outbound-protocol keying) compression-api CI failures were cascade artifacts of codex-tools-strict-default failing in the same force-exit shard process — no own defect (171/171 local). Refs #9985 * fix(test): compression-api — register both describes before the runner starts The DATA_DIR setup + route/db top-level awaits sat BETWEEN the two describes; under --test-force-exit (the CI unit-runner flag) the process exits once the already-registered tests finish, so on slow CI machines the whole second describe died as 'Promise resolution is still pending' — the recurring CI-only shard-2 failure that never reproduced locally without the flag. Moved to the top of the file; 10/10 under --test-force-exit locally. Refs #9985 * fix(quality): freeze modelCapabilities.ts at 1006 (annotated) — snapshot routing growth Refs #9985 * fix(quality): move the modelCapabilities freeze into the frozen map (nested schema) Refs #9985 * fix(i18n): translate all 39,718 pending UI keys across 42 locales (owner-approved) Mass-translated every __MISSING__ placeholder via the official i18n:sync-ui --translate-markers pipeline (operator backend), restoring i18nUiCoverage to the 100 baseline (was 89.9 after the merge-storm UI landings + the 42 keys #9038 never shipped). Post-pass repairs, all caught by the existing gates: - glossary: retired renderings the machine reintroduced normalized again (提供商→提供者 zh-CN/zh-TW, 鏈接→連結, 文檔→文件, 調用→呼叫, 供應商→提供者, 響應→回應, 不活躍→未啟用 zh-TW; 클로드→Claude, 옴니루트→OmniRoute ko); DATA_DIR forbidden rendering avoided via 数据文件夹 rephrase - ICU integrity: 120 values with renamed/dropped {params} repaired (39 positional renames, 81 reset to the en source — functional over fluent) Validation: glossary/pt-BR/vi/deno-relay/settings-keys/value-drift/google- loopback suites 76/76; placeholder diff en×42 locales = 0; worst-locale coverage = 100.0%. Refs #9985 --------- Co-authored-by: backryun <bakryun0718@proton.me>
1022 lines
37 KiB
TypeScript
1022 lines
37 KiB
TypeScript
/**
|
|
* Rate Limit Manager — Adaptive rate limiting using Bottleneck
|
|
*
|
|
* Creates per-provider+connection limiters that auto-learn rate limits
|
|
* from API response headers (x-ratelimit-*, retry-after, anthropic-ratelimit-*).
|
|
*
|
|
* Default: ENABLED for API key providers (safety net), DISABLED for OAuth.
|
|
* Can be toggled per provider connection via dashboard.
|
|
*/
|
|
|
|
import Bottleneck from "bottleneck";
|
|
import {
|
|
applyBottleneckDoExpirePatch,
|
|
applyBottleneckHeartbeatPatch,
|
|
} from "./bottleneckPatch.ts";
|
|
import { parseRetryAfterFromBody } from "./accountFallback.ts";
|
|
import { getAntigravityQuotaFamily } from "./antigravityQuotaFamily.ts";
|
|
import { getProviderCategory } from "../config/providerRegistry.ts";
|
|
import { getCodexRateLimitKey } from "../executors/codex.ts";
|
|
import { awaitProviderDefaultSlot, setProviderQuotaOverrides } from "./providerDefaultRateLimit.ts";
|
|
import {
|
|
DEFAULT_RESILIENCE_SETTINGS,
|
|
resolveResilienceSettings,
|
|
type RequestQueueSettings,
|
|
} from "../../src/lib/resilience/settings";
|
|
import {
|
|
STANDARD_HEADERS,
|
|
ANTHROPIC_HEADERS,
|
|
parseResetTime,
|
|
toPlainHeaders,
|
|
} from "./rateLimitManager/headers";
|
|
import { checkQueueAdmission } from "./rateLimitManager/admission";
|
|
import {
|
|
markLocalRateLimitError,
|
|
RATE_LIMIT_EXECUTION_TIMEOUT_CODE,
|
|
RATE_LIMIT_QUEUE_WEDGED_CODE,
|
|
} from "./rateLimitManager/errors";
|
|
import { LimiterWedgeWatchdog, WATCHDOG_INTERVAL_MS } from "./rateLimitManager/wedgeWatchdog";
|
|
import { toNumber } from "@/shared/utils/numeric";
|
|
|
|
interface LearnedLimitEntry {
|
|
provider: string;
|
|
connectionId: string;
|
|
lastUpdated: number;
|
|
limit?: number;
|
|
remaining?: number;
|
|
minTime?: number;
|
|
}
|
|
|
|
interface LimiterUpdateSettings {
|
|
maxConcurrent?: number | null;
|
|
minTime: number;
|
|
reservoir?: number | null;
|
|
reservoirRefreshAmount?: number | null;
|
|
reservoirRefreshInterval?: number | null;
|
|
}
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
function toRecord(value: unknown): JsonRecord {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
|
}
|
|
|
|
function isNodeTestRunnerChild(): boolean {
|
|
return typeof process.env.NODE_TEST_CONTEXT === "string";
|
|
}
|
|
|
|
function logRateLimit(...args: unknown[]): void {
|
|
if (!isNodeTestRunnerChild()) console.log(...args);
|
|
}
|
|
|
|
function warnRateLimit(...args: unknown[]): void {
|
|
if (!isNodeTestRunnerChild()) console.warn(...args);
|
|
}
|
|
|
|
function errorRateLimit(...args: unknown[]): void {
|
|
if (!isNodeTestRunnerChild()) console.error(...args);
|
|
}
|
|
|
|
// Store limiters keyed by "provider:connectionId" (and optionally ":model")
|
|
const limiters = new Map<string, Bottleneck>();
|
|
|
|
// Store connections that have rate limit protection enabled
|
|
const enabledConnections = new Set<string>();
|
|
|
|
// Store per-connection rate limit overrides (RPM, TPM, TPD, minTime, maxConcurrent)
|
|
// Populated from provider_connections.rateLimitOverrides on startup and refresh.
|
|
const connectionRateLimitOverrides = new Map<string, Record<string, number>>();
|
|
|
|
// Store learned limits for persistence (debounced)
|
|
const learnedLimits: Record<string, LearnedLimitEntry> = {};
|
|
const MAX_LEARNED_LIMITS = 200;
|
|
const limiterLastUsed = new Map<string, number>();
|
|
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const pendingAsyncOperations = new Set<Promise<unknown>>();
|
|
const PERSIST_DEBOUNCE_MS = 60_000; // Debounce persistence to every 60s max
|
|
|
|
// Track initialization
|
|
let initialized = false;
|
|
|
|
let currentRequestQueueSettings: RequestQueueSettings = DEFAULT_RESILIENCE_SETTINGS.requestQueue;
|
|
export const ZAI_WEB_REQUEST_QUEUE_MAX_WAIT_MS = 60_000;
|
|
|
|
const limiterEffectiveSettings = new WeakMap<Bottleneck, Bottleneck.ConstructorOptions>();
|
|
const preservedReplacementSettings = new Map<string, Bottleneck.ConstructorOptions>();
|
|
const limiterWatchdog = new LimiterWedgeWatchdog({
|
|
limiters,
|
|
limiterLastUsed,
|
|
limiterEffectiveSettings,
|
|
preservedReplacementSettings,
|
|
trackBackground: (promise) => {
|
|
trackAsyncOperation(promise);
|
|
},
|
|
log: logRateLimit,
|
|
warn: warnRateLimit,
|
|
});
|
|
let watchdogInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
type LimiterFactory = (options: Bottleneck.ConstructorOptions) => Bottleneck;
|
|
const defaultLimiterFactory: LimiterFactory = (options) => new Bottleneck(options);
|
|
let limiterFactory: LimiterFactory = defaultLimiterFactory;
|
|
|
|
/**
|
|
* Env-var override for the auto-enable safety net. Highest priority — wins
|
|
* over the persisted dashboard setting. Use to disable in an incident without
|
|
* needing dashboard access.
|
|
* RATE_LIMIT_AUTO_ENABLE=false → never auto-enable
|
|
* RATE_LIMIT_AUTO_ENABLE=true → force on regardless of dashboard
|
|
* (unset) → use dashboard setting
|
|
*/
|
|
function isAutoEnableActive(settings: RequestQueueSettings): boolean {
|
|
const env = process.env.RATE_LIMIT_AUTO_ENABLE?.trim().toLowerCase();
|
|
if (env === "false" || env === "0" || env === "off") return false;
|
|
if (env === "true" || env === "1" || env === "on") return true;
|
|
return settings.autoEnableApiKeyProviders;
|
|
}
|
|
|
|
// Sentinels for "no rate limit" / effectively infinite capacity. The reservoir
|
|
// value uses Number.MAX_SAFE_INTEGER so the bucket can never realistically be
|
|
// exhausted; maxConcurrent uses a smaller-but-still-vast ceiling since
|
|
// Bottleneck tracks concurrent jobs in memory and an unbounded number would
|
|
// risk internal counter overflow under sustained pressure.
|
|
const EFFECTIVELY_INFINITE = Number.MAX_SAFE_INTEGER;
|
|
const EFFECTIVELY_INFINITE_CONCURRENCY = 1000;
|
|
|
|
// Resolve an RPM override. 0 or missing means "infinite" (no rate cap).
|
|
function resolveRpm(override: number | undefined | null): number {
|
|
return typeof override === "number" && override > 0 ? override : EFFECTIVELY_INFINITE;
|
|
}
|
|
|
|
// Resolve a minTime override. 0 or missing means "no minimum gap".
|
|
function resolveMinTime(override: number | undefined | null): number {
|
|
return typeof override === "number" && override > 0 ? override : 0;
|
|
}
|
|
|
|
// Resolve a maxConcurrent override. 0 or missing means "effectively infinite".
|
|
function resolveMaxConcurrent(override: number | undefined | null): number {
|
|
return typeof override === "number" && override > 0 ? override : EFFECTIVELY_INFINITE_CONCURRENCY;
|
|
}
|
|
|
|
export function resolveRequestQueueMaxWaitMs(
|
|
provider: string,
|
|
configuredMaxWaitMs: number = currentRequestQueueSettings.maxWaitMs
|
|
): number {
|
|
return provider.trim().toLowerCase() === "zai-web"
|
|
? Math.max(configuredMaxWaitMs, ZAI_WEB_REQUEST_QUEUE_MAX_WAIT_MS)
|
|
: configuredMaxWaitMs;
|
|
}
|
|
|
|
function buildLimiterDefaults() {
|
|
// 0 or missing values mean "infinite" / no rate limit applies. This treats
|
|
// the global request-queue settings the same way per-connection overrides
|
|
// are interpreted (see resolveRpm / resolveMinTime / resolveMaxConcurrent).
|
|
return {
|
|
maxConcurrent: resolveMaxConcurrent(currentRequestQueueSettings.concurrentRequests),
|
|
minTime: resolveMinTime(currentRequestQueueSettings.minTimeBetweenRequestsMs),
|
|
reservoir: resolveRpm(currentRequestQueueSettings.requestsPerMinute),
|
|
reservoirRefreshAmount: resolveRpm(currentRequestQueueSettings.requestsPerMinute),
|
|
reservoirRefreshInterval: 60 * 1000,
|
|
};
|
|
}
|
|
|
|
function updateLimiterSettings(
|
|
limiter: Bottleneck,
|
|
updates: Bottleneck.ConstructorOptions
|
|
): Bottleneck {
|
|
const effective = limiterEffectiveSettings.get(limiter) ?? {};
|
|
limiterEffectiveSettings.set(limiter, { ...effective, ...updates });
|
|
return limiter.updateSettings(updates);
|
|
}
|
|
|
|
function updateAllLimiterSettings() {
|
|
const defaults = buildLimiterDefaults();
|
|
for (const limiter of limiters.values()) {
|
|
updateLimiterSettings(limiter, defaults);
|
|
}
|
|
}
|
|
|
|
function clearPreservedReplacementSettings(connectionId: string): void {
|
|
for (const key of preservedReplacementSettings.keys()) {
|
|
if (key.includes(connectionId)) preservedReplacementSettings.delete(key);
|
|
}
|
|
}
|
|
|
|
function reconcileEnabledConnections(
|
|
connectionsRaw: unknown[],
|
|
requestQueueSettings: RequestQueueSettings
|
|
) {
|
|
const nextEnabledConnections = new Set<string>();
|
|
let explicitCount = 0;
|
|
let autoCount = 0;
|
|
|
|
for (const connRaw of connectionsRaw) {
|
|
const conn = toRecord(connRaw);
|
|
const connectionId = typeof conn.id === "string" ? conn.id : "";
|
|
const provider = typeof conn.provider === "string" ? conn.provider : "";
|
|
const isActive = conn.isActive === true;
|
|
const rateLimitProtection = conn.rateLimitProtection === true;
|
|
if (!connectionId || !provider) continue;
|
|
|
|
if (rateLimitProtection) {
|
|
nextEnabledConnections.add(connectionId);
|
|
explicitCount++;
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
isAutoEnableActive(requestQueueSettings) &&
|
|
getProviderCategory(provider) === "apikey" &&
|
|
isActive
|
|
) {
|
|
nextEnabledConnections.add(connectionId);
|
|
autoCount++;
|
|
|
|
// Route through getLimiter so the queue-progress listeners are wired up.
|
|
// Otherwise a limiter created here could not be evaluated safely by the watchdog.
|
|
getLimiter(provider, connectionId);
|
|
}
|
|
}
|
|
|
|
for (const connectionId of Array.from(enabledConnections)) {
|
|
if (!nextEnabledConnections.has(connectionId)) {
|
|
disableRateLimitProtection(connectionId);
|
|
}
|
|
}
|
|
|
|
for (const connectionId of nextEnabledConnections) {
|
|
enabledConnections.add(connectionId);
|
|
}
|
|
|
|
return {
|
|
explicitCount,
|
|
autoCount,
|
|
};
|
|
}
|
|
|
|
let shutdownHandlersRegistered = false;
|
|
|
|
export function startRateLimitWatchdog(): void {
|
|
if (watchdogInterval) return;
|
|
watchdogInterval = setInterval(() => {
|
|
const run = trackAsyncOperation(limiterWatchdog.run());
|
|
void run.then(undefined, (error) => {
|
|
errorRateLimit("[RATE-LIMIT] Watchdog scan failed:", error);
|
|
});
|
|
}, WATCHDOG_INTERVAL_MS);
|
|
watchdogInterval.unref?.();
|
|
// Register SIGTERM/SIGINT shutdown handlers once, lazily, on first watchdog start.
|
|
// Registering here (rather than at module load) avoids interfering with test runner
|
|
// subprocess IPC teardown — the test suite does not call startRateLimitWatchdog().
|
|
if (!shutdownHandlersRegistered) {
|
|
shutdownHandlersRegistered = true;
|
|
process.once("SIGTERM", shutdownLimiters);
|
|
process.once("SIGINT", shutdownLimiters);
|
|
}
|
|
}
|
|
|
|
export function stopRateLimitWatchdog(): void {
|
|
if (!watchdogInterval) return;
|
|
clearInterval(watchdogInterval);
|
|
watchdogInterval = null;
|
|
}
|
|
|
|
/**
|
|
* Gracefully stop all limiters for process shutdown.
|
|
* Runtime wedge recovery also uses stop(), but only after synchronously
|
|
* removing that limiter from the cache so it can never accept new work.
|
|
*/
|
|
function shutdownLimiters(): void {
|
|
for (const limiter of limiters.values()) {
|
|
limiter.stop({ dropWaitingJobs: false });
|
|
}
|
|
limiters.clear();
|
|
limiterLastUsed.clear();
|
|
preservedReplacementSettings.clear();
|
|
}
|
|
|
|
// Only register shutdown handlers when there are active limiters to shut down.
|
|
// Guard with once() so repeated registrations (e.g. test resets) don't stack.
|
|
// Note: these are registered lazily in startRateLimitWatchdog() to avoid
|
|
// interfering with test runner subprocess IPC teardown.
|
|
|
|
function trackAsyncOperation<T>(promise: Promise<T>): Promise<T> {
|
|
pendingAsyncOperations.add(promise);
|
|
// Do not use a fire-and-forget `.finally()` here: it creates a derived
|
|
// Promise that mirrors rejections from `promise`. When the caller intentionally
|
|
// tracks a background cleanup without awaiting it, that derived Promise can be
|
|
// reported as an unhandled rejection during Node's test-runner IPC teardown.
|
|
void promise.then(
|
|
() => {
|
|
pendingAsyncOperations.delete(promise);
|
|
},
|
|
() => {
|
|
pendingAsyncOperations.delete(promise);
|
|
}
|
|
);
|
|
return promise;
|
|
}
|
|
|
|
/**
|
|
* Initialize rate limit protection from persisted connection settings.
|
|
* Called once on app startup.
|
|
*/
|
|
export async function initializeRateLimits() {
|
|
if (initialized) return;
|
|
initialized = true;
|
|
// Fix Bottleneck v2.19.5 doExpire bug before any limiter is created.
|
|
applyBottleneckDoExpirePatch();
|
|
applyBottleneckHeartbeatPatch();
|
|
|
|
try {
|
|
const { getCachedProviderConnections, getSettings } = await import("@/lib/localDb");
|
|
const [connections, settings] = await Promise.all([
|
|
getCachedProviderConnections(),
|
|
getSettings(),
|
|
]);
|
|
const resilience = resolveResilienceSettings(settings);
|
|
currentRequestQueueSettings = { ...resilience.requestQueue };
|
|
// #6846 Phase 1: operator overrides for header-less providers' static RPM
|
|
// budget + concurrency cap (nvidia today). No-op for every provider without
|
|
// an entry in either providerQuotaOverrides or PROVIDER_DEFAULT_RATE_LIMITS.
|
|
setProviderQuotaOverrides(resilience.providerQuotaOverrides);
|
|
const { explicitCount, autoCount } = reconcileEnabledConnections(
|
|
connections as unknown[],
|
|
currentRequestQueueSettings
|
|
);
|
|
updateAllLimiterSettings();
|
|
|
|
// Load per-connection rate limit overrides
|
|
connectionRateLimitOverrides.clear();
|
|
for (const conn of connections as Array<Record<string, unknown>>) {
|
|
const overrides = conn.rateLimitOverrides;
|
|
if (overrides && typeof overrides === "object" && !Array.isArray(overrides)) {
|
|
connectionRateLimitOverrides.set(String(conn.id), overrides as Record<string, number>);
|
|
}
|
|
}
|
|
|
|
if (explicitCount > 0 || autoCount > 0) {
|
|
logRateLimit(
|
|
`🛡️ [RATE-LIMIT] Loaded ${explicitCount} explicit + ${autoCount} auto-enabled protection(s)`
|
|
);
|
|
}
|
|
|
|
// Load persisted learned limits
|
|
await loadPersistedLimits();
|
|
|
|
// Watchdog runs unconditionally — cheap, only fires when something is
|
|
// actually wedged.
|
|
startRateLimitWatchdog();
|
|
} catch (err) {
|
|
errorRateLimit("[RATE-LIMIT] Failed to load settings:", err.message);
|
|
}
|
|
}
|
|
|
|
export async function applyRequestQueueSettings(nextSettings: RequestQueueSettings) {
|
|
currentRequestQueueSettings = { ...nextSettings };
|
|
// Global policy changes invalidate snapshots from the previous generation.
|
|
preservedReplacementSettings.clear();
|
|
const { getCachedProviderConnections } = await import("@/lib/localDb");
|
|
const connections = await getCachedProviderConnections();
|
|
// Also discard any snapshot created while the asynchronous DB read yielded.
|
|
preservedReplacementSettings.clear();
|
|
reconcileEnabledConnections(connections as unknown[], currentRequestQueueSettings);
|
|
updateAllLimiterSettings();
|
|
}
|
|
|
|
/**
|
|
* Get or create a limiter for a given provider+connection combination
|
|
*/
|
|
export function enableRateLimitProtection(connectionId) {
|
|
if (!enabledConnections.has(connectionId)) clearPreservedReplacementSettings(connectionId);
|
|
enabledConnections.add(connectionId);
|
|
}
|
|
|
|
/**
|
|
* Disable rate limit protection for a connection
|
|
*/
|
|
export function disableRateLimitProtection(connectionId) {
|
|
enabledConnections.delete(connectionId);
|
|
clearPreservedReplacementSettings(connectionId);
|
|
// Ordinary administrative eviction uses disconnect(), not stop(), so
|
|
// in-flight requests can finish. Wedge recovery is the deliberate exception:
|
|
// it removes the limiter from the cache first, then stops it to settle jobs
|
|
// that were already proven stranded.
|
|
for (const [key, limiter] of Array.from(limiters)) {
|
|
if (key.includes(connectionId)) {
|
|
limiters.delete(key);
|
|
limiterWatchdog.forget(limiter);
|
|
limiterLastUsed.delete(key);
|
|
trackAsyncOperation(limiter.disconnect());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if rate limit protection is enabled for a connection
|
|
*/
|
|
export function isRateLimitEnabled(connectionId) {
|
|
return enabledConnections.has(connectionId);
|
|
}
|
|
|
|
/**
|
|
* Refresh per-connection rate limit overrides.
|
|
*
|
|
* Called after a PATCH update to `rateLimitOverrides` on a provider connection.
|
|
* Updates the in-memory map and evicts existing Bottleneck limiters for the
|
|
* connection so the next request gets a fresh limiter with the new settings.
|
|
*
|
|
* @param {string} connectionId
|
|
* @param {Record<string, number> | null} overrides - New overrides (null/undefined clears)
|
|
*/
|
|
export function refreshConnectionRateLimits(connectionId, overrides) {
|
|
if (overrides === null || overrides === undefined) {
|
|
connectionRateLimitOverrides.delete(connectionId);
|
|
} else {
|
|
connectionRateLimitOverrides.set(connectionId, overrides);
|
|
}
|
|
clearPreservedReplacementSettings(connectionId);
|
|
// Evict limiters referencing this connection so they get recreated on next use
|
|
for (const [key, limiter] of Array.from(limiters)) {
|
|
if (key.includes(connectionId)) {
|
|
limiters.delete(key);
|
|
limiterWatchdog.forget(limiter);
|
|
limiterLastUsed.delete(key);
|
|
trackAsyncOperation(limiter.disconnect());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get or create a limiter for a given provider+connection combination
|
|
*/
|
|
function getLimiterKey(provider, connectionId, model = null) {
|
|
if (provider === "codex" && model) {
|
|
return `${provider}:${getCodexRateLimitKey(connectionId, model)}`;
|
|
}
|
|
if ((provider === "antigravity" || provider === "agy") && model) {
|
|
const family = getAntigravityQuotaFamily(model);
|
|
const scope = family === "other" ? model : family;
|
|
return `${provider}:${connectionId}:${scope}`;
|
|
}
|
|
// Gemini AI Studio and GitHub Copilot have per-model quotas — use model-scoped
|
|
// limiter keys so a 429 on one model doesn't pause requests for other models.
|
|
if ((provider === "gemini" || provider === "github") && model) {
|
|
return `${provider}:${connectionId}:${model}`;
|
|
}
|
|
return `${provider}:${connectionId}`;
|
|
}
|
|
|
|
function getLimiter(provider, connectionId, model = null) {
|
|
const key = getLimiterKey(provider, connectionId, model);
|
|
|
|
if (!limiters.has(key)) {
|
|
// Idempotent — covers callers (and tests) that reach limiter creation
|
|
// without going through initializeRateLimits().
|
|
applyBottleneckDoExpirePatch();
|
|
applyBottleneckHeartbeatPatch();
|
|
const preserved = preservedReplacementSettings.get(key);
|
|
let options: Bottleneck.ConstructorOptions;
|
|
if (preserved) {
|
|
preservedReplacementSettings.delete(key);
|
|
options = { ...preserved, id: key };
|
|
} else {
|
|
const defaults = buildLimiterDefaults();
|
|
const overrides = connectionRateLimitOverrides.get(connectionId);
|
|
if (overrides) {
|
|
// 0 (or missing) means "no override — fall through to buildLimiterDefaults()".
|
|
// Without this guard, an rpm of 0 sets reservoir=0, which Bottleneck treats
|
|
// as depleted and blocks all requests indefinitely.
|
|
if (typeof overrides.maxConcurrent === "number" && overrides.maxConcurrent > 0) {
|
|
defaults.maxConcurrent = overrides.maxConcurrent;
|
|
}
|
|
if (typeof overrides.minTime === "number" && overrides.minTime > 0) {
|
|
defaults.minTime = overrides.minTime;
|
|
}
|
|
if (typeof overrides.rpm === "number" && overrides.rpm > 0) {
|
|
defaults.reservoir = overrides.rpm;
|
|
defaults.reservoirRefreshAmount = overrides.rpm;
|
|
defaults.reservoirRefreshInterval = 60 * 1000;
|
|
}
|
|
// TODO: TPM/TPD integration requires separate token and request buckets.
|
|
}
|
|
options = { ...defaults, id: key };
|
|
}
|
|
const limiter = limiterFactory(options);
|
|
limiterEffectiveSettings.set(limiter, { ...options });
|
|
limiter.on("queued", () => {
|
|
limiterWatchdog.noteQueued(key, limiter);
|
|
});
|
|
const markQueueProgress = () => {
|
|
limiterWatchdog.noteProgress(key, limiter);
|
|
};
|
|
limiter.on("executing", markQueueProgress);
|
|
// A long-running job can leave older work queued. Start the idle grace
|
|
// from its completion, not from when that waiting work first arrived.
|
|
limiter.on("done", markQueueProgress);
|
|
|
|
limiters.set(key, limiter);
|
|
limiterLastUsed.set(key, Date.now());
|
|
}
|
|
|
|
limiterLastUsed.set(key, Date.now());
|
|
return limiters.get(key);
|
|
}
|
|
|
|
/**
|
|
* Acquire a rate limit slot before making a request.
|
|
* If rate limiting is disabled for this connection, returns immediately.
|
|
*
|
|
* @param {string} provider - Provider ID
|
|
* @param {string} connectionId - Connection ID
|
|
* @param {string} model - Model name (optional, for per-model limits)
|
|
* @param {Function} fn - The async function to execute (e.g., executor.execute)
|
|
* @param {AbortSignal} signal - Optional abort signal to cancel waiting
|
|
* @returns {Promise<unknown>} Result of fn()
|
|
*/
|
|
export async function withRateLimit(provider, connectionId, model, fn, signal = null) {
|
|
if (!enabledConnections.has(connectionId)) {
|
|
return fn();
|
|
}
|
|
|
|
if (signal?.aborted) {
|
|
const reason = signal.reason;
|
|
if (reason instanceof Error) throw reason;
|
|
const err = new Error(typeof reason === "string" ? reason : "The operation was aborted");
|
|
err.name = "AbortError";
|
|
throw err;
|
|
}
|
|
|
|
// Proactive sliding-window fallback for header-less providers with a declared cap
|
|
// (Fase 8.2). No-op unless PROVIDER_DEFAULT_RATE_LIMITS has an entry for `provider`.
|
|
const maxWaitMs = resolveRequestQueueMaxWaitMs(provider);
|
|
await awaitProviderDefaultSlot(
|
|
provider,
|
|
connectionId,
|
|
signal,
|
|
maxWaitMs
|
|
);
|
|
|
|
const limiter = getLimiter(provider, connectionId, model);
|
|
// Bottleneck's `expiration` starts only after a job leaves QUEUED. The
|
|
// legacy maxWaitMs setting therefore bounds limiter-managed execution; it
|
|
// is not a queue-wait deadline.
|
|
const executionExpirationMs = maxWaitMs;
|
|
const scheduleOpts =
|
|
executionExpirationMs && executionExpirationMs > 0 ? { expiration: executionExpirationMs } : {};
|
|
|
|
// Issue #6593: opt-in admission cap — fast-reject before Bottleneck's
|
|
// schedule() (and before any downstream compression/prompt work runs) when
|
|
// the queue is already at/over maxQueueDepth. Default 0 = disabled.
|
|
const admissionErr = checkQueueAdmission(
|
|
limiter.counts().QUEUED,
|
|
currentRequestQueueSettings.maxQueueDepth,
|
|
model ? `${provider}/${model}` : provider
|
|
);
|
|
if (admissionErr) {
|
|
logRateLimit(
|
|
`🚧 [RATE-LIMIT] ${getLimiterKey(provider, connectionId, model)} — queue full, rejecting fast (maxQueueDepth=${currentRequestQueueSettings.maxQueueDepth})`
|
|
);
|
|
throw admissionErr;
|
|
}
|
|
|
|
try {
|
|
if (signal) {
|
|
let abortListener: (() => void) | undefined;
|
|
const { promise: abortPromise, reject: rejectAbort } = Promise.withResolvers<never>();
|
|
const onAbort = () => {
|
|
const reason = signal.reason;
|
|
// Preserve native Error reasons (including AbortController's
|
|
// read-only DOMException) instead of mutating or wrapping them.
|
|
if (reason instanceof Error) {
|
|
rejectAbort(reason);
|
|
return;
|
|
}
|
|
const err = new Error(typeof reason === "string" ? reason : "The operation was aborted");
|
|
err.name = "AbortError";
|
|
if (reason !== undefined) {
|
|
(err as Error & { cause?: unknown }).cause = reason;
|
|
}
|
|
rejectAbort(err);
|
|
};
|
|
if (signal.aborted) {
|
|
onAbort();
|
|
} else {
|
|
abortListener = onAbort;
|
|
signal.addEventListener("abort", abortListener, { once: true });
|
|
}
|
|
|
|
try {
|
|
return await Promise.race([limiter.schedule(scheduleOpts, fn), abortPromise]);
|
|
} finally {
|
|
if (abortListener) {
|
|
signal.removeEventListener("abort", abortListener);
|
|
}
|
|
}
|
|
} else {
|
|
return await limiter.schedule(scheduleOpts, fn);
|
|
}
|
|
} catch (err) {
|
|
// Only Bottleneck-owned failures are rewritten. Application code can throw
|
|
// the same text and must retain its original identity and semantics.
|
|
if (
|
|
err instanceof Bottleneck.BottleneckError &&
|
|
/^This job timed out after \d+ ms\.$/.test(err.message)
|
|
) {
|
|
const key = getLimiterKey(provider, connectionId, model);
|
|
logRateLimit(
|
|
`⏰ [RATE-LIMIT] ${key} — limiter-managed execution expired after ${Math.ceil((executionExpirationMs || 0) / 1000)}s`
|
|
);
|
|
throw markLocalRateLimitError(
|
|
new Error(
|
|
`Request exceeded OmniRoute's local rate-limit execution expiration ` +
|
|
`(legacy resilienceSettings.requestQueue.maxWaitMs=${executionExpirationMs}ms) for ` +
|
|
`${model ? `${provider}/${model}` : provider}. Bottleneck applies this deadline only ` +
|
|
`after dispatch; it does not bound queue wait and is not an upstream-generated timeout.`,
|
|
{ cause: err }
|
|
),
|
|
RATE_LIMIT_EXECUTION_TIMEOUT_CODE
|
|
);
|
|
}
|
|
|
|
if (
|
|
err instanceof Bottleneck.BottleneckError &&
|
|
err.message === "rate-limit-watchdog-wedge-reset"
|
|
) {
|
|
const cleanup = limiterWatchdog.getEviction(limiter);
|
|
if (!cleanup) throw err;
|
|
|
|
let cleanupError: unknown;
|
|
try {
|
|
await cleanup;
|
|
} catch (error) {
|
|
cleanupError = error;
|
|
errorRateLimit("[RATE-LIMIT] Wedge cleanup failed:", error);
|
|
}
|
|
|
|
const key = getLimiterKey(provider, connectionId, model);
|
|
logRateLimit(`↪️ [RATE-LIMIT] ${key} — surfacing local wedge; caller will not be replayed`);
|
|
const wedgeErr = new Error(
|
|
`Request dropped: the local rate-limit queue for ${model ? `${provider}/${model}` : provider} ` +
|
|
`was detected as wedged (stalled with nothing executing) and force-reset. OmniRoute does ` +
|
|
`not replay dropped work automatically; combo routing may fall back to another target.`,
|
|
{ cause: err }
|
|
) as Error & { cleanupError?: unknown };
|
|
if (cleanupError !== undefined) wedgeErr.cleanupError = cleanupError;
|
|
throw markLocalRateLimitError(wedgeErr, RATE_LIMIT_QUEUE_WEDGED_CODE);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update rate limiter based on API response headers.
|
|
* Called after every successful or failed response from a provider.
|
|
*
|
|
* @param {string} provider - Provider ID
|
|
* @param {string} connectionId - Connection ID
|
|
* @param {Headers} headers - Response headers
|
|
* @param {number} status - HTTP status code
|
|
* @param {string} model - Model name
|
|
*/
|
|
export function updateFromHeaders(provider, connectionId, headers, status, model = null) {
|
|
if (!enabledConnections.has(connectionId)) return;
|
|
if (!headers) return;
|
|
|
|
const plainHeaders = toPlainHeaders(headers);
|
|
const limiter = getLimiter(provider, connectionId, model);
|
|
const headerMap =
|
|
provider === "claude" || provider === "anthropic" ? ANTHROPIC_HEADERS : STANDARD_HEADERS;
|
|
|
|
// Get header values (handle both Headers object and plain object)
|
|
const getHeader = (name: string) => {
|
|
return plainHeaders[name.toLowerCase()] || null;
|
|
};
|
|
|
|
const limit = parseInt(getHeader(headerMap.limit));
|
|
const remaining = parseInt(getHeader(headerMap.remaining));
|
|
const resetStr = getHeader(headerMap.reset);
|
|
const retryAfterStr = getHeader(headerMap.retryAfter);
|
|
const overLimit = getHeader(STANDARD_HEADERS.overLimit);
|
|
|
|
// Handle 429 — rate limited
|
|
if (status === 429) {
|
|
const retryAfterMs = parseResetTime(retryAfterStr) || 60000; // Default 60s
|
|
const counts = limiter.counts();
|
|
const limiterKey = getLimiterKey(provider, connectionId, model);
|
|
logRateLimit(
|
|
`🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — 429 received, pausing for ${Math.ceil(retryAfterMs / 1000)}s, dropping ${counts.QUEUED} queued request(s)`
|
|
);
|
|
|
|
// Evict from the cache so follow-up learning from the same error body
|
|
// can materialize a fresh limiter immediately. Do NOT call limiter.stop() —
|
|
// it permanently rejects future .schedule() calls with "This limiter has been stopped".
|
|
// In-flight requests holding a reference to the evicted instance will fail (they
|
|
// were already going to fail — the 429 means the API rejected them), but future
|
|
// requests will get a fresh Bottleneck instance via getLimiter().
|
|
// Call disconnect() (not stop()) to release Bottleneck's internal heartbeat timer
|
|
// without permanently poisoning the instance for any remaining in-flight jobs.
|
|
// Without disconnect() here, every 429 leaks a heartbeat timer until GC reclaims
|
|
// the abandoned Bottleneck; under sustained quota pressure that is a real leak.
|
|
limiters.delete(limiterKey);
|
|
limiterWatchdog.forget(limiter);
|
|
limiterLastUsed.delete(limiterKey);
|
|
preservedReplacementSettings.delete(limiterKey);
|
|
trackAsyncOperation(limiter.disconnect());
|
|
return;
|
|
}
|
|
|
|
// Handle "over limit" soft warning (Fireworks)
|
|
if (overLimit === "yes") {
|
|
logRateLimit(
|
|
`⚠️ [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — near capacity, slowing down`
|
|
);
|
|
updateLimiterSettings(limiter, {
|
|
minTime: 200, // Add 200ms between requests
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Normal response — update limiter from headers
|
|
if (!isNaN(limit) && limit > 0) {
|
|
const resetMs = parseResetTime(resetStr) || 60000;
|
|
|
|
// Calculate optimal minTime from RPM limit
|
|
const minTime = Math.max(0, Math.floor(60000 / limit) - 10); // Small buffer
|
|
|
|
const updates: LimiterUpdateSettings = { minTime };
|
|
|
|
// If remaining is low (< 10% of limit), set reservoir to throttle immediately
|
|
if (!isNaN(remaining)) {
|
|
if (remaining < limit * 0.1) {
|
|
updates.reservoir = remaining;
|
|
updates.reservoirRefreshAmount = limit;
|
|
updates.reservoirRefreshInterval = resetMs;
|
|
logRateLimit(
|
|
`⚠️ [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — ${remaining}/${limit} remaining, throttling`
|
|
);
|
|
} else if (remaining > limit * 0.5) {
|
|
// Plenty of headroom — relax the limiter
|
|
updates.minTime = 0;
|
|
updates.reservoir = null;
|
|
updates.reservoirRefreshAmount = null;
|
|
updates.reservoirRefreshInterval = null;
|
|
}
|
|
}
|
|
|
|
updateLimiterSettings(limiter, updates);
|
|
|
|
// Persist learned limits (debounced)
|
|
recordLearnedLimit(
|
|
provider,
|
|
connectionId,
|
|
{ limit, remaining, minTime: updates.minTime },
|
|
model
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current rate limit status for a provider+connection (for dashboard display)
|
|
*/
|
|
export function getRateLimitStatus(provider, connectionId) {
|
|
const key = `${provider}:${connectionId}`;
|
|
const limiter = limiters.get(key);
|
|
|
|
if (!limiter) {
|
|
return {
|
|
enabled: enabledConnections.has(connectionId),
|
|
active: false,
|
|
queued: 0,
|
|
running: 0,
|
|
};
|
|
}
|
|
|
|
const counts = limiter.counts();
|
|
return {
|
|
enabled: enabledConnections.has(connectionId),
|
|
active: true,
|
|
queued: counts.QUEUED || 0,
|
|
running: counts.RUNNING || 0,
|
|
executing: counts.EXECUTING || 0,
|
|
done: counts.DONE || 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get all active limiters status (for dashboard overview)
|
|
*/
|
|
export function getAllRateLimitStatus() {
|
|
const result: Record<string, { queued: number; running: number; executing: number }> = {};
|
|
for (const [key, limiter] of limiters) {
|
|
const counts = limiter.counts();
|
|
result[key] = {
|
|
queued: counts.QUEUED || 0,
|
|
running: counts.RUNNING || 0,
|
|
executing: counts.EXECUTING || 0,
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get all learned limits (for dashboard display).
|
|
*/
|
|
export function getLearnedLimits() {
|
|
return { ...learnedLimits };
|
|
}
|
|
|
|
// ─── Persistence ────────────────────────────────────────────────────────────
|
|
|
|
async function persistLearnedLimitsNow() {
|
|
try {
|
|
const { updateSettings } = await import("@/lib/db/settings");
|
|
await updateSettings({ learnedRateLimits: JSON.stringify(learnedLimits) });
|
|
logRateLimit(
|
|
`💾 [RATE-LIMIT] Persisted learned limits for ${Object.keys(learnedLimits).length} provider(s)`
|
|
);
|
|
} catch (err) {
|
|
errorRateLimit("[RATE-LIMIT] Failed to persist learned limits:", err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a learned limit for debounced persistence.
|
|
*/
|
|
function recordLearnedLimit(
|
|
provider: string,
|
|
connectionId: string,
|
|
limits: Partial<Omit<LearnedLimitEntry, "provider" | "connectionId" | "lastUpdated">>,
|
|
model: string | null = null
|
|
) {
|
|
const key = getLimiterKey(provider, connectionId, model);
|
|
learnedLimits[key] = {
|
|
...limits,
|
|
provider,
|
|
connectionId,
|
|
lastUpdated: Date.now(),
|
|
};
|
|
|
|
// Debounce: save at most once per PERSIST_DEBOUNCE_MS
|
|
if (!persistTimer) {
|
|
persistTimer = setTimeout(async () => {
|
|
persistTimer = null;
|
|
await trackAsyncOperation(persistLearnedLimitsNow());
|
|
}, PERSIST_DEBOUNCE_MS);
|
|
}
|
|
}
|
|
|
|
export async function __flushLearnedLimitsForTests() {
|
|
if (persistTimer) {
|
|
clearTimeout(persistTimer);
|
|
persistTimer = null;
|
|
}
|
|
await trackAsyncOperation(persistLearnedLimitsNow());
|
|
if (pendingAsyncOperations.size > 0) {
|
|
await Promise.allSettled(Array.from(pendingAsyncOperations));
|
|
}
|
|
}
|
|
|
|
export function __setLimiterFactoryForTests(factory: LimiterFactory): void {
|
|
limiterFactory = factory;
|
|
}
|
|
|
|
export async function __runLimiterWatchdogForTests(now = Date.now()): Promise<void> {
|
|
await limiterWatchdog.run(now);
|
|
}
|
|
|
|
export async function __resetRateLimitManagerForTests() {
|
|
if (persistTimer) {
|
|
clearTimeout(persistTimer);
|
|
persistTimer = null;
|
|
}
|
|
|
|
// Collect and await all disconnect() Promises so Bottleneck's internal
|
|
// yieldLoop(0) calls settle before the next test starts. Not awaiting
|
|
// these can cause the Node.js test runner IPC channel to receive a
|
|
// corrupted message when the pending Promise fires during IPC serialization.
|
|
const disconnectPromises: Promise<unknown>[] = [];
|
|
for (const limiter of limiters.values()) {
|
|
disconnectPromises.push(limiter.disconnect());
|
|
}
|
|
limiters.clear();
|
|
enabledConnections.clear();
|
|
initialized = false;
|
|
limiterLastUsed.clear();
|
|
preservedReplacementSettings.clear();
|
|
limiterFactory = defaultLimiterFactory;
|
|
limiterWatchdog.reset();
|
|
shutdownHandlersRegistered = false;
|
|
|
|
for (const key of Object.keys(learnedLimits)) {
|
|
delete learnedLimits[key];
|
|
}
|
|
|
|
if (pendingAsyncOperations.size > 0) {
|
|
await Promise.allSettled(Array.from(pendingAsyncOperations));
|
|
}
|
|
if (disconnectPromises.length > 0) {
|
|
await Promise.allSettled(disconnectPromises);
|
|
}
|
|
}
|
|
|
|
export async function __getLimiterStateForTests(provider, connectionId, model = null) {
|
|
const key = getLimiterKey(provider, connectionId, model);
|
|
const limiter = limiters.get(key);
|
|
if (!limiter) return null;
|
|
|
|
const counts = limiter.counts();
|
|
const reservoir = await limiter.currentReservoir();
|
|
return {
|
|
key,
|
|
reservoir,
|
|
queued: counts.QUEUED || 0,
|
|
running: counts.RUNNING || 0,
|
|
executing: counts.EXECUTING || 0,
|
|
done: counts.DONE || 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load persisted learned limits on startup.
|
|
*/
|
|
async function loadPersistedLimits() {
|
|
try {
|
|
const { getSettings } = await import("@/lib/db/settings");
|
|
const settings = await getSettings();
|
|
const raw = settings?.learnedRateLimits;
|
|
if (typeof raw !== "string" || raw.trim().length === 0) return;
|
|
|
|
const parsed = toRecord(JSON.parse(raw) as unknown);
|
|
let count = 0;
|
|
|
|
for (const [key, dataRaw] of Object.entries(parsed)) {
|
|
const data = toRecord(dataRaw);
|
|
const lastUpdated = toNumber(data.lastUpdated, 0);
|
|
// Skip stale entries (older than 24h)
|
|
if (lastUpdated > 0 && Date.now() - lastUpdated > 24 * 60 * 60 * 1000) continue;
|
|
|
|
const connectionId = typeof data.connectionId === "string" ? data.connectionId : "";
|
|
const provider = typeof data.provider === "string" ? data.provider : "";
|
|
const limit = toNumber(data.limit, 0);
|
|
const remaining = toNumber(data.remaining, 0);
|
|
const minTime = toNumber(data.minTime, 0);
|
|
|
|
learnedLimits[key] = {
|
|
provider,
|
|
connectionId,
|
|
lastUpdated,
|
|
...(limit > 0 ? { limit } : {}),
|
|
...(remaining >= 0 ? { remaining } : {}),
|
|
...(minTime >= 0 ? { minTime } : {}),
|
|
};
|
|
|
|
// Apply to limiter if it exists and has rate limit enabled
|
|
if (connectionId && enabledConnections.has(connectionId)) {
|
|
const limiter = limiters.get(key);
|
|
if (limiter && limit > 0) {
|
|
const inferredMinTime = minTime || Math.max(0, Math.floor(60000 / limit) - 10);
|
|
updateLimiterSettings(limiter, { minTime: inferredMinTime });
|
|
count++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (count > 0) {
|
|
logRateLimit(`📥 [RATE-LIMIT] Restored ${count} learned rate limit(s) from persistence`);
|
|
}
|
|
} catch (err) {
|
|
errorRateLimit("[RATE-LIMIT] Failed to load persisted limits:", err.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update rate limiter based on API response body (JSON error responses).
|
|
* Providers embed retry info in JSON payloads in different formats.
|
|
* Should be called alongside updateFromHeaders for 4xx/5xx responses.
|
|
*
|
|
* @param {string} provider - Provider ID
|
|
* @param {string} connectionId - Connection ID
|
|
* @param {string|object} responseBody - Response body (string or parsed JSON)
|
|
* @param {number} status - HTTP status code
|
|
* @param {string} model - Model name (for per-model lockouts)
|
|
*/
|
|
export function updateFromResponseBody(provider, connectionId, responseBody, status, model = null) {
|
|
if (!enabledConnections.has(connectionId)) return;
|
|
|
|
const { retryAfterMs, reason } = parseRetryAfterFromBody(responseBody);
|
|
|
|
if (retryAfterMs && retryAfterMs > 0) {
|
|
const limiter = getLimiter(provider, connectionId, model);
|
|
logRateLimit(
|
|
`🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — body-parsed retry: ${Math.ceil(retryAfterMs / 1000)}s (${reason})`
|
|
);
|
|
|
|
updateLimiterSettings(limiter, {
|
|
reservoir: 0,
|
|
reservoirRefreshAmount: 60,
|
|
reservoirRefreshInterval: retryAfterMs,
|
|
});
|
|
}
|
|
}
|