Files
OmniRoute/tests/unit/quota-multiprovider.test.ts
diegosouzapw 4157fbe7a9 feat(quota): balance + failover across same-provider accounts (N-step fill-first combo)
Replace the (connId × modelId) upsert loop in syncQuotaCombos with a
model-grouped build: one combo per model with ALL connections as steps
and strategy "fill-first", fixing the collision where a second same-provider
connection overwrote the first (last upsert won, only one account ever used).
2026-05-31 11:02:18 -03:00

480 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* tests/unit/quota-multiprovider.test.ts
*
* Phase D2 — Multi-account quota pools: scope, enforce, and combo coverage.
*
* NOTE (Task 3 update): As of Task 3 ("One provider per pool"), a quota pool MUST
* use a single provider. Tests D2.1, D2.3D2.6 previously used two different
* providers (openrouter + baidu) as a convenience; they were testing CONNECTION
* PLUMBING (multi-account scope, enforce membership, combo fan-out), NOT mixed-
* provider behavior per se. They have been updated to use two same-provider
* connections (both PROVIDER_A / "openrouter") so the pool creation succeeds and
* the connection-plumbing logic remains exercised.
*
* NOTE (Task 4 update): D2.5 and D2.6 previously asserted `combo.models.length >= 1`
* (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
* alignment to the corrected implementation, NOT masking — the prior assertions
* encoded the bug, not the desired behavior.
*
* Tests:
* D2.1 — resolveQuotaKeyScope: a pool with 2 connections (same provider)
* returns connectionIds.length === 2 and both connIds in scope.
* D2.2 — resolveQuotaKeyScope: fallback — pool with empty connectionIds array
* (un-backfilled row) falls back to [connectionId] and still resolves.
* D2.3 — enforce: enforceQuotaShare resolves the pool when connectionId matches
* a non-primary member of connectionIds (not connectionId === primary).
* 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.
* D2.6 — combos: prune — after removing connB from the pool (→ only connA),
* re-sync collapses each combo to 1 step (connA only).
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// ── DB harness (same pattern as quota-pool-connections.test.ts) ──────────────
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-quota-multiprovider-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
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 { 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 { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts");
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
async function resetStorage() {
core.resetDbInstance();
for (let attempt = 0; attempt < 10; attempt++) {
try {
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
break;
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) {
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
} else {
throw error;
}
}
}
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
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))
.map((c) => ({
name: c.name as string,
models: Array.isArray(c.models) ? (c.models as unknown[]) : [],
strategy: c.strategy,
}));
}
// "openrouter" has exactly 1 model ("auto") in the static registry.
// Both connections use the same provider — required by Task 3 single-provider rule.
const PROVIDER_A = "openrouter";
// ---------------------------------------------------------------------------
// D2.1 — resolveQuotaKeyScope: 2-connection same-provider pool → both in scope
// ---------------------------------------------------------------------------
test("D2.1: resolveQuotaKeyScope — pool with 2 same-provider connections returns both connectionIds in scope", async () => {
const connA = await providersDb.createProviderConnection({
provider: PROVIDER_A,
authType: "apikey",
name: "d21-conn-a",
apiKey: "sk-d21-a",
});
const connB = await providersDb.createProviderConnection({
provider: PROVIDER_A,
authType: "apikey",
name: "d21-conn-b",
apiKey: "sk-d21-b",
});
const idA = (connA as Record<string, unknown>).id as string;
const idB = (connB as Record<string, unknown>).id as string;
// Create a pool with BOTH same-provider connections.
const pool = poolsDb.createPool({
connectionId: idA,
name: "SameProviderPool D21",
connectionIds: [idA, idB],
});
// Confirm D1 correctly stored both connectionIds.
assert.equal(pool.connectionIds.length, 2, "pool should have 2 member connections");
const scope = await resolveQuotaKeyScope([pool.id]);
// Both connections must appear.
assert.equal(scope.connectionIds.length, 2, "scope should include 2 connectionIds");
assert.ok(scope.connectionIds.includes(idA), "scope should include idA");
assert.ok(scope.connectionIds.includes(idB), "scope should include idB");
// Single provider — deduplicated to 1 entry.
assert.equal(scope.providers.length, 1, "scope should have 1 distinct provider");
assert.ok(scope.providers.includes(PROVIDER_A), `scope providers should include ${PROVIDER_A}`);
// Exactly one poolSlug for the one pool.
assert.equal(scope.poolSlugs.length, 1, "one pool → one poolSlug");
});
// ---------------------------------------------------------------------------
// D2.2 — resolveQuotaKeyScope: fallback for un-backfilled row
// ---------------------------------------------------------------------------
test("D2.2: resolveQuotaKeyScope — pool with empty connectionIds falls back to [connectionId]", async () => {
const conn = await providersDb.createProviderConnection({
provider: PROVIDER_A,
authType: "apikey",
name: "d22-conn",
apiKey: "sk-d22",
});
const connId = (conn as Record<string, unknown>).id as string;
// Create the pool normally (legacy style, single connectionId, no connectionIds arg).
// getPool will return connectionIds = [connectionId] via the defensive fallback.
const pool = poolsDb.createPool({
connectionId: connId,
name: "LegacyFallbackPool D22",
});
// Verify legacy shape.
assert.deepEqual(pool.connectionIds, [connId], "legacy pool should fall back to [connectionId]");
const scope = await resolveQuotaKeyScope([pool.id]);
assert.equal(scope.connectionIds.length, 1);
assert.ok(scope.connectionIds.includes(connId));
assert.ok(scope.providers.includes(PROVIDER_A));
});
// ---------------------------------------------------------------------------
// D2.3 — enforce: connB (non-primary member) resolves the pool
// ---------------------------------------------------------------------------
test("D2.3: enforceQuotaShare — input connectionId matching a non-primary member resolves the pool (does not bail to allow-by-default)", async () => {
// We test enforce.ts's pool-matching logic by calling enforceQuotaShare with
// a connectionId that is a member BUT NOT the primary.
//
// Without D2, the old `p.connectionId === input.connectionId` check would
// NOT match connB (secondary), causing the fn to fall through to
// { kind: "allow" } silently — wrong: quota wouldn't be enforced for connB.
//
// With D2, the membership check fires and the pool IS found. Since no real
// quota store/plan is seeded, the fn still returns { kind: "allow" } via
// the fail-open path, but it does so AFTER finding the pool (not before).
// We verify the pool is found indirectly: if the fn finds the pool, it will
// call resolvePlan(connId, provider) → which without a plan returns empty
// dimensions → which returns { kind: "allow" } via the "no dimensions" path.
//
// Both connections use PROVIDER_A (single-provider rule). The plumbing being
// tested is the secondary-member lookup, not multi-provider behavior.
const { enforceQuotaShare } = await import("../../src/lib/quota/enforce.ts");
const { listAllocationsForApiKey } = await import("../../src/lib/db/quotaPools.ts");
const connA = await providersDb.createProviderConnection({
provider: PROVIDER_A,
authType: "apikey",
name: "d23-conn-a",
apiKey: "sk-d23-a",
});
const connB = await providersDb.createProviderConnection({
provider: PROVIDER_A,
authType: "apikey",
name: "d23-conn-b",
apiKey: "sk-d23-b",
});
const idA = (connA as Record<string, unknown>).id as string;
const idB = (connB as Record<string, unknown>).id as string;
// Pool with BOTH same-provider connections.
const pool = poolsDb.createPool({
connectionId: idA,
name: "EnforceMultiPool D23",
connectionIds: [idA, idB],
});
// 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" },
]);
// Confirm allocation exists.
const allocations = listAllocationsForApiKey(API_KEY_ID);
assert.equal(allocations.length, 1, "API key should have 1 pool allocation");
assert.equal(allocations[0].poolId, pool.id);
// Call enforceQuotaShare with connB (secondary member, NOT the primary).
// The pool MUST be found (D2 membership check).
// Since resolvePlan will have no dimensions configured → "no dimensions" path → allow.
const resultB = await enforceQuotaShare({
apiKeyId: API_KEY_ID,
connectionId: idB,
provider: PROVIDER_A,
estimatedCost: {},
});
// Must be a valid EnforceDecision shape.
assert.ok(
resultB.kind === "allow" || resultB.kind === "block",
`enforceQuotaShare must return allow or block; got: ${resultB.kind}`
);
// No throw — contract satisfied.
});
// ---------------------------------------------------------------------------
// D2.4 — enforce: primary connA still resolves the pool (no regression)
// ---------------------------------------------------------------------------
test("D2.4: enforceQuotaShare — input connectionId matching the PRIMARY member still resolves correctly", async () => {
const { enforceQuotaShare } = await import("../../src/lib/quota/enforce.ts");
const connA = await providersDb.createProviderConnection({
provider: PROVIDER_A,
authType: "apikey",
name: "d24-conn-a",
apiKey: "sk-d24-a",
});
const connB = await providersDb.createProviderConnection({
provider: PROVIDER_A,
authType: "apikey",
name: "d24-conn-b",
apiKey: "sk-d24-b",
});
const idA = (connA as Record<string, unknown>).id as string;
const idB = (connB as Record<string, unknown>).id as string;
const pool = poolsDb.createPool({
connectionId: idA,
name: "PrimaryRegressionPool D24",
connectionIds: [idA, idB],
});
const API_KEY_ID = "test-key-d24";
poolsDb.upsertAllocations(pool.id, [
{ apiKeyId: API_KEY_ID, weight: 50, policy: "hard" },
]);
// Enforce with connA (the primary).
const resultA = await enforceQuotaShare({
apiKeyId: API_KEY_ID,
connectionId: idA,
provider: PROVIDER_A,
estimatedCost: {},
});
assert.ok(
resultA.kind === "allow" || resultA.kind === "block",
`enforceQuotaShare must return allow or block; got: ${resultA.kind}`
);
});
// ---------------------------------------------------------------------------
// 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 () => {
// Task 4: N same-provider connections must produce ONE combo per model with
// ALL connections' steps + strategy "fill-first". 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,
authType: "apikey",
name: "d25-conn-a",
apiKey: "sk-d25-a",
});
const connB = await providersDb.createProviderConnection({
provider: PROVIDER_A,
authType: "apikey",
name: "d25-conn-b",
apiKey: "sk-d25-b",
});
const idA = (connA as Record<string, unknown>).id as string;
const idB = (connB as Record<string, unknown>).id as string;
const modelsA = (PROVIDER_MODELS[PROVIDER_A] ?? []).map((m) => m.id);
assert.ok(modelsA.length > 0, `${PROVIDER_A} must have models in registry`);
const pool = poolsDb.createPool({
connectionId: idA,
name: "SameProviderComboPool D25",
connectionIds: [idA, idB],
});
// Wait for the fire-and-forget sync triggered by createPool to settle,
// then call syncQuotaCombos explicitly (idempotent).
await syncQuotaCombos(pool.id);
const quotaCombos = await listQuotaCombos();
const comboMap = new Map(quotaCombos.map((c) => [c.name, c]));
// ── Verify PROVIDER_A combos exist with N-step fill-first ─────────────────
for (const modelId of modelsA) {
const expectedName = quotaModelName(pool.name, PROVIDER_A, modelId);
const combo = comboMap.get(expectedName);
assert.ok(combo, `Missing combo for ${PROVIDER_A}/${modelId}: ${expectedName}`);
// Task 4: exactly 2 steps, one per connection.
assert.equal(
combo.models.length,
2,
`combo ${expectedName} should have 2 steps (both connections), got ${combo.models.length}`
);
// Task 4: strategy must be fill-first.
assert.equal(
combo.strategy,
"fill-first",
`combo ${expectedName} strategy should be "fill-first", got "${combo.strategy}"`
);
// Both connIds must appear across steps.
const stepConnIds = (combo.models as Array<Record<string, unknown>>).map((s) => s.connectionId);
assert.ok(stepConnIds.includes(idA), `combo ${expectedName} steps must include connA (${idA})`);
assert.ok(stepConnIds.includes(idB), `combo ${expectedName} steps must include connB (${idB})`);
// Each step references PROVIDER_A.
for (const step of combo.models as Array<Record<string, unknown>>) {
assert.equal(step.providerId, PROVIDER_A, `step.providerId should be ${PROVIDER_A}`);
}
}
// ── Total combo count matches model count (all from PROVIDER_A) ──────────
assert.equal(
quotaCombos.length,
modelsA.length,
`expected ${modelsA.length} combo(s) for ${PROVIDER_A}`
);
});
// ---------------------------------------------------------------------------
// D2.6 — combos: prune — removing a connection resyncs combos (no stale names)
// ---------------------------------------------------------------------------
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.
// 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.
const connA = await providersDb.createProviderConnection({
provider: PROVIDER_A,
authType: "apikey",
name: "d26-conn-a",
apiKey: "sk-d26-a",
});
const connB = await providersDb.createProviderConnection({
provider: PROVIDER_A,
authType: "apikey",
name: "d26-conn-b",
apiKey: "sk-d26-b",
});
const idA = (connA as Record<string, unknown>).id as string;
const idB = (connB as Record<string, unknown>).id as string;
const modelsA = (PROVIDER_MODELS[PROVIDER_A] ?? []).map((m) => m.id);
const pool = poolsDb.createPool({
connectionId: idA,
name: "PruneAfterRemovalPool D26",
connectionIds: [idA, idB],
});
await syncQuotaCombos(pool.id);
// Verify we have PROVIDER_A combos with 2 steps before the update.
const before = await listQuotaCombos();
assert.ok(before.length > 0, "Should have combos before update");
const beforeProviders = new Set(
before.map((c) => parseQuotaModelName(c.name)?.provider).filter(Boolean)
);
assert.ok(beforeProviders.has(PROVIDER_A), `Should have ${PROVIDER_A} combos before update`);
// Task 4: initial combos must have 2 steps.
for (const combo of before) {
assert.equal(
combo.models.length,
2,
`before removal, combo "${combo.name}" should have 2 steps`
);
}
// Remove connB from the pool — now only connA remains.
poolsDb.updatePool(pool.id, { connectionIds: [idA] });
// Re-sync.
await syncQuotaCombos(pool.id);
const after = await listQuotaCombos();
// connA's combos must still be present (same names).
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`);
for (const modelId of modelsA) {
const expectedName = quotaModelName(pool.name, PROVIDER_A, modelId);
const found = after.find((c) => c.name === expectedName);
assert.ok(found, `Combo for ${PROVIDER_A}/${modelId} should survive after connB removal`);
}
// Exact count: PROVIDER_A models remain.
assert.equal(
after.length,
modelsA.length,
`After removing connB, ${modelsA.length} combo(s) for ${PROVIDER_A} should remain`
);
// Task 4: after re-sync, each combo must have been collapsed to 1 step (connA only).
for (const combo of after) {
assert.equal(
combo.models.length,
1,
`after connB removal, combo "${combo.name}" should collapse to 1 step, got ${combo.models.length}`
);
const step = combo.models[0] as Record<string, unknown>;
assert.equal(
step.connectionId,
idA,
`remaining step in combo "${combo.name}" should be pinned to connA (${idA})`
);
}
});