@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,102 @@
1
+ import {
2
+ type AiModelCallGroupRow,
3
+ aiModelCallGroupFragmentFromRow,
4
+ buildAiModelCallGroupsFromFragments,
5
+ normalizeAiModelCallAccountingQuery,
6
+ } from "@sixb/core/internal/ai-cost-storage-provider"
7
+ import type { ListAiModelCallGroupsInput, ListAiModelCallGroupsResult } from "@sixb/core/storage"
8
+ import type { PgStoreClient } from "./transactions"
9
+
10
+ /** Paginate initiating executions, then aggregate their matching calls without loading usage payloads. */
11
+ export async function listAiModelCallGroups(
12
+ sql: PgStoreClient,
13
+ input: ListAiModelCallGroupsInput
14
+ ): Promise<ListAiModelCallGroupsResult> {
15
+ const query = normalizeAiModelCallAccountingQuery(input)
16
+ const parameters = [
17
+ query.projectId,
18
+ query.from.toISOString(),
19
+ query.to.toISOString(),
20
+ query.providerId ?? null,
21
+ query.modelId ?? null,
22
+ query.valuationStatus ?? null,
23
+ ]
24
+ const [count] = await sql.unsafe<{ total: string }[]>(COUNT_SQL, parameters)
25
+ const rows = await sql.unsafe<AiModelCallGroupRow[]>(PAGE_SQL, [
26
+ ...parameters,
27
+ query.limit,
28
+ query.offset,
29
+ ])
30
+ const total = Number(count?.total)
31
+ if (!Number.isSafeInteger(total) || total < 0)
32
+ throw new RangeError("[Sixb] Invalid model-call group count.")
33
+ const items = buildAiModelCallGroupsFromFragments(rows.map(aiModelCallGroupFragmentFromRow))
34
+ return { items, total, hasMore: query.offset + items.length < total }
35
+ }
36
+
37
+ const FILTERED_SQL = `
38
+ WITH filtered AS (
39
+ SELECT COALESCE(root_agent.execution_id, usage.execution_id) AS root_execution_id,
40
+ usage.execution_id, usage.provider_id, usage.requested_model_id, usage.occurred_at,
41
+ usage.total_tokens,
42
+ COALESCE(cost.status, 'unvalued') AS valuation_status,
43
+ cost.currency AS amount_currency, cost.amount_nanos,
44
+ root_agent.id AS root_agent_run_id,
45
+ root_agent.thread_id AS root_thread_id,
46
+ direct_agent.kind AS direct_kind,
47
+ direct_agent.id AS direct_run_id,
48
+ direct_agent.parent_run_id AS parent_run_id,
49
+ workflow_node.node_id AS workflow_agent_step_id,
50
+ workflow_agent.node_run_id AS workflow_node_run_id,
51
+ workflow_node.workflow_id AS workflow_id,
52
+ workflow_node.workflow_run_id AS workflow_run_id
53
+ FROM ai_model_call_usage AS usage
54
+ LEFT JOIN agent_runs AS direct_agent
55
+ ON direct_agent.project_id = usage.project_id AND direct_agent.execution_id = usage.execution_id
56
+ LEFT JOIN agent_runs AS root_agent
57
+ ON root_agent.project_id = usage.project_id
58
+ AND root_agent.id = COALESCE(direct_agent.parent_run_id, direct_agent.id)
59
+ LEFT JOIN workflow_agent_node_runs AS workflow_agent
60
+ ON workflow_agent.project_id = usage.project_id AND workflow_agent.execution_id = usage.execution_id
61
+ LEFT JOIN workflow_node_runs AS workflow_node
62
+ ON workflow_node.project_id = workflow_agent.project_id AND workflow_node.id = workflow_agent.node_run_id
63
+ LEFT JOIN ai_model_call_valuations AS cost
64
+ ON cost.project_id = usage.project_id AND cost.usage_record_id = usage.id
65
+ WHERE usage.project_id = $1
66
+ AND usage.occurred_at >= $2::timestamptz
67
+ AND usage.occurred_at < $3::timestamptz
68
+ AND ($4::text IS NULL OR usage.provider_id = $4)
69
+ AND ($5::text IS NULL OR usage.requested_model_id = $5)
70
+ AND ($6::text IS NULL OR COALESCE(cost.status, 'unvalued') = $6)
71
+ ), roots AS (
72
+ SELECT root_execution_id, MAX(occurred_at) AS last_call_at
73
+ FROM filtered GROUP BY root_execution_id
74
+ )
75
+ `
76
+ const COUNT_SQL = `${FILTERED_SQL} SELECT CAST(COUNT(*) AS TEXT) AS total FROM roots`
77
+ const PAGE_SQL = `${FILTERED_SQL}, page AS (
78
+ SELECT root_execution_id FROM roots
79
+ ORDER BY last_call_at DESC, root_execution_id COLLATE "C" ASC LIMIT $7 OFFSET $8
80
+ )
81
+ SELECT filtered.root_execution_id, execution_id, provider_id, requested_model_id,
82
+ MIN(occurred_at) AS first_call_at, MAX(occurred_at) AS last_call_at,
83
+ CAST(COUNT(*) AS TEXT) AS call_count,
84
+ CAST(COUNT(total_tokens) AS TEXT) AS token_count,
85
+ CAST(SUM(total_tokens) AS TEXT) AS total_tokens,
86
+ valuation_status, amount_currency,
87
+ CAST(SUM(amount_nanos / 1000000000000) AS TEXT) AS amount_high,
88
+ CAST(SUM((amount_nanos / 1000000) % 1000000) AS TEXT) AS amount_middle,
89
+ CAST(SUM(amount_nanos % 1000000) AS TEXT) AS amount_low,
90
+ MAX(root_agent_run_id) AS root_agent_run_id,
91
+ MAX(root_thread_id) AS root_thread_id,
92
+ MAX(direct_kind) AS direct_kind,
93
+ MAX(direct_run_id) AS direct_run_id,
94
+ MAX(parent_run_id) AS parent_run_id,
95
+ MAX(workflow_agent_step_id) AS workflow_agent_step_id,
96
+ MAX(workflow_node_run_id) AS workflow_node_run_id,
97
+ MAX(workflow_id) AS workflow_id,
98
+ MAX(workflow_run_id) AS workflow_run_id
99
+ FROM filtered JOIN page ON page.root_execution_id = filtered.root_execution_id
100
+ GROUP BY filtered.root_execution_id, execution_id, provider_id, requested_model_id,
101
+ valuation_status, amount_currency
102
+ `
@@ -1,8 +1,11 @@
1
1
  import type {
2
2
  AuthStorage,
3
+ CompleteDeviceAuthorizationInput,
4
+ CompleteDeviceAuthorizationResult,
3
5
  CompleteMagicLinkSignInInput,
4
6
  CompleteOidcSignInInput,
5
7
  CompleteSignInResult,
8
+ DeviceAuthorizationRecord,
6
9
  GroupMembershipRecord,
7
10
  InvitationRecord,
8
11
  SessionRecord,
@@ -19,6 +22,10 @@ import {
19
22
  runPgTransaction,
20
23
  } from "../transactions"
21
24
  import { PgAuthAccessTokenStore } from "./access-tokens"
25
+ import {
26
+ getPgDeviceAuthorizationById,
27
+ PgAuthDeviceAuthorizationStore,
28
+ } from "./device-authorizations"
22
29
  import { PgAuthGroupMembershipStore } from "./group-memberships"
23
30
  import { PgAuthUserIdentityStore } from "./identities"
24
31
  import { PgAuthInvitationStore } from "./invitations"
@@ -77,6 +84,7 @@ export class PgAuthStorage implements AuthStorage {
77
84
  readonly groupMemberships: PgAuthGroupMembershipStore
78
85
  readonly magicLinks: PgAuthMagicLinkStore
79
86
  readonly oidcAuthorizationAttempts: PgAuthOidcAuthorizationAttemptStore
87
+ readonly deviceAuthorizations: PgAuthDeviceAuthorizationStore
80
88
 
81
89
  constructor(options: PgAuthStorageOptions) {
82
90
  this.sql = options.sql
@@ -90,6 +98,43 @@ export class PgAuthStorage implements AuthStorage {
90
98
  this.groupMemberships = new PgAuthGroupMembershipStore(this.sql)
91
99
  this.magicLinks = new PgAuthMagicLinkStore(this.sql)
92
100
  this.oidcAuthorizationAttempts = new PgAuthOidcAuthorizationAttemptStore(this.sql)
101
+ this.deviceAuthorizations = new PgAuthDeviceAuthorizationStore(this.sql)
102
+ }
103
+
104
+ async completeDeviceAuthorization(
105
+ input: CompleteDeviceAuthorizationInput
106
+ ): Promise<CompleteDeviceAuthorizationResult> {
107
+ return runPgTransaction(this.sql, async (tx) => {
108
+ const authorization = await getPgDeviceAuthorizationById(tx, input.projectId, input.id)
109
+ if (!authorization) {
110
+ throw new AuthStorageError(
111
+ "missing_device_authorization",
112
+ `[Sixb] Device authorization '${input.id}' not found for project '${input.projectId}'.`
113
+ )
114
+ }
115
+ assertCompletableDeviceAuthorization(authorization, input)
116
+ const accessToken = await new PgAuthAccessTokenStore(tx).create(input.accessToken)
117
+ const [consumed] = await tx`
118
+ UPDATE auth_device_authorizations
119
+ SET status = 'consumed', consumed_at = ${input.completedAt}
120
+ WHERE project_id = ${input.projectId} AND id = ${input.id} AND status = 'approved'
121
+ RETURNING *
122
+ `
123
+ if (!consumed) {
124
+ throw new AuthStorageError(
125
+ "invalid_device_authorization",
126
+ "[Sixb] Device authorization was already consumed."
127
+ )
128
+ }
129
+ return {
130
+ authorization: {
131
+ ...authorization,
132
+ status: "consumed" as const,
133
+ consumedAt: new Date(input.completedAt),
134
+ },
135
+ accessToken,
136
+ }
137
+ })
93
138
  }
94
139
 
95
140
  async completeMagicLinkSignIn(
@@ -796,3 +841,42 @@ function assertSignInSessionAudience(
796
841
  `[Sixb] Sign-in session audience '${sessionAudience}' does not match stored auth audience '${storedAudience}' for project '${projectId}'.`
797
842
  )
798
843
  }
844
+
845
+ function assertCompletableDeviceAuthorization(
846
+ authorization: DeviceAuthorizationRecord,
847
+ input: CompleteDeviceAuthorizationInput
848
+ ): void {
849
+ if (authorization.deviceCodeHash !== input.deviceCodeHash) {
850
+ throw new AuthStorageError(
851
+ "invalid_device_authorization",
852
+ "[Sixb] Invalid device authorization."
853
+ )
854
+ }
855
+ if (authorization.status === "pending") {
856
+ throw new AuthStorageError(
857
+ "pending_device_authorization",
858
+ "[Sixb] Device authorization is pending."
859
+ )
860
+ }
861
+ if (authorization.status === "denied") {
862
+ throw new AuthStorageError(
863
+ "device_authorization_denied",
864
+ "[Sixb] Device authorization was denied."
865
+ )
866
+ }
867
+ if (
868
+ authorization.status !== "approved" ||
869
+ authorization.expiresAt <= input.completedAt ||
870
+ !authorization.approvedUserId ||
871
+ !authorization.approvedSessionId ||
872
+ input.accessToken.projectId !== input.projectId ||
873
+ input.accessToken.subjectType !== "user" ||
874
+ input.accessToken.subjectId !== authorization.approvedUserId ||
875
+ input.accessToken.createdBySessionId !== authorization.approvedSessionId
876
+ ) {
877
+ throw new AuthStorageError(
878
+ "invalid_device_authorization",
879
+ "[Sixb] Device authorization is invalid."
880
+ )
881
+ }
882
+ }
@@ -0,0 +1,131 @@
1
+ import type {
2
+ AuthDeviceAuthorizationStore,
3
+ CreateDeviceAuthorizationInput,
4
+ DeviceAuthorizationRecord,
5
+ } from "@sixb/core/storage"
6
+ import { AuthStorageError, MAX_PENDING_DEVICE_AUTHORIZATIONS } from "@sixb/core/storage"
7
+ import {
8
+ authLockKey,
9
+ lockAdvisoryKeys,
10
+ type PgStoreClient,
11
+ runPgTransaction,
12
+ } from "../transactions"
13
+ import type { PgAuthDeviceAuthorizationRow } from "./rows"
14
+ import { rowToDeviceAuthorizationRecord } from "./rows"
15
+ import { assertNonEmpty, mapUniqueConstraintError } from "./shared"
16
+
17
+ export class PgAuthDeviceAuthorizationStore implements AuthDeviceAuthorizationStore {
18
+ constructor(private readonly sql: PgStoreClient) {}
19
+
20
+ async create(input: CreateDeviceAuthorizationInput): Promise<DeviceAuthorizationRecord> {
21
+ return runPgTransaction(this.sql, async (tx) => {
22
+ await lockAdvisoryKeys(tx, [authLockKey("device-authorizations", input.projectId)])
23
+ await tx`
24
+ DELETE FROM auth_device_authorizations
25
+ WHERE project_id = ${input.projectId} AND expires_at <= ${input.createdAt}
26
+ `
27
+ const [pending] = await tx<{ readonly count: string | number }[]>`
28
+ SELECT COUNT(*)::bigint AS count FROM auth_device_authorizations
29
+ WHERE project_id = ${input.projectId} AND status = 'pending'
30
+ `
31
+ if (Number(pending?.count ?? 0) >= MAX_PENDING_DEVICE_AUTHORIZATIONS) {
32
+ throw new AuthStorageError(
33
+ "device_authorization_limit_exceeded",
34
+ `[Sixb] Project '${input.projectId}' has too many pending device authorizations.`
35
+ )
36
+ }
37
+ try {
38
+ const [row] = await tx<PgAuthDeviceAuthorizationRow[]>`
39
+ INSERT INTO auth_device_authorizations (
40
+ project_id, id, device_code_hash, user_code, client_name, token_name,
41
+ token_expires_at, status, created_at, expires_at
42
+ ) VALUES (
43
+ ${assertNonEmpty(input.projectId, "Project id")},
44
+ ${assertNonEmpty(input.id, "Device authorization id")},
45
+ ${assertNonEmpty(input.deviceCodeHash, "Device code hash")},
46
+ ${assertNonEmpty(input.userCode, "User code")},
47
+ ${assertNonEmpty(input.clientName, "Client name")},
48
+ ${assertNonEmpty(input.tokenName, "Token name")},
49
+ ${input.tokenExpiresAt}, 'pending', ${input.createdAt}, ${input.expiresAt}
50
+ ) RETURNING *
51
+ `
52
+ return rowToDeviceAuthorizationRecord(row)
53
+ } catch (error) {
54
+ mapUniqueConstraintError(
55
+ error,
56
+ "duplicate_device_authorization",
57
+ `[Sixb] Device authorization '${input.id}' already exists for project '${input.projectId}'.`
58
+ )
59
+ }
60
+ })
61
+ }
62
+
63
+ async getById(params: { readonly projectId: string; readonly id: string }) {
64
+ return getPgDeviceAuthorizationById(this.sql, params.projectId, params.id)
65
+ }
66
+
67
+ async getByUserCode(params: { readonly projectId: string; readonly userCode: string }) {
68
+ const [row] = await this.sql<PgAuthDeviceAuthorizationRow[]>`
69
+ SELECT * FROM auth_device_authorizations
70
+ WHERE project_id = ${params.projectId} AND user_code = ${params.userCode}
71
+ `
72
+ return row ? rowToDeviceAuthorizationRecord(row) : null
73
+ }
74
+
75
+ async approve(params: {
76
+ readonly projectId: string
77
+ readonly id: string
78
+ readonly userId: string
79
+ readonly sessionId: string
80
+ readonly approvedAt: Date
81
+ }) {
82
+ const [row] = await this.sql<PgAuthDeviceAuthorizationRow[]>`
83
+ UPDATE auth_device_authorizations
84
+ SET status = 'approved', approved_user_id = ${params.userId},
85
+ approved_session_id = ${params.sessionId}, approved_at = ${params.approvedAt}
86
+ WHERE project_id = ${params.projectId} AND id = ${params.id}
87
+ AND status = 'pending' AND expires_at > ${params.approvedAt}
88
+ AND EXISTS (
89
+ SELECT 1 FROM auth_sessions
90
+ WHERE project_id = ${params.projectId} AND id = ${params.sessionId}
91
+ AND user_id = ${params.userId} AND revoked_at IS NULL
92
+ AND expires_at > ${params.approvedAt}
93
+ AND (absolute_expires_at IS NULL OR absolute_expires_at > ${params.approvedAt})
94
+ )
95
+ RETURNING *
96
+ `
97
+ if (!row) throw invalidTransition(params.id, params.projectId)
98
+ return rowToDeviceAuthorizationRecord(row)
99
+ }
100
+
101
+ async deny(params: { readonly projectId: string; readonly id: string; readonly deniedAt: Date }) {
102
+ const [row] = await this.sql<PgAuthDeviceAuthorizationRow[]>`
103
+ UPDATE auth_device_authorizations
104
+ SET status = 'denied', denied_at = ${params.deniedAt}
105
+ WHERE project_id = ${params.projectId} AND id = ${params.id}
106
+ AND status = 'pending' AND expires_at > ${params.deniedAt}
107
+ RETURNING *
108
+ `
109
+ if (!row) throw invalidTransition(params.id, params.projectId)
110
+ return rowToDeviceAuthorizationRecord(row)
111
+ }
112
+ }
113
+
114
+ export async function getPgDeviceAuthorizationById(
115
+ sql: PgStoreClient,
116
+ projectId: string,
117
+ id: string
118
+ ) {
119
+ const [row] = await sql<PgAuthDeviceAuthorizationRow[]>`
120
+ SELECT * FROM auth_device_authorizations
121
+ WHERE project_id = ${projectId} AND id = ${id}
122
+ `
123
+ return row ? rowToDeviceAuthorizationRecord(row) : null
124
+ }
125
+
126
+ function invalidTransition(id: string, projectId: string): AuthStorageError {
127
+ return new AuthStorageError(
128
+ "invalid_device_authorization",
129
+ `[Sixb] Device authorization '${id}' is no longer pending for project '${projectId}'.`
130
+ )
131
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Principal } from "@sixb/core"
2
2
  import type {
3
3
  AccessTokenRecord,
4
+ DeviceAuthorizationRecord,
4
5
  GroupMembershipRecord,
5
6
  GroupMembershipSource,
6
7
  InvitationRecord,
@@ -150,6 +151,24 @@ export interface PgAuthOidcAttemptRow {
150
151
  readonly consumed_at: PgDate | null
151
152
  }
152
153
 
154
+ export interface PgAuthDeviceAuthorizationRow {
155
+ readonly project_id: string
156
+ readonly id: string
157
+ readonly device_code_hash: string
158
+ readonly user_code: string
159
+ readonly client_name: string
160
+ readonly token_name: string
161
+ readonly token_expires_at: PgDate
162
+ readonly status: DeviceAuthorizationRecord["status"]
163
+ readonly approved_user_id: string | null
164
+ readonly approved_session_id: string | null
165
+ readonly created_at: PgDate
166
+ readonly expires_at: PgDate
167
+ readonly approved_at: PgDate | null
168
+ readonly denied_at: PgDate | null
169
+ readonly consumed_at: PgDate | null
170
+ }
171
+
153
172
  export function rowToUserRecord(row: PgAuthUserRow): UserRecord {
154
173
  return {
155
174
  id: row.id,
@@ -304,6 +323,28 @@ export function rowToOidcAuthorizationAttemptRecord(
304
323
  }
305
324
  }
306
325
 
326
+ export function rowToDeviceAuthorizationRecord(
327
+ row: PgAuthDeviceAuthorizationRow
328
+ ): DeviceAuthorizationRecord {
329
+ return {
330
+ id: row.id,
331
+ projectId: row.project_id,
332
+ deviceCodeHash: row.device_code_hash,
333
+ userCode: row.user_code,
334
+ clientName: row.client_name,
335
+ tokenName: row.token_name,
336
+ tokenExpiresAt: toDate(row.token_expires_at),
337
+ status: row.status,
338
+ approvedUserId: row.approved_user_id ?? undefined,
339
+ approvedSessionId: row.approved_session_id ?? undefined,
340
+ createdAt: toDate(row.created_at),
341
+ expiresAt: toDate(row.expires_at),
342
+ approvedAt: row.approved_at ? toDate(row.approved_at) : undefined,
343
+ deniedAt: row.denied_at ? toDate(row.denied_at) : undefined,
344
+ consumedAt: row.consumed_at ? toDate(row.consumed_at) : undefined,
345
+ }
346
+ }
347
+
307
348
  export function serializeOptionalRecord(
308
349
  value: Readonly<Record<string, unknown>> | undefined
309
350
  ): string | null {
@@ -761,6 +761,7 @@ export function mapUniqueConstraintError(
761
761
  error: unknown,
762
762
  code:
763
763
  | "duplicate_access_token"
764
+ | "duplicate_device_authorization"
764
765
  | "duplicate_identity"
765
766
  | "duplicate_invitation"
766
767
  | "duplicate_magic_link"
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  } from "@sixb/core/internal/storage-operation-scope"
21
21
  import {
22
22
  type AiCostStorage,
23
+ type AiLimitStorage,
23
24
  type AiUsageStorage,
24
25
  createTransactionStorageProxy,
25
26
  type ObjectStorage,
@@ -33,6 +34,7 @@ import { createPostgresStorageMigrators, dropSchema } from "./migrations"
33
34
  import { PgOntologyStorage, type PgOntologyTransactionContext } from "./ontology-storage"
34
35
  import { PgActionRunStorage } from "./pg-action-run-storage"
35
36
  import { PgAiCostStorage } from "./pg-ai-cost-storage"
37
+ import { PgAiLimitStorage } from "./pg-ai-limit-storage"
36
38
  import { PgAiUsageStorage } from "./pg-ai-usage-storage"
37
39
  import { createPgClient, type SQL, type SQLClient } from "./pg-client"
38
40
  import { PgExecutionStorage } from "./pg-execution-storage"
@@ -138,6 +140,7 @@ export class PostgresStorage implements MigrationCapableStorage {
138
140
  readonly agents: PgAgentStorage
139
141
  readonly aiUsage: AiUsageStorage
140
142
  readonly aiCosts: AiCostStorage
143
+ readonly aiLimits: AiLimitStorage
141
144
  readonly actionRuns: PgActionRunStorage
142
145
  readonly pipelineRuns: PgPipelineRunStorage
143
146
  readonly workflowRuns: PgWorkflowRunStorage
@@ -212,6 +215,7 @@ export class PostgresStorage implements MigrationCapableStorage {
212
215
  this.agents = createAgentOperationScope(stores.agents, scope)
213
216
  this.aiUsage = createOperationScopedFacade(stores.aiUsage, scope)
214
217
  this.aiCosts = createOperationScopedFacade(stores.aiCosts, scope)
218
+ this.aiLimits = createOperationScopedFacade(stores.aiLimits, scope)
215
219
  this.actionRuns = createOperationScopedFacade(stores.actionRuns, scope)
216
220
  this.pipelineRuns = createOperationScopedFacade(stores.pipelineRuns, scope)
217
221
  this.workflowRuns = createWorkflowRunOperationScope(stores.workflowRuns, scope)
@@ -258,6 +262,7 @@ export class PostgresStorage implements MigrationCapableStorage {
258
262
  transactionContext.materializations.assertCommittable()
259
263
  return result
260
264
  } finally {
265
+ await txStorage.aiLimits.settleOperations()
261
266
  transactionContext.active = false
262
267
  txStorage.ontology.deactivateSessions()
263
268
  transactionContext.materializations.deactivate()
@@ -305,7 +310,7 @@ export class PostgresStorage implements MigrationCapableStorage {
305
310
  private createTransactionStorage(
306
311
  client: PgStoreClient,
307
312
  transactionContext: PgOntologyTransactionContext
308
- ): Storage & { readonly ontology: PgOntologyStorage } {
313
+ ): Storage & { readonly ontology: PgOntologyStorage; readonly aiLimits: PgAiLimitStorage } {
309
314
  return {
310
315
  ...createPostgresStores(client, {
311
316
  runOntologyOperation: async (run) => run(client),
@@ -342,6 +347,7 @@ function createPostgresStores(
342
347
  agents: new PgAgentStorage({ sql, executions }),
343
348
  aiUsage: new PgAiUsageStorage(sql),
344
349
  aiCosts: new PgAiCostStorage(sql),
350
+ aiLimits: new PgAiLimitStorage(sql),
345
351
  actionRuns: new PgActionRunStorage(sql, executions),
346
352
  pipelineRuns: new PgPipelineRunStorage(sql, executions),
347
353
  workflowRuns: new PgWorkflowRunStorage(sql, executions),
@@ -363,6 +369,7 @@ interface PostgresStoreSet {
363
369
  readonly agents: PgAgentStorage
364
370
  readonly aiUsage: PgAiUsageStorage
365
371
  readonly aiCosts: PgAiCostStorage
372
+ readonly aiLimits: PgAiLimitStorage
366
373
  readonly actionRuns: PgActionRunStorage
367
374
  readonly pipelineRuns: PgPipelineRunStorage
368
375
  readonly workflowRuns: PgWorkflowRunStorage
@@ -0,0 +1,24 @@
1
+ -- Call-time model configuration and provider identity. Historical identity remains unknown.
2
+ ALTER TABLE ai_model_call_usage
3
+ ADD COLUMN requested_reasoning JSONB
4
+ CHECK (
5
+ requested_reasoning IS NULL OR
6
+ jsonb_typeof(requested_reasoning) IN ('string', 'object')
7
+ );
8
+ ALTER TABLE ai_model_call_usage ADD COLUMN provider_ids JSONB;
9
+
10
+ -- Upgrade the shipped valuation reason while retaining the selected-cost projection.
11
+ ALTER TABLE ai_model_call_valuations
12
+ DROP CONSTRAINT ai_model_call_valuations_reason_check;
13
+
14
+ UPDATE ai_model_call_valuations SET reason = 'missingRateCard'
15
+ WHERE reason = 'missingCatalogEntry';
16
+
17
+ ALTER TABLE ai_model_call_valuations ADD CONSTRAINT ai_model_call_valuations_reason_check
18
+ CHECK (reason IS NULL OR reason IN (
19
+ 'missingBillingIdentity',
20
+ 'missingRateCard',
21
+ 'missingUsageMeter',
22
+ 'unsupportedPricingDimension',
23
+ 'invalidUsageForFormula'
24
+ ));
@@ -0,0 +1,94 @@
1
+ CREATE TABLE ai_usage_limits (
2
+ project_id TEXT NOT NULL CHECK (length(btrim(project_id)) > 0),
3
+ id TEXT NOT NULL CHECK (length(btrim(id)) > 0),
4
+ subject_type TEXT NOT NULL CHECK (
5
+ subject_type IN ('project', 'group', 'user', 'serviceAccount')
6
+ ),
7
+ subject_id TEXT NOT NULL CHECK (
8
+ (subject_type = 'project' AND subject_id = '')
9
+ OR (subject_type <> 'project' AND length(btrim(subject_id)) > 0)
10
+ ),
11
+ meter TEXT NOT NULL CHECK (meter IN ('tokens.total', 'cost.catalogEstimated')),
12
+ currency TEXT NOT NULL CHECK (
13
+ (meter = 'tokens.total' AND currency = '')
14
+ OR (meter = 'cost.catalogEstimated' AND currency = 'USD')
15
+ ),
16
+ limit_amount NUMERIC(78, 0) NOT NULL CHECK (limit_amount >= 0),
17
+ period_kind TEXT NOT NULL CHECK (period_kind = 'calendarMonth'),
18
+ enabled BOOLEAN NOT NULL,
19
+ created_at TIMESTAMPTZ NOT NULL,
20
+ updated_at TIMESTAMPTZ NOT NULL,
21
+ PRIMARY KEY (project_id, id),
22
+ UNIQUE (project_id, subject_type, subject_id, meter, currency)
23
+ );
24
+
25
+ CREATE INDEX idx_ai_usage_limits_project_enabled
26
+ ON ai_usage_limits (project_id, enabled, subject_type, subject_id, meter, currency);
27
+
28
+ CREATE TABLE ai_usage_limit_periods (
29
+ project_id TEXT NOT NULL CHECK (length(btrim(project_id)) > 0),
30
+ subject_type TEXT NOT NULL CHECK (
31
+ subject_type IN ('project', 'group', 'user', 'serviceAccount')
32
+ ),
33
+ subject_id TEXT NOT NULL CHECK (
34
+ (subject_type = 'project' AND subject_id = '')
35
+ OR (subject_type <> 'project' AND length(btrim(subject_id)) > 0)
36
+ ),
37
+ meter TEXT NOT NULL CHECK (meter IN ('tokens.total', 'cost.catalogEstimated')),
38
+ currency TEXT NOT NULL CHECK (
39
+ (meter = 'tokens.total' AND currency = '')
40
+ OR (meter = 'cost.catalogEstimated' AND currency = 'USD')
41
+ ),
42
+ period_start TIMESTAMPTZ NOT NULL,
43
+ period_end TIMESTAMPTZ NOT NULL,
44
+ actual_amount NUMERIC(78, 0) NOT NULL DEFAULT 0 CHECK (actual_amount >= 0),
45
+ reserved_amount NUMERIC(78, 0) NOT NULL DEFAULT 0 CHECK (reserved_amount >= 0),
46
+ unknown_amount NUMERIC(78, 0) NOT NULL DEFAULT 0 CHECK (unknown_amount >= 0),
47
+ accounting_status TEXT NOT NULL DEFAULT 'complete' CHECK (
48
+ accounting_status IN ('complete', 'unavailable')
49
+ ),
50
+ updated_at TIMESTAMPTZ NOT NULL,
51
+ PRIMARY KEY (
52
+ project_id, subject_type, subject_id, meter, currency, period_start
53
+ )
54
+ );
55
+
56
+ CREATE INDEX idx_ai_usage_limit_periods_subject_period
57
+ ON ai_usage_limit_periods (
58
+ project_id, subject_type, subject_id, period_start, period_end, meter, currency
59
+ );
60
+
61
+ CREATE TABLE ai_model_call_reservations (
62
+ project_id TEXT NOT NULL CHECK (length(btrim(project_id)) > 0),
63
+ execution_id TEXT NOT NULL CHECK (length(btrim(execution_id)) > 0),
64
+ attempt BIGINT NOT NULL CHECK (attempt >= 1),
65
+ call_id TEXT NOT NULL CHECK (length(btrim(call_id)) > 0),
66
+ buckets JSONB NOT NULL CHECK (jsonb_typeof(buckets) = 'array'),
67
+ request_key TEXT NOT NULL,
68
+ period_start TIMESTAMPTZ NOT NULL,
69
+ period_end TIMESTAMPTZ NOT NULL,
70
+ state TEXT NOT NULL CHECK (state IN ('active', 'reconciled', 'unknown')),
71
+ usage_record_id TEXT,
72
+ reserved_at TIMESTAMPTZ NOT NULL,
73
+ updated_at TIMESTAMPTZ NOT NULL,
74
+ PRIMARY KEY (project_id, execution_id, attempt, call_id),
75
+ FOREIGN KEY (project_id, execution_id)
76
+ REFERENCES executions(project_id, id)
77
+ ON DELETE RESTRICT,
78
+ FOREIGN KEY (project_id, usage_record_id)
79
+ REFERENCES ai_model_call_usage(project_id, id)
80
+ ON DELETE RESTRICT,
81
+ CHECK (
82
+ (
83
+ state = 'reconciled'
84
+ AND usage_record_id IS NOT NULL
85
+ )
86
+ OR (
87
+ state <> 'reconciled'
88
+ AND usage_record_id IS NULL
89
+ )
90
+ )
91
+ );
92
+
93
+ CREATE INDEX idx_ai_model_call_reservations_active
94
+ ON ai_model_call_reservations (project_id, state, period_end, execution_id, attempt, call_id);