@hachej/boring-core 0.1.71 → 0.1.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,13 +13,13 @@ function createDatabase(config) {
13
13
  if (!config.databaseUrl) {
14
14
  throw new Error("databaseUrl is required to create a database connection");
15
15
  }
16
- const sql8 = postgres(config.databaseUrl, {
16
+ const sql10 = postgres(config.databaseUrl, {
17
17
  max: 10,
18
18
  idle_timeout: 20,
19
19
  connect_timeout: 10
20
20
  });
21
- const db = drizzle(sql8);
22
- return { db, sql: sql8 };
21
+ const db = drizzle(sql10);
22
+ return { db, sql: sql10 };
23
23
  }
24
24
 
25
25
  // src/server/db/migrate.ts
@@ -553,6 +553,7 @@ var schema_exports = {};
553
553
  __export(schema_exports, {
554
554
  accounts: () => accounts,
555
555
  accountsRelations: () => accountsRelations,
556
+ budgetReservations: () => budgetReservations,
556
557
  creditGrants: () => creditGrants,
557
558
  creditPurchases: () => creditPurchases,
558
559
  idempotencyKeys: () => idempotencyKeys,
@@ -968,16 +969,17 @@ var usageReservations = pgTable2(
968
969
  )
969
970
  ]
970
971
  );
971
- var modelBudgetReservations = pgTable2(
972
- "boring_model_budget_reservations",
972
+ var budgetReservations = pgTable2(
973
+ "boring_budget_reservations",
973
974
  {
974
975
  id: uuid2("id").default(sql3`gen_random_uuid()`).primaryKey(),
975
976
  userId: text2("user_id").notNull(),
976
977
  workspaceId: text2("workspace_id"),
977
978
  sessionId: text2("session_id"),
978
979
  runId: text2("run_id").notNull(),
979
- provider: text2("provider").notNull(),
980
- model: text2("model").notNull(),
980
+ scope: text2("scope").notNull().default("model"),
981
+ provider: text2("provider"),
982
+ model: text2("model"),
981
983
  period: text2("period").notNull(),
982
984
  amountMicros: bigint("amount_micros", { mode: "number" }).notNull(),
983
985
  status: text2("status").notNull().default("active"),
@@ -985,16 +987,23 @@ var modelBudgetReservations = pgTable2(
985
987
  expiresAt: timestamp2("expires_at").notNull()
986
988
  },
987
989
  (table) => [
988
- uniqueIndex("boring_model_budget_reservations_active_user_run_idx").on(table.userId, table.runId).where(sql3`${table.status} = 'active'`),
989
- index2("boring_model_budget_reservations_budget_idx").on(table.userId, table.provider, table.model, table.period, table.status),
990
- index2("boring_model_budget_reservations_stale_idx").on(table.status, table.expiresAt),
991
- check("boring_model_budget_reservations_amount_check", sql3`${table.amountMicros} > 0`),
990
+ uniqueIndex("boring_budget_reservations_active_user_run_idx").on(table.scope, table.userId, table.runId).where(sql3`${table.status} = 'active'`),
991
+ index2("boring_budget_reservations_budget_idx").on(table.scope, table.userId, table.provider, table.model, table.period, table.status),
992
+ index2("boring_budget_reservations_user_budget_idx").on(table.scope, table.userId, table.period, table.status),
993
+ index2("boring_budget_reservations_stale_idx").on(table.status, table.expiresAt),
994
+ check("boring_budget_reservations_amount_check", sql3`${table.amountMicros} > 0`),
995
+ check("boring_budget_reservations_scope_check", sql3`${table.scope} IN ('model', 'user')`),
992
996
  check(
993
- "boring_model_budget_reservations_status_check",
997
+ "boring_budget_reservations_scope_shape_check",
998
+ sql3`(${table.scope} = 'model' AND ${table.provider} IS NOT NULL AND length(btrim(${table.provider})) > 0 AND ${table.model} IS NOT NULL AND length(btrim(${table.model})) > 0) OR (${table.scope} = 'user' AND ${table.provider} IS NULL AND ${table.model} IS NULL)`
999
+ ),
1000
+ check(
1001
+ "boring_budget_reservations_status_check",
994
1002
  sql3`${table.status} IN ('active', 'settled', 'released', 'expired')`
995
1003
  )
996
1004
  ]
997
1005
  );
1006
+ var modelBudgetReservations = budgetReservations;
998
1007
  var usageLedger = pgTable2(
999
1008
  "boring_usage_ledger",
1000
1009
  {
@@ -2289,14 +2298,33 @@ var PostgresMeteringStore = class {
2289
2298
  providerCostMicros: usageLedger.providerCostMicros,
2290
2299
  billedCostMicros: usageLedger.billedCostMicros,
2291
2300
  stopReason: usageLedger.stopReason,
2292
- reservationId: sql6`${usageLedger.metadata}->>'reservationId'`
2301
+ reservationId: sql6`${usageLedger.metadata}->>'reservationId'`,
2302
+ modelBudgetReservationId: sql6`${usageLedger.metadata}->>'modelBudgetReservationId'`,
2303
+ userBudgetReservationId: sql6`${usageLedger.metadata}->>'userBudgetReservationId'`
2293
2304
  }).from(usageLedger).where(eq3(usageLedger.id, input.usageId)).limit(1);
2294
2305
  const e = existing[0];
2306
+ if (!e) throw new Error(`usage ledger id collision for ${input.usageId}: conflicting row disappeared before idempotency check`);
2295
2307
  const incomingReservationId = input.metadata?.reservationId ?? null;
2296
- const matches = e && e.userId === input.userId && e.runId === (input.runId ?? null) && e.messageId === (input.messageId ?? null) && e.source === (input.source ?? "") && e.provider === (input.provider ?? null) && e.model === (input.model ?? null) && e.inputTokens === (input.inputTokens ?? 0) && e.outputTokens === (input.outputTokens ?? 0) && e.cacheReadTokens === (input.cacheReadTokens ?? 0) && e.cacheWriteTokens === (input.cacheWriteTokens ?? 0) && e.providerCostMicros === (input.providerCostMicros ?? 0) && e.billedCostMicros === input.billedCostMicros && e.stopReason === (input.stopReason ?? null) && e.reservationId === incomingReservationId;
2308
+ const incomingModelBudgetReservationId = input.metadata?.modelBudgetReservationId ?? null;
2309
+ const incomingUserBudgetReservationId = input.metadata?.userBudgetReservationId ?? null;
2310
+ const legacyMetadataCanBeBackfilled = (existing2, incoming) => existing2 === null && incoming !== null;
2311
+ const retryMetadataIsAbsent = (existing2, incoming) => existing2 !== null && incoming === null;
2312
+ const metadataFieldIsIdempotent = (existing2, incoming) => existing2 === incoming || legacyMetadataCanBeBackfilled(existing2, incoming) || retryMetadataIsAbsent(existing2, incoming);
2313
+ const exactBudgetMetadataMatch = e.modelBudgetReservationId === incomingModelBudgetReservationId && e.userBudgetReservationId === incomingUserBudgetReservationId;
2314
+ const backfillableBudgetMetadata = !exactBudgetMetadataMatch && (legacyMetadataCanBeBackfilled(e.modelBudgetReservationId, incomingModelBudgetReservationId) || legacyMetadataCanBeBackfilled(e.userBudgetReservationId, incomingUserBudgetReservationId)) && metadataFieldIsIdempotent(e.modelBudgetReservationId, incomingModelBudgetReservationId) && metadataFieldIsIdempotent(e.userBudgetReservationId, incomingUserBudgetReservationId);
2315
+ const budgetMetadataIsIdempotent = exactBudgetMetadataMatch || backfillableBudgetMetadata || (retryMetadataIsAbsent(e.modelBudgetReservationId, incomingModelBudgetReservationId) || retryMetadataIsAbsent(e.userBudgetReservationId, incomingUserBudgetReservationId)) && metadataFieldIsIdempotent(e.modelBudgetReservationId, incomingModelBudgetReservationId) && metadataFieldIsIdempotent(e.userBudgetReservationId, incomingUserBudgetReservationId);
2316
+ const matches = e && e.userId === input.userId && e.runId === (input.runId ?? null) && e.messageId === (input.messageId ?? null) && e.source === (input.source ?? "") && e.provider === (input.provider ?? null) && e.model === (input.model ?? null) && e.inputTokens === (input.inputTokens ?? 0) && e.outputTokens === (input.outputTokens ?? 0) && e.cacheReadTokens === (input.cacheReadTokens ?? 0) && e.cacheWriteTokens === (input.cacheWriteTokens ?? 0) && e.providerCostMicros === (input.providerCostMicros ?? 0) && e.billedCostMicros === input.billedCostMicros && e.stopReason === (input.stopReason ?? null) && e.reservationId === incomingReservationId && budgetMetadataIsIdempotent;
2297
2317
  if (!matches) {
2298
2318
  throw new Error(`usage ledger id collision for ${input.usageId}: existing row does not match this usage (refusing to silently drop the debit or corrupt the audit trail)`);
2299
2319
  }
2320
+ if (!exactBudgetMetadataMatch && backfillableBudgetMetadata) {
2321
+ const metadataPatch = {};
2322
+ if (legacyMetadataCanBeBackfilled(e.modelBudgetReservationId, incomingModelBudgetReservationId)) metadataPatch.modelBudgetReservationId = incomingModelBudgetReservationId;
2323
+ if (legacyMetadataCanBeBackfilled(e.userBudgetReservationId, incomingUserBudgetReservationId)) metadataPatch.userBudgetReservationId = incomingUserBudgetReservationId;
2324
+ await tx.update(usageLedger).set({
2325
+ metadata: sql6`COALESCE(${usageLedger.metadata}, '{}'::jsonb) || ${JSON.stringify(metadataPatch)}::jsonb`
2326
+ }).where(eq3(usageLedger.id, input.usageId));
2327
+ }
2300
2328
  return { inserted: false };
2301
2329
  });
2302
2330
  }
@@ -2452,8 +2480,157 @@ function describeUsage(source) {
2452
2480
  return { kind: "usage", description: "Agent usage" };
2453
2481
  }
2454
2482
 
2455
- // src/server/db/stores/PostgresModelBudgetStore.ts
2456
- import { and as and3, eq as eq4, inArray as inArray2, lt as lt2, or as or2, sql as sql7 } from "drizzle-orm";
2483
+ // src/server/db/stores/PostgresBudgetReservationStore.ts
2484
+ import { and as and3, eq as eq4, inArray as inArray3, lt as lt2, or as or2, sql as sql9 } from "drizzle-orm";
2485
+
2486
+ // src/server/db/stores/BudgetReservationSupport.ts
2487
+ import { sql as sql7 } from "drizzle-orm";
2488
+ function assertPositiveSafeInteger(name, value) {
2489
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer`);
2490
+ }
2491
+ function monthPeriodUtc(now) {
2492
+ const year = now.getUTCFullYear();
2493
+ const month = now.getUTCMonth();
2494
+ return {
2495
+ period: `${year}-${String(month + 1).padStart(2, "0")}`,
2496
+ start: new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)),
2497
+ end: new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0))
2498
+ };
2499
+ }
2500
+ function chunkIds(ids, size = 1e3) {
2501
+ const chunks = [];
2502
+ for (let index3 = 0; index3 < ids.length; index3 += size) chunks.push(ids.slice(index3, index3 + size));
2503
+ return chunks;
2504
+ }
2505
+ async function lockBudgetTargets(executor, targets, keyForTarget) {
2506
+ const keys = Array.from(new Set(targets.map(keyForTarget))).sort();
2507
+ for (const key of keys) await executor.execute(sql7`SELECT pg_advisory_xact_lock(hashtext(${key}))`);
2508
+ }
2509
+
2510
+ // src/server/db/stores/BudgetSpendAttribution.ts
2511
+ import { inArray as inArray2, sql as sql8 } from "drizzle-orm";
2512
+ var budgetSpendAttributionStrategies = {
2513
+ model: { usageMicros: modelUsageMicros },
2514
+ user: { usageMicros: userUsageMicros }
2515
+ };
2516
+ async function computeBudgetSpend(tx, input, period, start, end, eligibleLegacySources, totals) {
2517
+ const strategy = budgetSpendAttributionStrategies[input.scope];
2518
+ return totals(input, await strategy.usageMicros(tx, input, period, start, end, eligibleLegacySources));
2519
+ }
2520
+ async function modelUsageMicros(tx, input, period, start, end) {
2521
+ const periodStartIso = start.toISOString();
2522
+ const periodEndIso = end.toISOString();
2523
+ const metadataExpr = sql8`${usageLedger.metadata}->>'modelBudgetReservationId'`;
2524
+ const usageRows = await tx.execute(sql8`
2525
+ SELECT COALESCE(SUM(${usageLedger.billedCostMicros}), 0)::text AS total
2526
+ FROM ${usageLedger}
2527
+ WHERE ${usageLedger.userId} = ${input.userId}
2528
+ AND ${usageLedger.provider} = ${input.provider}
2529
+ AND ${usageLedger.model} = ${input.model}
2530
+ AND (
2531
+ EXISTS (
2532
+ SELECT 1 FROM ${budgetReservations} r
2533
+ WHERE r.scope = 'model'
2534
+ AND r.user_id = ${usageLedger.userId}
2535
+ AND r.provider = ${usageLedger.provider}
2536
+ AND r.model = ${usageLedger.model}
2537
+ AND r.run_id = ${usageLedger.runId}
2538
+ AND r.period = ${period}
2539
+ AND r.status NOT IN ('active', 'settled')
2540
+ AND (
2541
+ ${metadataExpr} = r.id::text
2542
+ OR (
2543
+ ${metadataExpr} IS NULL
2544
+ AND r.created_at <= ${usageLedger.createdAt}
2545
+ AND NOT EXISTS (
2546
+ SELECT 1 FROM ${budgetReservations} newer
2547
+ WHERE newer.scope = 'model'
2548
+ AND newer.user_id = r.user_id
2549
+ AND newer.provider = r.provider
2550
+ AND newer.model = r.model
2551
+ AND newer.run_id = r.run_id
2552
+ AND newer.created_at <= ${usageLedger.createdAt}
2553
+ AND (newer.created_at > r.created_at OR (newer.created_at = r.created_at AND newer.id > r.id))
2554
+ )
2555
+ )
2556
+ )
2557
+ )
2558
+ OR (
2559
+ ${metadataExpr} IS NULL
2560
+ AND ${usageLedger.createdAt} >= ${periodStartIso}::timestamp
2561
+ AND ${usageLedger.createdAt} < ${periodEndIso}::timestamp
2562
+ AND NOT EXISTS (
2563
+ SELECT 1 FROM ${budgetReservations} r
2564
+ WHERE r.scope = 'model'
2565
+ AND r.user_id = ${usageLedger.userId}
2566
+ AND r.provider = ${usageLedger.provider}
2567
+ AND r.model = ${usageLedger.model}
2568
+ AND r.run_id = ${usageLedger.runId}
2569
+ AND r.created_at <= ${usageLedger.createdAt}
2570
+ )
2571
+ )
2572
+ )
2573
+ `);
2574
+ return Number(usageRows[0]?.total ?? 0);
2575
+ }
2576
+ async function userUsageMicros(tx, input, period, start, end, eligibleLegacySources) {
2577
+ const periodStartIso = start.toISOString();
2578
+ const periodEndIso = end.toISOString();
2579
+ const metadataExpr = sql8`${usageLedger.metadata}->>'userBudgetReservationId'`;
2580
+ const legacySourcePredicate = inArray2(usageLedger.source, [...eligibleLegacySources]);
2581
+ const usageRows = await tx.execute(sql8`
2582
+ SELECT COALESCE(SUM(${usageLedger.billedCostMicros}), 0)::text AS total
2583
+ FROM ${usageLedger}
2584
+ WHERE ${usageLedger.userId} = ${input.userId}
2585
+ AND (
2586
+ EXISTS (
2587
+ SELECT 1 FROM ${budgetReservations} r
2588
+ WHERE r.scope = 'user'
2589
+ AND r.id::text = ${metadataExpr}
2590
+ AND r.user_id = ${usageLedger.userId}
2591
+ AND r.period = ${period}
2592
+ AND r.status NOT IN ('active', 'settled')
2593
+ )
2594
+ OR (
2595
+ ${legacySourcePredicate}
2596
+ AND ${metadataExpr} IS NULL
2597
+ AND EXISTS (
2598
+ SELECT 1 FROM ${budgetReservations} r
2599
+ WHERE r.scope = 'user'
2600
+ AND r.user_id = ${usageLedger.userId}
2601
+ AND r.run_id = ${usageLedger.runId}
2602
+ AND r.period = ${period}
2603
+ AND r.status NOT IN ('active', 'settled')
2604
+ AND r.created_at <= ${usageLedger.createdAt}
2605
+ AND NOT EXISTS (
2606
+ SELECT 1 FROM ${budgetReservations} newer
2607
+ WHERE newer.scope = 'user'
2608
+ AND newer.user_id = r.user_id
2609
+ AND newer.run_id = r.run_id
2610
+ AND newer.created_at <= ${usageLedger.createdAt}
2611
+ AND (newer.created_at > r.created_at OR (newer.created_at = r.created_at AND newer.id > r.id))
2612
+ )
2613
+ )
2614
+ )
2615
+ OR (
2616
+ ${legacySourcePredicate}
2617
+ AND ${metadataExpr} IS NULL
2618
+ AND ${usageLedger.createdAt} >= ${periodStartIso}::timestamp
2619
+ AND ${usageLedger.createdAt} < ${periodEndIso}::timestamp
2620
+ AND NOT EXISTS (
2621
+ SELECT 1 FROM ${budgetReservations} r
2622
+ WHERE r.scope = 'user'
2623
+ AND r.user_id = ${usageLedger.userId}
2624
+ AND r.run_id = ${usageLedger.runId}
2625
+ AND r.created_at <= ${usageLedger.createdAt}
2626
+ )
2627
+ )
2628
+ )
2629
+ `);
2630
+ return Number(usageRows[0]?.total ?? 0);
2631
+ }
2632
+
2633
+ // src/server/db/stores/PostgresBudgetReservationStore.ts
2457
2634
  var ModelBudgetExceededError = class extends Error {
2458
2635
  constructor(usedMicros, heldMicros, budgetMicros, requestedMicros) {
2459
2636
  super("Budget reached for this model.");
@@ -2470,221 +2647,210 @@ var ModelBudgetExceededError = class extends Error {
2470
2647
  statusCode = 402;
2471
2648
  code = "MODEL_BUDGET_EXCEEDED";
2472
2649
  };
2473
- var UPDATE_ID_BATCH_SIZE = 1e3;
2474
- function assertPositiveSafeInteger(name, value) {
2475
- if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer`);
2650
+ var UserBudgetExceededError = class extends Error {
2651
+ constructor(usedMicros, heldMicros, budgetMicros, requestedMicros) {
2652
+ super("Budget reached for this user.");
2653
+ this.usedMicros = usedMicros;
2654
+ this.heldMicros = heldMicros;
2655
+ this.budgetMicros = budgetMicros;
2656
+ this.requestedMicros = requestedMicros;
2657
+ this.name = "UserBudgetExceededError";
2658
+ }
2659
+ usedMicros;
2660
+ heldMicros;
2661
+ budgetMicros;
2662
+ requestedMicros;
2663
+ statusCode = 402;
2664
+ code = "MODEL_BUDGET_EXCEEDED";
2665
+ };
2666
+ function toBudgetReservationRow(row) {
2667
+ if (row.scope !== "model" && row.scope !== "user") throw new Error(`invalid budget reservation scope: ${row.scope}`);
2668
+ return { id: row.id, scope: row.scope, userId: row.userId, provider: row.provider ?? null, model: row.model ?? null, period: row.period };
2476
2669
  }
2477
- function monthPeriodUtc(now) {
2478
- const year = now.getUTCFullYear();
2479
- const month = now.getUTCMonth();
2480
- return {
2481
- period: `${year}-${String(month + 1).padStart(2, "0")}`,
2482
- start: new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)),
2483
- end: new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0))
2484
- };
2670
+ function requireModelBudgetInput(input) {
2671
+ if (input.scope !== "model") throw new Error("expected model budget input");
2672
+ return input;
2673
+ }
2674
+ var budgetScopePolicies = {
2675
+ model: {
2676
+ metadataKey: "modelBudgetReservationId",
2677
+ lockKey: (target) => `model-budget:${target.userId}:${target.provider ?? ""}:${target.model ?? ""}:${target.period}`,
2678
+ assertInput: (input) => {
2679
+ const modelInput = requireModelBudgetInput(input);
2680
+ if (!modelInput.provider.trim() || !modelInput.model.trim()) throw new Error("reserve requires provider/model");
2681
+ },
2682
+ sameReservation: (existing, input, period) => {
2683
+ const modelInput = requireModelBudgetInput(input);
2684
+ return existing.period === period && existing.provider === modelInput.provider && existing.model === modelInput.model;
2685
+ },
2686
+ exceededError: ModelBudgetExceededError,
2687
+ totalFilter: (input, period) => {
2688
+ const modelInput = requireModelBudgetInput(input);
2689
+ return sql9`scope = 'model' AND user_id = ${modelInput.userId} AND provider = ${modelInput.provider} AND model = ${modelInput.model} AND period = ${period}`;
2690
+ },
2691
+ scopeBudgetWhere: (input) => {
2692
+ const modelInput = requireModelBudgetInput(input);
2693
+ return [eq4(budgetReservations.provider, modelInput.provider), eq4(budgetReservations.model, modelInput.model)];
2694
+ }
2695
+ },
2696
+ user: {
2697
+ metadataKey: "userBudgetReservationId",
2698
+ lockKey: (target) => `user-budget:${target.userId}:${target.period}`,
2699
+ assertInput: () => {
2700
+ },
2701
+ sameReservation: (existing, _input, period) => existing.period === period,
2702
+ exceededError: UserBudgetExceededError,
2703
+ totalFilter: (input, period) => sql9`scope = 'user' AND user_id = ${input.userId} AND period = ${period}`,
2704
+ scopeBudgetWhere: () => []
2705
+ }
2706
+ };
2707
+ function scopePolicy(scope) {
2708
+ return budgetScopePolicies[scope];
2709
+ }
2710
+ function budgetLockKey(target) {
2711
+ return scopePolicy(target.scope).lockKey(target);
2485
2712
  }
