mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
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).
480 lines
19 KiB
TypeScript
480 lines
19 KiB
TypeScript
/**
|
||
* 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.3–D2.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})`
|
||
);
|
||
}
|
||
});
|