mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 06:02:14 +03:00
feat(quota): group-aware quotaShared combos (qtSd/<group>/...) with provider-scoped prune
- resolvePoolForSync now resolves groupName via getGroupName(pool.groupId), falling back to pool.name when the group record is missing. - Both quotaModelName calls in syncQuotaCombos use groupName instead of pool.name, so combos are named qtSd/<groupSlug>/<provider>/<model>. - Prune is now scoped to group+provider: syncing pool A (openrouter) never deletes pool B (baidu) combos that share the same group. - removeQuotaCombosForPool likewise scoped to group+provider. - Updated quota-combos-sync, quota-combo-balancing, quota-combo-cli-providers tests for the new group-slug naming. - New tests/unit/quota-combo-groups.test.ts: G1–G4 cover two-pool same-group naming, provider-scoped prune isolation, default-group fallback, and stale prune within the same group+provider.
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import { getPool } from "@/lib/db/quotaPools";
|
||||
import { getGroupName } from "@/lib/db/quotaGroups";
|
||||
import { getProviderConnectionById } from "@/lib/db/providers";
|
||||
import {
|
||||
getCombos,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
updateCombo,
|
||||
} from "@/lib/db/combos";
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry";
|
||||
import { quotaModelName, parseQuotaModelName, isQuotaModelName, quotaPoolSlug } from "./quotaModelNaming";
|
||||
import { quotaModelName, parseQuotaModelName, isQuotaModelName, quotaGroupSlug } from "./quotaModelNaming";
|
||||
import { createLogger } from "@/shared/utils/logger";
|
||||
|
||||
const log = createLogger("quota/quotaCombos");
|
||||
@@ -34,9 +35,20 @@ const log = createLogger("quota/quotaCombos");
|
||||
* Returns null when the pool cannot be found.
|
||||
* Individual connection lookups are deferred to syncQuotaCombos so that a
|
||||
* single missing connection does not abort the whole sync.
|
||||
*
|
||||
* B4: also resolves the GROUP NAME so that combos are named
|
||||
* `qtSd/<groupSlug>/...` instead of `qtSd/<poolSlug>/...`.
|
||||
* Falls back to pool.name when the group record is missing.
|
||||
*/
|
||||
async function resolvePoolForSync(poolId: string): Promise<{
|
||||
pool: { id: string; connectionId: string; connectionIds: string[]; name: string };
|
||||
pool: {
|
||||
id: string;
|
||||
connectionId: string;
|
||||
connectionIds: string[];
|
||||
name: string;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
};
|
||||
} | null> {
|
||||
const pool = getPool(poolId);
|
||||
if (!pool) return null;
|
||||
@@ -47,7 +59,20 @@ async function resolvePoolForSync(poolId: string): Promise<{
|
||||
? pool.connectionIds
|
||||
: [pool.connectionId];
|
||||
|
||||
return { pool: { id: pool.id, connectionId: pool.connectionId, connectionIds, name: pool.name } };
|
||||
// B4: resolve the group name for combo naming.
|
||||
// Fall back to pool.name when the group is missing (legacy / test isolation).
|
||||
const groupName = getGroupName(pool.groupId) ?? pool.name;
|
||||
|
||||
return {
|
||||
pool: {
|
||||
id: pool.id,
|
||||
connectionId: pool.connectionId,
|
||||
connectionIds,
|
||||
name: pool.name,
|
||||
groupId: pool.groupId,
|
||||
groupName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +119,10 @@ export async function syncQuotaCombos(poolId: string): Promise<void> {
|
||||
}
|
||||
|
||||
const { pool } = resolved;
|
||||
const poolSlug = quotaPoolSlug(pool.name);
|
||||
// B4: use the GROUP name for combo naming (qtSd/<groupSlug>/...).
|
||||
// Falls back to pool.name when the group record is missing.
|
||||
const groupName = pool.groupName;
|
||||
const groupSlug = quotaGroupSlug(groupName);
|
||||
|
||||
// D2: build desired names as the UNION across ALL member connections.
|
||||
// A missing connection (no DB row / no provider field) is silently skipped —
|
||||
@@ -121,11 +149,17 @@ export async function syncQuotaCombos(poolId: string): Promise<void> {
|
||||
if (modelIds.length === 0) continue;
|
||||
|
||||
for (const modelId of modelIds) {
|
||||
desiredNames.add(quotaModelName(pool.name, provider, modelId));
|
||||
// B4: use groupName (not pool.name) as the first arg so combos carry the group slug.
|
||||
desiredNames.add(quotaModelName(groupName, provider, modelId));
|
||||
}
|
||||
upsertWork.push({ connId, provider, modelIds });
|
||||
}
|
||||
|
||||
// B4: the pool is single-provider (guard enforced at pool creation).
|
||||
// Compute the pool's provider so the prune is scoped to group+provider only
|
||||
// (never touching another provider's combos in the same group).
|
||||
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.
|
||||
@@ -139,7 +173,8 @@ export async function syncQuotaCombos(poolId: string): Promise<void> {
|
||||
}
|
||||
for (const [modelId, conns] of byModel) {
|
||||
const provider = conns[0].provider;
|
||||
const comboName = quotaModelName(pool.name, provider, modelId);
|
||||
// B4: use groupName for the combo name.
|
||||
const comboName = quotaModelName(groupName, provider, modelId);
|
||||
const steps = conns.map((c) => ({
|
||||
kind: "model" as const,
|
||||
model: `${provider}/${modelId}`,
|
||||
@@ -157,8 +192,17 @@ export async function syncQuotaCombos(poolId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Prune stale combos that belong to this pool slug but are no longer in the
|
||||
// desired set (union across all current connections).
|
||||
// B4: Prune stale combos that belong to THIS pool's group+provider but are no
|
||||
// longer in the desired set. CRITICAL: must NOT prune another provider's combos
|
||||
// in the same group (e.g. syncing openrouter pool must not delete baidu combos).
|
||||
//
|
||||
// Prune condition: groupSlug matches AND provider matches AND name not in desiredNames.
|
||||
// If poolProvider is undefined (no valid connection), skip pruning to be safe.
|
||||
if (!poolProvider) {
|
||||
// No valid connections → nothing to prune (can't scope by provider).
|
||||
return;
|
||||
}
|
||||
|
||||
let allCombos: Awaited<ReturnType<typeof getCombos>> = [];
|
||||
try {
|
||||
allCombos = await getCombos();
|
||||
@@ -174,9 +218,12 @@ export async function syncQuotaCombos(poolId: string): Promise<void> {
|
||||
|
||||
const parsed = parseQuotaModelName(name);
|
||||
if (!parsed) continue;
|
||||
if (parsed.groupSlug !== poolSlug) continue;
|
||||
|
||||
// Belongs to this pool slug but not produced by any current connection → prune.
|
||||
// B4: provider-scoped prune — only prune combos for THIS group+provider.
|
||||
if (parsed.groupSlug !== groupSlug) continue;
|
||||
if (parsed.provider !== poolProvider) continue;
|
||||
|
||||
// Belongs to this group+provider but not produced by any current connection → prune.
|
||||
if (!desiredNames.has(name)) {
|
||||
try {
|
||||
await deleteComboByName(name);
|
||||
@@ -217,15 +264,46 @@ export function filterModelsToQuotaPools<T extends { id: string }>(
|
||||
/**
|
||||
* Delete ALL `quotaShared-*` combos that belong to the given pool.
|
||||
*
|
||||
* Used on pool deletion. Because the pool may already be gone from the DB when
|
||||
* this is called, we look up the pool name first; if missing, we fall back to
|
||||
* scanning all quota combos and deleting those whose parsed slug matches the
|
||||
* pool's last-known slug (best-effort via poolId as slug).
|
||||
* B4: scoped to this pool's group+provider so that removing one pool does not
|
||||
* accidentally delete another provider's combos that share the same group.
|
||||
*
|
||||
* Used on pool deletion. The pool record is looked up to resolve the group
|
||||
* name and provider. If the pool is already gone from the DB, this is a
|
||||
* best-effort no-op (nothing to match on provider).
|
||||
*/
|
||||
export async function removeQuotaCombosForPool(poolId: string): Promise<void> {
|
||||
// Try to get the pool's name to compute the canonical slug
|
||||
const pool = getPool(poolId);
|
||||
const slug = pool ? quotaPoolSlug(pool.name) : null;
|
||||
// Resolve pool → groupName + provider for scoped deletion.
|
||||
const resolved = await resolvePoolForSync(poolId);
|
||||
|
||||
// If the pool is already gone, we can't safely scope deletion.
|
||||
// Fall back: try to delete by provider-discovery from DB combos (best-effort).
|
||||
if (!resolved) {
|
||||
// Pool gone — nothing to scope by; skip (no partial prune without knowing the provider).
|
||||
return;
|
||||
}
|
||||
|
||||
const { pool } = resolved;
|
||||
const groupName = pool.groupName;
|
||||
const groupSlug = quotaGroupSlug(groupName);
|
||||
|
||||
// Resolve the pool's provider from its connections.
|
||||
let poolProvider: string | undefined;
|
||||
for (const connId of pool.connectionIds) {
|
||||
try {
|
||||
const connection = (await getProviderConnectionById(connId)) as Record<string, unknown> | null;
|
||||
if (connection && typeof connection.provider === "string" && connection.provider.length > 0) {
|
||||
poolProvider = connection.provider;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
if (!poolProvider) {
|
||||
// Can't scope by provider — skip to avoid nuking unrelated combos.
|
||||
return;
|
||||
}
|
||||
|
||||
let allCombos: Awaited<ReturnType<typeof getCombos>> = [];
|
||||
try {
|
||||
@@ -243,8 +321,9 @@ export async function removeQuotaCombosForPool(poolId: string): Promise<void> {
|
||||
const parsed = parseQuotaModelName(name);
|
||||
if (!parsed) continue;
|
||||
|
||||
// Match by slug when we have a pool name; otherwise no match possible
|
||||
if (slug !== null && parsed.groupSlug !== slug) continue;
|
||||
// B4: only delete combos for this group+provider.
|
||||
if (parsed.groupSlug !== groupSlug) continue;
|
||||
if (parsed.provider !== poolProvider) continue;
|
||||
|
||||
try {
|
||||
await deleteComboByName(name);
|
||||
|
||||
@@ -122,7 +122,8 @@ test("B1: syncQuotaCombos — 2-connection same-provider pool produces ONE combo
|
||||
|
||||
// For each model, assert: one combo, 2 steps, fill-first, both connIds present.
|
||||
for (const modelId of modelsForProvider) {
|
||||
const comboName = quotaModelName(pool.name, PROVIDER, modelId);
|
||||
// B4: combos are named with the GROUP name ("GroupDemo"), not pool name.
|
||||
const comboName = quotaModelName("GroupDemo", PROVIDER, modelId);
|
||||
const matchingCombos = quotaCombos.filter((c) => c.name === comboName);
|
||||
|
||||
// Exactly ONE combo with this name (no duplicate/collision).
|
||||
@@ -332,7 +333,8 @@ test("B5: after syncQuotaCombos on 2-connection pool, getComboByName returns the
|
||||
|
||||
await syncQuotaCombos(pool.id);
|
||||
|
||||
const comboName = quotaModelName(pool.name, PROVIDER, FIRST_MODEL);
|
||||
// B4: combos are named with the GROUP name ("GroupDemo"), not pool name.
|
||||
const comboName = quotaModelName("GroupDemo", PROVIDER, FIRST_MODEL);
|
||||
const found = await combosDb.getComboByName(comboName);
|
||||
|
||||
assert.ok(found, `getComboByName("${comboName}") should return the combo`);
|
||||
|
||||
@@ -71,7 +71,8 @@ test("syncQuotaCombos generates qtSd/ combos for a CLI provider (codex) via REGI
|
||||
);
|
||||
assert.ok(quotaCombos.length > 0, "codex pool must produce qtSd/ combos (was 0 before fix)");
|
||||
|
||||
const expectedName = quotaModelName(pool.name, CLI_PROVIDER, firstModelId);
|
||||
// B4: combos are named with the GROUP name ("GroupDemo" for default group), not pool name.
|
||||
const expectedName = quotaModelName("GroupDemo", CLI_PROVIDER, firstModelId);
|
||||
const combo = await combosDb.getComboByName(expectedName);
|
||||
assert.ok(combo, `combo "${expectedName}" must exist for the codex pool`);
|
||||
});
|
||||
|
||||
303
tests/unit/quota-combo-groups.test.ts
Normal file
303
tests/unit/quota-combo-groups.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* tests/unit/quota-combo-groups.test.ts
|
||||
*
|
||||
* Task B4 TDD — Group-aware quotaShared combos.
|
||||
*
|
||||
* Two pools in the SAME group produce combos under the GROUP slug
|
||||
* (`qtSd/<group>/...`) and each pool's sync does NOT prune the other
|
||||
* provider's combos.
|
||||
*
|
||||
* Uses "openrouter" (1 model: "auto") as pool A's provider and "baidu"
|
||||
* (1 model: "ernie-4.0-8k") as pool B's provider — both have a small,
|
||||
* stable registry entry.
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-quota-combo-groups-")
|
||||
);
|
||||
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 { createGroup } = await import("../../src/lib/db/quotaGroups.ts");
|
||||
const { syncQuotaCombos } = await import("../../src/lib/quota/quotaCombos.ts");
|
||||
const { isQuotaModelName, parseQuotaModelName, quotaGroupSlug } = await import(
|
||||
"../../src/lib/quota/quotaModelNaming.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 });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function listQuotaCombos(): Promise<Array<{ name: string; models: unknown[] }>> {
|
||||
const all = await combosDb.getCombos();
|
||||
return all
|
||||
.filter((c) => typeof c.name === "string" && isQuotaModelName(c.name as string))
|
||||
.map((c) => ({
|
||||
name: c.name as string,
|
||||
models: Array.isArray(c.models) ? (c.models as unknown[]) : [],
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// G1 — Two pools in the SAME group produce combos under the GROUP slug
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("G1: two pools in same group → combos named qtSd/<group>/provider/model (group slug, not pool name)", async () => {
|
||||
// Create a named group
|
||||
const group = createGroup("MyGroup");
|
||||
|
||||
// Pool A: openrouter
|
||||
const connA = await providersDb.createProviderConnection({
|
||||
provider: "openrouter",
|
||||
authType: "apikey",
|
||||
name: "g1-or-conn",
|
||||
apiKey: "sk-g1-or",
|
||||
});
|
||||
const idA = (connA as Record<string, unknown>).id as string;
|
||||
const poolA = poolsDb.createPool({
|
||||
connectionId: idA,
|
||||
name: "OpenRouter Pool G1",
|
||||
groupId: group.id,
|
||||
});
|
||||
|
||||
// Pool B: baidu
|
||||
const connB = await providersDb.createProviderConnection({
|
||||
provider: "baidu",
|
||||
authType: "apikey",
|
||||
name: "g1-baidu-conn",
|
||||
apiKey: "sk-g1-baidu",
|
||||
});
|
||||
const idB = (connB as Record<string, unknown>).id as string;
|
||||
const poolB = poolsDb.createPool({
|
||||
connectionId: idB,
|
||||
name: "Baidu Pool G1",
|
||||
groupId: group.id,
|
||||
});
|
||||
|
||||
// Sync both pools
|
||||
await syncQuotaCombos(poolA.id);
|
||||
await syncQuotaCombos(poolB.id);
|
||||
|
||||
const allCombos = await listQuotaCombos();
|
||||
const groupSlug = quotaGroupSlug(group.name); // "mygroup"
|
||||
|
||||
// All combos should use the group slug, not the pool name slug
|
||||
for (const c of allCombos) {
|
||||
const parsed = parseQuotaModelName(c.name);
|
||||
assert.ok(parsed, `Could not parse quota model name: ${c.name}`);
|
||||
assert.equal(
|
||||
parsed.groupSlug,
|
||||
groupSlug,
|
||||
`Combo "${c.name}" groupSlug should be "${groupSlug}" (group name), got "${parsed.groupSlug}"`
|
||||
);
|
||||
}
|
||||
|
||||
// Combos for openrouter must exist under the group slug
|
||||
const orCombos = allCombos.filter((c) => {
|
||||
const p = parseQuotaModelName(c.name);
|
||||
return p?.groupSlug === groupSlug && p?.provider === "openrouter";
|
||||
});
|
||||
assert.ok(orCombos.length > 0, `Expected openrouter combos under qtSd/${groupSlug}/openrouter/...`);
|
||||
|
||||
// Combos for baidu must exist under the group slug
|
||||
const baiduCombos = allCombos.filter((c) => {
|
||||
const p = parseQuotaModelName(c.name);
|
||||
return p?.groupSlug === groupSlug && p?.provider === "baidu";
|
||||
});
|
||||
assert.ok(baiduCombos.length > 0, `Expected baidu combos under qtSd/${groupSlug}/baidu/...`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// G2 — Re-syncing pool A does NOT prune pool B's combos (provider-scoped prune)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("G2: re-syncing pool A (openrouter) does not delete pool B (baidu) combos in same group", async () => {
|
||||
const group = createGroup("SharedGroup");
|
||||
const groupSlug = quotaGroupSlug(group.name);
|
||||
|
||||
// Pool A: openrouter
|
||||
const connA = await providersDb.createProviderConnection({
|
||||
provider: "openrouter",
|
||||
authType: "apikey",
|
||||
name: "g2-or-conn",
|
||||
apiKey: "sk-g2-or",
|
||||
});
|
||||
const idA = (connA as Record<string, unknown>).id as string;
|
||||
const poolA = poolsDb.createPool({
|
||||
connectionId: idA,
|
||||
name: "OpenRouter Pool G2",
|
||||
groupId: group.id,
|
||||
});
|
||||
|
||||
// Pool B: baidu
|
||||
const connB = await providersDb.createProviderConnection({
|
||||
provider: "baidu",
|
||||
authType: "apikey",
|
||||
name: "g2-baidu-conn",
|
||||
apiKey: "sk-g2-baidu",
|
||||
});
|
||||
const idB = (connB as Record<string, unknown>).id as string;
|
||||
const poolB = poolsDb.createPool({
|
||||
connectionId: idB,
|
||||
name: "Baidu Pool G2",
|
||||
groupId: group.id,
|
||||
});
|
||||
|
||||
// Sync both
|
||||
await syncQuotaCombos(poolA.id);
|
||||
await syncQuotaCombos(poolB.id);
|
||||
|
||||
// Count baidu combos before re-sync of pool A
|
||||
const beforeResync = await listQuotaCombos();
|
||||
const baiduBefore = beforeResync.filter((c) => {
|
||||
const p = parseQuotaModelName(c.name);
|
||||
return p?.groupSlug === groupSlug && p?.provider === "baidu";
|
||||
});
|
||||
assert.ok(baiduBefore.length > 0, "baidu combos must exist before re-sync");
|
||||
|
||||
// Re-sync pool A (openrouter) again — must NOT touch baidu combos
|
||||
await syncQuotaCombos(poolA.id);
|
||||
|
||||
const afterResync = await listQuotaCombos();
|
||||
const baiduAfter = afterResync.filter((c) => {
|
||||
const p = parseQuotaModelName(c.name);
|
||||
return p?.groupSlug === groupSlug && p?.provider === "baidu";
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
baiduAfter.length,
|
||||
baiduBefore.length,
|
||||
`Re-syncing pool A (openrouter) must not prune pool B's (baidu) combos. ` +
|
||||
`Before: ${baiduBefore.length}, After: ${baiduAfter.length}`
|
||||
);
|
||||
|
||||
// Also verify openrouter combos still present
|
||||
const orAfter = afterResync.filter((c) => {
|
||||
const p = parseQuotaModelName(c.name);
|
||||
return p?.groupSlug === groupSlug && p?.provider === "openrouter";
|
||||
});
|
||||
assert.ok(orAfter.length > 0, "openrouter combos must still be present after re-sync");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// G3 — Pool in default group still works (group name = "GroupDemo", slug = "groupdemo")
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("G3: pool in default 'group-demo' group produces combos under groupdemo slug", async () => {
|
||||
// Create a pool without specifying a groupId → defaults to "group-demo"
|
||||
const conn = await providersDb.createProviderConnection({
|
||||
provider: "glm",
|
||||
authType: "apikey",
|
||||
name: "g3-glm-conn",
|
||||
apiKey: "sk-g3-glm",
|
||||
});
|
||||
const connId = (conn as Record<string, unknown>).id as string;
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId: connId,
|
||||
name: "Default Group Pool",
|
||||
// no groupId → defaults to "group-demo"
|
||||
});
|
||||
|
||||
// Sanity: groupId should be "group-demo"
|
||||
assert.equal(pool.groupId, "group-demo", "pool without groupId should default to group-demo");
|
||||
|
||||
await syncQuotaCombos(pool.id);
|
||||
|
||||
const allCombos = await listQuotaCombos();
|
||||
assert.ok(allCombos.length > 0, "should produce combos even for default group pool");
|
||||
|
||||
// All combos should be under the "groupdemo" slug (the group name is "GroupDemo")
|
||||
for (const c of allCombos) {
|
||||
const parsed = parseQuotaModelName(c.name);
|
||||
assert.ok(parsed, `Could not parse: ${c.name}`);
|
||||
assert.equal(
|
||||
parsed.groupSlug,
|
||||
"groupdemo",
|
||||
`Combo "${c.name}" should be under "groupdemo" slug (GroupDemo group), got "${parsed.groupSlug}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// G4 — Stale prune: a same-group same-provider stale combo IS pruned on re-sync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("G4: stale same-group same-provider combo is pruned on re-sync", async () => {
|
||||
const group = createGroup("PruneGroup");
|
||||
const groupSlug = quotaGroupSlug(group.name); // "prunegroup"
|
||||
|
||||
const conn = await providersDb.createProviderConnection({
|
||||
provider: "openrouter",
|
||||
authType: "apikey",
|
||||
name: "g4-or-conn",
|
||||
apiKey: "sk-g4-or",
|
||||
});
|
||||
const connId = (conn as Record<string, unknown>).id as string;
|
||||
const pool = poolsDb.createPool({
|
||||
connectionId: connId,
|
||||
name: "PrunePool G4",
|
||||
groupId: group.id,
|
||||
});
|
||||
|
||||
await syncQuotaCombos(pool.id);
|
||||
|
||||
// Manually insert a stale combo under same group+provider but a nonexistent model
|
||||
const staleComboName = `qtSd/${groupSlug}/openrouter/fake-stale-model`;
|
||||
await combosDb.createCombo({
|
||||
name: staleComboName,
|
||||
models: [{ kind: "model", model: "openrouter/fake-stale-model", providerId: "openrouter", weight: 100 }],
|
||||
strategy: "priority",
|
||||
isHidden: true,
|
||||
});
|
||||
|
||||
const beforePrune = await combosDb.getComboByName(staleComboName);
|
||||
assert.ok(beforePrune, "stale combo should exist before re-sync");
|
||||
|
||||
// Re-sync: stale same-group same-provider combo should be pruned
|
||||
await syncQuotaCombos(pool.id);
|
||||
|
||||
const afterPrune = await combosDb.getComboByName(staleComboName);
|
||||
assert.equal(afterPrune, null, "stale same-group same-provider combo should be pruned");
|
||||
});
|
||||
@@ -92,6 +92,7 @@ test("syncQuotaCombos: creates one combo per glm model with correct name and tar
|
||||
const connId = (conn as Record<string, unknown>).id as string;
|
||||
assert.ok(connId, "connection should have an id");
|
||||
|
||||
// Pool defaults to "group-demo" (GroupDemo → slug "groupdemo").
|
||||
const pool = poolsDb.createPool({ connectionId: connId, name: "TestGlmPool" });
|
||||
|
||||
await syncQuotaCombos(pool.id);
|
||||
@@ -102,20 +103,21 @@ test("syncQuotaCombos: creates one combo per glm model with correct name and tar
|
||||
const quotaCombos = await listQuotaCombos();
|
||||
const quotaComboNames = new Set(quotaCombos.map((c) => c.name));
|
||||
|
||||
// Every glm model should have a combo
|
||||
// B4: combos are named with the GROUP name ("GroupDemo" → slug "groupdemo"), not pool name.
|
||||
const expectedGroupSlug = quotaPoolSlug("GroupDemo");
|
||||
for (const model of glmModels) {
|
||||
const expectedName = quotaModelName("TestGlmPool", "glm", model.id);
|
||||
const expectedName = quotaModelName("GroupDemo", "glm", model.id);
|
||||
assert.ok(
|
||||
quotaComboNames.has(expectedName),
|
||||
`Missing combo for model ${model.id}: ${expectedName}`
|
||||
);
|
||||
}
|
||||
|
||||
// No extra quota combos for other pools
|
||||
// All combos should be under the group slug (not the pool name slug).
|
||||
for (const c of quotaCombos) {
|
||||
const parsed = parseQuotaModelName(c.name);
|
||||
assert.ok(parsed, `Could not parse quota model name: ${c.name}`);
|
||||
assert.equal(parsed?.groupSlug, quotaPoolSlug("TestGlmPool"));
|
||||
assert.equal(parsed?.groupSlug, expectedGroupSlug);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -203,9 +205,9 @@ test("syncQuotaCombos: prunes stale combos for same pool slug", async () => {
|
||||
const initialCount = afterInitial.length;
|
||||
assert.ok(initialCount > 0, "should have combos after initial sync");
|
||||
|
||||
// Manually insert a stale combo with the same pool slug but a nonexistent model
|
||||
// Use the new qtSd/ prefix so isQuotaModelName() recognises it as a quota combo to prune.
|
||||
const staleComboName = `qtSd/${quotaPoolSlug("PrunePool")}/glm/fake-model-stale`;
|
||||
// Manually insert a stale combo with the same group+provider slug but a nonexistent model.
|
||||
// B4: prune is group+provider scoped. Pool defaults to "group-demo" (GroupDemo → "groupdemo").
|
||||
const staleComboName = `qtSd/${quotaPoolSlug("GroupDemo")}/glm/fake-model-stale`;
|
||||
await combosDb.createCombo({
|
||||
name: staleComboName,
|
||||
models: [{ kind: "model", model: "glm/fake-model-stale", providerId: "glm", weight: 100 }],
|
||||
@@ -253,40 +255,64 @@ test("removeQuotaCombosForPool: removes all quota combos for the pool", async ()
|
||||
assert.equal(after.length, 0, "all quota combos should be removed");
|
||||
});
|
||||
|
||||
test("syncQuotaCombos: does not affect quota combos for a different pool slug", async () => {
|
||||
const conn = await providersDb.createProviderConnection({
|
||||
test("syncQuotaCombos: does not affect quota combos for a different provider in the same group", async () => {
|
||||
// B4: isolation is now by group+provider (not pool name slug).
|
||||
// Two pools in the same group (group-demo) but different providers:
|
||||
// PoolAlpha = glm, PoolBeta = openrouter. Removing PoolAlpha (glm) should
|
||||
// NOT touch PoolBeta's (openrouter) combos.
|
||||
const connGlm = await providersDb.createProviderConnection({
|
||||
provider: "glm",
|
||||
authType: "apikey",
|
||||
name: "quota-combos-isolation",
|
||||
name: "quota-combos-isolation-glm",
|
||||
apiKey: "sk-test-glm-isolation",
|
||||
});
|
||||
const connId = (conn as Record<string, unknown>).id as string;
|
||||
const connGlmId = (connGlm as Record<string, unknown>).id as string;
|
||||
|
||||
const poolA = poolsDb.createPool({ connectionId: connId, name: "PoolAlpha" });
|
||||
const poolB = poolsDb.createPool({ connectionId: connId, name: "PoolBeta" });
|
||||
const connOr = await providersDb.createProviderConnection({
|
||||
provider: "openrouter",
|
||||
authType: "apikey",
|
||||
name: "quota-combos-isolation-or",
|
||||
apiKey: "sk-test-or-isolation",
|
||||
});
|
||||
const connOrId = (connOr as Record<string, unknown>).id as string;
|
||||
|
||||
// Both pools default to "group-demo" (same group).
|
||||
const poolA = poolsDb.createPool({ connectionId: connGlmId, name: "PoolAlpha" });
|
||||
const poolB = poolsDb.createPool({ connectionId: connOrId, name: "PoolBeta" });
|
||||
|
||||
await syncQuotaCombos(poolA.id);
|
||||
await syncQuotaCombos(poolB.id);
|
||||
|
||||
const all = await listQuotaCombos();
|
||||
const slugA = quotaPoolSlug("PoolAlpha");
|
||||
const slugB = quotaPoolSlug("PoolBeta");
|
||||
const groupSlug = quotaPoolSlug("GroupDemo");
|
||||
|
||||
const forA = all.filter((c) => parseQuotaModelName(c.name)?.groupSlug === slugA);
|
||||
const forB = all.filter((c) => parseQuotaModelName(c.name)?.groupSlug === slugB);
|
||||
const forA = all.filter((c) => {
|
||||
const p = parseQuotaModelName(c.name);
|
||||
return p?.groupSlug === groupSlug && p?.provider === "glm";
|
||||
});
|
||||
const forB = all.filter((c) => {
|
||||
const p = parseQuotaModelName(c.name);
|
||||
return p?.groupSlug === groupSlug && p?.provider === "openrouter";
|
||||
});
|
||||
|
||||
assert.ok(forA.length > 0, "PoolAlpha should have combos");
|
||||
assert.ok(forB.length > 0, "PoolBeta should have combos");
|
||||
assert.ok(forA.length > 0, "PoolAlpha (glm) should have combos");
|
||||
assert.ok(forB.length > 0, "PoolBeta (openrouter) should have combos");
|
||||
|
||||
// Removing PoolA's combos should not touch PoolB's
|
||||
// Removing PoolAlpha (glm) combos should NOT touch PoolBeta's (openrouter) combos.
|
||||
await removeQuotaCombosForPool(poolA.id);
|
||||
|
||||
const remaining = await listQuotaCombos();
|
||||
const remainingForA = remaining.filter((c) => parseQuotaModelName(c.name)?.groupSlug === slugA);
|
||||
const remainingForB = remaining.filter((c) => parseQuotaModelName(c.name)?.groupSlug === slugB);
|
||||
const remainingForA = remaining.filter((c) => {
|
||||
const p = parseQuotaModelName(c.name);
|
||||
return p?.groupSlug === groupSlug && p?.provider === "glm";
|
||||
});
|
||||
const remainingForB = remaining.filter((c) => {
|
||||
const p = parseQuotaModelName(c.name);
|
||||
return p?.groupSlug === groupSlug && p?.provider === "openrouter";
|
||||
});
|
||||
|
||||
assert.equal(remainingForA.length, 0, "PoolAlpha combos should all be removed");
|
||||
assert.equal(remainingForB.length, forB.length, "PoolBeta combos should be untouched");
|
||||
assert.equal(remainingForA.length, 0, "PoolAlpha (glm) combos should all be removed");
|
||||
assert.equal(remainingForB.length, forB.length, "PoolBeta (openrouter) combos should be untouched");
|
||||
});
|
||||
|
||||
test("syncQuotaCombos: unknown pool id — no throw, prunes nothing (no combos exist)", async () => {
|
||||
|
||||
Reference in New Issue
Block a user