2486
2713
  function requireFinishKey(input) {
2487
- if (input.reservationId) return eq4(modelBudgetReservations.id, input.reservationId);
2714
+ if (input.reservationId) return and3(eq4(budgetReservations.id, input.reservationId), eq4(budgetReservations.scope, input.scope));
2488
2715
  if (!input.runId || !input.userId) throw new Error("finish requires reservationId or runId+userId");
2489
- return and3(eq4(modelBudgetReservations.runId, input.runId), eq4(modelBudgetReservations.userId, input.userId));
2716
+ return and3(eq4(budgetReservations.runId, input.runId), eq4(budgetReservations.userId, input.userId), eq4(budgetReservations.scope, input.scope));
2490
2717
  }
2491
- function chunkIds(ids) {
2492
- const chunks = [];
2493
- for (let index3 = 0; index3 < ids.length; index3 += UPDATE_ID_BATCH_SIZE) {
2494
- chunks.push(ids.slice(index3, index3 + UPDATE_ID_BATCH_SIZE));
2495
- }
2496
- return chunks;
2718
+ function metadataKey(scope) {
2719
+ return scopePolicy(scope).metadataKey;
2497
2720
  }
2498
- function modelBudgetAdvisoryLockKey(target) {
2499
- return `model-budget:${target.userId}:${target.provider}:${target.model}:${target.period}`;
2721
+ function admissionFromResults(results) {
2722
+ const user = results.find((result) => result.scope === "user");
2723
+ const model = results.find((result) => result.scope === "model");
2724
+ if (!model) throw new Error("budget admission requires a model reservation");
2725
+ const handles = user ? [user, model] : [model];
2726
+ return { user, model };
2500
2727
  }
2501
- async function lockModelBudgetTargets(executor, targets) {
2502
- const keys = Array.from(new Set(targets.map(modelBudgetAdvisoryLockKey))).sort();
2503
- for (const key of keys) {
2504
- await executor.execute(sql7`SELECT pg_advisory_xact_lock(hashtext(${key}))`);
2505
- }
2728
+ function normalizeTarget(input, period) {
2729
+ return { scope: input.scope, userId: input.userId, provider: input.provider ?? null, model: input.model ?? null, period };
2506
2730
  }
2507
- var PostgresModelBudgetStore = class {
2508
- constructor(db) {
2731
+ function assertReserveInput(input) {
2732
+ if (!input.userId) throw new Error("reserve requires userId");
2733
+ scopePolicy(input.scope).assertInput(input);
2734
+ assertPositiveSafeInteger("budgetMicros", input.budgetMicros);
2735
+ assertPositiveSafeInteger("holdMicros", input.holdMicros);
2736
+ if (!Number.isSafeInteger(input.ttlSeconds) || input.ttlSeconds <= 0) throw new Error("ttlSeconds must be a positive safe integer");
2737
+ }
2738
+ var PostgresBudgetReservationStore = class {
2739
+ constructor(db, options = {}) {
2509
2740
  this.db = db;
2741
+ this.options = options;
2510
2742
  }
2511
2743
  db;
2744
+ options;
2512
2745
  static monthPeriodUtc(now = /* @__PURE__ */ new Date()) {
2513
2746
  return monthPeriodUtc(now).period;
2514
2747
  }
2515
2748
  async sweepExpired(now = /* @__PURE__ */ new Date()) {
2516
2749
  return this.db.transaction(async (tx) => {
2517
2750
  const expired = await tx.select({
2518
- id: modelBudgetReservations.id,
2519
- userId: modelBudgetReservations.userId,
2520
- provider: modelBudgetReservations.provider,
2521
- model: modelBudgetReservations.model,
2522
- period: modelBudgetReservations.period
2523
- }).from(modelBudgetReservations).where(and3(eq4(modelBudgetReservations.status, "active"), lt2(modelBudgetReservations.expiresAt, now)));
2751
+ id: budgetReservations.id,
2752
+ scope: budgetReservations.scope,
2753
+ userId: budgetReservations.userId,
2754
+ provider: budgetReservations.provider,
2755
+ model: budgetReservations.model,
2756
+ period: budgetReservations.period
2757
+ }).from(budgetReservations).where(and3(eq4(budgetReservations.status, "active"), lt2(budgetReservations.expiresAt, now)));
2524
2758
  if (expired.length === 0) return 0;
2525
- await lockModelBudgetTargets(tx, expired);
2759
+ await lockBudgetTargets(tx, expired.map(toBudgetReservationRow), budgetLockKey);
2526
2760
  let count = 0;
2527
2761
  for (const ids of chunkIds(expired.map((row) => row.id))) {
2528
- const rows = await tx.update(modelBudgetReservations).set({ status: "expired" }).where(and3(
2529
- inArray2(modelBudgetReservations.id, ids),
2530
- eq4(modelBudgetReservations.status, "active"),
2531
- lt2(modelBudgetReservations.expiresAt, now)
2532
- )).returning({ id: modelBudgetReservations.id });
2762
+ const rows = await tx.update(budgetReservations).set({ status: "expired" }).where(and3(
2763
+ inArray3(budgetReservations.id, ids),
2764
+ eq4(budgetReservations.status, "active"),
2765
+ lt2(budgetReservations.expiresAt, now)
2766
+ )).returning({ id: budgetReservations.id });
2533
2767
  count += rows.length;
2534
2768
  }
2535
2769
  return count;
2536
2770
  });
2537
2771
  }
2538
2772
  async reserve(input) {
2539
- if (!input.userId) throw new Error("reserve requires userId");
2540
- if (!input.provider || !input.model) throw new Error("reserve requires provider/model");
2541
- assertPositiveSafeInteger("budgetMicros", input.budgetMicros);
2542
- assertPositiveSafeInteger("holdMicros", input.holdMicros);
2543
- if (!Number.isSafeInteger(input.ttlSeconds) || input.ttlSeconds <= 0) throw new Error("ttlSeconds must be a positive safe integer");
2773
+ assertReserveInput(input);
2544
2774
  const now = input.now ?? /* @__PURE__ */ new Date();
2775
+ return this.db.transaction((tx) => this.reserveInTransaction(tx, input, now));
2776
+ }
2777
+ async reserveAdmission(input) {
2778
+ return admissionFromResults(await this.reserveMany(input.user ? [input.user, input.model] : [input.model]));
2779
+ }
2780
+ async reserveMany(inputs) {
2781
+ const now = inputs[0]?.now ?? /* @__PURE__ */ new Date();
2782
+ const period = monthPeriodUtc(now).period;
2783
+ for (const input of inputs) {
2784
+ assertReserveInput(input);
2785
+ if (input.now && input.now.getTime() !== now.getTime()) throw new Error("reserveMany requires all item clocks to match");
2786
+ }
2787
+ return this.db.transaction(async (tx) => {
2788
+ await lockBudgetTargets(tx, inputs.map((input) => normalizeTarget(input, period)), budgetLockKey);
2789
+ const results = [];
2790
+ for (const input of inputs) results.push(await this.reserveInTransaction(tx, { ...input, now }, now));
2791
+ return results;
2792
+ });
2793
+ }
2794
+ async reserveInTransaction(tx, input, now) {
2545
2795
  const period = monthPeriodUtc(now);
2546
2796
  const expiresAt = new Date(now.getTime() + input.ttlSeconds * 1e3);
2547
- return this.db.transaction(async (tx) => {
2548
- const expired = await tx.select({
2549
- id: modelBudgetReservations.id,
2550
- userId: modelBudgetReservations.userId,
2551
- provider: modelBudgetReservations.provider,
2552
- model: modelBudgetReservations.model,
2553
- period: modelBudgetReservations.period
2554
- }).from(modelBudgetReservations).where(and3(
2555
- eq4(modelBudgetReservations.status, "active"),
2556
- lt2(modelBudgetReservations.expiresAt, now),
2557
- or2(
2558
- and3(
2559
- eq4(modelBudgetReservations.userId, input.userId),
2560
- eq4(modelBudgetReservations.provider, input.provider),
2561
- eq4(modelBudgetReservations.model, input.model),
2562
- eq4(modelBudgetReservations.period, period.period)
2563
- ),
2564
- and3(
2565
- eq4(modelBudgetReservations.userId, input.userId),
2566
- eq4(modelBudgetReservations.runId, input.runId)
2567
- )
2568
- )
2797
+ const expired = await tx.select({
2798
+ id: budgetReservations.id,
2799
+ scope: budgetReservations.scope,
2800
+ userId: budgetReservations.userId,
2801
+ provider: budgetReservations.provider,
2802
+ model: budgetReservations.model,
2803
+ period: budgetReservations.period
2804
+ }).from(budgetReservations).where(and3(
2805
+ eq4(budgetReservations.status, "active"),
2806
+ lt2(budgetReservations.expiresAt, now),
2807
+ or2(
2808
+ this.scopeBudgetWhere(input, period.period),
2809
+ and3(eq4(budgetReservations.scope, input.scope), eq4(budgetReservations.userId, input.userId), eq4(budgetReservations.runId, input.runId))
2810
+ )
2811
+ ));
2812
+ await lockBudgetTargets(tx, [normalizeTarget(input, period.period), ...expired.map(toBudgetReservationRow)], budgetLockKey);
2813
+ for (const ids of chunkIds(expired.map((row) => row.id))) {
2814
+ await tx.update(budgetReservations).set({ status: "expired" }).where(and3(
2815
+ inArray3(budgetReservations.id, ids),
2816
+ eq4(budgetReservations.status, "active"),
2817
+ lt2(budgetReservations.expiresAt, now)
2569
2818
  ));
2570
- await lockModelBudgetTargets(tx, [
2571
- { userId: input.userId, provider: input.provider, model: input.model, period: period.period },
2572
- ...expired
2573
- ]);
2574
- for (const ids of chunkIds(expired.map((row) => row.id))) {
2575
- await tx.update(modelBudgetReservations).set({ status: "expired" }).where(and3(
2576
- inArray2(modelBudgetReservations.id, ids),
2577
- eq4(modelBudgetReservations.status, "active"),
2578
- lt2(modelBudgetReservations.expiresAt, now)
2579
- ));
2580
- }
2581
- const existing = await tx.select({
2582
- id: modelBudgetReservations.id,
2583
- provider: modelBudgetReservations.provider,
2584
- model: modelBudgetReservations.model,
2585
- period: modelBudgetReservations.period
2586
- }).from(modelBudgetReservations).where(and3(
2587
- eq4(modelBudgetReservations.status, "active"),
2588
- eq4(modelBudgetReservations.userId, input.userId),
2589
- eq4(modelBudgetReservations.runId, input.runId)
2590
- )).limit(1);
2591
- const prior = existing[0];
2592
- if (prior) {
2593
- if (prior.provider !== input.provider || prior.model !== input.model || prior.period !== period.period) {
2594
- throw new Error(`active model budget reservation for run ${input.runId} has conflicting tuple`);
2595
- }
2596
- return { reservationId: prior.id, created: false, period: period.period };
2597
- }
2598
- const periodStartIso = period.start.toISOString();
2599
- const periodEndIso = period.end.toISOString();
2600
- const usageRows = await tx.execute(sql7`
2601
- SELECT COALESCE(SUM(${usageLedger.billedCostMicros}), 0)::text AS total
2602
- FROM ${usageLedger}
2603
- WHERE ${usageLedger.userId} = ${input.userId}
2604
- AND ${usageLedger.provider} = ${input.provider}
2605
- AND ${usageLedger.model} = ${input.model}
2606
- AND (
2607
- (
2608
- EXISTS (
2609
- SELECT 1 FROM ${modelBudgetReservations} r
2610
- WHERE r.user_id = ${usageLedger.userId}
2611
- AND r.provider = ${usageLedger.provider}
2612
- AND r.model = ${usageLedger.model}
2613
- AND r.run_id = ${usageLedger.runId}
2614
- AND r.period = ${period.period}
2615
- AND r.status NOT IN ('active', 'settled')
2616
- AND (
2617
- ${usageLedger.metadata}->>'modelBudgetReservationId' = r.id::text
2618
- OR (
2619
- ${usageLedger.metadata}->>'modelBudgetReservationId' IS NULL
2620
- AND r.created_at <= ${usageLedger.createdAt}
2621
- AND NOT EXISTS (
2622
- SELECT 1 FROM ${modelBudgetReservations} newer
2623
- WHERE newer.user_id = r.user_id
2624
- AND newer.provider = r.provider
2625
- AND newer.model = r.model
2626
- AND newer.run_id = r.run_id
2627
- AND newer.created_at <= ${usageLedger.createdAt}
2628
- AND (
2629
- newer.created_at > r.created_at
2630
- OR (newer.created_at = r.created_at AND newer.id > r.id)
2631
- )
2632
- )
2633
- )
2634
- )
2635
- )
2636
- )
2637
- OR (
2638
- ${usageLedger.createdAt} >= ${periodStartIso}::timestamp
2639
- AND ${usageLedger.createdAt} < ${periodEndIso}::timestamp
2640
- AND ${usageLedger.metadata}->>'modelBudgetReservationId' IS NULL
2641
- AND NOT EXISTS (
2642
- SELECT 1 FROM ${modelBudgetReservations} r
2643
- WHERE r.user_id = ${usageLedger.userId}
2644
- AND r.provider = ${usageLedger.provider}
2645
- AND r.model = ${usageLedger.model}
2646
- AND r.run_id = ${usageLedger.runId}
2647
- AND r.created_at <= ${usageLedger.createdAt}
2648
- )
2649
- )
2650
- )
2651
- `);
2652
- const heldRows = await tx.execute(sql7`
2653
- SELECT COALESCE(SUM(amount_micros), 0)::text AS total
2654
- FROM ${modelBudgetReservations}
2655
- WHERE status = 'active'
2656
- AND user_id = ${input.userId}
2657
- AND provider = ${input.provider}
2658
- AND model = ${input.model}
2659
- AND period = ${period.period}
2660
- `);
2661
- const settledFallbackRows = await tx.execute(sql7`
2662
- SELECT COALESCE(SUM(amount_micros), 0)::text AS total
2663
- FROM ${modelBudgetReservations}
2664
- WHERE status = 'settled'
2665
- AND user_id = ${input.userId}
2666
- AND provider = ${input.provider}
2667
- AND model = ${input.model}
2668
- AND period = ${period.period}
2669
- `);
2670
- const usedMicros = Number(usageRows[0]?.total ?? 0) + Number(settledFallbackRows[0]?.total ?? 0);
2671
- const heldMicros = Number(heldRows[0]?.total ?? 0);
2672
- if (usedMicros + heldMicros + input.holdMicros > input.budgetMicros) {
2673
- throw new ModelBudgetExceededError(usedMicros, heldMicros, input.budgetMicros, input.holdMicros);
2674
- }
2675
- const inserted = await tx.insert(modelBudgetReservations).values({
2676
- userId: input.userId,
2677
- workspaceId: input.workspaceId ?? null,
2678
- sessionId: input.sessionId ?? null,
2679
- runId: input.runId,
2680
- provider: input.provider,
2681
- model: input.model,
2682
- period: period.period,
2683
- amountMicros: input.holdMicros,
2684
- expiresAt
2685
- }).returning({ id: modelBudgetReservations.id });
2686
- return { reservationId: inserted[0].id, created: true, period: period.period };
2687
- });
2819
+ }
2820
+ const existing = await tx.select({
2821
+ id: budgetReservations.id,
2822
+ provider: budgetReservations.provider,
2823
+ model: budgetReservations.model,
2824
+ period: budgetReservations.period
2825
+ }).from(budgetReservations).where(and3(
2826
+ eq4(budgetReservations.status, "active"),
2827
+ eq4(budgetReservations.scope, input.scope),
2828
+ eq4(budgetReservations.userId, input.userId),
2829
+ eq4(budgetReservations.runId, input.runId)
2830
+ )).limit(1);
2831
+ const prior = existing[0];
2832
+ if (prior) {
2833
+ if (!scopePolicy(input.scope).sameReservation(prior, input, period.period)) throw new Error(`active ${input.scope} budget reservation for run ${input.runId} has conflicting target`);
2834
+ return { scope: input.scope, reservationId: prior.id, created: false, period: period.period };
2835
+ }
2836
+ const { usedMicros, heldMicros } = await this.spend(tx, input, period.period, period.start, period.end);
2837
+ if (usedMicros + heldMicros + input.holdMicros > input.budgetMicros) {
2838
+ const ErrorCtor = scopePolicy(input.scope).exceededError;
2839
+ throw new ErrorCtor(usedMicros, heldMicros, input.budgetMicros, input.holdMicros);
2840
+ }
2841
+ const inserted = await tx.insert(budgetReservations).values({
2842
+ userId: input.userId,
2843
+ workspaceId: input.workspaceId ?? null,
2844
+ sessionId: input.sessionId ?? null,
2845
+ runId: input.runId,
2846
+ scope: input.scope,
2847
+ provider: input.provider ?? null,
2848
+ model: input.model ?? null,
2849
+ period: period.period,
2850
+ amountMicros: input.holdMicros,
2851
+ expiresAt
2852
+ }).returning({ id: budgetReservations.id });
2853
+ return { scope: input.scope, reservationId: inserted[0].id, created: true, period: period.period };
2688
2854
  }
2689
2855
  async settle(input) {
2690
2856
  await this.finish(input, "settled");
@@ -2692,23 +2858,126 @@ var PostgresModelBudgetStore = class {
2692
2858
  async release(input) {
2693
2859
  await this.finish(input, "released");
2694
2860
  }
2695
- async finish(input, status) {
2861
+ metadataForAdmission(admission) {
2862
+ return Object.fromEntries(this.handlesForAdmission(admission).map((result) => [metadataKey(result.scope), result.reservationId]));
2863
+ }
2864
+ async releaseCreated(admission) {
2865
+ const created = this.handlesForAdmission(admission).filter((handle) => handle.created).map((handle) => ({ scope: handle.scope, reservationId: handle.reservationId }));
2866
+ if (created.length > 0) await this.finishMany(created, "released");
2867
+ }
2868
+ async finishAdmission(admission, status) {
2869
+ await this.finishMany(this.handlesForAdmission(admission).map((handle) => ({ scope: handle.scope, reservationId: handle.reservationId })), status);
2870
+ }
2871
+ handlesForAdmission(admission) {
2872
+ return admission.user ? [admission.user, admission.model] : [admission.model];
2873
+ }
2874
+ async finishRun(input, status) {
2875
+ await this.finishMany(["model", "user"].map((scope) => ({ scope, runId: input.runId, userId: input.userId })), status);
2876
+ }
2877
+ async finishMany(inputs, status) {
2696
2878
  await this.db.transaction(async (tx) => {
2697
- const active = await tx.select({
2698
- id: modelBudgetReservations.id,
2699
- userId: modelBudgetReservations.userId,
2700
- provider: modelBudgetReservations.provider,
2701
- model: modelBudgetReservations.model,
2702
- period: modelBudgetReservations.period
2703
- }).from(modelBudgetReservations).where(and3(requireFinishKey(input), eq4(modelBudgetReservations.status, "active")));
2879
+ const active = [];
2880
+ for (const input of inputs) {
2881
+ const scope = input.scope;
2882
+ const rows = await tx.select({
2883
+ id: budgetReservations.id,
2884
+ scope: budgetReservations.scope,
2885
+ userId: budgetReservations.userId,
2886
+ provider: budgetReservations.provider,
2887
+ model: budgetReservations.model,
2888
+ period: budgetReservations.period
2889
+ }).from(budgetReservations).where(and3(requireFinishKey({ ...input, scope }), eq4(budgetReservations.status, "active")));
2890
+ active.push(...rows.map(toBudgetReservationRow));
2891
+ }
2704
2892
  if (active.length === 0) return;
2705
- await lockModelBudgetTargets(tx, active);
2706
- await tx.update(modelBudgetReservations).set({ status }).where(and3(
2707
- inArray2(modelBudgetReservations.id, active.map((row) => row.id)),
2708
- eq4(modelBudgetReservations.status, "active")
2709
- ));
2893
+ await lockBudgetTargets(tx, active, budgetLockKey);
2894
+ for (const ids of chunkIds(active.map((row) => row.id))) {
2895
+ await tx.update(budgetReservations).set({ status }).where(and3(
2896
+ inArray3(budgetReservations.id, ids),
2897
+ eq4(budgetReservations.status, "active")
2898
+ ));
2899
+ }
2710
2900
  });
2711
2901
  }
2902
+ scopeBudgetWhere(input, period) {
2903
+ const base = [
2904
+ eq4(budgetReservations.scope, input.scope),
2905
+ eq4(budgetReservations.status, "active"),
2906
+ eq4(budgetReservations.userId, input.userId),
2907
+ eq4(budgetReservations.period, period)
2908
+ ];
2909
+ base.push(...scopePolicy(input.scope).scopeBudgetWhere(input));
2910
+ return and3(...base);
2911
+ }
2912
+ async reservationTotal(tx, input, period, status) {
2913
+ const scopeFilter = scopePolicy(input.scope).totalFilter(input, period);
2914
+ const rows = await tx.execute(sql9`
2915
+ SELECT COALESCE(SUM(amount_micros), 0)::text AS total
2916
+ FROM ${budgetReservations}
2917
+ WHERE status = ${status} AND ${scopeFilter}
2918
+ `);
2919
+ return Number(rows[0]?.total ?? 0);
2920
+ }
2921
+ async spend(tx, input, period, start, end) {
2922
+ return computeBudgetSpend(
2923
+ tx,
2924
+ input,
2925
+ period,
2926
+ start,
2927
+ end,
2928
+ this.options.eligibleLegacySources ?? [],
2929
+ (budgetInput, usageMicros) => this.spendTotals(tx, budgetInput, period, usageMicros)
2930
+ );
2931
+ }
2932
+ async spendTotals(tx, input, period, usageMicros) {
2933
+ const heldMicros = await this.reservationTotal(tx, input, period, "active");
2934
+ const settledFallbackMicros = await this.reservationTotal(tx, input, period, "settled");
2935
+ return { usedMicros: usageMicros + settledFallbackMicros, heldMicros };
2936
+ }
2937
+ async finish(input, status) {
2938
+ await this.db.transaction((tx) => this.finishInTransaction(tx, input, status));
2939
+ }
2940
+ async finishInTransaction(tx, input, status) {
2941
+ const scope = input.scope;
2942
+ const active = await tx.select({
2943
+ id: budgetReservations.id,
2944
+ scope: budgetReservations.scope,
2945
+ userId: budgetReservations.userId,
2946
+ provider: budgetReservations.provider,
2947
+ model: budgetReservations.model,
2948
+ period: budgetReservations.period
2949
+ }).from(budgetReservations).where(and3(requireFinishKey({ ...input, scope }), eq4(budgetReservations.status, "active")));
2950
+ if (active.length === 0) return;
2951
+ await lockBudgetTargets(tx, active.map(toBudgetReservationRow), budgetLockKey);
2952
+ await tx.update(budgetReservations).set({ status }).where(and3(
2953
+ inArray3(budgetReservations.id, active.map((row) => row.id)),
2954
+ eq4(budgetReservations.status, "active")
2955
+ ));
2956
+ }
2957
+ };
2958
+
2959
+ // src/server/db/stores/PostgresModelBudgetStore.ts
2960
+ var PostgresModelBudgetStore = class {
2961
+ store;
2962
+ constructor(db) {
2963
+ this.store = new PostgresBudgetReservationStore(db);
2964
+ }
2965
+ static monthPeriodUtc(now = /* @__PURE__ */ new Date()) {
2966
+ return PostgresBudgetReservationStore.monthPeriodUtc(now);
2967
+ }
2968
+ sweepExpired(now = /* @__PURE__ */ new Date()) {
2969
+ return this.store.sweepExpired(now);
2970
+ }
2971
+ async reserve(input) {
2972
+ const { scope: _scope, ...result } = await this.store.reserve({ ...input, scope: "model" });
2973
+ return result;
2974
+ }
2975
+ settle(input) {
2976
+ return this.store.settle({ ...input, scope: "model" });
2977
+ }
2978
+ release(input) {
2979
+ return this.store.release({ ...input, scope: "model" });
2980
+ }
2712
2981
  };
2713
2982
 
2714
2983
  export {
@@ -2723,6 +2992,7 @@ export {
2723
2992
  creditGrants,
2724
2993
  creditPurchases,
2725
2994
  usageReservations,
2995
+ modelBudgetReservations,
2726
2996
  usageLedger,
2727
2997
  telemetryEvents,
2728
2998
  schema_exports,
@@ -2735,5 +3005,7 @@ export {
2735
3005
  InsufficientCreditError,
2736
3006
  PostgresMeteringStore,
2737
3007
  ModelBudgetExceededError,
3008
+ UserBudgetExceededError,
3009
+ PostgresBudgetReservationStore,
2738
3010
  PostgresModelBudgetStore
2739
3011
  };