@sixb/pg 0.1.6 → 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.
- package/dist/agents/rows.d.ts +8 -5
- package/dist/agents/rows.d.ts.map +1 -1
- package/dist/agents/runs.d.ts +3 -2
- package/dist/agents/runs.d.ts.map +1 -1
- package/dist/agents/threads.d.ts.map +1 -1
- package/dist/ai-cost-groups.d.ts +5 -0
- package/dist/ai-cost-groups.d.ts.map +1 -0
- package/dist/auth-storage/auth-storage.d.ts +4 -1
- package/dist/auth-storage/auth-storage.d.ts.map +1 -1
- package/dist/auth-storage/device-authorizations.d.ts +29 -0
- package/dist/auth-storage/device-authorizations.d.ts.map +1 -0
- package/dist/auth-storage/rows.d.ts +19 -1
- package/dist/auth-storage/rows.d.ts.map +1 -1
- package/dist/auth-storage/shared.d.ts +1 -1
- package/dist/auth-storage/shared.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1721 -393
- package/dist/index.js.map +17 -14
- package/dist/migrations.d.ts.map +1 -1
- package/dist/pg-ai-cost-storage.d.ts +2 -1
- package/dist/pg-ai-cost-storage.d.ts.map +1 -1
- package/dist/pg-ai-limit-storage.d.ts +34 -0
- package/dist/pg-ai-limit-storage.d.ts.map +1 -0
- package/dist/pg-ai-usage-storage.d.ts +28 -0
- package/dist/pg-ai-usage-storage.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/agents/rows.ts +38 -9
- package/src/agents/runs.ts +148 -11
- package/src/agents/threads.ts +1 -17
- package/src/ai-cost-groups.ts +102 -0
- package/src/auth-storage/auth-storage.ts +84 -0
- package/src/auth-storage/device-authorizations.ts +131 -0
- package/src/auth-storage/rows.ts +41 -0
- package/src/auth-storage/shared.ts +1 -0
- package/src/index.ts +8 -1
- package/src/migrations/029-model-accounting.sql +24 -0
- package/src/migrations/030-ai-usage-limits.sql +94 -0
- package/src/migrations/031-subagent-runs.sql +97 -0
- package/src/migrations/032-conversation-run-spec.sql +23 -0
- package/src/migrations/033-retire-agent-definitions.sql +28 -0
- package/src/migrations/034-device-authorizations.sql +24 -0
- package/src/migrations.ts +18 -0
- package/src/pg-ai-cost-storage.ts +71 -24
- package/src/pg-ai-limit-storage.ts +901 -0
- package/src/pg-ai-usage-storage.ts +34 -0
- 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
|
+
}
|