@sixb/sqlite 0.1.1 → 0.1.3

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 (123) hide show
  1. package/README.md +18 -13
  2. package/dist/action-run-storage.d.ts +5 -2
  3. package/dist/action-run-storage.d.ts.map +1 -1
  4. package/dist/agents/agent-storage.d.ts +9 -5
  5. package/dist/agents/agent-storage.d.ts.map +1 -1
  6. package/dist/agents/checkpoints.d.ts +14 -0
  7. package/dist/agents/checkpoints.d.ts.map +1 -0
  8. package/dist/agents/index.d.ts +1 -0
  9. package/dist/agents/index.d.ts.map +1 -1
  10. package/dist/agents/messages.d.ts +2 -1
  11. package/dist/agents/messages.d.ts.map +1 -1
  12. package/dist/agents/rows.d.ts +21 -11
  13. package/dist/agents/rows.d.ts.map +1 -1
  14. package/dist/agents/runs.d.ts +3 -2
  15. package/dist/agents/runs.d.ts.map +1 -1
  16. package/dist/ai-cost-storage.d.ts +15 -0
  17. package/dist/ai-cost-storage.d.ts.map +1 -0
  18. package/dist/ai-usage-storage.d.ts +22 -0
  19. package/dist/ai-usage-storage.d.ts.map +1 -0
  20. package/dist/auth-storage/service-accounts.d.ts.map +1 -1
  21. package/dist/auth-storage/shared.d.ts +1 -10
  22. package/dist/auth-storage/shared.d.ts.map +1 -1
  23. package/dist/connector-connection-storage.d.ts +9 -0
  24. package/dist/connector-connection-storage.d.ts.map +1 -0
  25. package/dist/execution-storage.d.ts +14 -0
  26. package/dist/execution-storage.d.ts.map +1 -0
  27. package/dist/index.d.ts +14 -9
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +4633 -1066
  30. package/dist/index.js.map +33 -28
  31. package/dist/latest-run-query.d.ts.map +1 -1
  32. package/dist/migrations.d.ts +0 -1
  33. package/dist/migrations.d.ts.map +1 -1
  34. package/dist/ontology-storage/materialization-session.d.ts +50 -8
  35. package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
  36. package/dist/ontology-storage/materialization-state.d.ts +4 -5
  37. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  38. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  39. package/dist/ontology-storage/materializations.d.ts +0 -2
  40. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  41. package/dist/ontology-storage/outbox.d.ts.map +1 -1
  42. package/dist/ontology-storage/shared.d.ts +3 -3
  43. package/dist/ontology-storage/shared.d.ts.map +1 -1
  44. package/dist/ontology-storage/sources.d.ts.map +1 -1
  45. package/dist/pipeline-run-storage.d.ts +7 -2
  46. package/dist/pipeline-run-storage.d.ts.map +1 -1
  47. package/dist/projection-run-storage.d.ts +9 -2
  48. package/dist/projection-run-storage.d.ts.map +1 -1
  49. package/dist/run-list-query.d.ts +1 -1
  50. package/dist/run-list-query.d.ts.map +1 -1
  51. package/dist/sync-run-storage.d.ts +7 -2
  52. package/dist/sync-run-storage.d.ts.map +1 -1
  53. package/dist/testing.d.ts +6 -0
  54. package/dist/testing.d.ts.map +1 -0
  55. package/dist/transactions.d.ts +3 -0
  56. package/dist/transactions.d.ts.map +1 -1
  57. package/dist/webhook-run-storage.d.ts +9 -2
  58. package/dist/webhook-run-storage.d.ts.map +1 -1
  59. package/dist/workflow-run-storage.d.ts +7 -3
  60. package/dist/workflow-run-storage.d.ts.map +1 -1
  61. package/package.json +3 -3
  62. package/src/action-run-storage.ts +51 -74
  63. package/src/agents/agent-storage.ts +11 -6
  64. package/src/agents/checkpoints.ts +175 -0
  65. package/src/agents/index.ts +1 -0
  66. package/src/agents/messages.ts +59 -0
  67. package/src/agents/rows.ts +52 -40
  68. package/src/agents/runs.ts +27 -21
  69. package/src/ai-cost-storage.ts +744 -0
  70. package/src/ai-usage-storage.ts +324 -0
  71. package/src/auth-storage/service-accounts.ts +14 -21
  72. package/src/auth-storage/shared.ts +0 -22
  73. package/src/connector-connection-storage.ts +784 -0
  74. package/src/execution-storage.ts +158 -0
  75. package/src/index.ts +69 -22
  76. package/src/latest-run-query.ts +7 -1
  77. package/src/migrations/004-executions.sql +142 -0
  78. package/src/migrations/005-workflow-executions.sql +7 -0
  79. package/src/migrations/006-narrow-ontology-source-root-index.sql +4 -0
  80. package/src/migrations/007-split-overrides.sql +154 -0
  81. package/src/migrations/008-action-executions.sql +4 -0
  82. package/src/migrations/009-agent-executions.sql +115 -0
  83. package/src/migrations/010-ai-usage-accounting-foundation.sql +74 -0
  84. package/src/migrations/011-sync-failure-record.sql +22 -0
  85. package/src/migrations/012-pipeline-failure-record.sql +45 -0
  86. package/src/migrations/013-workflow-failure-record.sql +45 -0
  87. package/src/migrations/014-agent-failure-record.sql +59 -0
  88. package/src/migrations/015-projection-failure-record.sql +20 -0
  89. package/src/migrations/016-webhook-run-failure-record.sql +19 -0
  90. package/src/migrations/017-action-failure-record.sql +65 -0
  91. package/src/migrations/018-ontology-outbox-failure-record.sql +80 -0
  92. package/src/migrations/019-webhook-delivery-failure-record.sql +62 -0
  93. package/src/migrations/020-drop-run-usage-projections.sql +7 -0
  94. package/src/migrations/021-sync-pipeline-executions.sql +101 -0
  95. package/src/migrations/022-projection-executions.sql +244 -0
  96. package/src/migrations/023-webhook-executions.sql +51 -0
  97. package/src/migrations/024-ontology-commit-executions.sql +58 -0
  98. package/src/migrations/025-connector-connections.sql +257 -0
  99. package/src/migrations/026-ai-cost-accounting.sql +57 -0
  100. package/src/migrations/027-agent-context-checkpoints.sql +28 -0
  101. package/src/migrations/028-object-override-edit-times.sql +3 -0
  102. package/src/migrations.ts +129 -4
  103. package/src/ontology-storage/materialization-session.ts +317 -143
  104. package/src/ontology-storage/materialization-state.ts +323 -112
  105. package/src/ontology-storage/materialization-writer.ts +145 -67
  106. package/src/ontology-storage/materializations.ts +67 -141
  107. package/src/ontology-storage/outbox.ts +10 -3
  108. package/src/ontology-storage/shared.ts +10 -4
  109. package/src/ontology-storage/sources.ts +4 -1
  110. package/src/pipeline-run-storage.ts +128 -43
  111. package/src/projection-run-storage.ts +209 -97
  112. package/src/run-list-query.ts +9 -4
  113. package/src/sync-run-storage.ts +118 -32
  114. package/src/testing.ts +20 -0
  115. package/src/transactions.ts +10 -3
  116. package/src/webhook-run-storage.ts +118 -74
  117. package/src/workflow-run-storage.ts +117 -131
  118. package/dist/validation.d.ts +0 -5
  119. package/dist/validation.d.ts.map +0 -1
  120. package/dist/webhook-delivery-storage.d.ts +0 -27
  121. package/dist/webhook-delivery-storage.d.ts.map +0 -1
  122. package/src/validation.ts +0 -30
  123. package/src/webhook-delivery-storage.ts +0 -272
