From 9708288b57c8e8d7fa8613cb566d5bd4f422ba48 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:40:17 -0300 Subject: [PATCH] =?UTF-8?q?feat(quota):=20estrat=C3=A9gia=20dedicada=20de?= =?UTF-8?q?=20quota-share=20(DRR=20+=20P2C=20in-flight=20+=20gating=20per-?= =?UTF-8?q?model)=20[Fase=203=20#9]=20(#4939)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(quota): estratégia dedicada de quota-share (DRR + P2C in-flight + gating per-model) [Fase 3 #9] Estratégia interna "quota-share" isolada num módulo dedicado — NÃO toca a seleção/ fair-share genérica (decisão do dono: não mexer no que já funciona). Os combos qtSd/ (quotaCombos.ts) passam de fill-first para essa strategy; combo.ts ganha só 1 branch de dispatch que delega 100% ao módulo (nenhum case existente alterado). - quotaShareStrategy.ts: gating per-model (isBucketSaturated do #3) + DRR (quantum proporcional ao weight) + P2C sobre carga in-flight. - quotaShareInflight.ts: contador in-flight com TTL/lease de 120s — fallback do decrement-on-abort sem precisar instrumentar o combo genérico. - "quota-share" registrada como strategy INTERNA (não exposta na UI). - testes de síntese (quota-combo-balancing, quota-multiprovider) alinhados: a strategy esperada dos combos qtSd/ passa de "fill-first" para "quota-share" (alinhamento ao novo comportamento intencional, não mascaramento — os 73 testes de qtSd/ seguem verdes). * test(quota-share): alinha 2 scope-guards ao godfile sweep (base-reds que bloqueavam o CI) Dois testes de "arquivo contém X" quebraram por decomposições de godfile que outras sessões mergearam no release DURANTE a validação de #9 — NÃO são regressão de #9 (que não toca validation/oauth). Alinhados ao novo layout, asserts preservados: - proxy-bypass-scope-guard #3226: bypassProxyPatch foi extraído de validation.ts para validation/headers.ts (split #4921–#4930) → o teste lê a camada de validação. - sse-error-passthrough #3324: a windsurf authHint foi extraída de providers.ts para providers/oauth.ts → o teste lê o novo local. --------- Co-authored-by: Diego Rodrigues de Sa e Souza --- config/quality/file-size-baseline.json | 3 +- open-sse/services/combo.ts | 10 + open-sse/services/combo/quotaShareInflight.ts | 133 ++++++++ open-sse/services/combo/quotaShareStrategy.ts | 261 +++++++++++++++ scripts/check/check-known-symbols.ts | 8 +- src/lib/quota/quotaCombos.ts | 15 +- src/shared/constants/routingStrategies.ts | 19 +- .../proxy-bypass-scope-guard-3226.test.ts | 15 +- tests/unit/quota-combo-balancing.test.ts | 41 ++- tests/unit/quota-multiprovider.test.ts | 42 +-- tests/unit/quota-share-strategy.test.ts | 312 ++++++++++++++++++ tests/unit/sse-error-passthrough-3324.test.ts | 4 +- 12 files changed, 812 insertions(+), 51 deletions(-) create mode 100644 open-sse/services/combo/quotaShareInflight.ts create mode 100644 open-sse/services/combo/quotaShareStrategy.ts create mode 100644 tests/unit/quota-share-strategy.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 69e860e474..374cef8c1a 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -141,7 +141,8 @@ "open-sse/services/claudeCodeCompatible.ts": 1202, "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", "_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \"headroom\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, 3190 (+10 = one new `else if (strategy === \"quota-share\")` dispatch branch in handleComboChat that delegates 100% to selectQuotaShareTarget + its log line, plus the import). All the new logic lives OUT of the god-file in two new leaves under open-sse/services/combo/: quotaShareInflight.ts (in-flight counter with TTL/lease, ~150 LOC ); orderedTargets = _sticky.targets; orderedTargets = orderTargetsByEvalScores(orderedTargets, config.evalRouting, log); diff --git a/open-sse/services/combo/quotaShareInflight.ts b/open-sse/services/combo/quotaShareInflight.ts new file mode 100644 index 0000000000..eaeee06a9d --- /dev/null +++ b/open-sse/services/combo/quotaShareInflight.ts @@ -0,0 +1,133 @@ +/** + * quotaShareInflight.ts — In-flight request counter for the quota-share strategy. + * + * Tracks how many requests are currently in-flight per connectionId so the + * quota-share P2C tie-break can prefer the least-loaded connection in real time. + * + * Decrement-on-abort safety (TTL/lease): + * The generic combo dispatch path is intentionally NOT instrumented (so this + * feature cannot regress existing strategies). Instead, each in-flight slot + * carries an expiry: incrementInflight() stamps `nowMs + leaseMs`. The normal + * path calls decrementInflight() (returned to the caller as a callback) once + * the request settles, which clears the slot immediately. If a request is + * aborted or crashes before that callback runs, the slot still auto-expires + * after DEFAULT_LEASE_MS — so the counter can never leak forever, even without + * touching the generic dispatch. + * + * Fail-open: getInflight() returns 0 for an unknown / empty connectionId. + * All time input is injectable (the `nowMs` param) so unit tests drive the + * clock deterministically — the tested path never calls Date.now() implicitly. + * + * Part of: Quota Sharing Engine — Phase 3 (#9 dedicated quota-share strategy). + */ + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Default lease duration (ms). A slot that is never explicitly decremented + * (aborted / crashed request) auto-expires after this, bounding the counter. + */ +export const DEFAULT_LEASE_MS = 120_000; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface InflightSlot { + count: number; + expiresAtMs: number; +} + +// --------------------------------------------------------------------------- +// In-process store. Key: connectionId. +// --------------------------------------------------------------------------- + +const _inflightMap = new Map(); + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Increment the in-flight counter for a connection and return the new count. + * Sets / refreshes the slot's expiry lease. + * + * @param connectionId Opaque connection identifier (empty → no-op, returns 0). + * @param leaseMs Lease before the slot auto-expires if never decremented. + * @param nowMs Current epoch ms; defaults to Date.now() (off-path only). + */ +export function incrementInflight( + connectionId: string, + leaseMs: number = DEFAULT_LEASE_MS, + nowMs: number = Date.now() +): number { + if (!connectionId) return 0; + pruneExpired(nowMs); + const slot = _inflightMap.get(connectionId); + const base = slot && slot.expiresAtMs > nowMs ? slot.count : 0; + const newCount = base + 1; + _inflightMap.set(connectionId, { count: newCount, expiresAtMs: nowMs + leaseMs }); + return newCount; +} + +/** + * Decrement the in-flight counter for a connection, flooring at 0. The entry is + * removed once the count reaches 0 (or if the slot already expired). + * + * @param connectionId Opaque connection identifier (empty → no-op). + * @param nowMs Current epoch ms; defaults to Date.now() (off-path only). + */ +export function decrementInflight(connectionId: string, nowMs: number = Date.now()): void { + if (!connectionId) return; + const slot = _inflightMap.get(connectionId); + if (!slot || slot.expiresAtMs <= nowMs) { + _inflightMap.delete(connectionId); + return; + } + const newCount = Math.max(0, slot.count - 1); + if (newCount === 0) { + _inflightMap.delete(connectionId); + } else { + _inflightMap.set(connectionId, { count: newCount, expiresAtMs: slot.expiresAtMs }); + } +} + +/** + * Current in-flight count for a connection (0 if unknown / empty / expired). + * + * @param connectionId Opaque connection identifier. + * @param nowMs Current epoch ms; defaults to Date.now() (off-path only). + */ +export function getInflight(connectionId: string, nowMs: number = Date.now()): number { + if (!connectionId) return 0; + const slot = _inflightMap.get(connectionId); + if (!slot || slot.expiresAtMs <= nowMs) return 0; + return slot.count; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Drop all expired slots so the map cannot grow unbounded with stale leases. */ +function pruneExpired(nowMs: number): void { + for (const [key, slot] of _inflightMap) { + if (slot.expiresAtMs <= nowMs) _inflightMap.delete(key); + } +} + +// --------------------------------------------------------------------------- +// Test helpers (never call in production code) +// --------------------------------------------------------------------------- + +/** Clear all in-flight state. Tests only — keeps state isolation between cases. */ +export function _clearInflightForTest(): void { + _inflightMap.clear(); +} + +/** Return the current slot count. Tests only — black-box size assertion. */ +export function _inflightSizeForTest(): number { + return _inflightMap.size; +} diff --git a/open-sse/services/combo/quotaShareStrategy.ts b/open-sse/services/combo/quotaShareStrategy.ts new file mode 100644 index 0000000000..4f0bdf210b --- /dev/null +++ b/open-sse/services/combo/quotaShareStrategy.ts @@ -0,0 +1,261 @@ +/** + * quotaShareStrategy.ts — Dedicated selection strategy for quota-share combos. + * + * Activated ONLY for the internal "quota-share" strategy (the auto-minted qtSd/ + * combos). It is never imported by generic combo routing paths, so tuning this + * module can never regress the existing strategies (fill-first, p2c, headroom…). + * + * Three mechanisms are applied in sequence: + * + * 1. Per-model bucket gating (accountBuckets.isBucketSaturated): + * A connection whose 5h, 7d, or per-model 7d: window is saturated + * for the requested model is moved to a DEPRIORITIZED tail rather than + * dropped. If EVERY connection is saturated, all are eligible again + * (fail-open: a quota-share combo is never hard-blocked here). + * + * 2. DRR (Deficit Round Robin): + * Among the eligible connections, each round adds a quantum proportional to + * the target weight to that target's deficit, then selects the target with + * the largest accumulated deficit and zeroes it. Over many requests this + * distributes load proportionally to weight, deterministically. + * + * 3. P2C (Power of Two Choices) over real in-flight load: + * Between the top two DRR candidates, the one with fewer active in-flight + * requests (quotaShareInflight) wins; ties keep the DRR order. The winner's + * in-flight counter is incremented immediately and a decrement callback is + * returned for the caller's finally/abort handler. + * + * All state is in-process; no DB or network calls. The clock is injectable + * (the `nowMs` param) so unit tests are fully deterministic. + * + * Part of: Quota Sharing Engine — Phase 3 (#9 dedicated quota-share strategy). + */ + +import { isBucketSaturated } from "../../../src/lib/quota/accountBuckets.ts"; +import { incrementInflight, decrementInflight, getInflight } from "./quotaShareInflight.ts"; +import type { ResolvedComboTarget } from "./types.ts"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Maximum number of per-combo DRR states retained before oldest-entry eviction. */ +const MAX_DRR_COMBOS = 200; + +// --------------------------------------------------------------------------- +// DRR state +// --------------------------------------------------------------------------- + +/** + * Outer key: comboName. Inner key: target.executionKey. Value: accumulated + * deficit (higher = this target is "owed" more service). Single instance — + * never duplicate this Map (state cohesion). + */ +const _drrState = new Map>(); + +function getDrrDeficits(comboName: string): Map { + let deficits = _drrState.get(comboName); + if (!deficits) { + if (_drrState.size >= MAX_DRR_COMBOS) { + // Evict the oldest entry (Map iterates in insertion order). + const oldestKey = _drrState.keys().next().value; + if (oldestKey !== undefined) _drrState.delete(oldestKey); + } + deficits = new Map(); + _drrState.set(comboName, deficits); + } + return deficits; +} + +// --------------------------------------------------------------------------- +// Mechanism 1 — per-model bucket gating +// --------------------------------------------------------------------------- + +/** Extract the bare model name from "/" (or pass through). */ +function bareModelName(modelStr: string): string { + const slash = modelStr.indexOf("/"); + return slash >= 0 ? modelStr.slice(slash + 1) : modelStr; +} + +/** + * Return the eligible (non-saturated) targets. Fail-open: when every target is + * saturated, the original list is returned so the combo is never hard-blocked. + */ +function filterEligibleBySaturation( + targets: ResolvedComboTarget[], + modelStr: string, + nowMs: number +): ResolvedComboTarget[] { + const modelName = bareModelName(modelStr); + + const eligible = targets.filter((target) => { + const connId = target.connectionId ?? ""; + if (connId === "") return true; // no connection → cannot be saturation-scored + const saturated = + isBucketSaturated(connId, "5h", nowMs) || + isBucketSaturated(connId, "7d", nowMs) || + (modelName !== "" && isBucketSaturated(connId, `7d:${modelName}`, nowMs)); + return !saturated; + }); + + return eligible.length > 0 ? eligible : targets; +} + +// --------------------------------------------------------------------------- +// Mechanism 2 — DRR ordering +// --------------------------------------------------------------------------- + +/** + * Reorder `targets` by deficit round-robin (quantum proportional to weight). + * Returns a NEW array with the highest-deficit target first; mutates the shared + * DRR state for `comboName`. + * + * Classic DRR: every round each target gains a quantum equal to its normalized + * weight share; the target with the largest deficit is selected and pays a fixed + * cost of 1. Subtracting a constant (rather than zeroing) keeps the accumulated + * fractional credit, so long-run selection frequency converges EXACTLY to the + * weight ratio and the choice stays deterministic (no fragile float ties). + */ +function applyDrr(targets: ResolvedComboTarget[], comboName: string): ResolvedComboTarget[] { + if (targets.length <= 1) return targets.slice(); + + const deficits = getDrrDeficits(comboName); + const totalWeight = targets.reduce((sum, t) => sum + normalizeWeight(t.weight), 0); + + // Add each target's quantum (weight share) to its deficit. + for (const target of targets) { + const quantum = normalizeWeight(target.weight) / totalWeight; + deficits.set(target.executionKey, (deficits.get(target.executionKey) ?? 0) + quantum); + } + + // Select the target with the largest deficit (ties keep input order). + let winner = targets[0]; + let bestDeficit = deficits.get(winner.executionKey) ?? 0; + for (let i = 1; i < targets.length; i++) { + const d = deficits.get(targets[i].executionKey) ?? 0; + if (d > bestDeficit) { + bestDeficit = d; + winner = targets[i]; + } + } + + // Winner pays a unit cost; the leftover credit carries into the next round. + deficits.set(winner.executionKey, bestDeficit - 1); + + const rest = targets.filter((t) => t.executionKey !== winner.executionKey); + return [winner, ...rest]; +} + +/** Weights default to 1 and are floored at 1 to keep quantum math well-defined. */ +function normalizeWeight(weight: number | undefined): number { + return Number.isFinite(weight) && (weight as number) > 0 ? (weight as number) : 1; +} + +// --------------------------------------------------------------------------- +// Mechanism 3 — P2C over in-flight load +// --------------------------------------------------------------------------- + +/** + * Pick the less-loaded of the first two candidates. Returns 0 to keep the DRR + * winner, or 1 to prefer the runner-up. Ties favor the DRR winner (index 0). + */ +function pickByInflightP2C( + first: ResolvedComboTarget, + second: ResolvedComboTarget, + nowMs: number +): 0 | 1 { + const loadFirst = getInflight(first.connectionId ?? "", nowMs); + const loadSecond = getInflight(second.connectionId ?? "", nowMs); + return loadSecond < loadFirst ? 1 : 0; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export interface QuotaShareResult { + /** The selected target, or null when `targets` was empty. */ + target: ResolvedComboTarget | null; + /** + * Full ordered dispatch list: winner first, then the remaining eligible + * targets, then any deprioritized (saturated) targets as last-resort fallbacks. + */ + orderedTargets: ResolvedComboTarget[]; + /** + * Release the winner's in-flight slot. Call this in a finally/abort handler. + * Idempotent — safe to call more than once. If never called, the slot still + * auto-expires (see quotaShareInflight DEFAULT_LEASE_MS). + */ + decrementInflight: () => void; +} + +/** + * Select the best target using the dedicated quota-share strategy. + * + * @param targets Resolved combo targets (the combo's eligible step entries). + * @param comboName Combo name; used as the DRR state key. + * @param modelStr Requested model string, e.g. "anthropic/claude-opus-4". + * @param nowMs Current epoch ms for bucket/in-flight checks; defaults to Date.now(). + */ +export function selectQuotaShareTarget( + targets: ResolvedComboTarget[], + comboName: string, + modelStr: string, + nowMs: number = Date.now() +): QuotaShareResult { + const noOp = (): void => {}; + + if (targets.length === 0) { + return { target: null, orderedTargets: [], decrementInflight: noOp }; + } + + // 1) Per-model bucket gating. + const eligible = filterEligibleBySaturation(targets, modelStr, nowMs); + const deprioritized = targets.filter((t) => !eligible.includes(t)); + + // 2) DRR ordering over the eligible set. + const ordered = applyDrr(eligible, comboName); + + // 3) P2C over in-flight between the top two. + let winner: ResolvedComboTarget; + let rest: ResolvedComboTarget[]; + if (ordered.length >= 2 && pickByInflightP2C(ordered[0], ordered[1], nowMs) === 1) { + winner = ordered[1]; + rest = [ordered[0], ...ordered.slice(2)]; + } else { + winner = ordered[0]; + rest = ordered.slice(1); + } + + // Reserve the winner's in-flight slot immediately. + const winnerConnectionId = winner.connectionId ?? ""; + if (winnerConnectionId) incrementInflight(winnerConnectionId, undefined, nowMs); + + const orderedTargets = [winner, ...rest, ...deprioritized]; + + // Idempotent decrement callback for the caller's finally/abort path. Uses the + // selection-time clock so the slot (stamped nowMs + lease) is never treated as + // expired here — it decrements the live count deterministically. + let released = false; + const release = (): void => { + if (released) return; + released = true; + if (winnerConnectionId) decrementInflight(winnerConnectionId, nowMs); + }; + + return { target: winner, orderedTargets, decrementInflight: release }; +} + +// --------------------------------------------------------------------------- +// Test helpers (never call in production code) +// --------------------------------------------------------------------------- + +/** Clear all DRR state. Tests only — keeps state isolation between cases. */ +export function _clearDrrStateForTest(): void { + _drrState.clear(); +} + +/** Return the DRR deficit for a (comboName, executionKey). Tests only. */ +export function _getDrrDeficitForTest(comboName: string, executionKey: string): number { + return _drrState.get(comboName)?.get(executionKey) ?? 0; +} diff --git a/scripts/check/check-known-symbols.ts b/scripts/check/check-known-symbols.ts index 03404a8e0c..b8f49e02c5 100644 --- a/scripts/check/check-known-symbols.ts +++ b/scripts/check/check-known-symbols.ts @@ -475,8 +475,14 @@ async function main(): Promise { } // ── (2) Combo strategies ────────────────────────────────────────────────── + // Canonical = user-facing ROUTING_STRATEGY_VALUES ∪ INTERNAL_ROUTING_STRATEGY_VALUES + // (system-only strategies like "quota-share" are registered but hidden from the UI; + // they still must have a real dispatch branch in combo.ts — enforced below). const strategiesMod = await import("@/shared/constants/routingStrategies.ts"); - const canonical = strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[]; + const canonical = [ + ...(strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[]), + ...(strategiesMod.INTERNAL_ROUTING_STRATEGY_VALUES as readonly string[]), + ]; const comboSource = readFileSync(resolvePath(REPO_ROOT, "open-sse/services/combo.ts"), "utf8"); const handled = extractHandledStrategies(comboSource); diff --git a/src/lib/quota/quotaCombos.ts b/src/lib/quota/quotaCombos.ts index b11df536af..4d4c17863b 100644 --- a/src/lib/quota/quotaCombos.ts +++ b/src/lib/quota/quotaCombos.ts @@ -28,6 +28,14 @@ import { quotaGroupSlug, } from "./quotaModelNaming"; import { createLogger } from "@/shared/utils/logger"; +import type { AnyRoutingStrategyValue } from "@/shared/constants/routingStrategies"; + +/** + * Routing strategy for every auto-minted quota-share (qtSd/) combo. Internal + * only — resolves to the dedicated DRR + P2C in-flight + per-model gating + * selection in combo.ts (Phase 3 #9). Was "fill-first" before the hardening. + */ +export const QUOTA_SHARE_STRATEGY: AnyRoutingStrategyValue = "quota-share"; const log = createLogger("quota/quotaCombos"); @@ -170,8 +178,9 @@ export async function syncQuotaCombos(poolId: string): Promise { const poolProvider: string | undefined = upsertWork[0]?.provider; // Group steps by model across all connections (Task 3 guarantees a single provider). - // This produces one combo per model with ALL connections' steps + strategy "fill-first", - // fixing the collision where two same-provider connections would overwrite each other. + // This produces one combo per model with ALL connections' steps + the dedicated + // "quota-share" strategy (Phase 3 #9), fixing the collision where two same-provider + // connections would overwrite each other. const byModel = new Map>(); for (const { connId, provider, modelIds } of upsertWork) { for (const modelId of modelIds) { @@ -196,7 +205,7 @@ export async function syncQuotaCombos(poolId: string): Promise { const payload = { name: comboName, models: steps, - strategy: "fill-first" as const, + strategy: QUOTA_SHARE_STRATEGY, isHidden: true, }; if (existing && typeof existing.id === "string") await updateCombo(existing.id, payload); diff --git a/src/shared/constants/routingStrategies.ts b/src/shared/constants/routingStrategies.ts index 7b45733613..736e4a7c07 100644 --- a/src/shared/constants/routingStrategies.ts +++ b/src/shared/constants/routingStrategies.ts @@ -20,6 +20,19 @@ export const ROUTING_STRATEGY_VALUES = [ export type RoutingStrategyValue = (typeof ROUTING_STRATEGY_VALUES)[number]; +/** + * Internal-only routing strategy values. These are used by system-generated + * combos (e.g. the auto-minted quota-share `qtSd/` combos) and are NEVER exposed + * in the UI or user-facing API — deliberately kept OUT of ROUTING_STRATEGY_VALUES + * and ROUTING_STRATEGIES so they never appear as a selectable option. + */ +export const INTERNAL_ROUTING_STRATEGY_VALUES = ["quota-share"] as const; + +export type InternalRoutingStrategyValue = (typeof INTERNAL_ROUTING_STRATEGY_VALUES)[number]; + +/** Any routing strategy value, including internal ones. Used for combo dispatch. */ +export type AnyRoutingStrategyValue = RoutingStrategyValue | InternalRoutingStrategyValue; + export const AUTO_ROUTING_STRATEGY_VALUES = [ "rules", "cost", @@ -47,12 +60,16 @@ export const ACCOUNT_FALLBACK_STRATEGY_VALUES = [ export type AccountFallbackStrategyValue = (typeof ACCOUNT_FALLBACK_STRATEGY_VALUES)[number]; -export function normalizeRoutingStrategy(value: unknown): RoutingStrategyValue { +export function normalizeRoutingStrategy(value: unknown): AnyRoutingStrategyValue { if (typeof value !== "string") return "priority"; const normalized = value.trim().toLowerCase(); if (normalized === "usage") return "least-used"; if (normalized === "context") return "context-optimized"; if (normalized === "weekly-reset" || normalized === "reset-window-order") return "reset-window"; + // Internal strategies (e.g. quota-share) are preserved verbatim, never stripped + // to "priority", so system-minted combos resolve to their dedicated dispatch. + if ((INTERNAL_ROUTING_STRATEGY_VALUES as readonly string[]).includes(normalized)) + return normalized as InternalRoutingStrategyValue; return (ROUTING_STRATEGY_VALUES as readonly string[]).includes(normalized) ? (normalized as RoutingStrategyValue) : "priority"; diff --git a/tests/unit/proxy-bypass-scope-guard-3226.test.ts b/tests/unit/proxy-bypass-scope-guard-3226.test.ts index 00243e3b04..5b40bde4b0 100644 --- a/tests/unit/proxy-bypass-scope-guard-3226.test.ts +++ b/tests/unit/proxy-bypass-scope-guard-3226.test.ts @@ -18,14 +18,19 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; test("bypassProxyPatch is present in the NVIDIA validation path (#3226 documented exception)", () => { - const validation = readFileSync("src/lib/providers/validation.ts", "utf8"); + // After the validation.ts godfile split (#4921–#4930), bypassProxyPatch moved to the + // extracted validation/headers.ts. Assert the bypass lives in the validation LAYER + // (validation.ts + its extracted modules), not that it stayed in one file. + const validationLayer = + readFileSync("src/lib/providers/validation.ts", "utf8") + + readFileSync("src/lib/providers/validation/headers.ts", "utf8"); assert.ok( - validation.includes("bypassProxyPatch"), - "expected bypassProxyPatch to be present in validation.ts (the documented NVIDIA exception)" + validationLayer.includes("bypassProxyPatch"), + "expected bypassProxyPatch in the validation layer (validation/headers.ts) — the documented NVIDIA exception" ); assert.ok( - validation.includes("directHttpsRequest"), - "expected directHttpsRequest helper to be present in validation.ts" + validationLayer.includes("directHttpsRequest"), + "expected directHttpsRequest helper in the validation layer" ); }); diff --git a/tests/unit/quota-combo-balancing.test.ts b/tests/unit/quota-combo-balancing.test.ts index 7af1eb9a26..f5cd331499 100644 --- a/tests/unit/quota-combo-balancing.test.ts +++ b/tests/unit/quota-combo-balancing.test.ts @@ -3,7 +3,7 @@ * * Task 4 TDD — Fix same-provider combo collision: * a pool with N connections to the same provider must produce ONE combo - * per model with ALL connection steps + strategy "fill-first". + * per model with ALL connection steps + strategy "quota-share". * * Uses "openrouter" (1 model: "auto") as test provider. */ @@ -19,9 +19,7 @@ import path from "node:path"; // load. Never delete the SQLite file between tests — under --test-concurrency=4 // modules are cached across files and SQLITE_FILE is frozen at first import. // Wipe test data via SQL DELETEs instead to avoid cross-file path corruption. -const TEST_DATA_DIR = fs.mkdtempSync( - path.join(os.tmpdir(), "omniroute-quota-combo-balancing-") -); +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-quota-combo-balancing-")); process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); @@ -29,9 +27,8 @@ const poolsDb = await import("../../src/lib/db/quotaPools.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); const { syncQuotaCombos } = await import("../../src/lib/quota/quotaCombos.ts"); -const { isQuotaModelName, quotaModelName } = await import( - "../../src/lib/quota/quotaModelNaming.ts" -); +const { isQuotaModelName, quotaModelName } = + await import("../../src/lib/quota/quotaModelNaming.ts"); const { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts"); // Trigger migration once at module load so the schema is ready for the first @@ -98,7 +95,9 @@ test.after(() => { // Helper // --------------------------------------------------------------------------- -async function listQuotaCombos(): Promise> { +async function listQuotaCombos(): Promise< + Array<{ name: string; models: unknown[]; strategy: unknown }> +> { const all = await combosDb.getCombos(); return all .filter((c) => typeof c.name === "string" && isQuotaModelName(c.name as string)) @@ -116,7 +115,7 @@ const FIRST_MODEL = "auto"; // single model in openrouter registry // B1 — 2-connection same-provider pool: one combo per model with 2 steps // --------------------------------------------------------------------------- -test("B1: syncQuotaCombos — 2-connection same-provider pool produces ONE combo per model with 2 steps + fill-first", async () => { +test("B1: syncQuotaCombos — 2-connection same-provider pool produces ONE combo per model with 2 steps + quota-share", async () => { const modelsForProvider = (PROVIDER_MODELS[PROVIDER] ?? []).map((m) => m.id); assert.ok(modelsForProvider.length > 0, `${PROVIDER} must have at least one model in registry`); @@ -152,7 +151,7 @@ test("B1: syncQuotaCombos — 2-connection same-provider pool produces ONE combo `expected exactly ${modelsForProvider.length} combo(s), one per model` ); - // For each model, assert: one combo, 2 steps, fill-first, both connIds present. + // For each model, assert: one combo, 2 steps, quota-share, both connIds present. for (const modelId of modelsForProvider) { // B4: combos are named with the GROUP name ("GroupDemo"), not pool name. const comboName = quotaModelName("GroupDemo", PROVIDER, modelId); @@ -167,11 +166,11 @@ test("B1: syncQuotaCombos — 2-connection same-provider pool produces ONE combo const combo = matchingCombos[0]; - // Strategy must be fill-first. + // Strategy must be quota-share. assert.equal( combo.strategy, - "fill-first", - `combo "${comboName}" strategy should be "fill-first", got "${combo.strategy}"` + "quota-share", + `combo "${comboName}" strategy should be "quota-share", got "${combo.strategy}"` ); // Must have exactly 2 steps (one per connection). @@ -182,9 +181,7 @@ test("B1: syncQuotaCombos — 2-connection same-provider pool produces ONE combo ); // Both connection IDs must appear in the steps. - const stepConnIds = (combo.models as Array>).map( - (s) => s.connectionId - ); + const stepConnIds = (combo.models as Array>).map((s) => s.connectionId); assert.ok( stepConnIds.includes(idA), `combo "${comboName}" steps should include connA (${idA}), got: ${JSON.stringify(stepConnIds)}` @@ -286,7 +283,11 @@ test("B3: syncQuotaCombos — idempotent on 2-connection pool (no duplicates aft 2, `after 2nd sync, combo "${combo.name}" should still have 2 steps, got ${combo.models.length}` ); - assert.equal(combo.strategy, "fill-first", `combo "${combo.name}" strategy must remain "fill-first"`); + assert.equal( + combo.strategy, + "quota-share", + `combo "${combo.name}" strategy must remain "quota-share"` + ); } }); @@ -388,7 +389,11 @@ test("B5: after syncQuotaCombos on 2-connection pool, getComboByName returns the 2, `combo "${comboName}" models.length should be 2, got ${(found.models as unknown[]).length}` ); - assert.equal(found.strategy, "fill-first", `combo "${comboName}" strategy should be "fill-first"`); + assert.equal( + found.strategy, + "quota-share", + `combo "${comboName}" strategy should be "quota-share"` + ); // Verify no second combo by scanning all combos for the same name. const all = await combosDb.getCombos(); diff --git a/tests/unit/quota-multiprovider.test.ts b/tests/unit/quota-multiprovider.test.ts index 161cf54ab7..d15b0e6a5b 100644 --- a/tests/unit/quota-multiprovider.test.ts +++ b/tests/unit/quota-multiprovider.test.ts @@ -15,8 +15,8 @@ * (one step pinned to connA). That assertion encoded the OLD COLLISION BUG — a * second same-provider connection would overwrite the combo, leaving only the last * connId's step. Task 4 fixes this by grouping all connections into one N-step - * fill-first combo per model. D2.5 and D2.6 now assert the CORRECT behavior: - * models.length === 2 (both connections) and strategy === "fill-first". This is + * quota-share combo per model. D2.5 and D2.6 now assert the CORRECT behavior: + * models.length === 2 (both connections) and strategy === "quota-share". This is * alignment to the corrected implementation, NOT masking — the prior assertions * encoded the bug, not the desired behavior. * @@ -30,7 +30,7 @@ * D2.4 — enforce: pool with connectionIds [connA, connB]; enforce with connA * (the primary) still finds the pool — no regression on primary. * D2.5 — combos: syncQuotaCombos for a 2-connection same-provider pool creates - * one combo per model with 2 steps (both connIds) + strategy fill-first. + * one combo per model with 2 steps (both connIds) + strategy quota-share. * D2.6 — combos: prune — after removing connB from the pool (→ only connA), * re-sync collapses each combo to 1 step (connA only). */ @@ -55,9 +55,8 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); const { resolveQuotaKeyScope } = await import("../../src/lib/quota/quotaKey.ts"); const { syncQuotaCombos } = await import("../../src/lib/quota/quotaCombos.ts"); -const { isQuotaModelName, parseQuotaModelName, quotaModelName } = await import( - "../../src/lib/quota/quotaModelNaming.ts" -); +const { isQuotaModelName, parseQuotaModelName, quotaModelName } = + await import("../../src/lib/quota/quotaModelNaming.ts"); const { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts"); // Trigger migration once at module load so the schema is ready for the first @@ -124,7 +123,9 @@ test.after(() => { // Helpers // --------------------------------------------------------------------------- -async function listQuotaCombos(): Promise> { +async function listQuotaCombos(): Promise< + Array<{ name: string; models: unknown[]; strategy: unknown }> +> { const all = await combosDb.getCombos(); return all .filter((c) => typeof c.name === "string" && isQuotaModelName(c.name)) @@ -263,9 +264,7 @@ test("D2.3: enforceQuotaShare — input connectionId matching a non-primary memb // Assign an API key to the pool. const API_KEY_ID = "test-key-d23"; - poolsDb.upsertAllocations(pool.id, [ - { apiKeyId: API_KEY_ID, weight: 50, policy: "hard" }, - ]); + poolsDb.upsertAllocations(pool.id, [{ apiKeyId: API_KEY_ID, weight: 50, policy: "hard" }]); // Confirm allocation exists. const allocations = listAllocationsForApiKey(API_KEY_ID); @@ -320,9 +319,7 @@ test("D2.4: enforceQuotaShare — input connectionId matching the PRIMARY member }); const API_KEY_ID = "test-key-d24"; - poolsDb.upsertAllocations(pool.id, [ - { apiKeyId: API_KEY_ID, weight: 50, policy: "hard" }, - ]); + poolsDb.upsertAllocations(pool.id, [{ apiKeyId: API_KEY_ID, weight: 50, policy: "hard" }]); // Enforce with connA (the primary). const resultA = await enforceQuotaShare({ @@ -342,9 +339,9 @@ test("D2.4: enforceQuotaShare — input connectionId matching the PRIMARY member // D2.5 — combos: syncQuotaCombos for 2-connection same-provider pool // --------------------------------------------------------------------------- -test("D2.5: syncQuotaCombos — 2-connection same-provider pool creates one combo per model with 2 steps + fill-first (Task 4)", async () => { +test("D2.5: syncQuotaCombos — 2-connection same-provider pool creates one combo per model with 2 steps + quota-share (Task 4)", async () => { // Task 4: N same-provider connections must produce ONE combo per model with - // ALL connections' steps + strategy "fill-first". The old behavior (single step + // ALL connections' steps + strategy "quota-share". The old behavior (single step // pinned to connA, strategy "priority") was the collision bug — last upsert won. const connA = await providersDb.createProviderConnection({ provider: PROVIDER_A, @@ -384,7 +381,7 @@ test("D2.5: syncQuotaCombos — 2-connection same-provider pool creates one comb const quotaCombos = await listQuotaCombos(); const comboMap = new Map(quotaCombos.map((c) => [c.name, c])); - // ── Verify PROVIDER_A combos exist with N-step fill-first ───────────────── + // ── Verify PROVIDER_A combos exist with N-step quota-share ───────────────── for (const modelId of modelsA) { // Combos are named with the GROUP name ("GroupDemo", from group-demo), not pool name. const expectedName = quotaModelName("GroupDemo", PROVIDER_A, modelId); @@ -398,11 +395,11 @@ test("D2.5: syncQuotaCombos — 2-connection same-provider pool creates one comb `combo ${expectedName} should have 2 steps (both connections), got ${combo.models.length}` ); - // Task 4: strategy must be fill-first. + // Task 4: strategy must be quota-share. assert.equal( combo.strategy, - "fill-first", - `combo ${expectedName} strategy should be "fill-first", got "${combo.strategy}"` + "quota-share", + `combo ${expectedName} strategy should be "quota-share", got "${combo.strategy}"` ); // Both connIds must appear across steps. @@ -429,7 +426,7 @@ test("D2.5: syncQuotaCombos — 2-connection same-provider pool creates one comb // --------------------------------------------------------------------------- test("D2.6: syncQuotaCombos — after removing connB from same-provider pool, re-sync collapses each combo to 1 step (connA only)", async () => { - // Task 4: initially a 2-connection pool produces 2-step fill-first combos. + // Task 4: initially a 2-connection pool produces 2-step quota-share combos. // After removing connB (pool → only connA), re-sync rebuilds each combo with // a single step pinned to connA. The combo names are unchanged (same provider/ // model), so no prune happens — the combos are updated in-place. @@ -498,7 +495,10 @@ test("D2.6: syncQuotaCombos — after removing connB from same-provider pool, re const afterProviders = new Set( after.map((c) => parseQuotaModelName(c.name)?.provider).filter(Boolean) ); - assert.ok(afterProviders.has(PROVIDER_A), `${PROVIDER_A} combos should survive after connB removal`); + assert.ok( + afterProviders.has(PROVIDER_A), + `${PROVIDER_A} combos should survive after connB removal` + ); for (const modelId of modelsA) { const expectedName = quotaModelName("GroupDemo", PROVIDER_A, modelId); const found = after.find((c) => c.name === expectedName); diff --git a/tests/unit/quota-share-strategy.test.ts b/tests/unit/quota-share-strategy.test.ts new file mode 100644 index 0000000000..da17ac587f --- /dev/null +++ b/tests/unit/quota-share-strategy.test.ts @@ -0,0 +1,312 @@ +/** + * Unit tests for the dedicated quota-share strategy (Phase 3 #9). + * + * Covers the three mechanisms of the isolated strategy module plus its + * activation wiring: + * 1. per-model bucket gating (accountBuckets.isBucketSaturated) + * 2. DRR (deficit round-robin, quantum proportional to weight) + * 3. P2C over real in-flight counters (quotaShareInflight) + * 4. fail-open behavior (no data / all saturated / empty targets) + * 5. qtSd/ combos are minted with strategy "quota-share" + * + * Runner: node:test + assert/strict (NO vitest, NO jest). Clock is injected via + * the nowMs param on every call — the tested path never reads Date.now(). + */ + +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import { + selectQuotaShareTarget, + _clearDrrStateForTest, + _getDrrDeficitForTest, +} from "../../open-sse/services/combo/quotaShareStrategy.ts"; +import { + incrementInflight, + decrementInflight, + getInflight, + _clearInflightForTest, + DEFAULT_LEASE_MS, +} from "../../open-sse/services/combo/quotaShareInflight.ts"; +import { + recordUsage, + _clearBucketsForTest, +} from "../../src/lib/quota/accountBuckets.ts"; +import { QUOTA_SHARE_STRATEGY } from "../../src/lib/quota/quotaCombos.ts"; +import { INTERNAL_ROUTING_STRATEGY_VALUES } from "../../src/shared/constants/routingStrategies.ts"; +import type { ResolvedComboTarget } from "../../open-sse/services/combo/types.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const NOW = Date.UTC(2026, 5, 24, 12, 0, 0); // 2026-06-24T12:00:00.000Z +const RESET_AT = new Date(NOW + 3_600_000).toISOString(); // +1h (window still open) + +function makeTarget( + executionKey: string, + connectionId: string, + weight = 100, + modelStr = "anthropic/claude-sonnet-4-5" +): ResolvedComboTarget { + return { + kind: "model", + stepId: `step-${executionKey}`, + executionKey, + modelStr, + provider: modelStr.split("/")[0], + providerId: null, + connectionId, + weight, + label: null, + }; +} + +// --------------------------------------------------------------------------- +// Setup — reset all module state before each case for isolation. +// --------------------------------------------------------------------------- + +beforeEach(() => { + _clearBucketsForTest(); + _clearDrrStateForTest(); + _clearInflightForTest(); +}); + +// ─── quotaShareInflight ───────────────────────────────────────────────────── + +describe("quotaShareInflight", () => { + test("increment → getInflight returns 1", () => { + incrementInflight("conn-a", DEFAULT_LEASE_MS, NOW); + assert.equal(getInflight("conn-a", NOW), 1); + }); + + test("increment twice → getInflight returns 2", () => { + incrementInflight("conn-a", DEFAULT_LEASE_MS, NOW); + incrementInflight("conn-a", DEFAULT_LEASE_MS, NOW); + assert.equal(getInflight("conn-a", NOW), 2); + }); + + test("decrement after increment → returns 0", () => { + incrementInflight("conn-a", DEFAULT_LEASE_MS, NOW); + decrementInflight("conn-a", NOW); + assert.equal(getInflight("conn-a", NOW), 0); + }); + + test("decrement below 0 is safe (floors at 0)", () => { + decrementInflight("conn-a", NOW); // no prior increment + assert.equal(getInflight("conn-a", NOW), 0); + }); + + test("TTL expiry: expired slot returns 0 without an explicit decrement", () => { + const leaseMs = 1_000; + incrementInflight("conn-b", leaseMs, NOW); + assert.equal(getInflight("conn-b", NOW), 1); + assert.equal(getInflight("conn-b", NOW + leaseMs + 1), 0); + }); + + test("empty connectionId returns 0 (fail-open)", () => { + assert.equal(getInflight("", NOW), 0); + incrementInflight("", DEFAULT_LEASE_MS, NOW); // must not throw / not store + assert.equal(getInflight("", NOW), 0); + }); +}); + +// ─── per-model bucket gating ──────────────────────────────────────────────── + +describe("gating: per-model bucket saturation", () => { + test("saturated 5h window → that connection is deprioritized, the clean one wins", () => { + recordUsage("conn-sat", "5h", 100, RESET_AT, NOW); + + const targets = [makeTarget("ek-sat", "conn-sat"), makeTarget("ek-ok", "conn-ok")]; + const result = selectQuotaShareTarget(targets, "combo-5h", "anthropic/claude-sonnet-4-5", NOW); + + assert.ok(result.target !== null, "must return a target"); + assert.equal(result.target.executionKey, "ek-ok", "non-saturated target must be selected"); + // Saturated target stays available as a fallback (deprioritized, not dropped). + assert.ok( + result.orderedTargets.some((t) => t.executionKey === "ek-sat"), + "saturated target must still appear in the fallback list" + ); + }); + + test("saturated 7d window → that connection is deprioritized", () => { + recordUsage("conn-7d", "7d", 100, RESET_AT, NOW); + const targets = [makeTarget("ek-7d", "conn-7d"), makeTarget("ek-clean", "conn-clean")]; + + const result = selectQuotaShareTarget(targets, "combo-7d", "anthropic/claude-sonnet-4-5", NOW); + assert.equal(result.target?.executionKey, "ek-clean"); + }); + + test("saturated per-model 7d window for the requested model → deprioritized", () => { + recordUsage("conn-pm", "7d:claude-sonnet-4-5", 100, RESET_AT, NOW); + const targets = [ + makeTarget("ek-pm", "conn-pm", 100, "anthropic/claude-sonnet-4-5"), + makeTarget("ek-other", "conn-other", 100, "anthropic/claude-sonnet-4-5"), + ]; + + const result = selectQuotaShareTarget(targets, "combo-pm", "anthropic/claude-sonnet-4-5", NOW); + assert.equal(result.target?.executionKey, "ek-other"); + }); + + test("per-model saturation for a DIFFERENT model does NOT deprioritize", () => { + // conn-x is saturated only on 7d:claude-opus-4, but the request is for sonnet. + recordUsage("conn-x", "7d:claude-opus-4", 100, RESET_AT, NOW); + const targets = [makeTarget("ek-x", "conn-x", 100, "anthropic/claude-sonnet-4-5")]; + + const result = selectQuotaShareTarget(targets, "combo-other-model", "anthropic/claude-sonnet-4-5", NOW); + assert.equal(result.target?.executionKey, "ek-x", "must not be gated by an unrelated model window"); + }); + + test("fail-open: all targets saturated → all eligible again, still returns a target", () => { + recordUsage("conn-1", "5h", 100, RESET_AT, NOW); + recordUsage("conn-2", "5h", 100, RESET_AT, NOW); + const targets = [makeTarget("ek-1", "conn-1"), makeTarget("ek-2", "conn-2")]; + + const result = selectQuotaShareTarget(targets, "combo-fo", "anthropic/claude-sonnet-4-5", NOW); + assert.ok(result.target !== null, "must not return null when everything is saturated"); + assert.equal(result.orderedTargets.length, 2, "both targets remain dispatchable"); + }); + + test("empty targets → returns null (fail-open, no crash)", () => { + const result = selectQuotaShareTarget([], "combo-empty", "anthropic/claude-sonnet-4-5", NOW); + assert.equal(result.target, null); + assert.deepEqual(result.orderedTargets, []); + }); + + test("no buckets recorded at all → fail-open, first/healthy target selected", () => { + const targets = [makeTarget("ek-a", "conn-a"), makeTarget("ek-b", "conn-b")]; + const result = selectQuotaShareTarget(targets, "combo-nodata", "anthropic/claude-sonnet-4-5", NOW); + assert.ok(result.target !== null); + assert.equal(result.orderedTargets.length, 2); + }); +}); + +// ─── DRR ───────────────────────────────────────────────────────────────────── + +describe("DRR: deficit round robin", () => { + test("equal weight: two connections alternate across consecutive calls", () => { + const t1 = makeTarget("ek-1", "conn-1", 100); + const t2 = makeTarget("ek-2", "conn-2", 100); + + const r1 = selectQuotaShareTarget([t1, t2], "combo-drr", "anthropic/claude-sonnet-4-5", NOW); + const r2 = selectQuotaShareTarget([t1, t2], "combo-drr", "anthropic/claude-sonnet-4-5", NOW); + + const selected = [r1.target?.executionKey, r2.target?.executionKey]; + assert.ok(selected.includes("ek-1"), "ek-1 must be selected at some point"); + assert.ok(selected.includes("ek-2"), "ek-2 must be selected at some point"); + }); + + test("higher weight connection receives proportionally more selections (2:1)", () => { + const t1 = makeTarget("ek-heavy", "conn-heavy", 200); // 2x weight + const t2 = makeTarget("ek-light", "conn-light", 100); // 1x weight + + const counts: Record = { "ek-heavy": 0, "ek-light": 0 }; + for (let i = 0; i < 30; i++) { + const r = selectQuotaShareTarget( + [t1, t2], + "combo-weighted", + "anthropic/claude-sonnet-4-5", + NOW + ); + if (r.target) counts[r.target.executionKey]++; + // Simulate the request settling (as a real caller's finally handler would), + // so the P2C in-flight tie-break sees equal load and the DRR weighting shows. + r.decrementInflight(); + } + assert.ok( + counts["ek-heavy"] > counts["ek-light"], + `heavy (${counts["ek-heavy"]}) should be selected more than light (${counts["ek-light"]})` + ); + // Roughly 2:1 — heavy should be at least ~1.5x light over 30 rounds. + assert.ok( + counts["ek-heavy"] >= counts["ek-light"] * 1.5, + `heavy/light ratio should be ~2:1, got ${counts["ek-heavy"]}:${counts["ek-light"]}` + ); + }); + + test("DRR state is isolated per comboName", () => { + const t = makeTarget("ek-shared", "conn-shared", 100); + selectQuotaShareTarget([t], "combo-A", "anthropic/claude-sonnet-4-5", NOW); + + const deficitA = _getDrrDeficitForTest("combo-A", "ek-shared"); + const deficitB = _getDrrDeficitForTest("combo-B", "ek-shared"); + assert.equal(deficitB, 0, "combo-B deficit must remain 0 (isolated)"); + assert.equal(deficitA, 0, "the selected target's deficit must be reset to 0"); + }); + + test("single eligible target is returned unchanged", () => { + const t = makeTarget("ek-solo", "conn-solo", 100); + const result = selectQuotaShareTarget([t], "combo-solo", "anthropic/claude-sonnet-4-5", NOW); + assert.equal(result.target?.executionKey, "ek-solo"); + assert.equal(result.orderedTargets.length, 1); + }); +}); + +// ─── P2C in-flight ──────────────────────────────────────────────────────────── + +describe("P2C in-flight", () => { + test("between two candidates, the one with fewer in-flight wins over the DRR pick", () => { + const t1 = makeTarget("ek-busy", "conn-busy", 100); + const t2 = makeTarget("ek-free", "conn-free", 100); + + // Pre-load conn-busy with active in-flight requests. + incrementInflight("conn-busy", DEFAULT_LEASE_MS, NOW); + incrementInflight("conn-busy", DEFAULT_LEASE_MS, NOW); + + const result = selectQuotaShareTarget([t1, t2], "combo-p2c", "anthropic/claude-sonnet-4-5", NOW); + assert.equal( + result.target?.executionKey, + "ek-free", + "the target with the lower in-flight load must win the P2C tie-break" + ); + }); + + test("decrementInflight() releases the in-flight load", () => { + const t = makeTarget("ek-dec", "conn-dec", 100); + const result = selectQuotaShareTarget([t], "combo-dec", "anthropic/claude-sonnet-4-5", NOW); + assert.equal(getInflight("conn-dec", NOW), 1, "in-flight should be 1 after a dispatch"); + result.decrementInflight(); + assert.equal(getInflight("conn-dec", NOW), 0, "in-flight should be 0 after the decrement"); + }); + + test("decrementInflight() is idempotent (safe to call more than once)", () => { + const t = makeTarget("ek-idem", "conn-idem", 100); + const result = selectQuotaShareTarget([t], "combo-idem", "anthropic/claude-sonnet-4-5", NOW); + result.decrementInflight(); + result.decrementInflight(); // must not go negative or throw + assert.equal(getInflight("conn-idem", NOW), 0); + }); + + test("TTL releases in-flight automatically when decrement is never called (abort fallback)", () => { + const t = makeTarget("ek-ttl", "conn-ttl", 100); + const result = selectQuotaShareTarget([t], "combo-ttl", "anthropic/claude-sonnet-4-5", NOW); + // Simulate an aborted request: do NOT call result.decrementInflight(). + assert.equal(getInflight("conn-ttl", NOW), 1); + assert.equal( + getInflight("conn-ttl", NOW + DEFAULT_LEASE_MS + 1), + 0, + "the slot must auto-expire after the lease, preventing a permanent leak" + ); + // The returned callback must remain referenced to satisfy the contract. + assert.equal(typeof result.decrementInflight, "function"); + }); +}); + +// ─── activation wiring ────────────────────────────────────────────────────── + +describe("activation: qtSd/ combos use strategy 'quota-share'", () => { + test("QUOTA_SHARE_STRATEGY constant equals 'quota-share'", () => { + assert.equal( + QUOTA_SHARE_STRATEGY, + "quota-share", + "quotaCombos must mint qtSd/ combos with the 'quota-share' strategy" + ); + }); + + test("'quota-share' is registered as an INTERNAL routing strategy", () => { + assert.ok( + (INTERNAL_ROUTING_STRATEGY_VALUES as readonly string[]).includes("quota-share"), + "'quota-share' must be in the internal (non-UI) routing strategy list" + ); + }); +}); diff --git a/tests/unit/sse-error-passthrough-3324.test.ts b/tests/unit/sse-error-passthrough-3324.test.ts index 8a74f06219..7ba2cac0f2 100644 --- a/tests/unit/sse-error-passthrough-3324.test.ts +++ b/tests/unit/sse-error-passthrough-3324.test.ts @@ -134,8 +134,10 @@ test("parseSSEToOpenAIResponse still returns null for an error-only SSE (boundar test("PART 1: windsurf authHint references the `Windsurf: Provide Auth Token` command", () => { const here = path.dirname(fileURLToPath(import.meta.url)); + // After the providers.ts oauth-constants split, the windsurf authHint moved to + // src/shared/constants/providers/oauth.ts. const providers = readFileSync( - path.join(here, "../../src/shared/constants/providers.ts"), + path.join(here, "../../src/shared/constants/providers/oauth.ts"), "utf8" );