@sixb/pg 0.1.5 → 0.1.7

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.
Files changed (51) hide show
  1. package/dist/agents/rows.d.ts +8 -5
  2. package/dist/agents/rows.d.ts.map +1 -1
  3. package/dist/agents/runs.d.ts +3 -2
  4. package/dist/agents/runs.d.ts.map +1 -1
  5. package/dist/agents/threads.d.ts.map +1 -1
  6. package/dist/ai-cost-groups.d.ts +5 -0
  7. package/dist/ai-cost-groups.d.ts.map +1 -0
  8. package/dist/auth-storage/auth-storage.d.ts +4 -1
  9. package/dist/auth-storage/auth-storage.d.ts.map +1 -1
  10. package/dist/auth-storage/device-authorizations.d.ts +29 -0
  11. package/dist/auth-storage/device-authorizations.d.ts.map +1 -0
  12. package/dist/auth-storage/rows.d.ts +19 -1
  13. package/dist/auth-storage/rows.d.ts.map +1 -1
  14. package/dist/auth-storage/shared.d.ts +1 -1
  15. package/dist/auth-storage/shared.d.ts.map +1 -1
  16. package/dist/index.d.ts +2 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1804 -464
  19. package/dist/index.js.map +19 -16
  20. package/dist/migrations.d.ts.map +1 -1
  21. package/dist/ontology-storage/materialization-state.d.ts +7 -3
  22. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  23. package/dist/pg-ai-cost-storage.d.ts +2 -1
  24. package/dist/pg-ai-cost-storage.d.ts.map +1 -1
  25. package/dist/pg-ai-limit-storage.d.ts +34 -0
  26. package/dist/pg-ai-limit-storage.d.ts.map +1 -0
  27. package/dist/pg-ai-usage-storage.d.ts +28 -0
  28. package/dist/pg-ai-usage-storage.d.ts.map +1 -1
  29. package/package.json +4 -4
  30. package/src/agents/rows.ts +38 -9
  31. package/src/agents/runs.ts +148 -11
  32. package/src/agents/threads.ts +1 -17
  33. package/src/ai-cost-groups.ts +102 -0
  34. package/src/auth-storage/auth-storage.ts +84 -0
  35. package/src/auth-storage/device-authorizations.ts +131 -0
  36. package/src/auth-storage/rows.ts +41 -0
  37. package/src/auth-storage/shared.ts +1 -0
  38. package/src/index.ts +8 -1
  39. package/src/migrations/029-model-accounting.sql +24 -0
  40. package/src/migrations/030-ai-usage-limits.sql +94 -0
  41. package/src/migrations/031-subagent-runs.sql +97 -0
  42. package/src/migrations/032-conversation-run-spec.sql +23 -0
  43. package/src/migrations/033-retire-agent-definitions.sql +28 -0
  44. package/src/migrations/034-device-authorizations.sql +24 -0
  45. package/src/migrations.ts +18 -0
  46. package/src/ontology-storage/materialization-state.ts +45 -29
  47. package/src/ontology-storage/materializations.ts +3 -3
  48. package/src/pg-ai-cost-storage.ts +71 -24
  49. package/src/pg-ai-limit-storage.ts +901 -0
  50. package/src/pg-ai-usage-storage.ts +34 -0
  51. package/src/pg-workflow-run-storage.ts +8 -8
