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