mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
Merge remote-tracking branch 'origin/release/v3.8.29' into HEAD
This commit is contained in:
34
.github/workflows/nightly-mutation.yml
vendored
34
.github/workflows/nightly-mutation.yml
vendored
@@ -13,17 +13,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
# Mutation testing is expensive. History of the budget:
|
||||
# - Full 8-module set TIMED OUT at the 180min cap (run 27705123780 = exactly 180min).
|
||||
# The two god-files chatCore.ts/combo.ts dominate ~2/3 of the mutants and were
|
||||
# removed from stryker.conf.json `mutate` (the Onda 3 split re-adds them).
|
||||
# - The remaining 6 modules STILL timed out at 180min (run 27732877861 = exactly
|
||||
# 180min): auth.ts and accountFallback.ts are large and the perTest dry-run over
|
||||
# ~130 covering test files is itself costly.
|
||||
# So the 6 modules are split into 3 PARALLEL batches of 2 (the two big modules in
|
||||
# separate batches), each overriding the mutate set via `--mutate` (Stryker 9 CLI:
|
||||
# The two god-files chatCore.ts/combo.ts dominated ~2/3 of the mutants and were
|
||||
# removed from stryker.conf.json `mutate`.
|
||||
# - The remaining 6 modules in 3 batches: auth.ts and accountFallback.ts are large; the
|
||||
# a (auth+publicCreds) and b (accountFallback+error) batches still ran near the 180min
|
||||
# cap (the perTest dry-run over ~130 covering test files is itself costly), so the two
|
||||
# big modules are now ISOLATED into their own batches (a=auth, b=accountFallback).
|
||||
# - Onda 3 / Fase 9 T5 re-add: combo.ts was split into 11 leaves; the 8 well-covered
|
||||
# combo/* leaves are back in `mutate` (covered by the 24 combo-*.test.ts), grouped into
|
||||
# 2 batches (d=heavy, e=light). chatCore/* leaves + quotaScoring/quotaStrategies (#4204)
|
||||
# are follow-ups — see _mutate_godfiles_excluded_comment in stryker.conf.json.
|
||||
# 5 PARALLEL batches, each overriding the mutate set via `--mutate` (Stryker 9 CLI:
|
||||
# `-m, --mutate <comma-list>`; the conf's `mutate[]` remains the local-run default/union).
|
||||
# Each batch is ~1/3 of the mutants and fits 180min; full coverage every night, in
|
||||
# parallel (≈180min wall-clock). Re-merge into fewer batches once the Onda 3 god-file
|
||||
# split shrinks per-module mutant counts.
|
||||
# Each batch targets <180min; full coverage every night in parallel (~180min wall-clock).
|
||||
# The `incremental` cache (per-batch key) makes runs after the first cold one much cheaper,
|
||||
# so the isolated big-module batches recover even if a first cold run nears the cap.
|
||||
# Runs at stryker concurrency=4 with per-process DATA_DIR isolation
|
||||
# (tests/_setup/isolateDataDir.ts) — see _concurrency_comment in stryker.conf.json.
|
||||
strategy:
|
||||
@@ -31,11 +35,15 @@ jobs:
|
||||
matrix:
|
||||
batch:
|
||||
- name: a
|
||||
mutate: "src/sse/services/auth.ts,open-sse/utils/publicCreds.ts"
|
||||
mutate: "src/sse/services/auth.ts"
|
||||
- name: b
|
||||
mutate: "open-sse/services/accountFallback.ts,open-sse/utils/error.ts"
|
||||
mutate: "open-sse/services/accountFallback.ts"
|
||||
- name: c
|
||||
mutate: "src/server/authz/routeGuard.ts,src/shared/utils/circuitBreaker.ts"
|
||||
mutate: "src/server/authz/routeGuard.ts,src/shared/utils/circuitBreaker.ts,open-sse/utils/error.ts,open-sse/utils/publicCreds.ts"
|
||||
- name: d
|
||||
mutate: "open-sse/services/combo/comboStructure.ts,open-sse/services/combo/autoStrategy.ts,open-sse/services/combo/validateQuality.ts"
|
||||
- name: e
|
||||
mutate: "open-sse/services/combo/shadowRouting.ts,open-sse/services/combo/targetSorters.ts,open-sse/services/combo/comboPredicates.ts,open-sse/services/combo/rrState.ts,open-sse/services/combo/comboData.ts"
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"_rebaseline_2026_06_18_qg9_combo_split_d6": "QG v2 Fase 9 T5 D6: combo.ts 4430->3819 — combo structure resolution extracted byte-identically to the new open-sse/services/combo/comboStructure.ts (638, <cap): runtime-step normalization, nested-combo/DAG expansion (resolveNestedComboTargets/expandRuntimeStep/getComboFromData/getComboModelsFromData/validateComboDAG/resolveNestedComboModels), weighted/direct resolution (resolveComboTargets/resolveWeightedTargets/getDirectComboTargets + composite-tier ordering), request-compatibility filtering (filterTargetsByRequestCompatibility + deriveRequestCompatibilityRequirements/getTargetCompatibilityFailures and their private helpers) and context-size sorting. Shared dedupeTargetsByExecutionKey + the toTrimmedString/toComboLike/getCombosArray normalizers also moved (dedupe to comboData.ts since both combo.ts and comboStructure use it). The 7 previously-public symbols are re-exported from combo.ts; the ~20 external consumers (chatCore.ts, /api/combos routes, embeddings, usage, catalog) are unchanged. shadowRouting.ts's resolveNestedComboTargets import redirected from the barrel to ./comboStructure.ts (removing the D4 temporary edge). Orphaned combo.ts imports dropped (getComboModelString/normalizeComboStep, getModelContextLimit, getResolvedModelCapabilities, getTargetProvider, MAX_COMBO_DEPTH, and 6 now-unused type imports). Pure move, no logic change.",
|
||||
"_rebaseline_2026_06_18_qg9_combo_split_d8": "QG v2 Fase 9 T5 D8 (reduced): combo.ts 3819->3432 — auto-strategy scoring/intent/tag-routing extracted byte-identically to the new open-sse/services/combo/autoStrategy.ts (434, <cap): the quota-soft execution-candidate registry (the single _activeExecutionCandidates Map + setCandidateQuotaSoftPenalty/_registerExecutionCandidates/_unregisterExecutionCandidates, kept together for state cohesion), QUOTA_SOFT_DEPRIORITIZE_FACTOR, scoreAutoTargets, expandAutoComboCandidatePool, intent extraction (toTextContent/extractPromptForIntent/mapIntentToTaskType/toStringArray/getIntentConfig), applyRequestTagRouting and deriveComboSessionKey. buildAutoCandidates + its two private-only helpers calculateTargetContextAffinity/getBootstrapLatencyMs (and the DEFAULT_MODEL_P95_MS/MIN_HISTORY_SAMPLES/OUTPUT_TOKEN_RATIO consts they own) were DELIBERATELY KEPT in combo.ts: buildAutoCandidates is the sole user of the internal reset-window helpers (resolveResetWindowConfig/fetchResetAwareQuotaWithCache/calculateResetWindowAffinity + the ResetWindowConfig type), so keeping it there leaves those helpers private (no export) and avoids a combo <-> autoStrategy import cycle (a later task can move the reset-window block out of combo.ts without breaking autoStrategy). autoStrategy.ts never imports from the combo barrel. dedupeTargetsByExecutionKey was already in comboData.ts (D6) and is NOT re-moved. QUOTA_SOFT_DEPRIORITIZE_FACTOR + setCandidateQuotaSoftPenalty stay re-exported from combo.ts for chatCore.ts's dynamic import(\"../services/combo\"); scoreAutoTargets/expandAutoComboCandidatePool keep their public re-export too. Orphaned combo.ts imports dropped where moved-out symbols stopped being referenced; ProviderCandidate/AutoProviderCandidate/HistoricalLatencyStatsEntry types are imported into combo.ts for buildAutoCandidates. Pure move, no logic change.",
|
||||
"_rebaseline_2026_06_18_qg9_combo_split_d7a": "QG v2 Fase 9 T5 D7a: combo.ts 3440->3398 (wc -l 3397 + 1, stacked on #4194 which added the passthrough-quota guards) — round-robin sticky state extracted byte-identically to the new open-sse/services/combo/rrState.ts (71, <cap). Moved exactly the 6 RR-state symbols: MAX_RR_COUNTERS, the two mutable module Maps rrCounters (new Map<string,number>) + rrStickyTargets, and the three helpers clampStickyRoundRobinTargetLimit/getStickyRoundRobinStartIndex/recordStickyRoundRobinSuccess. STATE COHESION: rrCounters and rrStickyTargets remain SINGLE instances defined once in rrState.ts; combo.ts imports the same references back and keeps mutating them directly in orderTargetsByResetAwareQuota/orderTargetsByResetWindow/handleRoundRobinCombo (no Map duplicated — sticky-3 round-robin behavior preserved, guarded by combo-routing-e2e.test.ts). The quota state left behind (MAX_RESET_AWARE_CACHE/resetAwareConnectionCache/resetAwareQuotaCache) stays in combo.ts for a later task (D7b). None of the 6 were ever public, so combo.ts imports all six back (no re-export). rrState.ts never imports from the combo barrel; it imports only ResolvedComboTarget from ./types.ts. Pure move, no logic change.",
|
||||
"_rebaseline_2026_06_18_qg9_combo_split_d7b": "QG v2 Fase 9 T5 D7b: combo.ts 3398->2597 (wc -l 2596 + 1) — the reset-aware / reset-window quota block extracted byte-identically into two new leaves under open-sse/services/combo/ (both <cap). Split was size-driven (~818 source LOC > 800 cap): quotaScoring.ts (311) holds the PURE half (config consts/resolvers + window-math/scoring helpers, no state, no async); quotaStrategies.ts (568) holds the STATEFUL/async half — the two mutable caches resetAwareConnectionCache + resetAwareQuotaCache (new Map) + MAX_RESET_AWARE_CACHE kept as SINGLE instances next to their only readers/writers getQuotaAwareConnectionsForTarget + fetchResetAwareQuotaWithCache (STATE COHESION; grep 'resetAware*Cache = new Map' in combo.ts == 0), plus normalizeConnectionIds/filterAllowedConnectionIds/getTargetConnectionIds/mapWithConcurrency/preScreenTargets/orderTargetsByResetAwareQuota/orderTargetsByResetWindow. quotaStrategies imports rrCounters + MAX_RR_COUNTERS from ./rrState.ts (D7a) and the pure helpers from ./quotaScoring.ts; neither leaf imports the combo barrel. combo.ts imports back the 3 reset-window helpers buildAutoCandidates needs (resolveResetWindowConfig/fetchResetAwareQuotaWithCache/calculateResetWindowAffinity) + resolveSlaRoutingPolicy + the 3 orderers used by the orchestrator; preScreenTargets stays re-exported. Orphaned imports dropped (clamp01/PRE_SCREEN_CONCURRENCY/ProviderProfile/SlaRoutingPolicy/IsModelAvailable); hasPerModelQuota KEPT (used by the #4194 passthrough-quota guards that stay). buildAutoCandidates + calculateTargetContextAffinity/getBootstrapLatencyMs stay (D8). Pure move, no logic change.",
|
||||
"_rebaseline_2026_06_18_8_2_sliding_window": "Fase 8.2 own growth: rateLimitManager.ts 1017->1022 (+5 = one import + one `await awaitProviderDefaultSlot(...)` call + a 2-line comment at the existing withRateLimit chokepoint). All sliding-window logic was extracted to the new open-sse/services/providerDefaultRateLimit.ts + open-sse/services/slidingWindowLimiter.ts (both <cap), NOT inlined. Thin wiring only; not further shrinkable.",
|
||||
"_rebaseline_2026_06_18_8_1_no_thinking_alias": "Fase 8.1 own growth: catalog.ts 1435->1440 (+5 = appendNoThinkingVariants(finalModels) call + comment at the existing finalModels chokepoint) and chat.ts 1458->1471 (+13 = applyNoThinkingAlias(body) call + comment right after body.model is read, before model resolution). All real logic lives in the new open-sse/utils/noThinkingAlias.ts (<cap); both edits are thin wiring of tested helpers at the single correct integration point in each file. Not extractable.",
|
||||
"_rebaseline_2026_06_18_4_4_midstream": "Fase 4.4 own growth: chatCore.ts 5980->6009 (+29 at the existing streaming-return chokepoint = capture streamRecovery.continueMidStream alongside .enabled; refactor the early-retry reopen thunk into a shared runUpstreamStream(body) helper — net DRY — and add the gated continueStream(assistantSoFar) thunk that re-runs the upstream with makeContinuationBody(bodyToSend, …), plus the onContinue log). All continuation logic (scanOpenAiSseText, makeContinuationBody, trimContinuationOverlap, the createRecoverableStream continuation path) lives in open-sse/services/streamRecovery.ts (<cap). Closes over the same per-attempt executor locals as the existing reopen; not extractable without hiding the dispatch boundary. Opt-in (default OFF).",
|
||||
@@ -77,7 +78,7 @@
|
||||
"open-sse/services/batchProcessor.ts": 828,
|
||||
"open-sse/services/browserBackedChat.ts": 850,
|
||||
"open-sse/services/claudeCodeCompatible.ts": 1202,
|
||||
"open-sse/services/combo.ts": 3398,
|
||||
"open-sse/services/combo.ts": 2597,
|
||||
"open-sse/services/rateLimitManager.ts": 1035,
|
||||
"open-sse/services/tokenRefresh.ts": 1997,
|
||||
"open-sse/services/usage.ts": 3408,
|
||||
|
||||
@@ -17,11 +17,9 @@ import {
|
||||
recordProviderFailure,
|
||||
isProviderExhaustedReason,
|
||||
hasPerModelQuota,
|
||||
type ProviderProfile,
|
||||
} from "./accountFallback.ts";
|
||||
import { FETCH_TIMEOUT_MS, RateLimitReason } from "../config/constants.ts";
|
||||
import { errorResponse, unavailableResponse } from "../utils/error.ts";
|
||||
import { clamp01 } from "../utils/number.ts";
|
||||
import {
|
||||
recordComboIntent,
|
||||
recordComboRequest,
|
||||
@@ -32,7 +30,6 @@ import {
|
||||
resolveComboConfig,
|
||||
getDefaultComboConfig,
|
||||
resolveComboTargetTimeoutMs,
|
||||
PRE_SCREEN_CONCURRENCY,
|
||||
} from "./comboConfig.ts";
|
||||
import {
|
||||
maybeGenerateHandoff,
|
||||
@@ -62,7 +59,7 @@ import { emit } from "../../src/lib/events/eventBus";
|
||||
import { notifyWebhookEvent } from "../../src/lib/webhookDispatcher";
|
||||
import { classifyWithConfig } from "./intentClassifier.ts";
|
||||
import { selectProvider as selectAutoProvider } from "./autoCombo/engine.ts";
|
||||
import { selectWithStrategy, type SlaRoutingPolicy } from "./autoCombo/routerStrategy.ts";
|
||||
import { selectWithStrategy } from "./autoCombo/routerStrategy.ts";
|
||||
import { parseAutoPrefix } from "./autoCombo/autoPrefix.ts";
|
||||
import { handlePipelineCombo, buildPipelineResponse } from "./autoCombo/pipelineRouter.ts";
|
||||
import { DEFAULT_WEIGHTS, type ProviderCandidate, type ScoringWeights } from "./autoCombo/scoring.ts";
|
||||
@@ -91,7 +88,6 @@ import type {
|
||||
ComboRetryAfter,
|
||||
ComboErrorBody,
|
||||
SingleModelTarget,
|
||||
IsModelAvailable,
|
||||
HandleComboChatOptions,
|
||||
HandleRoundRobinOptions,
|
||||
ResolvedComboTarget,
|
||||
@@ -150,6 +146,19 @@ import {
|
||||
expandAutoComboCandidatePool,
|
||||
deriveComboSessionKey,
|
||||
} from "./combo/autoStrategy.ts";
|
||||
import {
|
||||
resolveResetWindowConfig,
|
||||
resolveSlaRoutingPolicy,
|
||||
calculateResetWindowAffinity,
|
||||
type ResetWindowConfig,
|
||||
} from "./combo/quotaScoring.ts";
|
||||
import {
|
||||
fetchResetAwareQuotaWithCache,
|
||||
preScreenTargets,
|
||||
orderTargetsByResetAwareQuota,
|
||||
orderTargetsByResetWindow,
|
||||
type PreScreenResult,
|
||||
} from "./combo/quotaStrategies.ts";
|
||||
|
||||
// Backward-compatible re-exports — these were public from combo.ts before the
|
||||
// types extraction (Quality Gate v2 / Fase 9). Keep the external surface stable.
|
||||
@@ -162,6 +171,9 @@ export type { SingleModelTarget, ResolvedComboTarget };
|
||||
export { validateResponseQuality };
|
||||
export { clampComboDepth, shouldSkipForPredictedTtft, shouldRecordProviderBreakerFailure };
|
||||
export { resolveShadowTargets, scheduleShadowRouting };
|
||||
// preScreenTargets was public from combo.ts before the reset-aware quota
|
||||
// extraction (combo split D7b). Keep the external surface stable.
|
||||
export { preScreenTargets };
|
||||
export { resolveComboTargets, filterTargetsByRequestCompatibility };
|
||||
export {
|
||||
getComboFromData,
|
||||
@@ -171,695 +183,13 @@ export {
|
||||
validateComboDAG,
|
||||
} from "./combo/comboStructure.ts";
|
||||
|
||||
const RESET_AWARE_SESSION_WINDOW_MS = 5 * 60 * 60 * 1000;
|
||||
const RESET_AWARE_WEEKLY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const RESET_AWARE_SESSION_REMAINING_WEIGHT = 0.45;
|
||||
const RESET_AWARE_SESSION_RESET_PRESSURE_WEIGHT = 0.55;
|
||||
const RESET_AWARE_WEEKLY_REMAINING_WEIGHT = 0.25;
|
||||
const RESET_AWARE_WEEKLY_RESET_PRESSURE_WEIGHT = 0.75;
|
||||
const RESET_AWARE_CONNECTION_CACHE_TTL_MS = 30_000;
|
||||
const RESET_AWARE_QUOTA_FETCH_CONCURRENCY = 5;
|
||||
const RESET_AWARE_DEFAULTS = {
|
||||
sessionWeight: 0.35,
|
||||
weeklyWeight: 0.65,
|
||||
tieBandPercent: 5,
|
||||
exhaustionGuardPercent: 10,
|
||||
};
|
||||
const RESET_WINDOW_DEFAULT_TIE_BAND_MS = 60_000;
|
||||
|
||||
type ResetWindowName = (typeof RESET_WINDOW_NAMES)[number];
|
||||
type QuotaFetchCacheConfig = {
|
||||
quotaCacheTtlMs: number;
|
||||
quotaCacheMaxStaleMs: number;
|
||||
};
|
||||
type ResetWindowConfig = ReturnType<typeof resolveResetWindowConfig>;
|
||||
|
||||
const MAX_RESET_AWARE_CACHE = 200;
|
||||
|
||||
const resetAwareConnectionCache = new Map<
|
||||
string,
|
||||
{ fetchedAt: number; connections: Array<Record<string, unknown>> }
|
||||
>();
|
||||
const resetAwareQuotaCache = new Map<
|
||||
string,
|
||||
{ fetchedAt: number; quota: unknown; refreshPromise: Promise<unknown> | null }
|
||||
>();
|
||||
|
||||
function finiteNumberOrNull(value: unknown): number | null {
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : null;
|
||||
}
|
||||
|
||||
function getPercentConfig(value: unknown, fallback: number): number {
|
||||
const numericValue = finiteNumberOrNull(value);
|
||||
if (numericValue === null) return fallback;
|
||||
return Math.max(0, Math.min(100, numericValue));
|
||||
}
|
||||
|
||||
function getWeightConfig(value: unknown, fallback: number): number {
|
||||
const numericValue = finiteNumberOrNull(value);
|
||||
if (numericValue === null || numericValue < 0) return fallback;
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
function getDurationConfig(value: unknown, fallback: number, max: number): number {
|
||||
const numericValue = finiteNumberOrNull(value);
|
||||
if (numericValue === null || numericValue < 0) return fallback;
|
||||
return Math.min(max, Math.floor(numericValue));
|
||||
}
|
||||
|
||||
function resolveResetAwareConfig(config: Record<string, unknown> | null | undefined) {
|
||||
const sessionWeight = getWeightConfig(
|
||||
config?.resetAwareSessionWeight,
|
||||
RESET_AWARE_DEFAULTS.sessionWeight
|
||||
);
|
||||
const weeklyWeight = getWeightConfig(
|
||||
config?.resetAwareWeeklyWeight,
|
||||
RESET_AWARE_DEFAULTS.weeklyWeight
|
||||
);
|
||||
const totalWeight = sessionWeight + weeklyWeight;
|
||||
const normalizedSessionWeight =
|
||||
totalWeight > 0 ? sessionWeight / totalWeight : RESET_AWARE_DEFAULTS.sessionWeight;
|
||||
|
||||
return {
|
||||
sessionWeight: normalizedSessionWeight,
|
||||
weeklyWeight: 1 - normalizedSessionWeight,
|
||||
tieBand:
|
||||
getPercentConfig(config?.resetAwareTieBandPercent, RESET_AWARE_DEFAULTS.tieBandPercent) / 100,
|
||||
exhaustionGuard:
|
||||
getPercentConfig(
|
||||
config?.resetAwareExhaustionGuardPercent,
|
||||
RESET_AWARE_DEFAULTS.exhaustionGuardPercent
|
||||
) / 100,
|
||||
quotaCacheTtlMs: getDurationConfig(config?.resetAwareQuotaCacheTtlMs, 0, 300_000),
|
||||
quotaCacheMaxStaleMs: getDurationConfig(config?.resetAwareQuotaCacheMaxStaleMs, 0, 3_600_000),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveResetWindowConfig(config: Record<string, unknown> | null | undefined) {
|
||||
const rawWindows = Array.isArray(config?.resetWindowWindows) ? config.resetWindowWindows : null;
|
||||
const windows = rawWindows
|
||||
?.filter((windowName): windowName is ResetWindowName =>
|
||||
(RESET_WINDOW_NAMES as readonly string[]).includes(String(windowName))
|
||||
)
|
||||
.filter((windowName, index, array) => array.indexOf(windowName) === index);
|
||||
|
||||
const effectiveWindows =
|
||||
windows && windows.length > 0
|
||||
? windows
|
||||
: config?.resetWindowIncludeSession === true
|
||||
? (["weekly", "session"] as ResetWindowName[])
|
||||
: (["weekly"] as ResetWindowName[]);
|
||||
|
||||
return {
|
||||
windows: effectiveWindows,
|
||||
tieBandMs: Math.max(
|
||||
0,
|
||||
finiteNumberOrNull(config?.resetWindowTieBandMs) ?? RESET_WINDOW_DEFAULT_TIE_BAND_MS
|
||||
),
|
||||
quotaCacheTtlMs: getDurationConfig(config?.resetWindowQuotaCacheTtlMs, 0, 300_000),
|
||||
quotaCacheMaxStaleMs: getDurationConfig(config?.resetWindowQuotaCacheMaxStaleMs, 0, 3_600_000),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSlaRoutingPolicy(
|
||||
config: Record<string, unknown> | null | undefined
|
||||
): SlaRoutingPolicy | undefined {
|
||||
if (!config) return undefined;
|
||||
const nestedSla = isRecord(config.sla) ? config.sla : {};
|
||||
const targetP95Ms = finiteNumberOrNull(config.slaTargetP95Ms ?? nestedSla.targetP95Ms);
|
||||
const maxErrorRate = finiteNumberOrNull(config.slaMaxErrorRate ?? nestedSla.maxErrorRate);
|
||||
const maxCostPer1MTokens = finiteNumberOrNull(
|
||||
config.slaMaxCostPer1MTokens ?? nestedSla.maxCostPer1MTokens
|
||||
);
|
||||
const hardConstraints = config.slaHardConstraints ?? nestedSla.hardConstraints;
|
||||
|
||||
const policy: SlaRoutingPolicy = {};
|
||||
if (targetP95Ms !== null && targetP95Ms > 0) policy.targetP95Ms = targetP95Ms;
|
||||
if (maxErrorRate !== null && maxErrorRate >= 0) policy.maxErrorRate = clamp01(maxErrorRate);
|
||||
if (maxCostPer1MTokens !== null && maxCostPer1MTokens > 0) {
|
||||
policy.maxCostPer1MTokens = maxCostPer1MTokens;
|
||||
}
|
||||
if (typeof hardConstraints === "boolean") policy.hardConstraints = hardConstraints;
|
||||
|
||||
return Object.keys(policy).length > 0 ? policy : undefined;
|
||||
}
|
||||
|
||||
function getResetAwareProvider(target: ResolvedComboTarget): string | null {
|
||||
const provider = (target.providerId || target.provider || "").toLowerCase();
|
||||
return provider || null;
|
||||
}
|
||||
|
||||
function normalizeResetAt(value: unknown): string | null {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseResetTimeMs(resetAt: string | null | undefined): number {
|
||||
if (!resetAt) return NaN;
|
||||
const resetTime = Date.parse(resetAt);
|
||||
if (Number.isFinite(resetTime)) return resetTime;
|
||||
|
||||
if (!/^\d+(?:\.\d+)?$/.test(resetAt)) return NaN;
|
||||
const numericResetAt = Number(resetAt);
|
||||
if (!Number.isFinite(numericResetAt)) return NaN;
|
||||
return numericResetAt < 10_000_000_000 ? numericResetAt * 1000 : numericResetAt;
|
||||
}
|
||||
|
||||
function getQuotaWindow(
|
||||
quota: unknown,
|
||||
key: "window5h" | "window7d" | "windowWeekly" | "windowMonthly"
|
||||
): { percentUsed: number | null; resetAt: string | null } | null {
|
||||
if (!isRecord(quota)) return null;
|
||||
const window = quota[key];
|
||||
if (!isRecord(window)) return null;
|
||||
const percentUsed = finiteNumberOrNull(window.percentUsed);
|
||||
const resetAt = normalizeResetAt(window.resetAt);
|
||||
return { percentUsed, resetAt };
|
||||
}
|
||||
|
||||
function normalizeWindowPercentUsed(value: unknown): number | null {
|
||||
const numericValue = finiteNumberOrNull(value);
|
||||
if (numericValue === null) return null;
|
||||
if (numericValue > 1) return clamp01(numericValue / 100);
|
||||
return clamp01(numericValue);
|
||||
}
|
||||
|
||||
function getNamedQuotaWindow(
|
||||
quota: unknown,
|
||||
windowName: ResetWindowName
|
||||
): { percentUsed: number | null; resetAt: string | null } | null {
|
||||
if (!quota || !isRecord(quota)) return null;
|
||||
|
||||
if (windowName === "session") return getQuotaWindow(quota, "window5h");
|
||||
if (windowName === "weekly") {
|
||||
return getQuotaWindow(quota, "window7d") || getQuotaWindow(quota, "windowWeekly");
|
||||
}
|
||||
if (windowName === "monthly") return getQuotaWindow(quota, "windowMonthly");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getWindowsMapQuotaWindow(
|
||||
quota: unknown,
|
||||
windowName: ResetWindowName
|
||||
): { percentUsed: number | null; resetAt: string | null } | null {
|
||||
if (!quota || !isRecord(quota) || !isRecord(quota.windows)) return null;
|
||||
const candidates = Object.entries(quota.windows)
|
||||
.map(([key, value]) => ({ key: key.toLowerCase(), value }))
|
||||
.filter(({ key }) => key === windowName || key.startsWith(`${windowName} `));
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
candidates.sort((a, b) => a.key.localeCompare(b.key));
|
||||
const window = candidates[0].value;
|
||||
if (!isRecord(window)) return null;
|
||||
|
||||
return {
|
||||
percentUsed: normalizeWindowPercentUsed(window.percentUsed),
|
||||
resetAt: normalizeResetAt(window.resetAt),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveQuotaWindowByName(
|
||||
quota: unknown,
|
||||
windowName: ResetWindowName
|
||||
): { percentUsed: number | null; resetAt: string | null } | null {
|
||||
return getNamedQuotaWindow(quota, windowName) || getWindowsMapQuotaWindow(quota, windowName);
|
||||
}
|
||||
|
||||
function getResetUrgency(resetAt: string | null | undefined, windowMs: number): number {
|
||||
if (!resetAt) return 0.5;
|
||||
const resetTime = parseResetTimeMs(resetAt);
|
||||
if (!Number.isFinite(resetTime)) return 0.5;
|
||||
const msUntilReset = resetTime - Date.now();
|
||||
if (msUntilReset <= 0) return 1;
|
||||
return clamp01(1 - msUntilReset / windowMs);
|
||||
}
|
||||
|
||||
function scoreQuotaWindow(
|
||||
remaining: number,
|
||||
resetAt: string | null | undefined,
|
||||
windowMs: number,
|
||||
remainingWeight: number,
|
||||
resetPressureWeight: number
|
||||
): number {
|
||||
const normalizedRemaining = clamp01(remaining);
|
||||
const resetUrgency = getResetUrgency(resetAt, windowMs);
|
||||
const resetPressure = resetUrgency * (1 - normalizedRemaining);
|
||||
return remainingWeight * normalizedRemaining + resetPressureWeight * resetPressure;
|
||||
}
|
||||
|
||||
function scoreResetAwareQuota(quota: unknown, config: ReturnType<typeof resolveResetAwareConfig>) {
|
||||
if (!quota || !isRecord(quota)) return { score: 0.5 };
|
||||
if (quota.limitReached === true) return { score: -Infinity };
|
||||
|
||||
const overallPercentUsed = clamp01(finiteNumberOrNull(quota.percentUsed) ?? 0.5);
|
||||
const sessionWindow = getQuotaWindow(quota, "window5h");
|
||||
const weeklyWindow = getQuotaWindow(quota, "window7d") || getQuotaWindow(quota, "windowWeekly");
|
||||
const sessionRemaining = clamp01(1 - (sessionWindow?.percentUsed ?? overallPercentUsed));
|
||||
const weeklyRemaining = clamp01(1 - (weeklyWindow?.percentUsed ?? overallPercentUsed));
|
||||
const sessionScore = scoreQuotaWindow(
|
||||
sessionRemaining,
|
||||
sessionWindow?.resetAt,
|
||||
RESET_AWARE_SESSION_WINDOW_MS,
|
||||
RESET_AWARE_SESSION_REMAINING_WEIGHT,
|
||||
RESET_AWARE_SESSION_RESET_PRESSURE_WEIGHT
|
||||
);
|
||||
const weeklyScore = scoreQuotaWindow(
|
||||
weeklyRemaining,
|
||||
weeklyWindow?.resetAt ?? normalizeResetAt(quota.resetAt),
|
||||
RESET_AWARE_WEEKLY_WINDOW_MS,
|
||||
RESET_AWARE_WEEKLY_REMAINING_WEIGHT,
|
||||
RESET_AWARE_WEEKLY_RESET_PRESSURE_WEIGHT
|
||||
);
|
||||
let score = config.sessionWeight * sessionScore + config.weeklyWeight * weeklyScore;
|
||||
|
||||
if (config.exhaustionGuard > 0 && sessionRemaining < config.exhaustionGuard) {
|
||||
score *= Math.max(0.05, sessionRemaining / config.exhaustionGuard);
|
||||
}
|
||||
|
||||
return { score };
|
||||
}
|
||||
|
||||
async function getQuotaAwareConnectionsForTarget(
|
||||
target: ResolvedComboTarget,
|
||||
connectionCache: Map<string, Array<Record<string, unknown>>>,
|
||||
connectionLoadPromises: Map<string, Promise<Array<Record<string, unknown>>>>,
|
||||
comboName: string,
|
||||
log: { warn?: (...args: unknown[]) => void }
|
||||
) {
|
||||
const provider = getResetAwareProvider(target);
|
||||
if (!provider || !getQuotaFetcher(provider)) return [];
|
||||
if (!connectionCache.has(provider)) {
|
||||
const cached = resetAwareConnectionCache.get(provider);
|
||||
if (cached && Date.now() - cached.fetchedAt < RESET_AWARE_CONNECTION_CACHE_TTL_MS) {
|
||||
connectionCache.set(provider, cached.connections);
|
||||
return cached.connections;
|
||||
}
|
||||
|
||||
if (!connectionLoadPromises.has(provider)) {
|
||||
connectionLoadPromises.set(
|
||||
provider,
|
||||
(async () => {
|
||||
try {
|
||||
const connections = await getProviderConnections({ provider, isActive: true });
|
||||
const activeConnections = Array.isArray(connections)
|
||||
? (connections as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
if (
|
||||
!resetAwareConnectionCache.has(provider) &&
|
||||
resetAwareConnectionCache.size >= MAX_RESET_AWARE_CACHE
|
||||
) {
|
||||
const oldest = resetAwareConnectionCache.keys().next().value;
|
||||
if (oldest !== undefined) resetAwareConnectionCache.delete(oldest);
|
||||
}
|
||||
resetAwareConnectionCache.set(provider, {
|
||||
connections: activeConnections,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
return activeConnections;
|
||||
} catch (error) {
|
||||
log.warn?.("COMBO", "Reset-aware failed to load quota-aware connections.", {
|
||||
comboName,
|
||||
err: error,
|
||||
operation: "getProviderConnections",
|
||||
provider,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
const connections = await connectionLoadPromises.get(provider)!;
|
||||
connectionCache.set(provider, connections);
|
||||
}
|
||||
return connectionCache.get(provider) || [];
|
||||
}
|
||||
|
||||
function normalizeConnectionIds(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const ids = value.filter(
|
||||
(connectionId): connectionId is string =>
|
||||
typeof connectionId === "string" && connectionId.trim().length > 0
|
||||
);
|
||||
return ids.length > 0 ? ids : null;
|
||||
}
|
||||
|
||||
function filterAllowedConnectionIds(
|
||||
connectionIds: string[],
|
||||
apiKeyAllowedConnectionIds: string[] | null | undefined
|
||||
): string[] {
|
||||
const allowedIds = normalizeConnectionIds(apiKeyAllowedConnectionIds);
|
||||
if (!allowedIds) return connectionIds;
|
||||
const allowedSet = new Set(allowedIds);
|
||||
return connectionIds.filter((connectionId) => allowedSet.has(connectionId));
|
||||
}
|
||||
|
||||
function getTargetConnectionIds(
|
||||
target: ResolvedComboTarget,
|
||||
connections: Array<Record<string, unknown>>
|
||||
): string[] {
|
||||
let connectionIds: string[];
|
||||
if (target.connectionId) {
|
||||
return [target.connectionId];
|
||||
}
|
||||
|
||||
if (Array.isArray(target.allowedConnectionIds) && target.allowedConnectionIds.length > 0) {
|
||||
return target.allowedConnectionIds.filter(
|
||||
(connectionId): connectionId is string =>
|
||||
typeof connectionId === "string" && connectionId.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
connectionIds = connections
|
||||
.map((connection) => (typeof connection.id === "string" ? connection.id : null))
|
||||
.filter((connectionId): connectionId is string => !!connectionId);
|
||||
return connectionIds;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const currentIndex = nextIndex++;
|
||||
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function fetchResetAwareQuotaWithCache({
|
||||
provider,
|
||||
connectionId,
|
||||
connection,
|
||||
fetcher,
|
||||
config,
|
||||
log,
|
||||
comboName,
|
||||
}: {
|
||||
provider: string;
|
||||
connectionId: string;
|
||||
connection?: Record<string, unknown>;
|
||||
fetcher: (connectionId: string, connection?: Record<string, unknown>) => Promise<unknown>;
|
||||
config: QuotaFetchCacheConfig;
|
||||
log: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void };
|
||||
comboName: string;
|
||||
}): Promise<unknown> {
|
||||
const cacheKey = `${provider}:${connectionId}`;
|
||||
const ttlMs = config.quotaCacheTtlMs;
|
||||
const maxStaleMs = config.quotaCacheMaxStaleMs;
|
||||
const now = Date.now();
|
||||
const cached = resetAwareQuotaCache.get(cacheKey);
|
||||
|
||||
if (ttlMs <= 0 && maxStaleMs <= 0) {
|
||||
try {
|
||||
return await fetcher(connectionId, connection);
|
||||
} catch (error) {
|
||||
log.warn?.("COMBO", "Reset-aware quota fetch failed.", {
|
||||
comboName,
|
||||
connectionId,
|
||||
err: error,
|
||||
operation: "quotaFetch",
|
||||
provider,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
const existing = resetAwareQuotaCache.get(cacheKey);
|
||||
if (existing?.refreshPromise != null) return existing.refreshPromise;
|
||||
|
||||
const refreshPromise = fetcher(connectionId, connection)
|
||||
.then((quota) => {
|
||||
if (quota) {
|
||||
if (
|
||||
!resetAwareQuotaCache.has(cacheKey) &&
|
||||
resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE
|
||||
) {
|
||||
const oldest = resetAwareQuotaCache.keys().next().value;
|
||||
if (oldest !== undefined) resetAwareQuotaCache.delete(oldest);
|
||||
}
|
||||
resetAwareQuotaCache.set(cacheKey, {
|
||||
quota,
|
||||
fetchedAt: Date.now(),
|
||||
refreshPromise: null,
|
||||
});
|
||||
} else {
|
||||
resetAwareQuotaCache.delete(cacheKey);
|
||||
}
|
||||
return quota;
|
||||
})
|
||||
.catch((error) => {
|
||||
const previous = resetAwareQuotaCache.get(cacheKey);
|
||||
if (previous) {
|
||||
if (
|
||||
!resetAwareQuotaCache.has(cacheKey) &&
|
||||
resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE
|
||||
) {
|
||||
const oldest = resetAwareQuotaCache.keys().next().value;
|
||||
if (oldest !== undefined) resetAwareQuotaCache.delete(oldest);
|
||||
}
|
||||
resetAwareQuotaCache.set(cacheKey, { ...previous, refreshPromise: null });
|
||||
}
|
||||
log.warn?.("COMBO", "Reset-aware quota fetch failed.", {
|
||||
comboName,
|
||||
connectionId,
|
||||
err: error,
|
||||
operation: "quotaFetch",
|
||||
provider,
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!resetAwareQuotaCache.has(cacheKey) && resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) {
|
||||
const oldest = resetAwareQuotaCache.keys().next().value;
|
||||
if (oldest !== undefined) resetAwareQuotaCache.delete(oldest);
|
||||
}
|
||||
resetAwareQuotaCache.set(cacheKey, {
|
||||
quota: existing?.quota ?? cached?.quota ?? null,
|
||||
fetchedAt: existing?.fetchedAt ?? cached?.fetchedAt ?? 0,
|
||||
refreshPromise,
|
||||
});
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
if (ttlMs > 0 && cached) {
|
||||
const age = now - cached.fetchedAt;
|
||||
if (age <= ttlMs) return cached.quota;
|
||||
if (maxStaleMs > 0 && age <= ttlMs + maxStaleMs) {
|
||||
void refresh();
|
||||
return cached.quota;
|
||||
}
|
||||
}
|
||||
|
||||
return refresh();
|
||||
}
|
||||
|
||||
type PreScreenResult = { profile: ProviderProfile | null; available: boolean };
|
||||
|
||||
export async function preScreenTargets(
|
||||
targets: ResolvedComboTarget[],
|
||||
isModelAvailable?: IsModelAvailable | null
|
||||
): Promise<Map<string, PreScreenResult>> {
|
||||
if (targets.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const results = await mapWithConcurrency(
|
||||
targets,
|
||||
PRE_SCREEN_CONCURRENCY,
|
||||
async (target): Promise<{ key: string; result: PreScreenResult }> => {
|
||||
const profile = await getRuntimeProviderProfile(target.provider).catch(() => null);
|
||||
|
||||
const breaker = getCircuitBreaker(target.provider);
|
||||
if (breaker.getStatus().state === "OPEN") {
|
||||
return { key: target.executionKey, result: { profile, available: false } };
|
||||
}
|
||||
|
||||
let available = true;
|
||||
if (isModelAvailable) {
|
||||
// IsModelAvailable may return a sync boolean or a Promise; Promise.resolve
|
||||
// normalizes both so the .catch() never runs against a bare boolean.
|
||||
available = await Promise.resolve(isModelAvailable(target.modelStr, target)).catch(
|
||||
() => true
|
||||
);
|
||||
}
|
||||
return { key: target.executionKey, result: { profile, available } };
|
||||
}
|
||||
);
|
||||
|
||||
const map = new Map<string, PreScreenResult>();
|
||||
for (const { key, result } of results) {
|
||||
map.set(key, result);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function orderTargetsByResetAwareQuota(
|
||||
targets: ResolvedComboTarget[],
|
||||
comboName: string,
|
||||
configSource: Record<string, unknown> | null | undefined,
|
||||
log: { warn?: (...args: unknown[]) => void },
|
||||
apiKeyAllowedConnectionIds?: string[] | null
|
||||
) {
|
||||
if (targets.length === 0) return targets;
|
||||
|
||||
const config = resolveResetAwareConfig(configSource);
|
||||
const connectionCache = new Map<string, Array<Record<string, unknown>>>();
|
||||
const connectionLoadPromises = new Map<string, Promise<Array<Record<string, unknown>>>>();
|
||||
const quotaPromises = new Map<string, Promise<unknown>>();
|
||||
const connectionById = new Map<string, Record<string, unknown>>();
|
||||
const expandedTargets: ResolvedComboTarget[] = [];
|
||||
|
||||
const targetsWithConnections = await Promise.all(
|
||||
targets.map(async (target) => ({
|
||||
connections: await getQuotaAwareConnectionsForTarget(
|
||||
target,
|
||||
connectionCache,
|
||||
connectionLoadPromises,
|
||||
comboName,
|
||||
log
|
||||
),
|
||||
target,
|
||||
}))
|
||||
);
|
||||
|
||||
for (const { target, connections } of targetsWithConnections) {
|
||||
for (const connection of connections) {
|
||||
if (typeof connection.id === "string") connectionById.set(connection.id, connection);
|
||||
}
|
||||
|
||||
const unrestrictedConnectionIds = getTargetConnectionIds(target, connections);
|
||||
const connectionIds = filterAllowedConnectionIds(
|
||||
unrestrictedConnectionIds,
|
||||
apiKeyAllowedConnectionIds
|
||||
);
|
||||
if (connectionIds.length === 0) {
|
||||
if (
|
||||
unrestrictedConnectionIds.length > 0 &&
|
||||
normalizeConnectionIds(apiKeyAllowedConnectionIds)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
expandedTargets.push(target);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const connectionId of connectionIds) {
|
||||
expandedTargets.push({
|
||||
...target,
|
||||
connectionId,
|
||||
executionKey:
|
||||
target.connectionId === connectionId
|
||||
? target.executionKey
|
||||
: `${target.executionKey}@${connectionId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const scoredTargets = await mapWithConcurrency(
|
||||
expandedTargets,
|
||||
RESET_AWARE_QUOTA_FETCH_CONCURRENCY,
|
||||
async (target, index) => {
|
||||
let quota: unknown = null;
|
||||
const provider = getResetAwareProvider(target);
|
||||
const fetcher = provider ? getQuotaFetcher(provider) : null;
|
||||
if (fetcher && provider && target.connectionId) {
|
||||
const quotaKey = `${provider}:${target.connectionId}`;
|
||||
if (!quotaPromises.has(quotaKey)) {
|
||||
quotaPromises.set(
|
||||
quotaKey,
|
||||
fetchResetAwareQuotaWithCache({
|
||||
provider,
|
||||
connectionId: target.connectionId,
|
||||
connection: connectionById.get(target.connectionId),
|
||||
fetcher,
|
||||
config,
|
||||
log,
|
||||
comboName,
|
||||
})
|
||||
);
|
||||
}
|
||||
quota = await quotaPromises.get(quotaKey)!;
|
||||
}
|
||||
const { score } = scoreResetAwareQuota(quota, config);
|
||||
return { target, score, index };
|
||||
}
|
||||
);
|
||||
|
||||
scoredTargets.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
const bestScore = scoredTargets[0]?.score ?? 0;
|
||||
const tiedTargets = scoredTargets.filter((entry) => bestScore - entry.score <= config.tieBand);
|
||||
let orderedTiedTargets = tiedTargets;
|
||||
if (tiedTargets.length > 1) {
|
||||
const key = `reset-aware:${comboName}`;
|
||||
const counter = rrCounters.get(key) || 0;
|
||||
if (!rrCounters.has(key) && rrCounters.size >= MAX_RR_COUNTERS) {
|
||||
const oldest = rrCounters.keys().next().value;
|
||||
if (oldest !== undefined) rrCounters.delete(oldest);
|
||||
}
|
||||
rrCounters.set(key, counter + 1);
|
||||
const startIndex = counter % tiedTargets.length;
|
||||
orderedTiedTargets = [...tiedTargets.slice(startIndex), ...tiedTargets.slice(0, startIndex)];
|
||||
}
|
||||
|
||||
const tiedExecutionKeys = new Set(orderedTiedTargets.map((entry) => entry.target.executionKey));
|
||||
return [
|
||||
...orderedTiedTargets,
|
||||
...scoredTargets.filter((entry) => !tiedExecutionKeys.has(entry.target.executionKey)),
|
||||
].map((entry) => entry.target);
|
||||
}
|
||||
|
||||
function getResetWindowTimestampMs(quota: unknown, windows: ResetWindowName[]): number {
|
||||
if (!quota || !isRecord(quota) || quota.limitReached === true) return Infinity;
|
||||
|
||||
let selectedResetMs = Infinity;
|
||||
for (const windowName of windows) {
|
||||
const window = resolveQuotaWindowByName(quota, windowName);
|
||||
const resetMs = parseResetTimeMs(window?.resetAt ?? null);
|
||||
if (Number.isFinite(resetMs)) {
|
||||
selectedResetMs = Math.min(selectedResetMs, resetMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(selectedResetMs)) {
|
||||
selectedResetMs = parseResetTimeMs(normalizeResetAt(quota.resetAt));
|
||||
}
|
||||
|
||||
return Number.isFinite(selectedResetMs) ? selectedResetMs : Infinity;
|
||||
}
|
||||
|
||||
function getResetWindowHorizonMs(windows: ResetWindowName[]): number {
|
||||
if (windows.includes("monthly")) return 30 * 24 * 60 * 60 * 1000;
|
||||
if (windows.includes("weekly")) return RESET_AWARE_WEEKLY_WINDOW_MS;
|
||||
return RESET_AWARE_SESSION_WINDOW_MS;
|
||||
}
|
||||
|
||||
function calculateResetWindowAffinity(quota: unknown, config: ResetWindowConfig): number {
|
||||
const resetMs = getResetWindowTimestampMs(quota, config.windows);
|
||||
if (!Number.isFinite(resetMs)) return 0.5;
|
||||
|
||||
const msUntilReset = resetMs - Date.now();
|
||||
if (msUntilReset <= 0) return 1;
|
||||
return clamp01(1 - msUntilReset / getResetWindowHorizonMs(config.windows));
|
||||
}
|
||||
// Reset-aware / reset-window quota config, scoring, and window-math helpers were
|
||||
// extracted to combo/quotaScoring.ts (pure) and the stateful cache + strategy
|
||||
// orderers to combo/quotaStrategies.ts (combo split D7b). The two cache Maps
|
||||
// (resetAwareConnectionCache, resetAwareQuotaCache) live as single instances in
|
||||
// quotaStrategies.ts alongside their only readers/writers (state cohesion).
|
||||
// combo.ts imports back the three reset-window helpers buildAutoCandidates +
|
||||
// orchestration need, plus the strategy orderers and preScreenTargets (above).
|
||||
|
||||
// Bootstrap defaults from ClawRouter benchmark (used when no local latency history exists yet)
|
||||
const DEFAULT_MODEL_P95_MS: Record<string, number> = {
|
||||
@@ -1069,137 +399,6 @@ export async function buildAutoCandidates(
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function orderTargetsByResetWindow(
|
||||
targets: ResolvedComboTarget[],
|
||||
comboName: string,
|
||||
configSource: Record<string, unknown> | null | undefined,
|
||||
log: { warn?: (...args: unknown[]) => void },
|
||||
apiKeyAllowedConnectionIds?: string[] | null
|
||||
) {
|
||||
if (targets.length === 0) return targets;
|
||||
|
||||
const config = resolveResetWindowConfig(configSource);
|
||||
const connectionCache = new Map<string, Array<Record<string, unknown>>>();
|
||||
const connectionLoadPromises = new Map<string, Promise<Array<Record<string, unknown>>>>();
|
||||
const quotaPromises = new Map<string, Promise<unknown>>();
|
||||
const connectionById = new Map<string, Record<string, unknown>>();
|
||||
const expandedTargets: ResolvedComboTarget[] = [];
|
||||
|
||||
const targetsWithConnections = await Promise.all(
|
||||
targets.map(async (target) => ({
|
||||
connections: await getQuotaAwareConnectionsForTarget(
|
||||
target,
|
||||
connectionCache,
|
||||
connectionLoadPromises,
|
||||
comboName,
|
||||
log
|
||||
),
|
||||
target,
|
||||
}))
|
||||
);
|
||||
|
||||
for (const { target, connections } of targetsWithConnections) {
|
||||
for (const connection of connections) {
|
||||
if (typeof connection.id === "string") connectionById.set(connection.id, connection);
|
||||
}
|
||||
|
||||
const unrestrictedConnectionIds = getTargetConnectionIds(target, connections);
|
||||
const connectionIds = filterAllowedConnectionIds(
|
||||
unrestrictedConnectionIds,
|
||||
apiKeyAllowedConnectionIds
|
||||
);
|
||||
if (connectionIds.length === 0) {
|
||||
if (
|
||||
unrestrictedConnectionIds.length > 0 &&
|
||||
normalizeConnectionIds(apiKeyAllowedConnectionIds)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
expandedTargets.push(target);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const connectionId of connectionIds) {
|
||||
expandedTargets.push({
|
||||
...target,
|
||||
connectionId,
|
||||
executionKey:
|
||||
target.connectionId === connectionId
|
||||
? target.executionKey
|
||||
: `${target.executionKey}@${connectionId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const scoredTargets = await mapWithConcurrency(
|
||||
expandedTargets,
|
||||
RESET_AWARE_QUOTA_FETCH_CONCURRENCY,
|
||||
async (target, index) => {
|
||||
let quota: unknown = null;
|
||||
const provider = getResetAwareProvider(target);
|
||||
const fetcher = provider ? getQuotaFetcher(provider) : null;
|
||||
if (fetcher && provider && target.connectionId) {
|
||||
const quotaKey = `${provider}:${target.connectionId}`;
|
||||
if (!quotaPromises.has(quotaKey)) {
|
||||
quotaPromises.set(
|
||||
quotaKey,
|
||||
fetchResetAwareQuotaWithCache({
|
||||
provider,
|
||||
connectionId: target.connectionId,
|
||||
connection: connectionById.get(target.connectionId),
|
||||
fetcher,
|
||||
config,
|
||||
log,
|
||||
comboName,
|
||||
})
|
||||
);
|
||||
}
|
||||
quota = await quotaPromises.get(quotaKey)!;
|
||||
}
|
||||
|
||||
return {
|
||||
target,
|
||||
resetMs: getResetWindowTimestampMs(quota, config.windows),
|
||||
index,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
scoredTargets.sort((a, b) => {
|
||||
if (a.resetMs !== b.resetMs) return a.resetMs - b.resetMs;
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
const bestResetMs = scoredTargets[0]?.resetMs ?? Infinity;
|
||||
if (!Number.isFinite(bestResetMs) || config.tieBandMs <= 0) {
|
||||
return scoredTargets.map((entry) => entry.target);
|
||||
}
|
||||
|
||||
const tiedTargets = scoredTargets.filter(
|
||||
(entry) => entry.resetMs - bestResetMs <= config.tieBandMs
|
||||
);
|
||||
if (tiedTargets.length <= 1) return scoredTargets.map((entry) => entry.target);
|
||||
|
||||
const key = `reset-window:${comboName}`;
|
||||
const counter = rrCounters.get(key) || 0;
|
||||
if (!rrCounters.has(key) && rrCounters.size >= MAX_RR_COUNTERS) {
|
||||
const oldest = rrCounters.keys().next().value;
|
||||
if (oldest !== undefined) rrCounters.delete(oldest);
|
||||
}
|
||||
rrCounters.set(key, counter + 1);
|
||||
const startIndex = counter % tiedTargets.length;
|
||||
const orderedTiedTargets = [
|
||||
...tiedTargets.slice(startIndex),
|
||||
...tiedTargets.slice(0, startIndex),
|
||||
];
|
||||
const tiedExecutionKeys = new Set(orderedTiedTargets.map((entry) => entry.target.executionKey));
|
||||
|
||||
return [
|
||||
...orderedTiedTargets,
|
||||
...scoredTargets.filter((entry) => !tiedExecutionKeys.has(entry.target.executionKey)),
|
||||
].map((entry) => entry.target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle combo chat with fallback.
|
||||
* @param {Object} options
|
||||
|
||||
311
open-sse/services/combo/quotaScoring.ts
Normal file
311
open-sse/services/combo/quotaScoring.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Pure scoring + window-math helpers for the reset-aware / reset-window combo
|
||||
* strategies. No module state, no async, no I/O — extracted byte-identically
|
||||
* from combo.ts (QG v2 Fase 9 T5 D7b) as the pure half of the reset-aware quota
|
||||
* block. The stateful/async half (cache Maps + connection/quota fetchers +
|
||||
* orderTargets*) lives in ./quotaStrategies.ts, which imports the scoring
|
||||
* helpers from here.
|
||||
*
|
||||
* Pure leaf: this module never imports from the combo barrel.
|
||||
*/
|
||||
|
||||
import { clamp01 } from "../../utils/number.ts";
|
||||
import { isRecord } from "./comboData.ts";
|
||||
import type { SlaRoutingPolicy } from "../autoCombo/routerStrategy.ts";
|
||||
import { RESET_WINDOW_NAMES } from "./types.ts";
|
||||
import type { ResolvedComboTarget } from "./types.ts";
|
||||
|
||||
const RESET_AWARE_SESSION_WINDOW_MS = 5 * 60 * 60 * 1000;
|
||||
const RESET_AWARE_WEEKLY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const RESET_AWARE_SESSION_REMAINING_WEIGHT = 0.45;
|
||||
const RESET_AWARE_SESSION_RESET_PRESSURE_WEIGHT = 0.55;
|
||||
const RESET_AWARE_WEEKLY_REMAINING_WEIGHT = 0.25;
|
||||
const RESET_AWARE_WEEKLY_RESET_PRESSURE_WEIGHT = 0.75;
|
||||
const RESET_AWARE_DEFAULTS = {
|
||||
sessionWeight: 0.35,
|
||||
weeklyWeight: 0.65,
|
||||
tieBandPercent: 5,
|
||||
exhaustionGuardPercent: 10,
|
||||
};
|
||||
const RESET_WINDOW_DEFAULT_TIE_BAND_MS = 60_000;
|
||||
|
||||
type ResetWindowName = (typeof RESET_WINDOW_NAMES)[number];
|
||||
export type QuotaFetchCacheConfig = {
|
||||
quotaCacheTtlMs: number;
|
||||
quotaCacheMaxStaleMs: number;
|
||||
};
|
||||
export type ResetWindowConfig = ReturnType<typeof resolveResetWindowConfig>;
|
||||
|
||||
function finiteNumberOrNull(value: unknown): number | null {
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : null;
|
||||
}
|
||||
|
||||
function getPercentConfig(value: unknown, fallback: number): number {
|
||||
const numericValue = finiteNumberOrNull(value);
|
||||
if (numericValue === null) return fallback;
|
||||
return Math.max(0, Math.min(100, numericValue));
|
||||
}
|
||||
|
||||
function getWeightConfig(value: unknown, fallback: number): number {
|
||||
const numericValue = finiteNumberOrNull(value);
|
||||
if (numericValue === null || numericValue < 0) return fallback;
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
function getDurationConfig(value: unknown, fallback: number, max: number): number {
|
||||
const numericValue = finiteNumberOrNull(value);
|
||||
if (numericValue === null || numericValue < 0) return fallback;
|
||||
return Math.min(max, Math.floor(numericValue));
|
||||
}
|
||||
|
||||
export function resolveResetAwareConfig(config: Record<string, unknown> | null | undefined) {
|
||||
const sessionWeight = getWeightConfig(
|
||||
config?.resetAwareSessionWeight,
|
||||
RESET_AWARE_DEFAULTS.sessionWeight
|
||||
);
|
||||
const weeklyWeight = getWeightConfig(
|
||||
config?.resetAwareWeeklyWeight,
|
||||
RESET_AWARE_DEFAULTS.weeklyWeight
|
||||
);
|
||||
const totalWeight = sessionWeight + weeklyWeight;
|
||||
const normalizedSessionWeight =
|
||||
totalWeight > 0 ? sessionWeight / totalWeight : RESET_AWARE_DEFAULTS.sessionWeight;
|
||||
|
||||
return {
|
||||
sessionWeight: normalizedSessionWeight,
|
||||
weeklyWeight: 1 - normalizedSessionWeight,
|
||||
tieBand:
|
||||
getPercentConfig(config?.resetAwareTieBandPercent, RESET_AWARE_DEFAULTS.tieBandPercent) / 100,
|
||||
exhaustionGuard:
|
||||
getPercentConfig(
|
||||
config?.resetAwareExhaustionGuardPercent,
|
||||
RESET_AWARE_DEFAULTS.exhaustionGuardPercent
|
||||
) / 100,
|
||||
quotaCacheTtlMs: getDurationConfig(config?.resetAwareQuotaCacheTtlMs, 0, 300_000),
|
||||
quotaCacheMaxStaleMs: getDurationConfig(config?.resetAwareQuotaCacheMaxStaleMs, 0, 3_600_000),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveResetWindowConfig(config: Record<string, unknown> | null | undefined) {
|
||||
const rawWindows = Array.isArray(config?.resetWindowWindows) ? config.resetWindowWindows : null;
|
||||
const windows = rawWindows
|
||||
?.filter((windowName): windowName is ResetWindowName =>
|
||||
(RESET_WINDOW_NAMES as readonly string[]).includes(String(windowName))
|
||||
)
|
||||
.filter((windowName, index, array) => array.indexOf(windowName) === index);
|
||||
|
||||
const effectiveWindows =
|
||||
windows && windows.length > 0
|
||||
? windows
|
||||
: config?.resetWindowIncludeSession === true
|
||||
? (["weekly", "session"] as ResetWindowName[])
|
||||
: (["weekly"] as ResetWindowName[]);
|
||||
|
||||
return {
|
||||
windows: effectiveWindows,
|
||||
tieBandMs: Math.max(
|
||||
0,
|
||||
finiteNumberOrNull(config?.resetWindowTieBandMs) ?? RESET_WINDOW_DEFAULT_TIE_BAND_MS
|
||||
),
|
||||
quotaCacheTtlMs: getDurationConfig(config?.resetWindowQuotaCacheTtlMs, 0, 300_000),
|
||||
quotaCacheMaxStaleMs: getDurationConfig(config?.resetWindowQuotaCacheMaxStaleMs, 0, 3_600_000),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSlaRoutingPolicy(
|
||||
config: Record<string, unknown> | null | undefined
|
||||
): SlaRoutingPolicy | undefined {
|
||||
if (!config) return undefined;
|
||||
const nestedSla = isRecord(config.sla) ? config.sla : {};
|
||||
const targetP95Ms = finiteNumberOrNull(config.slaTargetP95Ms ?? nestedSla.targetP95Ms);
|
||||
const maxErrorRate = finiteNumberOrNull(config.slaMaxErrorRate ?? nestedSla.maxErrorRate);
|
||||
const maxCostPer1MTokens = finiteNumberOrNull(
|
||||
config.slaMaxCostPer1MTokens ?? nestedSla.maxCostPer1MTokens
|
||||
);
|
||||
const hardConstraints = config.slaHardConstraints ?? nestedSla.hardConstraints;
|
||||
|
||||
const policy: SlaRoutingPolicy = {};
|
||||
if (targetP95Ms !== null && targetP95Ms > 0) policy.targetP95Ms = targetP95Ms;
|
||||
if (maxErrorRate !== null && maxErrorRate >= 0) policy.maxErrorRate = clamp01(maxErrorRate);
|
||||
if (maxCostPer1MTokens !== null && maxCostPer1MTokens > 0) {
|
||||
policy.maxCostPer1MTokens = maxCostPer1MTokens;
|
||||
}
|
||||
if (typeof hardConstraints === "boolean") policy.hardConstraints = hardConstraints;
|
||||
|
||||
return Object.keys(policy).length > 0 ? policy : undefined;
|
||||
}
|
||||
|
||||
export function getResetAwareProvider(target: ResolvedComboTarget): string | null {
|
||||
const provider = (target.providerId || target.provider || "").toLowerCase();
|
||||
return provider || null;
|
||||
}
|
||||
|
||||
function normalizeResetAt(value: unknown): string | null {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseResetTimeMs(resetAt: string | null | undefined): number {
|
||||
if (!resetAt) return NaN;
|
||||
const resetTime = Date.parse(resetAt);
|
||||
if (Number.isFinite(resetTime)) return resetTime;
|
||||
|
||||
if (!/^\d+(?:\.\d+)?$/.test(resetAt)) return NaN;
|
||||
const numericResetAt = Number(resetAt);
|
||||
if (!Number.isFinite(numericResetAt)) return NaN;
|
||||
return numericResetAt < 10_000_000_000 ? numericResetAt * 1000 : numericResetAt;
|
||||
}
|
||||
|
||||
function getQuotaWindow(
|
||||
quota: unknown,
|
||||
key: "window5h" | "window7d" | "windowWeekly" | "windowMonthly"
|
||||
): { percentUsed: number | null; resetAt: string | null } | null {
|
||||
if (!isRecord(quota)) return null;
|
||||
const window = quota[key];
|
||||
if (!isRecord(window)) return null;
|
||||
const percentUsed = finiteNumberOrNull(window.percentUsed);
|
||||
const resetAt = normalizeResetAt(window.resetAt);
|
||||
return { percentUsed, resetAt };
|
||||
}
|
||||
|
||||
function normalizeWindowPercentUsed(value: unknown): number | null {
|
||||
const numericValue = finiteNumberOrNull(value);
|
||||
if (numericValue === null) return null;
|
||||
if (numericValue > 1) return clamp01(numericValue / 100);
|
||||
return clamp01(numericValue);
|
||||
}
|
||||
|
||||
function getNamedQuotaWindow(
|
||||
quota: unknown,
|
||||
windowName: ResetWindowName
|
||||
): { percentUsed: number | null; resetAt: string | null } | null {
|
||||
if (!quota || !isRecord(quota)) return null;
|
||||
|
||||
if (windowName === "session") return getQuotaWindow(quota, "window5h");
|
||||
if (windowName === "weekly") {
|
||||
return getQuotaWindow(quota, "window7d") || getQuotaWindow(quota, "windowWeekly");
|
||||
}
|
||||
if (windowName === "monthly") return getQuotaWindow(quota, "windowMonthly");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getWindowsMapQuotaWindow(
|
||||
quota: unknown,
|
||||
windowName: ResetWindowName
|
||||
): { percentUsed: number | null; resetAt: string | null } | null {
|
||||
if (!quota || !isRecord(quota) || !isRecord(quota.windows)) return null;
|
||||
const candidates = Object.entries(quota.windows)
|
||||
.map(([key, value]) => ({ key: key.toLowerCase(), value }))
|
||||
.filter(({ key }) => key === windowName || key.startsWith(`${windowName} `));
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
candidates.sort((a, b) => a.key.localeCompare(b.key));
|
||||
const window = candidates[0].value;
|
||||
if (!isRecord(window)) return null;
|
||||
|
||||
return {
|
||||
percentUsed: normalizeWindowPercentUsed(window.percentUsed),
|
||||
resetAt: normalizeResetAt(window.resetAt),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveQuotaWindowByName(
|
||||
quota: unknown,
|
||||
windowName: ResetWindowName
|
||||
): { percentUsed: number | null; resetAt: string | null } | null {
|
||||
return getNamedQuotaWindow(quota, windowName) || getWindowsMapQuotaWindow(quota, windowName);
|
||||
}
|
||||
|
||||
function getResetUrgency(resetAt: string | null | undefined, windowMs: number): number {
|
||||
if (!resetAt) return 0.5;
|
||||
const resetTime = parseResetTimeMs(resetAt);
|
||||
if (!Number.isFinite(resetTime)) return 0.5;
|
||||
const msUntilReset = resetTime - Date.now();
|
||||
if (msUntilReset <= 0) return 1;
|
||||
return clamp01(1 - msUntilReset / windowMs);
|
||||
}
|
||||
|
||||
function scoreQuotaWindow(
|
||||
remaining: number,
|
||||
resetAt: string | null | undefined,
|
||||
windowMs: number,
|
||||
remainingWeight: number,
|
||||
resetPressureWeight: number
|
||||
): number {
|
||||
const normalizedRemaining = clamp01(remaining);
|
||||
const resetUrgency = getResetUrgency(resetAt, windowMs);
|
||||
const resetPressure = resetUrgency * (1 - normalizedRemaining);
|
||||
return remainingWeight * normalizedRemaining + resetPressureWeight * resetPressure;
|
||||
}
|
||||
|
||||
export function scoreResetAwareQuota(
|
||||
quota: unknown,
|
||||
config: ReturnType<typeof resolveResetAwareConfig>
|
||||
) {
|
||||
if (!quota || !isRecord(quota)) return { score: 0.5 };
|
||||
if (quota.limitReached === true) return { score: -Infinity };
|
||||
|
||||
const overallPercentUsed = clamp01(finiteNumberOrNull(quota.percentUsed) ?? 0.5);
|
||||
const sessionWindow = getQuotaWindow(quota, "window5h");
|
||||
const weeklyWindow = getQuotaWindow(quota, "window7d") || getQuotaWindow(quota, "windowWeekly");
|
||||
const sessionRemaining = clamp01(1 - (sessionWindow?.percentUsed ?? overallPercentUsed));
|
||||
const weeklyRemaining = clamp01(1 - (weeklyWindow?.percentUsed ?? overallPercentUsed));
|
||||
const sessionScore = scoreQuotaWindow(
|
||||
sessionRemaining,
|
||||
sessionWindow?.resetAt,
|
||||
RESET_AWARE_SESSION_WINDOW_MS,
|
||||
RESET_AWARE_SESSION_REMAINING_WEIGHT,
|
||||
RESET_AWARE_SESSION_RESET_PRESSURE_WEIGHT
|
||||
);
|
||||
const weeklyScore = scoreQuotaWindow(
|
||||
weeklyRemaining,
|
||||
weeklyWindow?.resetAt ?? normalizeResetAt(quota.resetAt),
|
||||
RESET_AWARE_WEEKLY_WINDOW_MS,
|
||||
RESET_AWARE_WEEKLY_REMAINING_WEIGHT,
|
||||
RESET_AWARE_WEEKLY_RESET_PRESSURE_WEIGHT
|
||||
);
|
||||
let score = config.sessionWeight * sessionScore + config.weeklyWeight * weeklyScore;
|
||||
|
||||
if (config.exhaustionGuard > 0 && sessionRemaining < config.exhaustionGuard) {
|
||||
score *= Math.max(0.05, sessionRemaining / config.exhaustionGuard);
|
||||
}
|
||||
|
||||
return { score };
|
||||
}
|
||||
|
||||
export function getResetWindowTimestampMs(quota: unknown, windows: ResetWindowName[]): number {
|
||||
if (!quota || !isRecord(quota) || quota.limitReached === true) return Infinity;
|
||||
|
||||
let selectedResetMs = Infinity;
|
||||
for (const windowName of windows) {
|
||||
const window = resolveQuotaWindowByName(quota, windowName);
|
||||
const resetMs = parseResetTimeMs(window?.resetAt ?? null);
|
||||
if (Number.isFinite(resetMs)) {
|
||||
selectedResetMs = Math.min(selectedResetMs, resetMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(selectedResetMs)) {
|
||||
selectedResetMs = parseResetTimeMs(normalizeResetAt(quota.resetAt));
|
||||
}
|
||||
|
||||
return Number.isFinite(selectedResetMs) ? selectedResetMs : Infinity;
|
||||
}
|
||||
|
||||
function getResetWindowHorizonMs(windows: ResetWindowName[]): number {
|
||||
if (windows.includes("monthly")) return 30 * 24 * 60 * 60 * 1000;
|
||||
if (windows.includes("weekly")) return RESET_AWARE_WEEKLY_WINDOW_MS;
|
||||
return RESET_AWARE_SESSION_WINDOW_MS;
|
||||
}
|
||||
|
||||
export function calculateResetWindowAffinity(quota: unknown, config: ResetWindowConfig): number {
|
||||
const resetMs = getResetWindowTimestampMs(quota, config.windows);
|
||||
if (!Number.isFinite(resetMs)) return 0.5;
|
||||
|
||||
const msUntilReset = resetMs - Date.now();
|
||||
if (msUntilReset <= 0) return 1;
|
||||
return clamp01(1 - msUntilReset / getResetWindowHorizonMs(config.windows));
|
||||
}
|
||||
568
open-sse/services/combo/quotaStrategies.ts
Normal file
568
open-sse/services/combo/quotaStrategies.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* Stateful + async reset-aware / reset-window quota strategies for combo routing.
|
||||
*
|
||||
* Holds the two mutable module-level caches that back reset-aware routing
|
||||
* (`resetAwareConnectionCache` for per-provider active connections and
|
||||
* `resetAwareQuotaCache` for per-connection quota snapshots), plus the helpers
|
||||
* that read/write them and the strategy orderers. Extracted byte-identically
|
||||
* from combo.ts (QG v2 Fase 9 T5 D7b) — the larger, stateful half of the
|
||||
* reset-aware quota block. The pure scoring/window-math half lives in
|
||||
* ./quotaScoring.ts and is imported here.
|
||||
*
|
||||
* State cohesion: `resetAwareConnectionCache`, `resetAwareQuotaCache`, and
|
||||
* `MAX_RESET_AWARE_CACHE` MUST remain single instances defined once here,
|
||||
* alongside their only readers/writers (getQuotaAwareConnectionsForTarget,
|
||||
* fetchResetAwareQuotaWithCache) — never duplicate a Map.
|
||||
*
|
||||
* Cross-module state: the tie-band round-robin in orderTargetsByResetAwareQuota
|
||||
* and orderTargetsByResetWindow shares the same rrCounters Map from ./rrState.ts
|
||||
* (D7a) so reset-aware tie rotation stays consistent with round-robin routing.
|
||||
*
|
||||
* Pure leaf: this module never imports from the combo barrel.
|
||||
*/
|
||||
|
||||
import { getRuntimeProviderProfile, type ProviderProfile } from "../accountFallback.ts";
|
||||
import { PRE_SCREEN_CONCURRENCY } from "../comboConfig.ts";
|
||||
import { getQuotaFetcher } from "../quotaPreflight.ts";
|
||||
import { getCircuitBreaker } from "../../../src/shared/utils/circuitBreaker";
|
||||
import { getProviderConnections } from "../../../src/lib/db/providers";
|
||||
import { MAX_RR_COUNTERS, rrCounters } from "./rrState.ts";
|
||||
import type { ResolvedComboTarget, IsModelAvailable } from "./types.ts";
|
||||
import {
|
||||
resolveResetAwareConfig,
|
||||
resolveResetWindowConfig,
|
||||
getResetAwareProvider,
|
||||
scoreResetAwareQuota,
|
||||
getResetWindowTimestampMs,
|
||||
type QuotaFetchCacheConfig,
|
||||
} from "./quotaScoring.ts";
|
||||
|
||||
const RESET_AWARE_CONNECTION_CACHE_TTL_MS = 30_000;
|
||||
const RESET_AWARE_QUOTA_FETCH_CONCURRENCY = 5;
|
||||
|
||||
const MAX_RESET_AWARE_CACHE = 200;
|
||||
|
||||
const resetAwareConnectionCache = new Map<
|
||||
string,
|
||||
{ fetchedAt: number; connections: Array<Record<string, unknown>> }
|
||||
>();
|
||||
const resetAwareQuotaCache = new Map<
|
||||
string,
|
||||
{ fetchedAt: number; quota: unknown; refreshPromise: Promise<unknown> | null }
|
||||
>();
|
||||
|
||||
async function getQuotaAwareConnectionsForTarget(
|
||||
target: ResolvedComboTarget,
|
||||
connectionCache: Map<string, Array<Record<string, unknown>>>,
|
||||
connectionLoadPromises: Map<string, Promise<Array<Record<string, unknown>>>>,
|
||||
comboName: string,
|
||||
log: { warn?: (...args: unknown[]) => void }
|
||||
) {
|
||||
const provider = getResetAwareProvider(target);
|
||||
if (!provider || !getQuotaFetcher(provider)) return [];
|
||||
if (!connectionCache.has(provider)) {
|
||||
const cached = resetAwareConnectionCache.get(provider);
|
||||
if (cached && Date.now() - cached.fetchedAt < RESET_AWARE_CONNECTION_CACHE_TTL_MS) {
|
||||
connectionCache.set(provider, cached.connections);
|
||||
return cached.connections;
|
||||
}
|
||||
|
||||
if (!connectionLoadPromises.has(provider)) {
|
||||
connectionLoadPromises.set(
|
||||
provider,
|
||||
(async () => {
|
||||
try {
|
||||
const connections = await getProviderConnections({ provider, isActive: true });
|
||||
const activeConnections = Array.isArray(connections)
|
||||
? (connections as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
if (
|
||||
!resetAwareConnectionCache.has(provider) &&
|
||||
resetAwareConnectionCache.size >= MAX_RESET_AWARE_CACHE
|
||||
) {
|
||||
const oldest = resetAwareConnectionCache.keys().next().value;
|
||||
if (oldest !== undefined) resetAwareConnectionCache.delete(oldest);
|
||||
}
|
||||
resetAwareConnectionCache.set(provider, {
|
||||
connections: activeConnections,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
return activeConnections;
|
||||
} catch (error) {
|
||||
log.warn?.("COMBO", "Reset-aware failed to load quota-aware connections.", {
|
||||
comboName,
|
||||
err: error,
|
||||
operation: "getProviderConnections",
|
||||
provider,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
const connections = await connectionLoadPromises.get(provider)!;
|
||||
connectionCache.set(provider, connections);
|
||||
}
|
||||
return connectionCache.get(provider) || [];
|
||||
}
|
||||
|
||||
function normalizeConnectionIds(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const ids = value.filter(
|
||||
(connectionId): connectionId is string =>
|
||||
typeof connectionId === "string" && connectionId.trim().length > 0
|
||||
);
|
||||
return ids.length > 0 ? ids : null;
|
||||
}
|
||||
|
||||
function filterAllowedConnectionIds(
|
||||
connectionIds: string[],
|
||||
apiKeyAllowedConnectionIds: string[] | null | undefined
|
||||
): string[] {
|
||||
const allowedIds = normalizeConnectionIds(apiKeyAllowedConnectionIds);
|
||||
if (!allowedIds) return connectionIds;
|
||||
const allowedSet = new Set(allowedIds);
|
||||
return connectionIds.filter((connectionId) => allowedSet.has(connectionId));
|
||||
}
|
||||
|
||||
function getTargetConnectionIds(
|
||||
target: ResolvedComboTarget,
|
||||
connections: Array<Record<string, unknown>>
|
||||
): string[] {
|
||||
let connectionIds: string[];
|
||||
if (target.connectionId) {
|
||||
return [target.connectionId];
|
||||
}
|
||||
|
||||
if (Array.isArray(target.allowedConnectionIds) && target.allowedConnectionIds.length > 0) {
|
||||
return target.allowedConnectionIds.filter(
|
||||
(connectionId): connectionId is string =>
|
||||
typeof connectionId === "string" && connectionId.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
connectionIds = connections
|
||||
.map((connection) => (typeof connection.id === "string" ? connection.id : null))
|
||||
.filter((connectionId): connectionId is string => !!connectionId);
|
||||
return connectionIds;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const currentIndex = nextIndex++;
|
||||
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function fetchResetAwareQuotaWithCache({
|
||||
provider,
|
||||
connectionId,
|
||||
connection,
|
||||
fetcher,
|
||||
config,
|
||||
log,
|
||||
comboName,
|
||||
}: {
|
||||
provider: string;
|
||||
connectionId: string;
|
||||
connection?: Record<string, unknown>;
|
||||
fetcher: (connectionId: string, connection?: Record<string, unknown>) => Promise<unknown>;
|
||||
config: QuotaFetchCacheConfig;
|
||||
log: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void };
|
||||
comboName: string;
|
||||
}): Promise<unknown> {
|
||||
const cacheKey = `${provider}:${connectionId}`;
|
||||
const ttlMs = config.quotaCacheTtlMs;
|
||||
const maxStaleMs = config.quotaCacheMaxStaleMs;
|
||||
const now = Date.now();
|
||||
const cached = resetAwareQuotaCache.get(cacheKey);
|
||||
|
||||
if (ttlMs <= 0 && maxStaleMs <= 0) {
|
||||
try {
|
||||
return await fetcher(connectionId, connection);
|
||||
} catch (error) {
|
||||
log.warn?.("COMBO", "Reset-aware quota fetch failed.", {
|
||||
comboName,
|
||||
connectionId,
|
||||
err: error,
|
||||
operation: "quotaFetch",
|
||||
provider,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
const existing = resetAwareQuotaCache.get(cacheKey);
|
||||
if (existing?.refreshPromise != null) return existing.refreshPromise;
|
||||
|
||||
const refreshPromise = fetcher(connectionId, connection)
|
||||
.then((quota) => {
|
||||
if (quota) {
|
||||
if (
|
||||
!resetAwareQuotaCache.has(cacheKey) &&
|
||||
resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE
|
||||
) {
|
||||
const oldest = resetAwareQuotaCache.keys().next().value;
|
||||
if (oldest !== undefined) resetAwareQuotaCache.delete(oldest);
|
||||
}
|
||||
resetAwareQuotaCache.set(cacheKey, {
|
||||
quota,
|
||||
fetchedAt: Date.now(),
|
||||
refreshPromise: null,
|
||||
});
|
||||
} else {
|
||||
resetAwareQuotaCache.delete(cacheKey);
|
||||
}
|
||||
return quota;
|
||||
})
|
||||
.catch((error) => {
|
||||
const previous = resetAwareQuotaCache.get(cacheKey);
|
||||
if (previous) {
|
||||
if (
|
||||
!resetAwareQuotaCache.has(cacheKey) &&
|
||||
resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE
|
||||
) {
|
||||
const oldest = resetAwareQuotaCache.keys().next().value;
|
||||
if (oldest !== undefined) resetAwareQuotaCache.delete(oldest);
|
||||
}
|
||||
resetAwareQuotaCache.set(cacheKey, { ...previous, refreshPromise: null });
|
||||
}
|
||||
log.warn?.("COMBO", "Reset-aware quota fetch failed.", {
|
||||
comboName,
|
||||
connectionId,
|
||||
err: error,
|
||||
operation: "quotaFetch",
|
||||
provider,
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!resetAwareQuotaCache.has(cacheKey) && resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) {
|
||||
const oldest = resetAwareQuotaCache.keys().next().value;
|
||||
if (oldest !== undefined) resetAwareQuotaCache.delete(oldest);
|
||||
}
|
||||
resetAwareQuotaCache.set(cacheKey, {
|
||||
quota: existing?.quota ?? cached?.quota ?? null,
|
||||
fetchedAt: existing?.fetchedAt ?? cached?.fetchedAt ?? 0,
|
||||
refreshPromise,
|
||||
});
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
if (ttlMs > 0 && cached) {
|
||||
const age = now - cached.fetchedAt;
|
||||
if (age <= ttlMs) return cached.quota;
|
||||
if (maxStaleMs > 0 && age <= ttlMs + maxStaleMs) {
|
||||
void refresh();
|
||||
return cached.quota;
|
||||
}
|
||||
}
|
||||
|
||||
return refresh();
|
||||
}
|
||||
|
||||
export type PreScreenResult = { profile: ProviderProfile | null; available: boolean };
|
||||
|
||||
export async function preScreenTargets(
|
||||
targets: ResolvedComboTarget[],
|
||||
isModelAvailable?: IsModelAvailable | null
|
||||
): Promise<Map<string, PreScreenResult>> {
|
||||
if (targets.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const results = await mapWithConcurrency(
|
||||
targets,
|
||||
PRE_SCREEN_CONCURRENCY,
|
||||
async (target): Promise<{ key: string; result: PreScreenResult }> => {
|
||||
const profile = await getRuntimeProviderProfile(target.provider).catch(() => null);
|
||||
|
||||
const breaker = getCircuitBreaker(target.provider);
|
||||
if (breaker.getStatus().state === "OPEN") {
|
||||
return { key: target.executionKey, result: { profile, available: false } };
|
||||
}
|
||||
|
||||
let available = true;
|
||||
if (isModelAvailable) {
|
||||
// IsModelAvailable may return a sync boolean or a Promise; Promise.resolve
|
||||
// normalizes both so the .catch() never runs against a bare boolean.
|
||||
available = await Promise.resolve(isModelAvailable(target.modelStr, target)).catch(
|
||||
() => true
|
||||
);
|
||||
}
|
||||
return { key: target.executionKey, result: { profile, available } };
|
||||
}
|
||||
);
|
||||
|
||||
const map = new Map<string, PreScreenResult>();
|
||||
for (const { key, result } of results) {
|
||||
map.set(key, result);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function orderTargetsByResetAwareQuota(
|
||||
targets: ResolvedComboTarget[],
|
||||
comboName: string,
|
||||
configSource: Record<string, unknown> | null | undefined,
|
||||
log: { warn?: (...args: unknown[]) => void },
|
||||
apiKeyAllowedConnectionIds?: string[] | null
|
||||
) {
|
||||
if (targets.length === 0) return targets;
|
||||
|
||||
const config = resolveResetAwareConfig(configSource);
|
||||
const connectionCache = new Map<string, Array<Record<string, unknown>>>();
|
||||
const connectionLoadPromises = new Map<string, Promise<Array<Record<string, unknown>>>>();
|
||||
const quotaPromises = new Map<string, Promise<unknown>>();
|
||||
const connectionById = new Map<string, Record<string, unknown>>();
|
||||
const expandedTargets: ResolvedComboTarget[] = [];
|
||||
|
||||
const targetsWithConnections = await Promise.all(
|
||||
targets.map(async (target) => ({
|
||||
connections: await getQuotaAwareConnectionsForTarget(
|
||||
target,
|
||||
connectionCache,
|
||||
connectionLoadPromises,
|
||||
comboName,
|
||||
log
|
||||
),
|
||||
target,
|
||||
}))
|
||||
);
|
||||
|
||||
for (const { target, connections } of targetsWithConnections) {
|
||||
for (const connection of connections) {
|
||||
if (typeof connection.id === "string") connectionById.set(connection.id, connection);
|
||||
}
|
||||
|
||||
const unrestrictedConnectionIds = getTargetConnectionIds(target, connections);
|
||||
const connectionIds = filterAllowedConnectionIds(
|
||||
unrestrictedConnectionIds,
|
||||
apiKeyAllowedConnectionIds
|
||||
);
|
||||
if (connectionIds.length === 0) {
|
||||
if (
|
||||
unrestrictedConnectionIds.length > 0 &&
|
||||
normalizeConnectionIds(apiKeyAllowedConnectionIds)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
expandedTargets.push(target);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const connectionId of connectionIds) {
|
||||
expandedTargets.push({
|
||||
...target,
|
||||
connectionId,
|
||||
executionKey:
|
||||
target.connectionId === connectionId
|
||||
? target.executionKey
|
||||
: `${target.executionKey}@${connectionId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const scoredTargets = await mapWithConcurrency(
|
||||
expandedTargets,
|
||||
RESET_AWARE_QUOTA_FETCH_CONCURRENCY,
|
||||
async (target, index) => {
|
||||
let quota: unknown = null;
|
||||
const provider = getResetAwareProvider(target);
|
||||
const fetcher = provider ? getQuotaFetcher(provider) : null;
|
||||
if (fetcher && provider && target.connectionId) {
|
||||
const quotaKey = `${provider}:${target.connectionId}`;
|
||||
if (!quotaPromises.has(quotaKey)) {
|
||||
quotaPromises.set(
|
||||
quotaKey,
|
||||
fetchResetAwareQuotaWithCache({
|
||||
provider,
|
||||
connectionId: target.connectionId,
|
||||
connection: connectionById.get(target.connectionId),
|
||||
fetcher,
|
||||
config,
|
||||
log,
|
||||
comboName,
|
||||
})
|
||||
);
|
||||
}
|
||||
quota = await quotaPromises.get(quotaKey)!;
|
||||
}
|
||||
const { score } = scoreResetAwareQuota(quota, config);
|
||||
return { target, score, index };
|
||||
}
|
||||
);
|
||||
|
||||
scoredTargets.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
const bestScore = scoredTargets[0]?.score ?? 0;
|
||||
const tiedTargets = scoredTargets.filter((entry) => bestScore - entry.score <= config.tieBand);
|
||||
let orderedTiedTargets = tiedTargets;
|
||||
if (tiedTargets.length > 1) {
|
||||
const key = `reset-aware:${comboName}`;
|
||||
const counter = rrCounters.get(key) || 0;
|
||||
if (!rrCounters.has(key) && rrCounters.size >= MAX_RR_COUNTERS) {
|
||||
const oldest = rrCounters.keys().next().value;
|
||||
if (oldest !== undefined) rrCounters.delete(oldest);
|
||||
}
|
||||
rrCounters.set(key, counter + 1);
|
||||
const startIndex = counter % tiedTargets.length;
|
||||
orderedTiedTargets = [...tiedTargets.slice(startIndex), ...tiedTargets.slice(0, startIndex)];
|
||||
}
|
||||
|
||||
const tiedExecutionKeys = new Set(orderedTiedTargets.map((entry) => entry.target.executionKey));
|
||||
return [
|
||||
...orderedTiedTargets,
|
||||
...scoredTargets.filter((entry) => !tiedExecutionKeys.has(entry.target.executionKey)),
|
||||
].map((entry) => entry.target);
|
||||
}
|
||||
|
||||
export async function orderTargetsByResetWindow(
|
||||
targets: ResolvedComboTarget[],
|
||||
comboName: string,
|
||||
configSource: Record<string, unknown> | null | undefined,
|
||||
log: { warn?: (...args: unknown[]) => void },
|
||||
apiKeyAllowedConnectionIds?: string[] | null
|
||||
) {
|
||||
if (targets.length === 0) return targets;
|
||||
|
||||
const config = resolveResetWindowConfig(configSource);
|
||||
const connectionCache = new Map<string, Array<Record<string, unknown>>>();
|
||||
const connectionLoadPromises = new Map<string, Promise<Array<Record<string, unknown>>>>();
|
||||
const quotaPromises = new Map<string, Promise<unknown>>();
|
||||
const connectionById = new Map<string, Record<string, unknown>>();
|
||||
const expandedTargets: ResolvedComboTarget[] = [];
|
||||
|
||||
const targetsWithConnections = await Promise.all(
|
||||
targets.map(async (target) => ({
|
||||
connections: await getQuotaAwareConnectionsForTarget(
|
||||
target,
|
||||
connectionCache,
|
||||
connectionLoadPromises,
|
||||
comboName,
|
||||
log
|
||||
),
|
||||
target,
|
||||
}))
|
||||
);
|
||||
|
||||
for (const { target, connections } of targetsWithConnections) {
|
||||
for (const connection of connections) {
|
||||
if (typeof connection.id === "string") connectionById.set(connection.id, connection);
|
||||
}
|
||||
|
||||
const unrestrictedConnectionIds = getTargetConnectionIds(target, connections);
|
||||
const connectionIds = filterAllowedConnectionIds(
|
||||
unrestrictedConnectionIds,
|
||||
apiKeyAllowedConnectionIds
|
||||
);
|
||||
if (connectionIds.length === 0) {
|
||||
if (
|
||||
unrestrictedConnectionIds.length > 0 &&
|
||||
normalizeConnectionIds(apiKeyAllowedConnectionIds)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
expandedTargets.push(target);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const connectionId of connectionIds) {
|
||||
expandedTargets.push({
|
||||
...target,
|
||||
connectionId,
|
||||
executionKey:
|
||||
target.connectionId === connectionId
|
||||
? target.executionKey
|
||||
: `${target.executionKey}@${connectionId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const scoredTargets = await mapWithConcurrency(
|
||||
expandedTargets,
|
||||
RESET_AWARE_QUOTA_FETCH_CONCURRENCY,
|
||||
async (target, index) => {
|
||||
let quota: unknown = null;
|
||||
const provider = getResetAwareProvider(target);
|
||||
const fetcher = provider ? getQuotaFetcher(provider) : null;
|
||||
if (fetcher && provider && target.connectionId) {
|
||||
const quotaKey = `${provider}:${target.connectionId}`;
|
||||
if (!quotaPromises.has(quotaKey)) {
|
||||
quotaPromises.set(
|
||||
quotaKey,
|
||||
fetchResetAwareQuotaWithCache({
|
||||
provider,
|
||||
connectionId: target.connectionId,
|
||||
connection: connectionById.get(target.connectionId),
|
||||
fetcher,
|
||||
config,
|
||||
log,
|
||||
comboName,
|
||||
})
|
||||
);
|
||||
}
|
||||
quota = await quotaPromises.get(quotaKey)!;
|
||||
}
|
||||
|
||||
return {
|
||||
target,
|
||||
resetMs: getResetWindowTimestampMs(quota, config.windows),
|
||||
index,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
scoredTargets.sort((a, b) => {
|
||||
if (a.resetMs !== b.resetMs) return a.resetMs - b.resetMs;
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
const bestResetMs = scoredTargets[0]?.resetMs ?? Infinity;
|
||||
if (!Number.isFinite(bestResetMs) || config.tieBandMs <= 0) {
|
||||
return scoredTargets.map((entry) => entry.target);
|
||||
}
|
||||
|
||||
const tiedTargets = scoredTargets.filter(
|
||||
(entry) => entry.resetMs - bestResetMs <= config.tieBandMs
|
||||
);
|
||||
if (tiedTargets.length <= 1) return scoredTargets.map((entry) => entry.target);
|
||||
|
||||
const key = `reset-window:${comboName}`;
|
||||
const counter = rrCounters.get(key) || 0;
|
||||
if (!rrCounters.has(key) && rrCounters.size >= MAX_RR_COUNTERS) {
|
||||
const oldest = rrCounters.keys().next().value;
|
||||
if (oldest !== undefined) rrCounters.delete(oldest);
|
||||
}
|
||||
rrCounters.set(key, counter + 1);
|
||||
const startIndex = counter % tiedTargets.length;
|
||||
const orderedTiedTargets = [
|
||||
...tiedTargets.slice(startIndex),
|
||||
...tiedTargets.slice(0, startIndex),
|
||||
];
|
||||
const tiedExecutionKeys = new Set(orderedTiedTargets.map((entry) => entry.target.executionKey));
|
||||
|
||||
return [
|
||||
...orderedTiedTargets,
|
||||
...scoredTargets.filter((entry) => !tiedExecutionKeys.has(entry.target.executionKey)),
|
||||
].map((entry) => entry.target);
|
||||
}
|
||||
@@ -142,14 +142,37 @@ function isCanonicalFilter(value: z.infer<typeof rtkFilterSchema>): value is Rtk
|
||||
return "label" in value && "match" in value && "rules" in value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative, dependency-free ReDoS guard. Custom RTK filters (DATA_DIR/rtk/filters.json)
|
||||
* carry user-supplied regex strings that are compiled and run against untrusted tool output;
|
||||
* a nested unbounded quantifier ((a+)+, (a*)*, ([a-z]+)+, (a+|b)+ …) causes catastrophic
|
||||
* backtracking. This flags the common single-group nested-quantifier shapes so the loader
|
||||
* never compiles them. Heuristic by design — a full analysis would use `safe-regex` (not
|
||||
* installable in this symlinked worktree); it is itself linear (no nested quantifier).
|
||||
*/
|
||||
export function isReDoSProne(pattern: string): boolean {
|
||||
return /\([^()]*(?:[+*]|\{\d+,\})[^()]*\)\s*(?:[+*]|\{\d+,\})/.test(pattern);
|
||||
}
|
||||
|
||||
function dropReDoSProne(patterns: string[]): string[] {
|
||||
return patterns.filter((p) => !isReDoSProne(p));
|
||||
}
|
||||
|
||||
export function validateRtkFilter(value: unknown): RtkFilterDefinition {
|
||||
const parsed = rtkFilterSchema.parse(value);
|
||||
if (!isCanonicalFilter(parsed)) {
|
||||
const collapse = dropReDoSProne(parsed.collapsePatterns);
|
||||
return {
|
||||
...parsed,
|
||||
stripPatterns: dropReDoSProne(parsed.stripPatterns),
|
||||
keepPatterns: dropReDoSProne(parsed.keepPatterns),
|
||||
priorityPatterns: dropReDoSProne(parsed.priorityPatterns),
|
||||
collapsePatterns: collapse,
|
||||
replace: parsed.replace.filter((r) => !isReDoSProne(r.pattern)),
|
||||
matchOutput: parsed.matchOutput.filter((r) => !isReDoSProne(r.pattern)),
|
||||
commandPatterns: [],
|
||||
matchPatterns: [],
|
||||
deduplicate: parsed.collapsePatterns.length > 0,
|
||||
deduplicate: collapse.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,17 +182,17 @@ export function validateRtkFilter(value: unknown): RtkFilterDefinition {
|
||||
name: parsed.label,
|
||||
description: parsed.description,
|
||||
commandTypes: parsed.match.outputTypes,
|
||||
commandPatterns: parsed.match.commands,
|
||||
matchPatterns: parsed.match.patterns,
|
||||
commandPatterns: dropReDoSProne(parsed.match.commands),
|
||||
matchPatterns: dropReDoSProne(parsed.match.patterns),
|
||||
category: parsed.category,
|
||||
priority: parsed.priority,
|
||||
stripPatterns: parsed.rules.dropPatterns,
|
||||
keepPatterns: parsed.rules.includePatterns,
|
||||
priorityPatterns: preservePatterns,
|
||||
collapsePatterns: parsed.rules.collapsePatterns,
|
||||
stripPatterns: dropReDoSProne(parsed.rules.dropPatterns),
|
||||
keepPatterns: dropReDoSProne(parsed.rules.includePatterns),
|
||||
priorityPatterns: dropReDoSProne(preservePatterns),
|
||||
collapsePatterns: dropReDoSProne(parsed.rules.collapsePatterns),
|
||||
stripAnsi: parsed.rules.stripAnsi,
|
||||
replace: parsed.rules.replace,
|
||||
matchOutput: parsed.rules.matchOutput,
|
||||
replace: parsed.rules.replace.filter((r) => !isReDoSProne(r.pattern)),
|
||||
matchOutput: parsed.rules.matchOutput.filter((r) => !isReDoSProne(r.pattern)),
|
||||
truncateLineAt: parsed.rules.truncateLineAt,
|
||||
onEmpty: parsed.rules.onEmpty,
|
||||
filterStderr: parsed.rules.filterStderr,
|
||||
|
||||
@@ -19,8 +19,14 @@ const SECRET_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/\b(sk-[A-Za-z0-9_-]{16,})\b/g, "[REDACTED_OPENAI_KEY]"],
|
||||
[/\b(xox[baprs]-[A-Za-z0-9-]{16,})\b/g, "[REDACTED_SLACK_TOKEN]"],
|
||||
[/\b(AKIA[0-9A-Z]{16})\b/g, "[REDACTED_AWS_KEY]"],
|
||||
[/((?:api[_-]?key|token|secret|password)\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s]+)/gi, "$1[REDACTED]"],
|
||||
[/(Authorization:\s*Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, "$1[REDACTED]"],
|
||||
// key=value / key: value for common credential field names (flat alternation — no nesting,
|
||||
// so no ReDoS). Covers names the bare token/secret/password set misses (private_key, etc).
|
||||
[
|
||||
/((?:api[_-]?key|api[_-]?token|access[_-]?key|access[_-]?token|client[_-]?secret|auth[_-]?token|private[_-]?key|secret[_-]?key|credentials?|token|secret|password)\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s]+)/gi,
|
||||
"$1[REDACTED]",
|
||||
],
|
||||
// Authorization / Proxy-Authorization with Bearer OR Basic (curl -v emits Basic <base64>).
|
||||
[/((?:Proxy-)?Authorization:\s*(?:Bearer|Basic)\s+)[A-Za-z0-9._~+/-]+=*/gi, "$1[REDACTED]"],
|
||||
];
|
||||
|
||||
function dataDir(): string {
|
||||
|
||||
@@ -230,6 +230,8 @@ export const DEFAULT_RTK_CONFIG: RtkConfig = {
|
||||
trustProjectFilters: false,
|
||||
rawOutputRetention: "never",
|
||||
rawOutputMaxBytes: 1_048_576,
|
||||
enableGrouping: false,
|
||||
groupingThreshold: 3,
|
||||
};
|
||||
|
||||
export const DEFAULT_COMPRESSION_LANGUAGE_CONFIG: CompressionLanguageConfig = {
|
||||
|
||||
@@ -172,6 +172,16 @@ function normalizeRtkConfig(value: unknown): RtkConfig {
|
||||
1024,
|
||||
10_000_000
|
||||
),
|
||||
enableGrouping:
|
||||
typeof record.enableGrouping === "boolean"
|
||||
? record.enableGrouping
|
||||
: (DEFAULT_RTK_CONFIG.enableGrouping ?? false),
|
||||
groupingThreshold: boundedInt(
|
||||
record.groupingThreshold,
|
||||
DEFAULT_RTK_CONFIG.groupingThreshold ?? 3,
|
||||
2,
|
||||
100
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ export const rtkConfigSchema = z
|
||||
trustProjectFilters: z.boolean().optional(),
|
||||
rawOutputRetention: rtkRawOutputRetentionSchema.optional(),
|
||||
rawOutputMaxBytes: z.number().int().min(1024).max(10_000_000).optional(),
|
||||
enableGrouping: z.boolean().optional(),
|
||||
groupingThreshold: z.number().int().min(2).max(100).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -186,15 +186,30 @@
|
||||
]
|
||||
},
|
||||
"_mutate_godfiles_excluded_comment": [
|
||||
"2026-06-18 (Onda 2 budget): chatCore.ts (5874 LOC) + combo.ts (5282 LOC) — the two",
|
||||
"god-files — were REMOVED from `mutate`. They dominate ~2/3 of the ~15k mutants, and the",
|
||||
"full 8-module run TIMED OUT at the 180min nightly cap (run 27705123780, concurrency=4 +",
|
||||
"DATA_DIR isolation: started 16:47:33 -> killed 19:47:48 = exactly 180min; the prior 120min",
|
||||
"scheduled run also timed out). #4078 made concurrency safe but the tap-runner re-spawns a",
|
||||
"node process per test file PER MUTANT, so spawn cost dominates and 15k mutants does not fit.",
|
||||
"The 6 smaller modules below fit the budget and produce REAL mutation scores now; the two",
|
||||
"god-files regain mutation coverage after the Onda 3 split into smaller units (re-add them",
|
||||
"here — ideally as the split sub-modules). See project memory: Quality Gate v2 / Fase 9."
|
||||
"2026-06-18 (Onda 2 budget): chatCore.ts + combo.ts — the two god-files — were REMOVED",
|
||||
"from `mutate`. They dominated ~2/3 of the ~15k mutants; the full 8-module run TIMED OUT",
|
||||
"at the 180min nightly cap (run 27705123780: 16:47:33 -> killed 19:47:48 = exactly 180min;",
|
||||
"the prior 120min scheduled run also timed out). #4078 made concurrency safe but the",
|
||||
"tap-runner re-spawns a node process per test file PER MUTANT, so spawn cost dominates and",
|
||||
"15k mutants does not fit.",
|
||||
"",
|
||||
"2026-06-18 (Onda 3 / Fase 9 T5 re-add): the combo.ts god-file was split into 11 small",
|
||||
"leaf modules under open-sse/services/combo/ (PRs #4162/#4175/#4186/#4196/#4204). The",
|
||||
"routing LOGIC that justified combo.ts being in `mutate` now lives in those leaves, so the",
|
||||
"8 well-covered combo/* leaves are ADDED back to `mutate` here (comboStructure/autoStrategy/",
|
||||
"validateQuality/shadowRouting/targetSorters/comboPredicates/rrState/comboData). They are",
|
||||
"covered by the 24 combo-*.test.ts files already in tap.testFiles. types.ts is omitted",
|
||||
"(pure type declarations produce 0 mutants).",
|
||||
"",
|
||||
"STILL EXCLUDED (follow-ups, NOT in `mutate` yet):",
|
||||
" - combo.ts + chatCore.ts barrels: their handleComboChat/handleChatCore CORES were not",
|
||||
" split (out of scope — Fase 3 ChatCoreContext refactor). The barrels are now thin-ish",
|
||||
" but still large; keep excluded until the cores are split.",
|
||||
" - combo/quotaScoring.ts + combo/quotaStrategies.ts: in PR #4204 (D7b), not yet merged.",
|
||||
" Add them here once #4204 lands (they are covered by combo-prescreen/combo-config tests).",
|
||||
" - chatCore/* leaves (15 modules): thinner test coverage (only ~7 chatcore-*.test.ts in",
|
||||
" tap.testFiles); add after a covering-test audit so they don't all 'survive (no coverage)'.",
|
||||
"See project memory: Quality Gate v2 / Fase 9 (project-combo-split)."
|
||||
],
|
||||
"mutate": [
|
||||
"open-sse/services/accountFallback.ts",
|
||||
@@ -202,7 +217,15 @@
|
||||
"src/server/authz/routeGuard.ts",
|
||||
"open-sse/utils/error.ts",
|
||||
"open-sse/utils/publicCreds.ts",
|
||||
"src/shared/utils/circuitBreaker.ts"
|
||||
"src/shared/utils/circuitBreaker.ts",
|
||||
"open-sse/services/combo/comboStructure.ts",
|
||||
"open-sse/services/combo/autoStrategy.ts",
|
||||
"open-sse/services/combo/validateQuality.ts",
|
||||
"open-sse/services/combo/shadowRouting.ts",
|
||||
"open-sse/services/combo/targetSorters.ts",
|
||||
"open-sse/services/combo/comboPredicates.ts",
|
||||
"open-sse/services/combo/rrState.ts",
|
||||
"open-sse/services/combo/comboData.ts"
|
||||
],
|
||||
"_ignorePatterns_comment": [
|
||||
"ignorePatterns = files NOT copied into the Stryker sandbox. It does NOT scope",
|
||||
|
||||
54
tests/unit/compression/rtk-filter-redos-guard.test.ts
Normal file
54
tests/unit/compression/rtk-filter-redos-guard.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
validateRtkFilter,
|
||||
isReDoSProne,
|
||||
} from "../../../open-sse/services/compression/engines/rtk/filterSchema.ts";
|
||||
|
||||
// Custom RTK filters (DATA_DIR/rtk/filters.json) carry user-supplied regex strings that are
|
||||
// compiled and run against untrusted tool output. A nested unbounded quantifier ((a+)+, (a*)*)
|
||||
// can trigger catastrophic backtracking (ReDoS). validateRtkFilter must drop such patterns so
|
||||
// they are never compiled. Dependency-free heuristic (safe-regex not installable here).
|
||||
describe("RTK filter ReDoS guard", () => {
|
||||
it("flags nested unbounded quantifiers and accepts safe patterns", () => {
|
||||
assert.equal(isReDoSProne("(a+)+"), true);
|
||||
assert.equal(isReDoSProne("(a*)*"), true);
|
||||
assert.equal(isReDoSProne("([a-z]+)+"), true);
|
||||
assert.equal(isReDoSProne("(a+|b)+"), true);
|
||||
assert.equal(isReDoSProne("(?:\\d+)+"), true);
|
||||
|
||||
assert.equal(isReDoSProne("(ab)+"), false);
|
||||
assert.equal(isReDoSProne("\\d{1,8}"), false);
|
||||
assert.equal(isReDoSProne("error|fail|FAIL"), false);
|
||||
assert.equal(isReDoSProne("^\\s*$"), false);
|
||||
});
|
||||
|
||||
it("strips ReDoS-prone patterns from a canonical filter at validation", () => {
|
||||
const def = validateRtkFilter({
|
||||
id: "x",
|
||||
label: "X",
|
||||
category: "generic",
|
||||
match: { commands: [], patterns: ["(a+)+", "ERROR\\b"], outputTypes: [] },
|
||||
rules: { dropPatterns: ["(.*)*", "^\\s*$"] },
|
||||
// preserve provided explicitly: a pack filter omitting it crashes validateRtkFilter
|
||||
// (pre-existing: rtkFilterPreserveSchema.default({}) leaves errorPatterns undefined).
|
||||
preserve: { errorPatterns: [], summaryPatterns: [] },
|
||||
});
|
||||
|
||||
assert.deepEqual(def.matchPatterns, ["ERROR\\b"], "catastrophic matchPattern dropped");
|
||||
assert.deepEqual(def.stripPatterns, ["^\\s*$"], "catastrophic dropPattern removed");
|
||||
});
|
||||
|
||||
it("strips ReDoS-prone patterns from a legacy filter at validation", () => {
|
||||
const def = validateRtkFilter({
|
||||
id: "y",
|
||||
name: "Y",
|
||||
category: "generic",
|
||||
commandTypes: ["shell"],
|
||||
stripPatterns: ["([a-z]+)*", "keepme"],
|
||||
});
|
||||
|
||||
assert.deepEqual(def.stripPatterns, ["keepme"], "catastrophic legacy stripPattern removed");
|
||||
});
|
||||
});
|
||||
63
tests/unit/compression/rtk-grouping-config.test.ts
Normal file
63
tests/unit/compression/rtk-grouping-config.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, beforeEach, afterEach, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { rtkConfigSchema } from "../../../src/shared/validation/compressionConfigSchemas.ts";
|
||||
import { DEFAULT_RTK_CONFIG } from "../../../open-sse/services/compression/types.ts";
|
||||
|
||||
// The RTK R5 grouping feature is read by the engine (config.enableGrouping / groupingThreshold)
|
||||
// but was unreachable in production: the Zod schema (.strict()) rejected the two fields on write
|
||||
// and normalizeRtkConfig dropped them on read. This proves both gates now let them through.
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-rtk-grouping-"));
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const { getCompressionSettings, updateCompressionSettings } = await import(
|
||||
"../../../src/lib/db/compression.ts"
|
||||
);
|
||||
|
||||
describe("RTK grouping config persistence (R5)", () => {
|
||||
beforeEach(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
core.resetDbInstance();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
});
|
||||
|
||||
it("accepts enableGrouping / groupingThreshold on the write schema", () => {
|
||||
assert.equal(
|
||||
rtkConfigSchema.safeParse({ enableGrouping: true, groupingThreshold: 5 }).success,
|
||||
true
|
||||
);
|
||||
// groupingThreshold below the minimum run length (2) is rejected.
|
||||
assert.equal(rtkConfigSchema.safeParse({ groupingThreshold: 1 }).success, false);
|
||||
});
|
||||
|
||||
it("preserves enableGrouping / groupingThreshold through a DB round-trip", async () => {
|
||||
const settings = await updateCompressionSettings({
|
||||
rtkConfig: { ...DEFAULT_RTK_CONFIG, enableGrouping: true, groupingThreshold: 7 },
|
||||
});
|
||||
assert.equal(settings.rtkConfig.enableGrouping, true);
|
||||
assert.equal(settings.rtkConfig.groupingThreshold, 7);
|
||||
|
||||
// Survives a fresh read (not just the write-path return value).
|
||||
core.resetDbInstance();
|
||||
const reread = await getCompressionSettings();
|
||||
assert.equal(reread.rtkConfig.enableGrouping, true);
|
||||
assert.equal(reread.rtkConfig.groupingThreshold, 7);
|
||||
});
|
||||
});
|
||||
@@ -146,4 +146,22 @@ describe("RTK raw output retention", () => {
|
||||
assert.equal(pointer, null);
|
||||
fs.rmSync(blocker, { force: true });
|
||||
});
|
||||
|
||||
it("redacts Basic/Proxy auth headers and key fields the base patterns miss", () => {
|
||||
const basic = redactRtkRawOutput("> Authorization: Basic dXNlcjpwYXNzd29yZA==");
|
||||
assert.equal(basic.redacted, true);
|
||||
assert.ok(!basic.text.includes("dXNlcjpwYXNzd29yZA=="), "Authorization: Basic base64 redacted");
|
||||
|
||||
const proxy = redactRtkRawOutput("Proxy-Authorization: Basic c2VjcmV0OnBhc3M=");
|
||||
assert.equal(proxy.redacted, true);
|
||||
assert.ok(!proxy.text.includes("c2VjcmV0OnBhc3M="), "Proxy-Authorization redacted");
|
||||
|
||||
const pkey = redactRtkRawOutput("private_key=abc123def456ghi");
|
||||
assert.equal(pkey.redacted, true);
|
||||
assert.ok(!pkey.text.includes("abc123def456ghi"), "private_key value redacted");
|
||||
|
||||
const cred = redactRtkRawOutput("credential: my-credential-value-x");
|
||||
assert.equal(cred.redacted, true);
|
||||
assert.ok(!cred.text.includes("my-credential-value-x"), "credential value redacted");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user