diff --git a/src/app/api/quota/pools/[id]/route.ts b/src/app/api/quota/pools/[id]/route.ts index d5bf6167b7..cac6216e47 100644 --- a/src/app/api/quota/pools/[id]/route.ts +++ b/src/app/api/quota/pools/[id]/route.ts @@ -65,19 +65,35 @@ export async function PATCH(request: Request, { params }: RouteParams): Promise< } } + // Combos must be reconciled when the pool's group OR connection set changes, + // since the qtSd/ combo name embeds /. Each provider in a + // group is served by at most one pool, so removing this pool's CURRENT (old) + // group+provider combos BEFORE the update — then re-syncing AFTER — cleanly + // drops stale combos and mints the new ones, using the existing per-pool + // helpers. Without the pre-update removal, a group/provider switch would leave + // orphan qtSd/ combos a quota key still sees. Guarded + non-fatal. + const combosNeedResync = + body !== null && + typeof body === "object" && + ("connectionIds" in body || "groupId" in body); + if (combosNeedResync) { + try { + const { removeQuotaCombosForPool } = await import("@/lib/quota/quotaCombos"); + await removeQuotaCombosForPool(id); // pool still has its OLD group/provider here + } catch { + // Guard: combo cleanup failure must never break pool update. + } + } + const pool = updatePool(id, parsed.data); if (!pool) { return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 }); } - // Re-sync quota combos when connectionIds was explicitly changed. - // updatePool already fires a guarded sync internally, but we add an explicit - // awaited sync here so the caller's response reflects the updated combo state. - // Mirrors the dynamic-import + non-fatal pattern from groups/[id]/route.ts PATCH. - if (body !== null && typeof body === "object" && "connectionIds" in body) { + if (combosNeedResync) { try { const { syncQuotaCombos } = await import("@/lib/quota/quotaCombos"); - await syncQuotaCombos(id); + await syncQuotaCombos(id); // pool now has its NEW group/provider } catch { // Guard: combo-sync failure must never break pool update callers. } diff --git a/tests/unit/quota-pool-update-full.test.ts b/tests/unit/quota-pool-update-full.test.ts index 2d8aeabf77..1497b144b6 100644 --- a/tests/unit/quota-pool-update-full.test.ts +++ b/tests/unit/quota-pool-update-full.test.ts @@ -24,7 +24,9 @@ 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 { syncQuotaCombos, removeQuotaCombosForPool } = await import( + "../../src/lib/quota/quotaCombos.ts" +); const { PoolUpdateSchema } = await import("../../src/shared/schemas/quota.ts"); const { isQuotaModelName, parseQuotaModelName, quotaGroupSlug } = await import( "../../src/lib/quota/quotaModelNaming.ts" @@ -178,6 +180,52 @@ test("updatePool with new connectionIds triggers combo re-sync (openrouter → b assert.deepEqual(reread.connectionIds, [idB], "pool.connectionIds must contain only baidu conn"); }); +test("PATCH route sequence (remove→update→sync) prunes OLD-provider combos on switch", async () => { + const group = createGroup("RouteSwitchGroup"); + const groupSlug = quotaGroupSlug(group.name); + + const connA = await providersDb.createProviderConnection({ + provider: "openrouter", + authType: "apikey", + name: "rt-or-conn", + apiKey: "sk-rt-or", + }); + const idA = (connA as Record).id as string; + const connB = await providersDb.createProviderConnection({ + provider: "baidu", + authType: "apikey", + name: "rt-baidu-conn", + apiKey: "sk-rt-baidu", + }); + const idB = (connB as Record).id as string; + + const pool = poolsDb.createPool({ connectionId: idA, name: "Route Switch", groupId: group.id }); + await syncQuotaCombos(pool.id); + assert.ok( + (await listQuotaCombos()).some((c) => parseQuotaModelName(c.name)?.provider === "openrouter"), + "precondition: openrouter combos exist" + ); + + // Mirror the PATCH route's connection/group-change path exactly: + await removeQuotaCombosForPool(pool.id); // pool still has OLD (openrouter) provider here + poolsDb.updatePool(pool.id, { connectionIds: [idB] }); + await syncQuotaCombos(pool.id); // pool now has NEW (baidu) provider + + const after = await listQuotaCombos(); + const orphans = after.filter((c) => { + const p = parseQuotaModelName(c.name); + return p?.groupSlug === groupSlug && p?.provider === "openrouter"; + }); + assert.equal(orphans.length, 0, `OLD openrouter combos must be pruned; found ${orphans.length}`); + assert.ok( + after.some((c) => { + const p = parseQuotaModelName(c.name); + return p?.groupSlug === groupSlug && p?.provider === "baidu"; + }), + "NEW baidu combos must exist after the switch" + ); +}); + test("updatePool with groupId persists the new group assignment", () => { const groupA = createGroup("GroupAlpha"); const groupB = createGroup("GroupBeta");