@@ -0,0 +1,744 @@
1
+ import {
2
+ type AiAccountingAggregateFragment,
3
+ type AiAccountingRecordSetItem,
4
+ aiModelCallCostDetails,
5
+ aiModelCallCostMatchesUsage,
6
+ buildAiAccountingOverviewFromFragments,
7
+ normalizeAiAccountingQuery,
8
+ normalizeAiModelCallAccountingQuery,
9
+ normalizeAiModelCallCostRecord,
10
+ parseAiModelCallCostDetails,
11
+ toAccountingItem,
12
+ } from "@sixb/core/internal/ai-cost-storage-provider"
13
+ import type { ReadonlyJsonObject } from "@sixb/core/storage"
14
+ import {
15
+ type AiAccountingOverview,
16
+ type AiCostStorage,
17
+ AiCostStorageError,
18
+ type AiCostSummary,
19
+ type AiModelCallCostRecord,
20
+ type AiModelCallUsageRecord,
21
+ type ListAiModelCallAccountingInput,
22
+ type ListAiModelCallAccountingResult,
23
+ type QueryAiAccountingOverviewInput,
24
+ type SummarizeAiCostExecutionsInput,
25
+ } from "@sixb/core/storage"
26
+ import { isUniqueConstraintError } from "./storage-errors"
27
+ import { runImmediateTransaction, type SqliteStoreConnection } from "./transactions"
28
+
29
+ /** SQLite-backed immutable valuations stored in one append-only table. */
30
+ export class SqliteAiCostStorage implements AiCostStorage {
31
+ constructor(private readonly connection: SqliteStoreConnection) {}
32
+
33
+ async recordModelCallCost(input: AiModelCallCostRecord): Promise<void> {
34
+ const record = normalizeAiModelCallCostRecord(input)
35
+ runImmediateTransaction(this.connection.db, () => {
36
+ const usage = this.findUsage(record.projectId, record.usageRecordId)
37
+ if (!usage) throw missingUsage(record)
38
+ assertCostMatchesUsage(record, usage)
39
+ if (this.findCost(record.projectId, record.usageRecordId)) return
40
+ try {
41
+ this.insertCost(record)
42
+ } catch (error) {
43
+ if (!isUniqueConstraintError(error)) throw error
44
+ if (!this.findCost(record.projectId, record.usageRecordId)) throw error
45
+ }
46
+ })
47
+ }
48
+
49
+ async summarizeExecutions(
50
+ input: SummarizeAiCostExecutionsInput
51
+ ): Promise<readonly AiCostSummary[]> {
52
+ assertNonBlank(input.projectId, "cost summary projectId")
53
+ for (const id of input.executionIds) assertNonBlank(id, "cost summary executionId")
54
+ if (input.executionIds.length === 0) return []
55
+ const rows = this.connection.db
56
+ .query(
57
+ `
58
+ WITH requested AS (SELECT DISTINCT value AS execution_id FROM json_each(?))
59
+ SELECT usage.execution_id,
60
+ cost.status,
61
+ cost.currency,
62
+ CAST(cost.amount_nanos AS TEXT) AS amount_nanos
63
+ FROM requested
64
+ JOIN ai_model_call_usage AS usage
65
+ ON usage.project_id = ? AND usage.execution_id = requested.execution_id
66
+ LEFT JOIN ai_model_call_valuations AS cost
67
+ ON cost.project_id = usage.project_id AND cost.usage_record_id = usage.id
68
+ `
69
+ )
70
+ .all(JSON.stringify([...new Set(input.executionIds)]), input.projectId) as SummaryRow[]
71
+ return aggregateSummaryRows(input.executionIds, rows)
72
+ }
73
+
74
+ async queryProjectOverview(input: QueryAiAccountingOverviewInput): Promise<AiAccountingOverview> {
75
+ const query = normalizeAiAccountingQuery(input)
76
+ const rows = this.connection.db
77
+ .query(SQLITE_ACCOUNTING_OVERVIEW_SQL)
78
+ .all(
79
+ query.projectId,
80
+ query.from.toISOString(),
81
+ query.to.toISOString(),
82
+ query.providerId ?? null,
83
+ query.providerId ?? null,
84
+ query.modelId ?? null,
85
+ query.modelId ?? null,
86
+ input.bucket
87
+ ) as AggregateRow[]
88
+ return buildAiAccountingOverviewFromFragments(input, rows.map(aggregateFragmentFromRow))
89
+ }
90
+
91
+ async listModelCalls(
92
+ input: ListAiModelCallAccountingInput
93
+ ): Promise<ListAiModelCallAccountingResult> {
94
+ const query = normalizeAiModelCallAccountingQuery(input)
95
+ const parameters = accountingListFilterParameters(query)
96
+ const count = this.connection.db
97
+ .query(SQLITE_ACCOUNTING_LIST_COUNT_SQL)
98
+ .get(...parameters) as CountRow
99
+ const rows = this.connection.db
100
+ .query(SQLITE_ACCOUNTING_LIST_PAGE_SQL)
101
+ .all(...parameters, query.limit, query.offset) as JoinedRow[]
102
+ const items = rows.map((row) => toAccountingItem(accountingItemFromRow(row)))
103
+ const total = safeSqlCount(count.total, "model-call list total")
104
+ return structuredClone({
105
+ items,
106
+ total,
107
+ hasMore: query.offset + items.length < total,
108
+ })
109
+ }
110
+
111
+ private insertCost(record: AiModelCallCostRecord): void {
112
+ const rated = record.status === "rated" ? record : undefined
113
+ const details = aiModelCallCostDetails(record)
114
+ this.connection.db
115
+ .query(
116
+ `INSERT INTO ai_model_call_valuations (
117
+ project_id, usage_record_id, status, provider_id, model_id,
118
+ currency, amount_nanos, reason, details, rated_at
119
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
120
+ )
121
+ .run(
122
+ record.projectId,
123
+ record.usageRecordId,
124
+ record.status,
125
+ record.billingIdentity?.providerId ?? null,
126
+ record.billingIdentity?.modelId ?? null,
127
+ rated?.money.currency ?? null,
128
+ rated ? BigInt(rated.money.amountNanos) : null,
129
+ record.status === "unpriceable" ? record.reason : null,
130
+ JSON.stringify(details),
131
+ record.ratedAt.toISOString()
132
+ )
133
+ }
134
+
135
+ private findCost(projectId: string, usageId: string): AiModelCallCostRecord | null {
136
+ const row = this.connection.db
137
+ .query(
138
+ `SELECT *, CAST(amount_nanos AS TEXT) AS amount_nanos
139
+ FROM ai_model_call_valuations
140
+ WHERE project_id = ? AND usage_record_id = ?`
141
+ )
142
+ .get(projectId, usageId) as ValuationRow | null
143
+ return row ? rowToCost(row) : null
144
+ }
145
+
146
+ private findUsage(projectId: string, id: string): AiModelCallUsageRecord | null {
147
+ const row = this.connection.db
148
+ .query("SELECT * FROM ai_model_call_usage WHERE project_id = ? AND id = ?")
149
+ .get(projectId, id) as UsageRow | null
150
+ return row ? usageFromRow(row, []) : null
151
+ }
152
+ }
153
+
154
+ const SQLITE_ACCOUNTING_LIST_FILTER = `
155
+ usage.project_id = ? AND usage.occurred_at >= ? AND usage.occurred_at < ?
156
+ AND (? IS NULL OR usage.provider_id = ?)
157
+ AND (? IS NULL OR usage.requested_model_id = ?)
158
+ AND (? IS NULL OR usage.execution_id = ?)
159
+ AND (
160
+ ? IS NULL OR ? = CASE
161
+ WHEN cost.status = 'rated' THEN 'rated'
162
+ WHEN cost.status = 'unpriceable' THEN 'unpriceable'
163
+ ELSE 'unvalued'
164
+ END
165
+ )
166
+ `
167
+
168
+ const SQLITE_ACCOUNTING_LIST_COUNT_SQL = `
169
+ SELECT CAST(COUNT(*) AS TEXT) AS total
170
+ FROM ai_model_call_usage AS usage
171
+ LEFT JOIN ai_model_call_valuations AS cost
172
+ ON cost.project_id = usage.project_id AND cost.usage_record_id = usage.id
173
+ WHERE ${SQLITE_ACCOUNTING_LIST_FILTER}
174
+ `
175
+
176
+ const SQLITE_ACCOUNTING_LIST_PAGE_SQL = `
177
+ SELECT usage.*,
178
+ CASE
179
+ WHEN direct_agent.id IS NOT NULL THEN 'agent'
180
+ WHEN workflow_agent.node_run_id IS NOT NULL THEN 'workflowAgent'
181
+ ELSE NULL
182
+ END AS attribution_kind,
183
+ COALESCE(direct_agent.agent_id, workflow_agent.agent_id) AS attribution_agent_id,
184
+ direct_agent.id AS attribution_agent_run_id,
185
+ direct_agent.thread_id AS attribution_thread_id,
186
+ workflow_agent.node_run_id AS attribution_node_run_id,
187
+ workflow_node.workflow_id AS attribution_workflow_id,
188
+ workflow_node.workflow_run_id AS attribution_workflow_run_id,
189
+ COALESCE((
190
+ SELECT json_group_array(group_id) FROM (
191
+ SELECT group_id FROM ai_model_call_usage_groups
192
+ WHERE project_id = usage.project_id AND usage_record_id = usage.id
193
+ ORDER BY group_id
194
+ )
195
+ ), '[]') AS requester_group_ids,
196
+ cost.status AS cost_status,
197
+ cost.provider_id AS cost_provider_id,
198
+ cost.model_id AS cost_model_id,
199
+ cost.currency AS cost_currency,
200
+ CAST(cost.amount_nanos AS TEXT) AS cost_amount_nanos,
201
+ cost.reason AS cost_reason,
202
+ cost.details AS cost_details,
203
+ cost.rated_at AS cost_rated_at
204
+ FROM ai_model_call_usage AS usage
205
+ LEFT JOIN agent_runs AS direct_agent
206
+ ON direct_agent.project_id = usage.project_id
207
+ AND direct_agent.execution_id = usage.execution_id
208
+ LEFT JOIN workflow_agent_node_runs AS workflow_agent
209
+ ON workflow_agent.project_id = usage.project_id
210
+ AND workflow_agent.execution_id = usage.execution_id
211
+ LEFT JOIN workflow_node_runs AS workflow_node
212
+ ON workflow_node.project_id = workflow_agent.project_id
213
+ AND workflow_node.id = workflow_agent.node_run_id
214
+ LEFT JOIN ai_model_call_valuations AS cost
215
+ ON cost.project_id = usage.project_id AND cost.usage_record_id = usage.id
216
+ WHERE ${SQLITE_ACCOUNTING_LIST_FILTER}
217
+ ORDER BY usage.occurred_at DESC, usage.id ASC
218
+ LIMIT ? OFFSET ?
219
+ `
220
+
221
+ const SQLITE_ACCOUNTING_OVERVIEW_SQL = `
222
+ WITH classified AS (
223
+ SELECT usage.provider_id,
224
+ usage.requested_model_id,
225
+ usage.occurred_at,
226
+ usage.input_tokens,
227
+ usage.output_tokens,
228
+ usage.uncached_input_tokens,
229
+ usage.cache_read_input_tokens,
230
+ usage.cache_write_input_tokens,
231
+ usage.text_output_tokens,
232
+ usage.reasoning_output_tokens,
233
+ usage.reporting_status,
234
+ COALESCE(direct_agent.agent_id, workflow_agent.agent_id) AS attribution_agent_id,
235
+ workflow_node.workflow_id AS attribution_workflow_id,
236
+ CASE
237
+ WHEN cost.status = 'rated' THEN 'rated'
238
+ WHEN cost.status = 'unpriceable' THEN 'unpriceable'
239
+ ELSE 'unvalued'
240
+ END AS valuation_status,
241
+ CASE WHEN cost.status = 'rated' THEN cost.currency ELSE NULL END AS amount_currency,
242
+ CASE WHEN cost.status = 'rated' THEN cost.amount_nanos ELSE NULL END AS amount_nanos
243
+ FROM ai_model_call_usage AS usage
244
+ LEFT JOIN agent_runs AS direct_agent
245
+ ON direct_agent.project_id = usage.project_id
246
+ AND direct_agent.execution_id = usage.execution_id
247
+ LEFT JOIN workflow_agent_node_runs AS workflow_agent
248
+ ON workflow_agent.project_id = usage.project_id
249
+ AND workflow_agent.execution_id = usage.execution_id
250
+ LEFT JOIN workflow_node_runs AS workflow_node
251
+ ON workflow_node.project_id = workflow_agent.project_id
252
+ AND workflow_node.id = workflow_agent.node_run_id
253
+ LEFT JOIN ai_model_call_valuations AS cost
254
+ ON cost.project_id = usage.project_id AND cost.usage_record_id = usage.id
255
+ WHERE usage.project_id = ? AND usage.occurred_at >= ? AND usage.occurred_at < ?
256
+ AND (? IS NULL OR usage.provider_id = ?)
257
+ AND (? IS NULL OR usage.requested_model_id = ?)
258
+ ), expanded AS (
259
+ SELECT 'totals' AS dimension, NULL AS key_1, NULL AS key_2, NULL AS bucket_start,
260
+ classified.*
261
+ FROM classified
262
+ UNION ALL
263
+ SELECT 'series', NULL, NULL,
264
+ CASE ?
265
+ WHEN 'hour' THEN strftime('%Y-%m-%dT%H:00:00.000Z', occurred_at)
266
+ WHEN 'day' THEN strftime('%Y-%m-%dT00:00:00.000Z', occurred_at)
267
+ ELSE strftime(
268
+ '%Y-%m-%dT00:00:00.000Z',
269
+ occurred_at,
270
+ printf('-%d days', (CAST(strftime('%w', occurred_at) AS INTEGER) + 6) % 7)
271
+ )
272
+ END,
273
+ classified.*
274
+ FROM classified
275
+ UNION ALL
276
+ SELECT 'model', provider_id, requested_model_id, NULL, classified.* FROM classified
277
+ UNION ALL
278
+ SELECT 'agent', attribution_agent_id, NULL, NULL, classified.*
279
+ FROM classified WHERE attribution_agent_id IS NOT NULL
280
+ UNION ALL
281
+ SELECT 'workflow', attribution_workflow_id, NULL, NULL, classified.*
282
+ FROM classified WHERE attribution_workflow_id IS NOT NULL
283
+ )
284
+ SELECT dimension,
285
+ key_1,
286
+ key_2,
287
+ bucket_start,
288
+ amount_currency,
289
+ CAST(COUNT(*) AS TEXT) AS model_call_count,
290
+ CAST(SUM(CASE WHEN reporting_status <> 'unavailable' THEN 1 ELSE 0 END) AS TEXT)
291
+ AS reported_usage_call_count,
292
+ CAST(COUNT(input_tokens) AS TEXT) AS input_tokens_count,
293
+ CAST(SUM(input_tokens) AS TEXT) AS input_tokens_sum,
294
+ CAST(COUNT(output_tokens) AS TEXT) AS output_tokens_count,
295
+ CAST(SUM(output_tokens) AS TEXT) AS output_tokens_sum,
296
+ CAST(COUNT(uncached_input_tokens) AS TEXT) AS uncached_input_tokens_count,
297
+ CAST(SUM(uncached_input_tokens) AS TEXT) AS uncached_input_tokens_sum,
298
+ CAST(COUNT(cache_read_input_tokens) AS TEXT) AS cache_read_input_tokens_count,
299
+ CAST(SUM(cache_read_input_tokens) AS TEXT) AS cache_read_input_tokens_sum,
300
+ CAST(COUNT(cache_write_input_tokens) AS TEXT) AS cache_write_input_tokens_count,
301
+ CAST(SUM(cache_write_input_tokens) AS TEXT) AS cache_write_input_tokens_sum,
302
+ CAST(COUNT(text_output_tokens) AS TEXT) AS text_output_tokens_count,
303
+ CAST(SUM(text_output_tokens) AS TEXT) AS text_output_tokens_sum,
304
+ CAST(COUNT(reasoning_output_tokens) AS TEXT) AS reasoning_output_tokens_count,
305
+ CAST(SUM(reasoning_output_tokens) AS TEXT) AS reasoning_output_tokens_sum,
306
+ CAST(SUM(CASE WHEN valuation_status = 'rated' THEN 1 ELSE 0 END) AS TEXT)
307
+ AS rated_call_count,
308
+ CAST(SUM(CASE WHEN valuation_status = 'unpriceable' THEN 1 ELSE 0 END) AS TEXT)
309
+ AS unpriceable_call_count,
310
+ CAST(SUM(CASE WHEN valuation_status = 'unvalued' THEN 1 ELSE 0 END) AS TEXT)
311
+ AS unvalued_call_count,
312
+ CAST(SUM(amount_nanos / 1000000000000) AS TEXT) AS amount_high,
313
+ CAST(SUM((amount_nanos / 1000000) % 1000000) AS TEXT) AS amount_middle,
314
+ CAST(SUM(amount_nanos % 1000000) AS TEXT) AS amount_low
315
+ FROM expanded
316
+ GROUP BY dimension, key_1, key_2, bucket_start, amount_currency
317
+ `
318
+
319
+ function accountingListFilterParameters(
320
+ query: ReturnType<typeof normalizeAiModelCallAccountingQuery>
321
+ ): readonly (string | null)[] {
322
+ return [
323
+ query.projectId,
324
+ query.from.toISOString(),
325
+ query.to.toISOString(),
326
+ query.providerId ?? null,
327
+ query.providerId ?? null,
328
+ query.modelId ?? null,
329
+ query.modelId ?? null,
330
+ query.executionId ?? null,
331
+ query.executionId ?? null,
332
+ query.valuationStatus ?? null,
333
+ query.valuationStatus ?? null,
334
+ ]
335
+ }
336
+
337
+ interface ValuationRow {
338
+ readonly project_id: string
339
+ readonly usage_record_id: string
340
+ readonly status: "rated" | "unpriceable"
341
+ readonly provider_id: string | null
342
+ readonly model_id: string | null
343
+ readonly currency: string | null
344
+ readonly amount_nanos: string | null
345
+ readonly reason: string | null
346
+ readonly details: string
347
+ readonly rated_at: string
348
+ }
349
+
350
+ interface UsageRow {
351
+ readonly project_id: string
352
+ readonly id: string
353
+ readonly execution_id: string
354
+ readonly attempt: number
355
+ readonly call_id: string
356
+ readonly provider_id: string
357
+ readonly requested_model_id: string
358
+ readonly response_model_id: string | null
359
+ readonly response_id: string
360
+ readonly input_tokens: number | null
361
+ readonly output_tokens: number | null
362
+ readonly total_tokens: number | null
363
+ readonly uncached_input_tokens: number | null
364
+ readonly cache_read_input_tokens: number | null
365
+ readonly cache_write_input_tokens: number | null
366
+ readonly text_output_tokens: number | null
367
+ readonly reasoning_output_tokens: number | null
368
+ readonly reporting_status: AiModelCallUsageRecord["usage"]["reportingStatus"]
369
+ readonly raw_usage: string | null
370
+ readonly occurred_at: string
371
+ readonly recorded_at: string
372
+ }
373
+
374
+ interface JoinedRow extends UsageRow {
375
+ readonly attribution_kind: "agent" | "workflowAgent" | null
376
+ readonly attribution_agent_id: string | null
377
+ readonly attribution_agent_run_id: string | null
378
+ readonly attribution_thread_id: string | null
379
+ readonly attribution_node_run_id: string | null
380
+ readonly attribution_workflow_id: string | null
381
+ readonly attribution_workflow_run_id: string | null
382
+ readonly requester_group_ids: string
383
+ readonly cost_status: ValuationRow["status"] | null
384
+ readonly cost_provider_id: string | null
385
+ readonly cost_model_id: string | null
386
+ readonly cost_currency: string | null
387
+ readonly cost_amount_nanos: string | null
388
+ readonly cost_reason: string | null
389
+ readonly cost_details: string | null
390
+ readonly cost_rated_at: string | null
391
+ }
392
+
393
+ interface AggregateRow {
394
+ readonly dimension: AiAccountingAggregateFragment["dimension"]
395
+ readonly key_1: string | null
396
+ readonly key_2: string | null
397
+ readonly bucket_start: string | null
398
+ readonly amount_currency: string | null
399
+ readonly model_call_count: string
400
+ readonly reported_usage_call_count: string
401
+ readonly input_tokens_count: string
402
+ readonly input_tokens_sum: string | null
403
+ readonly output_tokens_count: string
404
+ readonly output_tokens_sum: string | null
405
+ readonly uncached_input_tokens_count: string
406
+ readonly uncached_input_tokens_sum: string | null
407
+ readonly cache_read_input_tokens_count: string
408
+ readonly cache_read_input_tokens_sum: string | null
409
+ readonly cache_write_input_tokens_count: string
410
+ readonly cache_write_input_tokens_sum: string | null
411
+ readonly text_output_tokens_count: string
412
+ readonly text_output_tokens_sum: string | null
413
+ readonly reasoning_output_tokens_count: string
414
+ readonly reasoning_output_tokens_sum: string | null
415
+ readonly rated_call_count: string
416
+ readonly unpriceable_call_count: string
417
+ readonly unvalued_call_count: string
418
+ readonly amount_high: string | null
419
+ readonly amount_middle: string | null
420
+ readonly amount_low: string | null
421
+ }
422
+
423
+ interface CountRow {
424
+ readonly total: string
425
+ }
426
+
427
+ interface SummaryRow {
428
+ readonly execution_id: string
429
+ readonly status: ValuationRow["status"] | null
430
+ readonly currency: string | null
431
+ readonly amount_nanos: string | null
432
+ }
433
+
434
+ function aggregateFragmentFromRow(row: AggregateRow): AiAccountingAggregateFragment {
435
+ const dimensionKeys =
436
+ row.dimension === "model"
437
+ ? { providerId: required(row.key_1), modelId: required(row.key_2) }
438
+ : row.dimension === "agent"
439
+ ? { agentId: required(row.key_1) }
440
+ : row.dimension === "workflow"
441
+ ? { workflowId: required(row.key_1) }
442
+ : row.dimension === "series"
443
+ ? { start: new Date(required(row.bucket_start)) }
444
+ : {}
445
+ return {
446
+ dimension: row.dimension,
447
+ ...dimensionKeys,
448
+ modelCallCount: row.model_call_count,
449
+ usage: {
450
+ reportedCallCount: row.reported_usage_call_count,
451
+ inputTokens: meterFragment(row.input_tokens_count, row.input_tokens_sum),
452
+ outputTokens: meterFragment(row.output_tokens_count, row.output_tokens_sum),
453
+ uncachedInputTokens: meterFragment(
454
+ row.uncached_input_tokens_count,
455
+ row.uncached_input_tokens_sum
456
+ ),
457
+ cacheReadInputTokens: meterFragment(
458
+ row.cache_read_input_tokens_count,
459
+ row.cache_read_input_tokens_sum
460
+ ),
461
+ cacheWriteInputTokens: meterFragment(
462
+ row.cache_write_input_tokens_count,
463
+ row.cache_write_input_tokens_sum
464
+ ),
465
+ textOutputTokens: meterFragment(row.text_output_tokens_count, row.text_output_tokens_sum),
466
+ reasoningOutputTokens: meterFragment(
467
+ row.reasoning_output_tokens_count,
468
+ row.reasoning_output_tokens_sum
469
+ ),
470
+ },
471
+ costs: {
472
+ ...(row.amount_currency === null ||
473
+ row.amount_high === null ||
474
+ row.amount_middle === null ||
475
+ row.amount_low === null
476
+ ? {}
477
+ : {
478
+ amount: {
479
+ currency: row.amount_currency,
480
+ amountNanos: sqliteAggregateAmount(row),
481
+ },
482
+ }),
483
+ ratedCallCount: row.rated_call_count,
484
+ unpriceableCallCount: row.unpriceable_call_count,
485
+ unvaluedCallCount: row.unvalued_call_count,
486
+ },
487
+ }
488
+ }
489
+
490
+ function meterFragment(presentCallCount: string, total: string | null) {
491
+ return { presentCallCount, total }
492
+ }
493
+
494
+ function sqliteAggregateAmount(
495
+ row: Pick<AggregateRow, "amount_high" | "amount_middle" | "amount_low">
496
+ ): string {
497
+ return (
498
+ BigInt(required(row.amount_high)) * 1_000_000_000_000n +
499
+ BigInt(required(row.amount_middle)) * 1_000_000n +
500
+ BigInt(required(row.amount_low))
501
+ ).toString()
502
+ }
503
+
504
+ function accountingItemFromRow(row: JoinedRow): AiAccountingRecordSetItem {
505
+ const usage = usageFromRow(row, parseStringArray(row.requester_group_ids))
506
+ const cost = row.cost_status === null ? undefined : costFromJoinedRow(row)
507
+ const attribution =
508
+ row.attribution_kind === null
509
+ ? undefined
510
+ : row.attribution_kind === "agent"
511
+ ? {
512
+ kind: "agent" as const,
513
+ agentId: required(row.attribution_agent_id),
514
+ agentRunId: required(row.attribution_agent_run_id),
515
+ threadId: required(row.attribution_thread_id),
516
+ }
517
+ : {
518
+ kind: "workflowAgent" as const,
519
+ agentId: required(row.attribution_agent_id),
520
+ nodeRunId: required(row.attribution_node_run_id),
521
+ workflowId: required(row.attribution_workflow_id),
522
+ workflowRunId: required(row.attribution_workflow_run_id),
523
+ }
524
+ return {
525
+ usage,
526
+ ...(attribution ? { attribution } : {}),
527
+ ...(cost ? { cost } : {}),
528
+ }
529
+ }
530
+
531
+ function usageFromRow(row: UsageRow, requesterGroupIds: readonly string[]): AiModelCallUsageRecord {
532
+ return {
533
+ id: row.id,
534
+ projectId: row.project_id,
535
+ executionId: row.execution_id,
536
+ attempt: row.attempt,
537
+ callId: row.call_id,
538
+ requesterGroupIds,
539
+ providerId: row.provider_id,
540
+ requestedModelId: row.requested_model_id,
541
+ ...(row.response_model_id === null ? {} : { responseModelId: row.response_model_id }),
542
+ responseId: row.response_id,
543
+ usage: {
544
+ ...(row.input_tokens === null ? {} : { inputTokens: row.input_tokens }),
545
+ ...(row.output_tokens === null ? {} : { outputTokens: row.output_tokens }),
546
+ ...(row.total_tokens === null ? {} : { totalTokens: row.total_tokens }),
547
+ ...(row.uncached_input_tokens === null
548
+ ? {}
549
+ : { uncachedInputTokens: row.uncached_input_tokens }),
550
+ ...(row.cache_read_input_tokens === null
551
+ ? {}
552
+ : { cacheReadInputTokens: row.cache_read_input_tokens }),
553
+ ...(row.cache_write_input_tokens === null
554
+ ? {}
555
+ : { cacheWriteInputTokens: row.cache_write_input_tokens }),
556
+ ...(row.text_output_tokens === null ? {} : { textOutputTokens: row.text_output_tokens }),
557
+ ...(row.reasoning_output_tokens === null
558
+ ? {}
559
+ : { reasoningOutputTokens: row.reasoning_output_tokens }),
560
+ reportingStatus: row.reporting_status,
561
+ },
562
+ ...(row.raw_usage === null
563
+ ? {}
564
+ : { rawUsage: JSON.parse(row.raw_usage) as ReadonlyJsonObject }),
565
+ occurredAt: new Date(row.occurred_at),
566
+ recordedAt: new Date(row.recorded_at),
567
+ }
568
+ }
569
+
570
+ function rowToCost(row: ValuationRow): AiModelCallCostRecord {
571
+ return costRecord({
572
+ projectId: row.project_id,
573
+ usageId: row.usage_record_id,
574
+ status: row.status,
575
+ providerId: row.provider_id,
576
+ modelId: row.model_id,
577
+ currency: row.currency,
578
+ amountNanos: row.amount_nanos,
579
+ reason: row.reason,
580
+ details: row.details,
581
+ ratedAt: row.rated_at,
582
+ })
583
+ }
584
+
585
+ function costFromJoinedRow(row: JoinedRow): AiModelCallCostRecord {
586
+ return costRecord({
587
+ projectId: row.project_id,
588
+ usageId: row.id,
589
+ status: required(row.cost_status),
590
+ providerId: row.cost_provider_id,
591
+ modelId: row.cost_model_id,
592
+ currency: row.cost_currency,
593
+ amountNanos: row.cost_amount_nanos,
594
+ reason: row.cost_reason,
595
+ details: required(row.cost_details),
596
+ ratedAt: required(row.cost_rated_at),
597
+ })
598
+ }
599
+
600
+ function costRecord(input: {
601
+ projectId: string
602
+ usageId: string
603
+ status: ValuationRow["status"]
604
+ providerId: string | null
605
+ modelId: string | null
606
+ currency: string | null
607
+ amountNanos: string | null
608
+ reason: string | null
609
+ details: string
610
+ ratedAt: string
611
+ }): AiModelCallCostRecord {
612
+ const details = parseAiModelCallCostDetails(input.details)
613
+ const base = {
614
+ projectId: input.projectId,
615
+ usageRecordId: input.usageId,
616
+ pricingContext: details.pricingContext,
617
+ priceSource: {
618
+ ...details.priceSource,
619
+ observedAt: new Date(details.priceSource.observedAt),
620
+ },
621
+ ratedAt: new Date(input.ratedAt),
622
+ }
623
+ if (input.status === "rated") {
624
+ return normalizeAiModelCallCostRecord({
625
+ ...base,
626
+ status: "rated",
627
+ billingIdentity: { providerId: required(input.providerId), modelId: required(input.modelId) },
628
+ money: { currency: required(input.currency), amountNanos: required(input.amountNanos) },
629
+ components: required(details.components),
630
+ })
631
+ }
632
+ return normalizeAiModelCallCostRecord({
633
+ ...base,
634
+ status: "unpriceable",
635
+ ...(input.providerId && input.modelId
636
+ ? { billingIdentity: { providerId: input.providerId, modelId: input.modelId } }
637
+ : {}),
638
+ reason: required(input.reason) as Extract<
639
+ AiModelCallCostRecord,
640
+ { status: "unpriceable" }
641
+ >["reason"],
642
+ ...(details.missingMeters ? { missingMeters: details.missingMeters } : {}),
643
+ })
644
+ }
645
+
646
+ function aggregateSummaryRows(
647
+ ids: readonly string[],
648
+ rows: readonly SummaryRow[]
649
+ ): AiCostSummary[] {
650
+ const map = new Map(ids.map((id) => [id, accumulator()]))
651
+ for (const row of rows) {
652
+ const value = map.get(row.execution_id)
653
+ if (!value) continue
654
+ if (row.status === null) value.unvaluedCallCount += 1
655
+ else if (row.status === "unpriceable") value.unpriceableCallCount += 1
656
+ else {
657
+ value.ratedCallCount += 1
658
+ add(value.amounts, required(row.currency), required(row.amount_nanos))
659
+ }
660
+ }
661
+ return ids.map((id) => finish(map.get(id)!))
662
+ }
663
+
664
+ interface Accumulator {
665
+ amounts: Map<string, bigint>
666
+ ratedCallCount: number
667
+ unpriceableCallCount: number
668
+ unvaluedCallCount: number
669
+ }
670
+
671
+ function accumulator(): Accumulator {
672
+ return {
673
+ amounts: new Map(),
674
+ ratedCallCount: 0,
675
+ unpriceableCallCount: 0,
676
+ unvaluedCallCount: 0,
677
+ }
678
+ }
679
+
680
+ function finish(value: Accumulator): AiCostSummary {
681
+ return {
682
+ amounts: [...value.amounts]
683
+ .sort(([left], [right]) => left.localeCompare(right))
684
+ .map(([currency, amount]) => ({ currency, amountNanos: amount.toString() })),
685
+ ratedCallCount: value.ratedCallCount,
686
+ unpriceableCallCount: value.unpriceableCallCount,
687
+ unvaluedCallCount: value.unvaluedCallCount,
688
+ }
689
+ }
690
+
691
+ function add(amounts: Map<string, bigint>, currency: string, amount: string): void {
692
+ amounts.set(currency, (amounts.get(currency) ?? 0n) + BigInt(amount))
693
+ }
694
+
695
+ function assertCostMatchesUsage(
696
+ record: AiModelCallCostRecord,
697
+ usage: AiModelCallUsageRecord
698
+ ): void {
699
+ if (!aiModelCallCostMatchesUsage(record, usage)) {
700
+ throw new AiCostStorageError(
701
+ "cost_mismatch",
702
+ `[SixbSqlite] AI valuation for usage '${record.usageRecordId}' does not match its usage record.`
703
+ )
704
+ }
705
+ }
706
+
707
+ function missingUsage(record: { projectId: string; usageRecordId: string }) {
708
+ return new AiCostStorageError(
709
+ "missing_usage",
710
+ `[SixbSqlite] AI usage '${record.usageRecordId}' does not exist.`
711
+ )
712
+ }
713
+
714
+ function parseStringArray(value: string): string[] {
715
+ const parsed: unknown = JSON.parse(value)
716
+ if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) {
717
+ throw new Error("[SixbSqlite] AI requester group ids are invalid.")
718
+ }
719
+ return parsed
720
+ }
721
+
722
+ function assertNonBlank(value: string, field: string): void {
723
+ if (typeof value !== "string" || value.trim().length === 0) {
724
+ throw new TypeError(`[Sixb] AI ${field} must be nonblank.`)
725
+ }
726
+ }
727
+
728
+ function safeSqlCount(value: string, field: string): number {
729
+ if (!/^(0|[1-9]\d*)$/.test(value)) {
730
+ throw new Error(`[SixbSqlite] AI accounting ${field} is invalid.`)
731
+ }
732
+ const count = Number(value)
733
+ if (!Number.isSafeInteger(count)) {
734
+ throw new TypeError(`[Sixb] AI accounting ${field} exceeds the safe integer range.`)
735
+ }
736
+ return count
737
+ }
738
+
739
+ function required<T>(value: T | null | undefined): T {
740
+ if (value === null || value === undefined) {
741
+ throw new Error("[SixbSqlite] AI valuation column is unexpectedly null.")
742
+ }
743
+ return value
744
+ }