fix(api): scale pool usage snapshot limits by pool member count (summed budget) (#10253)

* fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190)

Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13
(with monaco-editor scoped override). Closes Dependabot #189, #190.

Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge —
awaiting Dependabot re-scan.

npm audit → 0 vulnerabilities.

* fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks)

_tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing
slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential
_tasks symlink can slip in via git add -A and, once pulled, checkout materializes
it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks
ignores the symlink too, preventing re-capture.

* Hide health-check excluded models from /v1/models catalog (#10026)

Mirror the request-time exclusion rule (provider_specific_data.excludedModels)
in the unified catalog builder: a model is hidden when its provider has
connections but none of them is eligible for it. Applied across the
PROVIDER_MODELS, synced, custom, alias-backed, and managed-fallback loops
so ghost models no longer appear as available.

Co-authored-by: ritheshcn25 <ritheshcn25@users.noreply.github.com>

* fix(models): memoize getModelsDevPricing (event loop / healthz) (#10055)

* fix(models): memoize getModelsDevPricing for /v1/models catalog

resolveCatalogPricing called getModelsDevPricing once per model while
building GET /v1/models. Each call re-scanned models_dev_pricing and
JSON.parsed every row (~10k SQL scans + multi-GB parse work), pegging
the event loop so even /healthz timed out (#9685, #10052).

Memoize the parsed map until saveModelsDevPricing / clearModelsDevPricing
and add a unit test for invalidation.

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>

* fix(db): invalidate modelsDevPricing cache on DB reset (#10055)

Copilot review fixes:
1. Register invalidateModelsDevPricingCache() with DB state reset system
   so resetDbInstance() clears the process-local memo, preventing stale
   pricing data from surviving across DB reset/restore operations.
2. Add test assertion verifying DB reset bypasses the memo (Copilot #10055).

The process-local memo at modelsDevSync.ts:204 caches getModelsDevPricing()
results until saveModelsDevPricing()/clearModelsDevPricing() to avoid
re-scanning all pricing rows on every /v1/models request. Without this hook,
backup restore and test DB resets would serve stale cached data from the
previous connection.

Tests: npm run test:unit:serial -- tests/unit/modelsDevSync-extended.test.ts

---------

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* fix(api): scale pool usage snapshot limits by member count (summed budget)

---------

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@outlook.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: ritheshcn25 <rithesh.chandran@snb.ca>
Co-authored-by: ritheshcn25 <ritheshcn25@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Damian Pozimski
2026-08-18 15:49:28 +02:00
committed by GitHub
parent 1089c24bc8
commit ac2439b8af
2 changed files with 168 additions and 5 deletions

View File

@@ -1,8 +1,10 @@
/**
* GET /api/quota/pools/[id]/usage — pool consumption snapshot with dimensions
*
* Resolves the pool's provider plan to get dimensions, then calls
* poolUsageWithDimensions on the QuotaStore interface.
* Resolves the pool's provider plan to get dimensions, scales each dimension
* limit by the pool's member-connection count (the same summed budget
* enforce.ts applies), then calls poolUsageWithDimensions on the QuotaStore
* interface.
*
* Auth: requireManagementAuth
* Sanitization: all error responses via buildErrorBody (Hard Rule #12, B25)
@@ -43,12 +45,27 @@ export async function GET(request: Request, { params }: RouteParams): Promise<Re
const provider = await resolveConnectionProvider(pool.connectionId);
const plan = resolvePlan(pool.connectionId, provider);
// 3. Get the quota store and call poolUsageWithDimensions (on the interface since v3.8.12)
// 3. Scale each dimension by the pool's member count, mirroring enforce.ts:
// a pool with N same-type connections has an effective budget of
// perAccountLimit × N per dimension. Without this the snapshot reports
// per-account limits and fair shares while enforcement uses the summed
// budget, so a multi-connection pool looks ~N× more utilised than it is
// (and per-key `borrowing` flags trip N× too early).
const accountCount =
Array.isArray(pool.connectionIds) && pool.connectionIds.length > 0
? pool.connectionIds.length
: 1;
const effectiveDimensions = plan.dimensions.map((dim) => ({
...dim,
limit: dim.limit * accountCount,
}));
// 4. Get the quota store and call poolUsageWithDimensions (on the interface since v3.8.12)
const store = await getQuotaStore();
let snapshot: PoolUsageSnapshot;
if (plan.dimensions.length > 0) {
snapshot = await store.poolUsageWithDimensions(id, plan.dimensions);
if (effectiveDimensions.length > 0) {
snapshot = await store.poolUsageWithDimensions(id, effectiveDimensions);
} else {
// Fallback: no plan dimensions configured — return minimal snapshot
snapshot = await store.poolUsage(id);

View File

@@ -0,0 +1,146 @@
/**
* tests/unit/quota-pool-usage-summed-budget.test.ts
*
* Regression: GET /api/quota/pools/[id]/usage reported per-account plan limits
* for multi-connection pools while enforce.ts scales every dimension by the
* pool's member-connection count (summed budget, see quota-summed-budget.test.ts).
* A pool with N connections therefore looked ~N× more utilised on the dashboard
* than enforcement actually allowed: a 27-connection pool at 3% real utilisation
* rendered as 81%, and per-key `borrowing` flags tripped N× too early.
*
* The fix scales plan.dimensions by accountCount in the usage route before
* calling poolUsageWithDimensions — the same multiply enforce.ts applies — so
* the snapshot's limit, fairShare, deficit and borrowing all describe the
* budget enforcement really uses.
*
* Levels:
* A (structural): the route computes accountCount with the enforce.ts
* fallback semantics and passes the scaled dimensions to the store.
* B (logic): replicate the store's per-key math to prove that scaling the
* dimension limit corrects fairShare and the borrowing flag for a pool
* shape where the unscaled snapshot misreports both.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(fileURLToPath(import.meta.url), "..", "..", "..");
const read = (rel: string) => readFileSync(join(ROOT, rel), "utf8");
const ROUTE = "src/app/api/quota/pools/[id]/usage/route.ts";
// ---------------------------------------------------------------------------
// Level A — structural: the route applies the summed-budget multiply
// ---------------------------------------------------------------------------
test("usage route computes accountCount with the enforce.ts fallback semantics", () => {
const src = read(ROUTE);
assert.ok(
/Array\.isArray\(pool\.connectionIds\)\s*&&\s*pool\.connectionIds\.length\s*>\s*0/.test(src),
"route must guard connectionIds exactly like enforce.ts"
);
assert.ok(
/\?\s*pool\.connectionIds\.length\s*:\s*1/.test(src),
"route must fall back to accountCount = 1 for legacy pools without connectionIds"
);
});
test("usage route scales every dimension limit by accountCount before calling the store", () => {
const src = read(ROUTE);
assert.ok(
/limit:\s*dim\.limit\s*\*\s*accountCount/.test(src),
"route must multiply dim.limit by accountCount"
);
assert.ok(
/poolUsageWithDimensions\(\s*id,\s*effectiveDimensions\s*\)/.test(src),
"route must pass the scaled dimensions to poolUsageWithDimensions"
);
assert.ok(
!/poolUsageWithDimensions\(\s*id,\s*plan\.dimensions\s*\)/.test(src),
"route must NOT pass the unscaled plan.dimensions to the store"
);
});
test("usage endpoint still wraps the snapshot as { usage: snapshot }", () => {
const src = read(ROUTE);
assert.ok(
/NextResponse\.json\(\s*\{\s*usage:/.test(src),
"endpoint contract from quota-pool-usage-shape.test.ts must survive the fix"
);
});
// ---------------------------------------------------------------------------
// Level B — logic: scaled limits correct fairShare and borrowing
//
// Replicates the per-key math from sqliteQuotaStore.poolUsageWithDimensions:
// fairShare = (weight / 100) × planDim.limit
// borrowing = consumed > fairShare
// for a 27-connection pool where one key consumed more than its per-account
// slice but far less than its share of the summed budget.
// ---------------------------------------------------------------------------
test("summed-budget snapshot: fairShare and borrowing describe the enforced budget", () => {
const PER_ACCOUNT_LIMIT = 66.67; // per-connection plan limit (L)
const ACCOUNT_COUNT = 27; // pool members (N)
const WEIGHT = 3.97; // key's allocation weight (%)
const CONSUMED = 10.75; // above weight% × L, far below weight% × N × L
const perKeySnapshot = (dimLimit: number) => {
const fairShare = (WEIGHT / 100) * dimLimit;
return { fairShare, borrowing: CONSUMED > fairShare };
};
// Unscaled (the bug): the key looks like a borrower at 27× the real threshold.
const unscaled = perKeySnapshot(PER_ACCOUNT_LIMIT);
assert.ok(
unscaled.borrowing,
"sanity: against the per-account limit this consumption reads as borrowing"
);
// Scaled (the fix): same consumption sits comfortably inside the enforced fair share.
const scaled = perKeySnapshot(PER_ACCOUNT_LIMIT * ACCOUNT_COUNT);
assert.equal(
Math.round(scaled.fairShare * 100) / 100,
Math.round(((WEIGHT / 100) * PER_ACCOUNT_LIMIT * ACCOUNT_COUNT) * 100) / 100,
"fairShare must be weight% of the summed budget"
);
assert.equal(
scaled.borrowing,
false,
"a key inside its summed-budget fair share must not be flagged as borrowing"
);
// Utilisation follows the same correction: consumedTotal / limit.
const CONSUMED_TOTAL = 54.09;
const shownUnscaled = CONSUMED_TOTAL / PER_ACCOUNT_LIMIT;
const shownScaled = CONSUMED_TOTAL / (PER_ACCOUNT_LIMIT * ACCOUNT_COUNT);
assert.ok(shownUnscaled > 0.8, "sanity: the bug rendered ~81% utilisation");
assert.ok(shownScaled < 0.035, "the fix renders the real ~3% utilisation");
});
test("summed-budget snapshot: single-connection and legacy pools are unchanged", () => {
const PER_ACCOUNT_LIMIT = 1000;
const accountCount = (pool: { connectionIds?: string[] }) =>
Array.isArray(pool.connectionIds) && pool.connectionIds.length > 0
? pool.connectionIds.length
: 1;
assert.equal(
PER_ACCOUNT_LIMIT * accountCount({ connectionIds: ["conn-a"] }),
PER_ACCOUNT_LIMIT,
"1-connection pool: limit unchanged"
);
assert.equal(
PER_ACCOUNT_LIMIT * accountCount({ connectionIds: [] }),
PER_ACCOUNT_LIMIT,
"empty connectionIds: fallback to 1"
);
assert.equal(
PER_ACCOUNT_LIMIT * accountCount({}),
PER_ACCOUNT_LIMIT,
"legacy pool without connectionIds: fallback to 1"
);
});