@sixb/sqlite 0.1.2 → 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.
- package/dist/agents/agent-storage.d.ts +5 -3
- package/dist/agents/agent-storage.d.ts.map +1 -1
- package/dist/agents/checkpoints.d.ts +14 -0
- package/dist/agents/checkpoints.d.ts.map +1 -0
- package/dist/agents/index.d.ts +1 -0
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/messages.d.ts +2 -1
- package/dist/agents/messages.d.ts.map +1 -1
- package/dist/agents/rows.d.ts +18 -1
- package/dist/agents/rows.d.ts.map +1 -1
- package/dist/ai-cost-storage.d.ts +15 -0
- package/dist/ai-cost-storage.d.ts.map +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +800 -53
- package/dist/index.js.map +12 -10
- package/dist/migrations.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/agents/agent-storage.ts +6 -3
- package/src/agents/checkpoints.ts +175 -0
- package/src/agents/index.ts +1 -0
- package/src/agents/messages.ts +59 -0
- package/src/agents/rows.ts +44 -0
- package/src/ai-cost-storage.ts +744 -0
- package/src/index.ts +8 -2
- package/src/migrations/026-ai-cost-accounting.sql +57 -0
- package/src/migrations/027-agent-context-checkpoints.sql +28 -0
- package/src/migrations/028-object-override-edit-times.sql +3 -0
- package/src/migrations.ts +10 -0
- package/src/ontology-storage/materialization-state.ts +2 -0
- package/src/ontology-storage/materialization-writer.ts +6 -3
|
@@ -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
|
+
}
|