@@ -0,0 +1,901 @@
1
+ import {
2
+ type AiLimitAccountingEntry,
3
+ AiLimitOperationLock,
4
+ aiLimitAmountKey,
5
+ aiLimitQuantityFromAmount,
6
+ aiLimitReservationBuckets,
7
+ aiLimitReservationRequestKey,
8
+ aiLimitReservationRequestMatches,
9
+ aiLimitSubjectFromParts,
10
+ aiLimitSubjectKey,
11
+ aiLimitSubjectParts,
12
+ aiLimitSubjectsFromAccountingEntry,
13
+ assertAiLimitAccountingMatchesReservation,
14
+ assertNonBlank,
15
+ cloneValidDate,
16
+ type NormalizedAiLimitAmount,
17
+ normalizeAiLimitAmount,
18
+ normalizeAiLimitQuantities,
19
+ normalizeAiModelCallReservationIdentity,
20
+ normalizeCreateAiLimitPolicy,
21
+ normalizeReserveAiModelCall,
22
+ normalizeUpdateAiLimitPolicy,
23
+ resolveAiLimitActual,
24
+ } from "@sixb/core/internal/ai-limit-storage-provider"
25
+ import type {
26
+ AiLimitPeriod,
27
+ AiLimitPolicy,
28
+ AiLimitPolicyStatus,
29
+ AiLimitQuantity,
30
+ AiLimitReservationBucket,
31
+ AiLimitStorage,
32
+ AiLimitSubject,
33
+ AiModelCallReservation,
34
+ CreateAiLimitPolicyInput,
35
+ DeleteAiLimitPolicyInput,
36
+ GetAiLimitPolicyInput,
37
+ ListAiLimitPoliciesInput,
38
+ ListAiLimitPolicyStatusesInput,
39
+ MarkAiModelCallReservationUnknownInput,
40
+ ReconcileAiModelCallInput,
41
+ RecordAiModelCallLimitActualsInput,
42
+ ReserveAiModelCallInput,
43
+ ReserveAiModelCallResult,
44
+ UpdateAiLimitPolicyInput,
45
+ } from "@sixb/core/storage"
46
+ import { AiLimitStorageError, aiLimitCalendarMonth } from "@sixb/core/storage"
47
+ import type { SQLClient } from "./pg-client"
48
+ import { lockAdvisoryKeys, type PgStoreClient, runPgTransaction } from "./transactions"
49
+
50
+ /** PostgreSQL-backed editable AI limits and atomic model-call reservations. */
51
+ export class PgAiLimitStorage implements AiLimitStorage {
52
+ private readonly operations = new AiLimitOperationLock()
53
+
54
+ constructor(private readonly sql: PgStoreClient) {}
55
+
56
+ /** @internal Keep queued work inside the lifetime of the owning SQL transaction. */
57
+ settleOperations(): Promise<void> {
58
+ return this.operations.settle()
59
+ }
60
+
61
+ async createPolicy(input: CreateAiLimitPolicyInput): Promise<AiLimitPolicy> {
62
+ const policy = normalizeCreateAiLimitPolicy(input)
63
+ return this.withProjectLock(policy.projectId, async (tx) => {
64
+ const subject = aiLimitSubjectParts(policy.subject)
65
+ const limit = normalizeAiLimitAmount(policy.limit)
66
+ const inserted = await tx<{ id: string }[]>`
67
+ INSERT INTO ai_usage_limits (
68
+ project_id, id, subject_type, subject_id, meter, currency, limit_amount,
69
+ period_kind, enabled, created_at, updated_at
70
+ ) VALUES (
71
+ ${policy.projectId}, ${policy.id}, ${subject.type}, ${subject.id}, ${limit.meter},
72
+ ${limit.currency}, ${limit.amount.toString()}, ${policy.period}, ${policy.enabled},
73
+ ${policy.createdAt}, ${policy.updatedAt}
74
+ )
75
+ ON CONFLICT DO NOTHING
76
+ RETURNING id
77
+ `
78
+ if (inserted.length === 0) throw duplicatePolicy(policy)
79
+ return this.requirePolicy(tx, policy.projectId, policy.id)
80
+ })
81
+ }
82
+
83
+ async updatePolicy(input: UpdateAiLimitPolicyInput): Promise<AiLimitPolicy> {
84
+ const update = normalizeUpdateAiLimitPolicy(input)
85
+ return this.withProjectLock(update.projectId, async (tx) => {
86
+ const existing = await this.requirePolicy(tx, update.projectId, update.id)
87
+ if (update.limit !== undefined) assertSameDimension(existing.limit, update.limit)
88
+ const limit = normalizeAiLimitAmount(update.limit ?? existing.limit)
89
+ await tx`
90
+ UPDATE ai_usage_limits
91
+ SET limit_amount = ${limit.amount.toString()},
92
+ enabled = ${update.enabled ?? existing.enabled},
93
+ updated_at = ${update.updatedAt}
94
+ WHERE project_id = ${update.projectId} AND id = ${update.id}
95
+ `
96
+ return this.requirePolicy(tx, update.projectId, update.id)
97
+ })
98
+ }
99
+
100
+ async deletePolicy(input: DeleteAiLimitPolicyInput): Promise<boolean> {
101
+ validatePolicyIdentity(input)
102
+ return this.withProjectLock(input.projectId, async (tx) => {
103
+ const deleted = await tx<{ id: string }[]>`
104
+ DELETE FROM ai_usage_limits
105
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
106
+ RETURNING id
107
+ `
108
+ return deleted.length > 0
109
+ })
110
+ }
111
+
112
+ async getPolicy(input: GetAiLimitPolicyInput): Promise<AiLimitPolicy | null> {
113
+ validatePolicyIdentity(input)
114
+ const [row] = await this.sql<AiLimitPolicyRow[]>`
115
+ SELECT * FROM ai_usage_limits
116
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
117
+ `
118
+ return row ? policyFromRow(row) : null
119
+ }
120
+
121
+ async listPolicies(input: ListAiLimitPoliciesInput): Promise<readonly AiLimitPolicy[]> {
122
+ assertNonBlank(input.projectId, "projectId")
123
+ return (await this.policyRows(this.sql, input.projectId, input.includeDisabled ?? false)).map(
124
+ policyFromRow
125
+ )
126
+ }
127
+
128
+ async listPolicyStatuses(
129
+ input: ListAiLimitPolicyStatusesInput
130
+ ): Promise<readonly AiLimitPolicyStatus[]> {
131
+ assertNonBlank(input.projectId, "projectId")
132
+ const period = aiLimitCalendarMonth(input.at ?? new Date())
133
+ const existingGroups =
134
+ input.existingGroupIds === undefined ? undefined : new Set(input.existingGroupIds)
135
+ return this.withProjectLock(input.projectId, async (tx) => {
136
+ const rows = await this.policyRows(tx, input.projectId, input.includeDisabled ?? false)
137
+ return Promise.all(
138
+ rows.map((row) => this.statusForPolicy(tx, policyFromRow(row), period, existingGroups))
139
+ )
140
+ })
141
+ }
142
+
143
+ async reserveModelCall(input: ReserveAiModelCallInput): Promise<ReserveAiModelCallResult> {
144
+ const request = normalizeReserveAiModelCall(input)
145
+ return this.withProjectLock(request.identity.projectId, async (tx) => {
146
+ const existing = await this.findReservation(tx, request.identity)
147
+ if (existing) {
148
+ if (!aiLimitReservationRequestMatches(existing, request)) {
149
+ throw new AiLimitStorageError(
150
+ "reservation_conflict",
151
+ `[SixbPg] AI model-call reservation '${input.callId}' was replayed with different subjects, estimates, or period.`
152
+ )
153
+ }
154
+ if (existing.state !== "active") {
155
+ return { status: "terminal", reservation: existing, created: false }
156
+ }
157
+ return { status: "reserved", reservation: existing, created: false }
158
+ }
159
+ await this.assertExecutionExists(tx, request.identity.projectId, request.identity.executionId)
160
+
161
+ const subjectKeys = new Set(request.subjects.map(aiLimitSubjectKey))
162
+ const estimates = new Map(
163
+ request.estimates.map((quantity) => {
164
+ const amount = normalizeAiLimitAmount(quantity)
165
+ return [aiLimitAmountKey(amount), amount] as const
166
+ })
167
+ )
168
+ const enabledPolicies = (await this.policyRows(tx, request.identity.projectId, false)).map(
169
+ policyFromRow
170
+ )
171
+ const exhaustedPolicies: AiLimitPolicyStatus[] = []
172
+ const unavailablePolicies: AiLimitPolicyStatus[] = []
173
+ const unavailableReasons = new Set<"missingEstimate" | "incompleteAccounting">()
174
+ for (const policy of enabledPolicies) {
175
+ if (!subjectKeys.has(aiLimitSubjectKey(policy.subject))) continue
176
+ const limit = normalizeAiLimitAmount(policy.limit)
177
+ const estimate = estimates.get(aiLimitAmountKey(limit))
178
+ const status = await this.statusForPolicy(tx, policy, request.period)
179
+ if (!estimate || status.accountingStatus === "unavailable") {
180
+ unavailablePolicies.push(status)
181
+ if (!estimate) unavailableReasons.add("missingEstimate")
182
+ if (status.accountingStatus === "unavailable") {
183
+ unavailableReasons.add("incompleteAccounting")
184
+ }
185
+ continue
186
+ }
187
+ const consumed =
188
+ normalizeAiLimitAmount(status.consumption.actual).amount +
189
+ normalizeAiLimitAmount(status.consumption.reserved).amount +
190
+ normalizeAiLimitAmount(status.consumption.unknown).amount
191
+ if (consumed + estimate.amount > limit.amount) exhaustedPolicies.push(status)
192
+ }
193
+ if (unavailablePolicies.length > 0) {
194
+ unavailablePolicies.sort((left, right) => left.policy.id.localeCompare(right.policy.id))
195
+ return {
196
+ status: "unavailable",
197
+ unavailablePolicies,
198
+ reasons: [...unavailableReasons].sort(),
199
+ }
200
+ }
201
+ if (exhaustedPolicies.length > 0) {
202
+ exhaustedPolicies.sort((left, right) => left.policy.id.localeCompare(right.policy.id))
203
+ return {
204
+ status: "denied",
205
+ exhaustedPolicies,
206
+ resetAt: new Date(
207
+ Math.min(...exhaustedPolicies.map((status) => status.period.resetAt.getTime()))
208
+ ),
209
+ }
210
+ }
211
+
212
+ const buckets = aiLimitReservationBuckets(
213
+ enabledPolicies,
214
+ request.subjects,
215
+ request.estimates
216
+ )
217
+ if (buckets.length === 0) return { status: "notRequired" }
218
+ for (const bucket of buckets) {
219
+ const amount = normalizeAiLimitAmount(bucket.estimate)
220
+ await this.changeCounters(
221
+ tx,
222
+ request.identity.projectId,
223
+ bucket.subject,
224
+ amount,
225
+ request.period,
226
+ { reserved: amount.amount },
227
+ request.reservedAt
228
+ )
229
+ }
230
+ const reservation: AiModelCallReservation = {
231
+ ...request.identity,
232
+ buckets,
233
+ requestKey: aiLimitReservationRequestKey(request),
234
+ period: request.period,
235
+ state: "active",
236
+ reservedAt: request.reservedAt,
237
+ updatedAt: new Date(request.reservedAt),
238
+ }
239
+ await this.insertReservation(tx, reservation)
240
+ return { status: "reserved", reservation: structuredClone(reservation), created: true }
241
+ })
242
+ }
243
+
244
+ async recordModelCallActuals(input: RecordAiModelCallLimitActualsInput): Promise<void> {
245
+ assertNonBlank(input.projectId, "projectId")
246
+ assertNonBlank(input.usageRecordId, "usageRecordId")
247
+ const recordedAt = cloneValidDate(input.recordedAt ?? new Date(), "recordedAt")
248
+ await this.withProjectLock(input.projectId, async (tx) => {
249
+ const entry = await this.requireAccountingEntry(tx, input.projectId, input.usageRecordId)
250
+ const period = aiLimitCalendarMonth(entry.occurredAt)
251
+ for (const subjectValue of aiLimitSubjectsFromAccountingEntry(entry)) {
252
+ const subject = aiLimitSubjectParts(subjectValue)
253
+ const rows = await tx<AiLimitActualStateRow[]>`
254
+ SELECT meter, currency, actual_amount, accounting_status
255
+ FROM ai_usage_limit_periods
256
+ WHERE project_id = ${input.projectId}
257
+ AND subject_type = ${subject.type}
258
+ AND subject_id = ${subject.id}
259
+ AND period_start = ${period.start}
260
+ FOR UPDATE
261
+ `
262
+ for (const row of rows) {
263
+ const actual = resolveAiLimitActual([entry], row)
264
+ await tx`
265
+ UPDATE ai_usage_limit_periods
266
+ SET actual_amount = ${(BigInt(row.actual_amount) + actual.amount).toString()},
267
+ accounting_status = ${
268
+ row.accounting_status === "unavailable" ||
269
+ actual.accountingStatus === "unavailable"
270
+ ? "unavailable"
271
+ : "complete"
272
+ },
273
+ updated_at = ${recordedAt}
274
+ WHERE project_id = ${input.projectId}
275
+ AND subject_type = ${subject.type}
276
+ AND subject_id = ${subject.id}
277
+ AND meter = ${row.meter}
278
+ AND currency = ${row.currency}
279
+ AND period_start = ${period.start}
280
+ `
281
+ }
282
+ }
283
+ })
284
+ }
285
+
286
+ async reconcileModelCall(input: ReconcileAiModelCallInput): Promise<AiModelCallReservation> {
287
+ const identity = normalizeAiModelCallReservationIdentity(input)
288
+ assertNonBlank(input.usageRecordId, "usageRecordId")
289
+ const reconciledAt = cloneValidDate(input.reconciledAt ?? new Date(), "reconciledAt")
290
+ return this.withProjectLock(identity.projectId, async (tx) => {
291
+ const reservation = await this.findReservation(tx, identity)
292
+ if (!reservation) throw missingReservation(identity)
293
+ if (reservation.state === "reconciled") {
294
+ if (reservation.usageRecordId === input.usageRecordId) {
295
+ return reservation
296
+ }
297
+ throw reconciliationConflict(identity.callId)
298
+ }
299
+ const accounting = await this.requireAccountingEntry(
300
+ tx,
301
+ identity.projectId,
302
+ input.usageRecordId
303
+ )
304
+ assertAiLimitAccountingMatchesReservation(reservation, accounting)
305
+ await this.reconcileEstimateCounters(tx, reservation, accounting, reconciledAt)
306
+ const reconciled: AiModelCallReservation = {
307
+ ...reservation,
308
+ state: "reconciled",
309
+ usageRecordId: input.usageRecordId,
310
+ updatedAt: reconciledAt,
311
+ }
312
+ await this.updateReservation(tx, reconciled)
313
+ return structuredClone(reconciled)
314
+ })
315
+ }
316
+
317
+ async markReservationUnknown(
318
+ input: MarkAiModelCallReservationUnknownInput
319
+ ): Promise<AiModelCallReservation> {
320
+ const identity = normalizeAiModelCallReservationIdentity(input)
321
+ const markedAt = cloneValidDate(input.markedAt ?? new Date(), "markedAt")
322
+ return this.withProjectLock(identity.projectId, async (tx) => {
323
+ const reservation = await this.findReservation(tx, identity)
324
+ if (!reservation) throw missingReservation(identity)
325
+ if (reservation.state === "unknown") return reservation
326
+ if (reservation.state !== "active") throw invalidState(reservation, "mark unknown")
327
+ await this.moveEstimateCounters(tx, reservation, { reserved: -1n, unknown: 1n }, markedAt)
328
+ const unknown = { ...reservation, state: "unknown" as const, updatedAt: markedAt }
329
+ await this.updateReservation(tx, unknown)
330
+ return structuredClone(unknown)
331
+ })
332
+ }
333
+
334
+ private async withProjectLock<T>(
335
+ projectId: string,
336
+ run: (tx: SQLClient) => Promise<T>
337
+ ): Promise<T> {
338
+ return runPgTransaction(this.sql, async (tx) => {
339
+ const operation = async () => {
340
+ await lockAdvisoryKeys(tx, [`ai-limit:${projectId}`])
341
+ return run(tx)
342
+ }
343
+ // Advisory locks are reentrant within one transaction. Serialize calls sharing this
344
+ // transaction's store locally; independent root transactions retain their concurrency.
345
+ return tx === this.sql ? this.operations.run(operation) : operation()
346
+ })
347
+ }
348
+
349
+ private policyRows(
350
+ sql: PgStoreClient,
351
+ projectId: string,
352
+ includeDisabled: boolean
353
+ ): Promise<AiLimitPolicyRow[]> {
354
+ return sql<AiLimitPolicyRow[]>`
355
+ SELECT * FROM ai_usage_limits
356
+ WHERE project_id = ${projectId} AND (${includeDisabled} OR enabled)
357
+ ORDER BY id
358
+ `
359
+ }
360
+
361
+ private async requirePolicy(
362
+ sql: PgStoreClient,
363
+ projectId: string,
364
+ id: string
365
+ ): Promise<AiLimitPolicy> {
366
+ const [row] = await sql<AiLimitPolicyRow[]>`
367
+ SELECT * FROM ai_usage_limits WHERE project_id = ${projectId} AND id = ${id}
368
+ `
369
+ if (row) return policyFromRow(row)
370
+ throw new AiLimitStorageError(
371
+ "missing_policy",
372
+ `[SixbPg] AI limit policy '${id}' does not exist in project '${projectId}'.`
373
+ )
374
+ }
375
+
376
+ private async statusForPolicy(
377
+ sql: PgStoreClient,
378
+ policy: AiLimitPolicy,
379
+ period: AiLimitPeriod,
380
+ existingGroups?: ReadonlySet<string>
381
+ ): Promise<AiLimitPolicyStatus> {
382
+ const subject = aiLimitSubjectParts(policy.subject)
383
+ const limit = normalizeAiLimitAmount(policy.limit)
384
+ let [row] = await sql<AiLimitPeriodStateRow[]>`
385
+ SELECT actual_amount, reserved_amount, unknown_amount, accounting_status
386
+ FROM ai_usage_limit_periods
387
+ WHERE project_id = ${policy.projectId}
388
+ AND subject_type = ${subject.type}
389
+ AND subject_id = ${subject.id}
390
+ AND meter = ${limit.meter}
391
+ AND currency = ${limit.currency}
392
+ AND period_start = ${period.start}
393
+ `
394
+ // Re-read incomplete accounting under the project lock; retain in-flight estimates.
395
+ if (!row || row.accounting_status === "unavailable") {
396
+ const actual = resolveAiLimitActual(await this.accountingEntries(sql, policy, period), limit)
397
+ const [inserted] = await sql<AiLimitPeriodStateRow[]>`
398
+ INSERT INTO ai_usage_limit_periods (
399
+ project_id, subject_type, subject_id, meter, currency, period_start, period_end,
400
+ actual_amount, reserved_amount, unknown_amount, accounting_status, updated_at
401
+ ) VALUES (
402
+ ${policy.projectId}, ${subject.type}, ${subject.id}, ${limit.meter}, ${limit.currency},
403
+ ${period.start}, ${period.end}, ${actual.amount.toString()}, 0, 0,
404
+ ${actual.accountingStatus}, ${new Date()}
405
+ )
406
+ ON CONFLICT (project_id, subject_type, subject_id, meter, currency, period_start)
407
+ DO UPDATE SET
408
+ actual_amount = excluded.actual_amount,
409
+ accounting_status = excluded.accounting_status,
410
+ updated_at = excluded.updated_at
411
+ RETURNING actual_amount, reserved_amount, unknown_amount, accounting_status
412
+ `
413
+ row = inserted
414
+ }
415
+ if (!row) throw new Error("[SixbPg] Failed to initialize AI limit period state.")
416
+ return policyStatus(policy, period, row, existingGroups)
417
+ }
418
+
419
+ private async changeCounters(
420
+ sql: PgStoreClient,
421
+ projectId: string,
422
+ subjectValue: AiLimitSubject,
423
+ amount: NormalizedAiLimitAmount,
424
+ period: AiLimitPeriod,
425
+ changes: { readonly reserved?: bigint; readonly unknown?: bigint },
426
+ updatedAt: Date
427
+ ): Promise<void> {
428
+ const subject = aiLimitSubjectParts(subjectValue)
429
+ const [row] = await sql<AiLimitPeriodStateRow[]>`
430
+ SELECT actual_amount, reserved_amount, unknown_amount, accounting_status
431
+ FROM ai_usage_limit_periods
432
+ WHERE project_id = ${projectId}
433
+ AND subject_type = ${subject.type}
434
+ AND subject_id = ${subject.id}
435
+ AND meter = ${amount.meter}
436
+ AND currency = ${amount.currency}
437
+ AND period_start = ${period.start}
438
+ FOR UPDATE
439
+ `
440
+ const reserved = BigInt(row?.reserved_amount ?? "0") + (changes.reserved ?? 0n)
441
+ const unknown = BigInt(row?.unknown_amount ?? "0") + (changes.unknown ?? 0n)
442
+ if (reserved < 0n || unknown < 0n) {
443
+ throw new Error("[SixbPg] AI limit period counter became negative.")
444
+ }
445
+ await sql`
446
+ INSERT INTO ai_usage_limit_periods (
447
+ project_id, subject_type, subject_id, meter, currency, period_start, period_end,
448
+ actual_amount, reserved_amount, unknown_amount, accounting_status, updated_at
449
+ ) VALUES (
450
+ ${projectId}, ${subject.type}, ${subject.id}, ${amount.meter}, ${amount.currency},
451
+ ${period.start}, ${period.end}, ${row?.actual_amount ?? "0"}, ${reserved.toString()},
452
+ ${unknown.toString()}, ${row?.accounting_status ?? "complete"}, ${updatedAt}
453
+ )
454
+ ON CONFLICT (project_id, subject_type, subject_id, meter, currency, period_start)
455
+ DO UPDATE SET
456
+ period_end = excluded.period_end,
457
+ actual_amount = excluded.actual_amount,
458
+ reserved_amount = excluded.reserved_amount,
459
+ unknown_amount = excluded.unknown_amount,
460
+ accounting_status = excluded.accounting_status,
461
+ updated_at = excluded.updated_at
462
+ `
463
+ }
464
+
465
+ private async moveEstimateCounters(
466
+ sql: PgStoreClient,
467
+ reservation: AiModelCallReservation,
468
+ directions: { readonly reserved?: bigint; readonly unknown?: bigint },
469
+ updatedAt: Date
470
+ ): Promise<void> {
471
+ for (const bucket of reservation.buckets) {
472
+ const amount = normalizeAiLimitAmount(bucket.estimate)
473
+ await this.changeCounters(
474
+ sql,
475
+ reservation.projectId,
476
+ bucket.subject,
477
+ amount,
478
+ reservation.period,
479
+ {
480
+ ...(directions.reserved === undefined
481
+ ? {}
482
+ : { reserved: directions.reserved * amount.amount }),
483
+ ...(directions.unknown === undefined
484
+ ? {}
485
+ : { unknown: directions.unknown * amount.amount }),
486
+ },
487
+ updatedAt
488
+ )
489
+ }
490
+ }
491
+
492
+ private async reconcileEstimateCounters(
493
+ sql: PgStoreClient,
494
+ reservation: AiModelCallReservation,
495
+ entry: AiLimitAccountingEntry,
496
+ updatedAt: Date
497
+ ): Promise<void> {
498
+ for (const bucket of reservation.buckets) {
499
+ const amount = normalizeAiLimitAmount(bucket.estimate)
500
+ const actual = resolveAiLimitActual([entry], amount)
501
+ if (actual.accountingStatus === "complete") {
502
+ await this.changeCounters(
503
+ sql,
504
+ reservation.projectId,
505
+ bucket.subject,
506
+ amount,
507
+ reservation.period,
508
+ reservation.state === "active"
509
+ ? { reserved: -amount.amount }
510
+ : { unknown: -amount.amount },
511
+ updatedAt
512
+ )
513
+ } else if (reservation.state === "active") {
514
+ await this.changeCounters(
515
+ sql,
516
+ reservation.projectId,
517
+ bucket.subject,
518
+ amount,
519
+ reservation.period,
520
+ { reserved: -amount.amount, unknown: amount.amount },
521
+ updatedAt
522
+ )
523
+ }
524
+ }
525
+ }
526
+
527
+ private async insertReservation(
528
+ sql: PgStoreClient,
529
+ reservation: AiModelCallReservation
530
+ ): Promise<void> {
531
+ await sql`
532
+ INSERT INTO ai_model_call_reservations (
533
+ project_id, execution_id, attempt, call_id, buckets, request_key,
534
+ period_start, period_end, state, usage_record_id, reserved_at, updated_at
535
+ ) VALUES (
536
+ ${reservation.projectId}, ${reservation.executionId}, ${reservation.attempt},
537
+ ${reservation.callId}, ${JSON.stringify(reservation.buckets)}::text::jsonb,
538
+ ${reservation.requestKey}, ${reservation.period.start},
539
+ ${reservation.period.end}, ${reservation.state}, ${reservation.usageRecordId ?? null},
540
+ ${reservation.reservedAt}, ${reservation.updatedAt}
541
+ )
542
+ `
543
+ }
544
+
545
+ private async updateReservation(
546
+ sql: PgStoreClient,
547
+ reservation: AiModelCallReservation
548
+ ): Promise<void> {
549
+ await sql`
550
+ UPDATE ai_model_call_reservations
551
+ SET state = ${reservation.state},
552
+ usage_record_id = ${reservation.usageRecordId ?? null},
553
+ updated_at = ${reservation.updatedAt}
554
+ WHERE project_id = ${reservation.projectId}
555
+ AND execution_id = ${reservation.executionId}
556
+ AND attempt = ${reservation.attempt}
557
+ AND call_id = ${reservation.callId}
558
+ `
559
+ }
560
+
561
+ private async findReservation(
562
+ sql: PgStoreClient,
563
+ input: {
564
+ readonly projectId: string
565
+ readonly executionId: string
566
+ readonly attempt: number
567
+ readonly callId: string
568
+ }
569
+ ): Promise<AiModelCallReservation | null> {
570
+ const [row] = await sql<AiModelCallReservationRow[]>`
571
+ SELECT * FROM ai_model_call_reservations
572
+ WHERE project_id = ${input.projectId}
573
+ AND execution_id = ${input.executionId}
574
+ AND attempt = ${input.attempt}
575
+ AND call_id = ${input.callId}
576
+ `
577
+ return row ? reservationFromRow(row) : null
578
+ }
579
+
580
+ private async accountingEntries(
581
+ sql: PgStoreClient,
582
+ policy: AiLimitPolicy,
583
+ period: AiLimitPeriod
584
+ ): Promise<readonly AiLimitAccountingEntry[]> {
585
+ const subject = aiLimitSubjectParts(policy.subject)
586
+ const rows = await sql<AiLimitAccountingRow[]>`
587
+ SELECT usage.project_id, usage.id AS usage_record_id, usage.execution_id,
588
+ usage.attempt, usage.call_id, usage.occurred_at,
589
+ usage.total_tokens::text AS total_tokens,
590
+ executions.requested_by_user_id,
591
+ executions.requested_by_service_account_id,
592
+ valuations.status AS valuation_status,
593
+ valuations.currency AS valuation_currency,
594
+ valuations.amount_nanos::text AS valuation_amount_nanos
595
+ FROM ai_model_call_usage AS usage
596
+ JOIN executions
597
+ ON executions.project_id = usage.project_id AND executions.id = usage.execution_id
598
+ LEFT JOIN ai_model_call_valuations AS valuations
599
+ ON valuations.project_id = usage.project_id
600
+ AND valuations.usage_record_id = usage.id
601
+ WHERE usage.project_id = ${policy.projectId}
602
+ AND usage.occurred_at >= ${period.start}
603
+ AND usage.occurred_at < ${period.end}
604
+ AND (
605
+ ${subject.type} = 'project'
606
+ OR (
607
+ ${subject.type} = 'group'
608
+ AND EXISTS (
609
+ SELECT 1 FROM ai_model_call_usage_groups AS usage_groups
610
+ WHERE usage_groups.project_id = usage.project_id
611
+ AND usage_groups.usage_record_id = usage.id
612
+ AND usage_groups.group_id = ${subject.id}
613
+ )
614
+ )
615
+ OR (${subject.type} = 'user' AND executions.requested_by_user_id = ${subject.id})
616
+ OR (
617
+ ${subject.type} = 'serviceAccount'
618
+ AND executions.requested_by_service_account_id = ${subject.id}
619
+ )
620
+ )
621
+ ORDER BY usage.occurred_at, usage.id
622
+ `
623
+ return rows.map((row) => accountingEntryFromRow(row, policy.subject))
624
+ }
625
+
626
+ private async requireAccountingEntry(
627
+ sql: PgStoreClient,
628
+ projectId: string,
629
+ usageRecordId: string
630
+ ): Promise<AiLimitAccountingEntry> {
631
+ const [row] = await sql<AiLimitAccountingRow[]>`
632
+ SELECT usage.project_id, usage.id AS usage_record_id, usage.execution_id,
633
+ usage.attempt, usage.call_id, usage.occurred_at,
634
+ usage.total_tokens::text AS total_tokens,
635
+ executions.requested_by_user_id,
636
+ executions.requested_by_service_account_id,
637
+ valuations.status AS valuation_status,
638
+ valuations.currency AS valuation_currency,
639
+ valuations.amount_nanos::text AS valuation_amount_nanos
640
+ FROM ai_model_call_usage AS usage
641
+ JOIN executions
642
+ ON executions.project_id = usage.project_id AND executions.id = usage.execution_id
643
+ LEFT JOIN ai_model_call_valuations AS valuations
644
+ ON valuations.project_id = usage.project_id
645
+ AND valuations.usage_record_id = usage.id
646
+ WHERE usage.project_id = ${projectId} AND usage.id = ${usageRecordId}
647
+ `
648
+ if (row) {
649
+ const groups = await sql<{ group_id: string }[]>`
650
+ SELECT group_id FROM ai_model_call_usage_groups
651
+ WHERE project_id = ${projectId} AND usage_record_id = ${usageRecordId}
652
+ ORDER BY group_id
653
+ `
654
+ return accountingEntryFromRow(
655
+ row,
656
+ undefined,
657
+ groups.map((group) => group.group_id)
658
+ )
659
+ }
660
+ throw new AiLimitStorageError(
661
+ "missing_usage_record",
662
+ `[SixbPg] AI usage record '${usageRecordId}' does not exist in project '${projectId}'.`
663
+ )
664
+ }
665
+
666
+ private async assertExecutionExists(
667
+ sql: PgStoreClient,
668
+ projectId: string,
669
+ executionId: string
670
+ ): Promise<void> {
671
+ const [row] = await sql<{ exists: boolean }[]>`
672
+ SELECT EXISTS (
673
+ SELECT 1 FROM executions WHERE project_id = ${projectId} AND id = ${executionId}
674
+ ) AS exists
675
+ `
676
+ if (row?.exists) return
677
+ throw new AiLimitStorageError(
678
+ "missing_execution",
679
+ `[SixbPg] AI limit execution '${executionId}' does not exist in project '${projectId}'.`
680
+ )
681
+ }
682
+ }
683
+
684
+ interface AiLimitPolicyRow {
685
+ readonly project_id: string
686
+ readonly id: string
687
+ readonly subject_type: AiLimitSubject["type"]
688
+ readonly subject_id: string
689
+ readonly meter: AiLimitQuantity["meter"]
690
+ readonly currency: string
691
+ readonly limit_amount: string
692
+ readonly period_kind: "calendarMonth"
693
+ readonly enabled: boolean
694
+ readonly created_at: Date | string
695
+ readonly updated_at: Date | string
696
+ }
697
+
698
+ interface AiLimitPeriodStateRow {
699
+ readonly actual_amount: string
700
+ readonly reserved_amount: string
701
+ readonly unknown_amount: string
702
+ readonly accounting_status: "complete" | "unavailable"
703
+ }
704
+
705
+ interface AiLimitActualStateRow {
706
+ readonly meter: AiLimitQuantity["meter"]
707
+ readonly currency: string
708
+ readonly actual_amount: string
709
+ readonly accounting_status: "complete" | "unavailable"
710
+ }
711
+
712
+ interface AiLimitAccountingRow {
713
+ readonly project_id: string
714
+ readonly usage_record_id: string
715
+ readonly execution_id: string
716
+ readonly attempt: number | string
717
+ readonly call_id: string
718
+ readonly occurred_at: Date | string
719
+ readonly total_tokens: string | null
720
+ readonly requested_by_user_id: string | null
721
+ readonly requested_by_service_account_id: string | null
722
+ readonly valuation_status: "rated" | "unpriceable" | null
723
+ readonly valuation_currency: string | null
724
+ readonly valuation_amount_nanos: string | null
725
+ }
726
+
727
+ interface AiModelCallReservationRow {
728
+ readonly project_id: string
729
+ readonly execution_id: string
730
+ readonly attempt: number | string
731
+ readonly call_id: string
732
+ readonly buckets: readonly AiLimitReservationBucket[] | string
733
+ readonly request_key: string
734
+ readonly period_start: Date | string
735
+ readonly period_end: Date | string
736
+ readonly state: AiModelCallReservation["state"]
737
+ readonly usage_record_id: string | null
738
+ readonly reserved_at: Date | string
739
+ readonly updated_at: Date | string
740
+ }
741
+
742
+ function policyFromRow(row: AiLimitPolicyRow): AiLimitPolicy {
743
+ return {
744
+ id: row.id,
745
+ projectId: row.project_id,
746
+ subject: aiLimitSubjectFromParts(row.subject_type, row.subject_id),
747
+ limit: aiLimitQuantityFromAmount({
748
+ meter: row.meter,
749
+ currency: row.currency,
750
+ amount: BigInt(row.limit_amount),
751
+ }),
752
+ period: row.period_kind,
753
+ enabled: row.enabled,
754
+ createdAt: new Date(row.created_at),
755
+ updatedAt: new Date(row.updated_at),
756
+ }
757
+ }
758
+
759
+ function policyStatus(
760
+ policy: AiLimitPolicy,
761
+ period: AiLimitPeriod,
762
+ row: AiLimitPeriodStateRow,
763
+ existingGroups?: ReadonlySet<string>
764
+ ): AiLimitPolicyStatus {
765
+ const limit = normalizeAiLimitAmount(policy.limit)
766
+ const actual = BigInt(row.actual_amount)
767
+ const reserved = BigInt(row.reserved_amount)
768
+ const unknown = BigInt(row.unknown_amount)
769
+ const consumed = actual + reserved + unknown
770
+ const quantity = (amount: bigint) => aiLimitQuantityFromAmount({ ...limit, amount })
771
+ return {
772
+ policy,
773
+ period: structuredClone(period),
774
+ consumption: {
775
+ actual: quantity(actual),
776
+ reserved: quantity(reserved),
777
+ unknown: quantity(unknown),
778
+ remaining: quantity(limit.amount > consumed ? limit.amount - consumed : 0n),
779
+ },
780
+ accountingStatus: row.accounting_status,
781
+ exhausted: consumed >= limit.amount,
782
+ orphaned:
783
+ policy.subject.type === "group" &&
784
+ existingGroups !== undefined &&
785
+ !existingGroups.has(policy.subject.id),
786
+ }
787
+ }
788
+
789
+ function accountingEntryFromRow(
790
+ row: AiLimitAccountingRow,
791
+ subject?: AiLimitSubject,
792
+ requesterGroupIds = subject?.type === "group" ? [subject.id] : []
793
+ ): AiLimitAccountingEntry {
794
+ const totalTokens = row.total_tokens === null ? undefined : Number(row.total_tokens)
795
+ const requester = row.requested_by_user_id
796
+ ? ({ type: "user", id: row.requested_by_user_id } as const)
797
+ : row.requested_by_service_account_id
798
+ ? ({ type: "serviceAccount", id: row.requested_by_service_account_id } as const)
799
+ : undefined
800
+ return {
801
+ projectId: row.project_id,
802
+ usageRecordId: row.usage_record_id,
803
+ executionId: row.execution_id,
804
+ attempt: Number(row.attempt),
805
+ callId: row.call_id,
806
+ occurredAt: new Date(row.occurred_at),
807
+ ...(totalTokens !== undefined && Number.isSafeInteger(totalTokens) ? { totalTokens } : {}),
808
+ requesterGroupIds,
809
+ ...(requester ? { requester } : {}),
810
+ ...(row.valuation_status === "rated" &&
811
+ row.valuation_currency !== null &&
812
+ row.valuation_amount_nanos !== null
813
+ ? {
814
+ valuation: {
815
+ status: "rated" as const,
816
+ money: {
817
+ currency: row.valuation_currency,
818
+ amountNanos: row.valuation_amount_nanos,
819
+ },
820
+ },
821
+ }
822
+ : row.valuation_status === "unpriceable"
823
+ ? { valuation: { status: "unavailable" as const } }
824
+ : {}),
825
+ }
826
+ }
827
+
828
+ function reservationFromRow(row: AiModelCallReservationRow): AiModelCallReservation {
829
+ const buckets = jsonValue<readonly AiLimitReservationBucket[]>(row.buckets)
830
+ return {
831
+ projectId: row.project_id,
832
+ executionId: row.execution_id,
833
+ attempt: Number(row.attempt),
834
+ callId: row.call_id,
835
+ buckets: buckets.map((bucket) => ({
836
+ subject: structuredClone(bucket.subject),
837
+ estimate: normalizeAiLimitQuantities([bucket.estimate], "stored bucket")[0]!,
838
+ })),
839
+ requestKey: row.request_key,
840
+ period: {
841
+ kind: "calendarMonth",
842
+ start: new Date(row.period_start),
843
+ end: new Date(row.period_end),
844
+ resetAt: new Date(row.period_end),
845
+ },
846
+ state: row.state,
847
+ ...(row.usage_record_id === null ? {} : { usageRecordId: row.usage_record_id }),
848
+ reservedAt: new Date(row.reserved_at),
849
+ updatedAt: new Date(row.updated_at),
850
+ }
851
+ }
852
+
853
+ function jsonValue<T>(value: T | string): T {
854
+ return typeof value === "string" ? (JSON.parse(value) as T) : value
855
+ }
856
+
857
+ function assertSameDimension(previous: AiLimitQuantity, next: AiLimitQuantity): void {
858
+ if (
859
+ aiLimitAmountKey(normalizeAiLimitAmount(previous)) !==
860
+ aiLimitAmountKey(normalizeAiLimitAmount(next))
861
+ ) {
862
+ throw new TypeError("[Sixb] AI limit policy meter and cost currency are immutable.")
863
+ }
864
+ }
865
+
866
+ function validatePolicyIdentity(input: GetAiLimitPolicyInput): void {
867
+ assertNonBlank(input.projectId, "projectId")
868
+ assertNonBlank(input.id, "policy id")
869
+ }
870
+
871
+ function duplicatePolicy(policy: AiLimitPolicy): AiLimitStorageError {
872
+ return new AiLimitStorageError(
873
+ "duplicate_policy",
874
+ `[SixbPg] An AI limit policy already exists for policy ID or subject-meter dimension '${policy.id}'.`
875
+ )
876
+ }
877
+
878
+ function missingReservation(input: {
879
+ readonly executionId: string
880
+ readonly attempt: number
881
+ readonly callId: string
882
+ }): AiLimitStorageError {
883
+ return new AiLimitStorageError(
884
+ "missing_reservation",
885
+ `[SixbPg] AI model-call reservation '${input.callId}' does not exist for execution '${input.executionId}' attempt ${input.attempt}.`
886
+ )
887
+ }
888
+
889
+ function reconciliationConflict(callId: string): AiLimitStorageError {
890
+ return new AiLimitStorageError(
891
+ "reconciliation_conflict",
892
+ `[SixbPg] AI model-call reservation '${callId}' was reconciled with a different usage record.`
893
+ )
894
+ }
895
+
896
+ function invalidState(reservation: AiModelCallReservation, operation: string): AiLimitStorageError {
897
+ return new AiLimitStorageError(
898
+ "invalid_reservation_state",
899
+ `[SixbPg] Cannot ${operation} AI model-call reservation '${reservation.callId}' in state '${reservation.state}'.`
900
+ )
901
+ }