@sixb/pg 0.1.21 → 0.1.22

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 (110) hide show
  1. package/README.md +1 -0
  2. package/dist/agents/agent-storage.d.ts +8 -2
  3. package/dist/agents/agent-storage.d.ts.map +1 -1
  4. package/dist/agents/approvals.d.ts +48 -0
  5. package/dist/agents/approvals.d.ts.map +1 -0
  6. package/dist/agents/checkpoints.d.ts +1 -0
  7. package/dist/agents/checkpoints.d.ts.map +1 -1
  8. package/dist/agents/index.d.ts +1 -0
  9. package/dist/agents/index.d.ts.map +1 -1
  10. package/dist/agents/memory.d.ts +19 -0
  11. package/dist/agents/memory.d.ts.map +1 -0
  12. package/dist/agents/messages.d.ts.map +1 -1
  13. package/dist/agents/rows.d.ts +15 -1
  14. package/dist/agents/rows.d.ts.map +1 -1
  15. package/dist/agents/runs.d.ts +3 -1
  16. package/dist/agents/runs.d.ts.map +1 -1
  17. package/dist/agents/settings.d.ts +29 -0
  18. package/dist/agents/settings.d.ts.map +1 -0
  19. package/dist/agents/threads.d.ts +6 -1
  20. package/dist/agents/threads.d.ts.map +1 -1
  21. package/dist/ai-cost-groups.d.ts.map +1 -1
  22. package/dist/auth-storage/auth-storage.d.ts +3 -2
  23. package/dist/auth-storage/auth-storage.d.ts.map +1 -1
  24. package/dist/auth-storage/users.d.ts +8 -4
  25. package/dist/auth-storage/users.d.ts.map +1 -1
  26. package/dist/index.js +3720 -1594
  27. package/dist/index.js.map +37 -31
  28. package/dist/migrations.d.ts.map +1 -1
  29. package/dist/objects/conditional-reader.d.ts +17 -0
  30. package/dist/objects/conditional-reader.d.ts.map +1 -0
  31. package/dist/objects/query-compiler.d.ts +27 -1
  32. package/dist/objects/query-compiler.d.ts.map +1 -1
  33. package/dist/objects/query-indexes.d.ts.map +1 -1
  34. package/dist/objects/read-scope.d.ts +10 -1
  35. package/dist/objects/read-scope.d.ts.map +1 -1
  36. package/dist/objects/reader.d.ts +2 -0
  37. package/dist/objects/reader.d.ts.map +1 -1
  38. package/dist/objects/scoped-reader.d.ts +12 -0
  39. package/dist/objects/scoped-reader.d.ts.map +1 -0
  40. package/dist/objects/selected-reader.d.ts +1 -0
  41. package/dist/objects/selected-reader.d.ts.map +1 -1
  42. package/dist/objects/storage.d.ts +7 -1
  43. package/dist/objects/storage.d.ts.map +1 -1
  44. package/dist/ontology-storage/index.d.ts +2 -0
  45. package/dist/ontology-storage/index.d.ts.map +1 -1
  46. package/dist/ontology-storage/materialization-session.d.ts +6 -0
  47. package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
  48. package/dist/ontology-storage/materialization-writer.d.ts +15 -0
  49. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  50. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  51. package/dist/ontology-storage/unique-values.d.ts +60 -0
  52. package/dist/ontology-storage/unique-values.d.ts.map +1 -0
  53. package/dist/pg-ai-cost-storage.d.ts.map +1 -1
  54. package/dist/pg-ai-usage-storage.d.ts +8 -1
  55. package/dist/pg-ai-usage-storage.d.ts.map +1 -1
  56. package/dist/pg-artifact-storage.d.ts +8 -1
  57. package/dist/pg-artifact-storage.d.ts.map +1 -1
  58. package/dist/pg-execution-storage.d.ts +2 -1
  59. package/dist/pg-execution-storage.d.ts.map +1 -1
  60. package/dist/storage-errors.d.ts +2 -0
  61. package/dist/storage-errors.d.ts.map +1 -1
  62. package/package.json +3 -3
  63. package/src/agents/agent-storage.ts +11 -2
  64. package/src/agents/approvals.ts +400 -0
  65. package/src/agents/checkpoints.ts +8 -4
  66. package/src/agents/index.ts +1 -0
  67. package/src/agents/memory.ts +175 -0
  68. package/src/agents/messages.ts +14 -7
  69. package/src/agents/rows.ts +46 -1
  70. package/src/agents/runs.ts +154 -22
  71. package/src/agents/settings.ts +105 -0
  72. package/src/agents/threads.ts +356 -3
  73. package/src/ai-cost-groups.ts +11 -1
  74. package/src/auth-storage/auth-storage.ts +45 -13
  75. package/src/auth-storage/users.ts +105 -43
  76. package/src/migrations/069-artifact-kinds.sql +4 -0
  77. package/src/migrations/070-artifact-state.sql +16 -0
  78. package/src/migrations/071-agent-thread-participants.sql +16 -0
  79. package/src/migrations/072-forked-context-checkpoints.sql +23 -0
  80. package/src/migrations/073-unique-values.sql +25 -0
  81. package/src/migrations/074-generated-media-usage.sql +21 -0
  82. package/src/migrations/075-model-call-files.sql +11 -0
  83. package/src/migrations/076-agent-memory.sql +33 -0
  84. package/src/migrations/077-artifact-threads.sql +21 -0
  85. package/src/migrations/078-agent-approvals.sql +58 -0
  86. package/src/migrations/079-agent-approval-resume.sql +20 -0
  87. package/src/migrations/080-subagent-threads.sql +145 -0
  88. package/src/migrations/081-subagent-questions.sql +6 -0
  89. package/src/migrations.ts +34 -0
  90. package/src/objects/conditional-reader.ts +44 -0
  91. package/src/objects/prepare-queries.ts +1 -1
  92. package/src/objects/query-compiler.ts +96 -33
  93. package/src/objects/query-indexes.ts +29 -10
  94. package/src/objects/read-scope.ts +140 -25
  95. package/src/objects/reader.ts +50 -6
  96. package/src/objects/scoped-reader.ts +101 -0
  97. package/src/objects/selected-reader.ts +16 -76
  98. package/src/objects/storage.ts +24 -5
  99. package/src/objects/text-count-index.ts +1 -0
  100. package/src/ontology-storage/index.ts +3 -0
  101. package/src/ontology-storage/materialization-session.ts +8 -0
  102. package/src/ontology-storage/materialization-writer.ts +76 -1
  103. package/src/ontology-storage/materializations.ts +20 -1
  104. package/src/ontology-storage/unique-values.ts +274 -0
  105. package/src/pg-ai-cost-storage.ts +62 -12
  106. package/src/pg-ai-usage-storage.ts +90 -1
  107. package/src/pg-artifact-storage.ts +205 -9
  108. package/src/pg-execution-storage.ts +31 -0
  109. package/src/storage-errors.ts +7 -0
  110. package/src/transactions.ts +1 -1
@@ -0,0 +1,274 @@
1
+ import {
2
+ uniquePropertyNotPrepared,
3
+ uniqueValueViolation,
4
+ } from "@sixb/core/internal/ontology-storage-provider"
5
+ import type {
6
+ ExactUniqueValueWrite,
7
+ OntologyUniqueValueStorage,
8
+ PreparedUniqueProperty,
9
+ UniqueValueEntry,
10
+ UniqueValueRef,
11
+ } from "@sixb/core/storage"
12
+ import type postgres from "postgres"
13
+ import type { SQLClient } from "../pg-client"
14
+ import { jsonParameter, type PgRootOperation } from "./shared"
15
+
16
+ type Query = postgres.PendingQuery<postgres.Row[]>
17
+
18
+ interface PgClaimCounts {
19
+ readonly staged: number | string
20
+ readonly written: number | string
21
+ readonly unprepared_type: string | null
22
+ readonly unprepared_property: string | null
23
+ }
24
+
25
+ /**
26
+ * Gives each candidate value to its object, set-based. `candidates` selects `object_type_id`,
27
+ * `property_id`, `value_key` and `primary_id`, and with `requirePrepared` also the claim's
28
+ * `key_kind` and `key_version`, which must match how the property was prepared.
29
+ *
30
+ * The insert goes first and conflicts do nothing, as the plan writer's statements do: only when
31
+ * it writes fewer rows than it was given does a second query look for the holder to name. A value
32
+ * another object holds, or that two candidates give to different objects, throws
33
+ * `UniqueValueViolationError`; a value already held by its own object is no conflict. A value a
34
+ * concurrent transaction claimed after this one's snapshot fails serialization instead, so the
35
+ * commit retries and then finds it held.
36
+ */
37
+ export async function claimPgUniqueValues(
38
+ sql: SQLClient,
39
+ projectId: string,
40
+ candidates: Query,
41
+ options: { readonly requirePrepared: boolean }
42
+ ): Promise<number> {
43
+ const unprepared = options.requirePrepared
44
+ ? sql`
45
+ SELECT claimed.object_type_id, claimed.property_id
46
+ FROM (
47
+ SELECT DISTINCT object_type_id, property_id, key_kind, key_version FROM candidates
48
+ ) AS claimed
49
+ WHERE NOT EXISTS (
50
+ SELECT 1 FROM object_unique_properties AS prepared
51
+ WHERE prepared.project_id = ${projectId}
52
+ AND prepared.object_type_id = claimed.object_type_id
53
+ AND prepared.property_id = claimed.property_id
54
+ AND prepared.key_kind = claimed.key_kind
55
+ AND prepared.key_version = claimed.key_version
56
+ )
57
+ LIMIT 1
58
+ `
59
+ : sql`SELECT NULL::text AS object_type_id, NULL::text AS property_id WHERE false`
60
+
61
+ const [counts] = await sql<PgClaimCounts[]>`
62
+ WITH candidates AS MATERIALIZED (${candidates}),
63
+ unprepared AS (${unprepared}),
64
+ written AS (
65
+ INSERT INTO object_unique_values (
66
+ project_id, object_type_id, property_id, value_hash, value_key, primary_id
67
+ )
68
+ SELECT ${projectId}, object_type_id, property_id,
69
+ sha256(convert_to(value_key, 'UTF8')), value_key, primary_id
70
+ FROM candidates
71
+ WHERE NOT EXISTS (SELECT 1 FROM unprepared)
72
+ ON CONFLICT DO NOTHING
73
+ RETURNING 1
74
+ )
75
+ SELECT (SELECT COUNT(*) FROM candidates) AS staged, (SELECT COUNT(*) FROM written) AS written,
76
+ (SELECT object_type_id FROM unprepared) AS unprepared_type,
77
+ (SELECT property_id FROM unprepared) AS unprepared_property
78
+ `
79
+
80
+ if (counts?.unprepared_type && counts.unprepared_property) {
81
+ throw uniquePropertyNotPrepared({
82
+ objectTypeId: counts.unprepared_type,
83
+ propertyId: counts.unprepared_property,
84
+ })
85
+ }
86
+
87
+ const written = Number(counts?.written ?? 0)
88
+ if (written !== Number(counts?.staged ?? 0)) await assertNoOtherHolder(sql, projectId, candidates)
89
+ return written
90
+ }
91
+
92
+ /** Names a candidate whose value another object holds, now that every candidate was inserted. */
93
+ async function assertNoOtherHolder(
94
+ sql: SQLClient,
95
+ projectId: string,
96
+ candidates: Query
97
+ ): Promise<void> {
98
+ const [conflict] = await sql<
99
+ {
100
+ readonly object_type_id: string
101
+ readonly property_id: string
102
+ readonly holder: string
103
+ readonly claimant: string
104
+ }[]
105
+ >`
106
+ WITH candidates AS MATERIALIZED (${candidates})
107
+ SELECT candidates.object_type_id, candidates.property_id,
108
+ held.primary_id AS holder, candidates.primary_id AS claimant
109
+ FROM candidates
110
+ CROSS JOIN LATERAL (${heldValue(sql, projectId)} LIMIT 1) AS held
111
+ WHERE held.primary_id <> candidates.primary_id
112
+ LIMIT 1
113
+ `
114
+ if (!conflict) return
115
+
116
+ throw uniqueValueViolation(
117
+ { objectTypeId: conflict.object_type_id, propertyId: conflict.property_id },
118
+ conflict.holder,
119
+ conflict.claimant
120
+ )
121
+ }
122
+
123
+ /**
124
+ * The stored row of `candidates`' value, by its whole primary key: a lateral lookup per candidate
125
+ * keeps the planner on the index whatever it guesses of the table.
126
+ */
127
+ export function heldValue(sql: SQLClient, projectId: string) {
128
+ return sql`
129
+ SELECT stored.primary_id, stored.value_hash
130
+ FROM object_unique_values AS stored
131
+ WHERE stored.project_id = ${projectId}
132
+ AND stored.object_type_id = candidates.object_type_id
133
+ AND stored.property_id = candidates.property_id
134
+ AND stored.value_hash = sha256(convert_to(candidates.value_key, 'UTF8'))
135
+ AND stored.value_key = candidates.value_key
136
+ `
137
+ }
138
+
139
+ /** The objects holding these unique values; a value no object holds is left out. */
140
+ export async function pgUniqueHolders(
141
+ sql: SQLClient,
142
+ projectId: string,
143
+ values: readonly UniqueValueRef[]
144
+ ): Promise<ExactUniqueValueWrite[]> {
145
+ if (values.length === 0) return []
146
+ const requested = values.map((value) => ({
147
+ object_type_id: value.objectTypeId,
148
+ property_id: value.propertyId,
149
+ value_key: value.valueKey,
150
+ }))
151
+ const rows = await sql<
152
+ {
153
+ readonly object_type_id: string
154
+ readonly property_id: string
155
+ readonly value_key: string
156
+ readonly primary_id: string
157
+ }[]
158
+ >`
159
+ WITH candidates AS MATERIALIZED (
160
+ SELECT * FROM jsonb_to_recordset(${jsonParameter(sql, requested)}::jsonb)
161
+ AS requested(object_type_id text, property_id text, value_key text)
162
+ )
163
+ SELECT candidates.object_type_id, candidates.property_id, candidates.value_key,
164
+ held.primary_id
165
+ FROM candidates
166
+ CROSS JOIN LATERAL (${heldValue(sql, projectId)} LIMIT 1) AS held
167
+ `
168
+ return rows.map((row) => ({
169
+ objectTypeId: row.object_type_id,
170
+ propertyId: row.property_id,
171
+ valueKey: row.value_key,
172
+ primaryId: row.primary_id,
173
+ }))
174
+ }
175
+
176
+ export class PgOntologyUniqueValueStorage implements OntologyUniqueValueStorage {
177
+ constructor(private readonly runRootOperation: PgRootOperation) {}
178
+
179
+ /**
180
+ * `LOCK TABLE` takes no snapshot, so as the transaction's first statement it waits before the
181
+ * serializable snapshot is taken, and a preparer that waited sees the values the previous one
182
+ * built instead of building them again. An advisory lock would be taken by a `SELECT`, after
183
+ * the snapshot. Only preparation writes this table, so claims, which read it, never wait.
184
+ */
185
+ async lockPreparation(_input: { readonly projectId: string }): Promise<void> {
186
+ await this.runRootOperation(async (sql) => {
187
+ await sql`LOCK TABLE object_unique_properties IN SHARE ROW EXCLUSIVE MODE`
188
+ })
189
+ }
190
+
191
+ async listPrepared(input: {
192
+ readonly projectId: string
193
+ }): Promise<readonly PreparedUniqueProperty[]> {
194
+ return this.runRootOperation(async (sql) => {
195
+ const rows = await sql<
196
+ {
197
+ readonly object_type_id: string
198
+ readonly property_id: string
199
+ readonly key_kind: PreparedUniqueProperty["keyKind"]
200
+ readonly key_version: number
201
+ }[]
202
+ >`
203
+ SELECT object_type_id, property_id, key_kind, key_version FROM object_unique_properties
204
+ WHERE project_id = ${input.projectId}
205
+ ORDER BY object_type_id, property_id
206
+ `
207
+ return rows.map((row) => ({
208
+ objectTypeId: row.object_type_id,
209
+ propertyId: row.property_id,
210
+ keyKind: row.key_kind,
211
+ keyVersion: row.key_version,
212
+ }))
213
+ })
214
+ }
215
+
216
+ async clear(input: {
217
+ readonly projectId: string
218
+ readonly objectTypeId: string
219
+ readonly propertyId: string
220
+ }): Promise<void> {
221
+ await this.runRootOperation(async (sql) => {
222
+ await sql`
223
+ DELETE FROM object_unique_values
224
+ WHERE project_id = ${input.projectId} AND object_type_id = ${input.objectTypeId}
225
+ AND property_id = ${input.propertyId}
226
+ `
227
+ await sql`
228
+ DELETE FROM object_unique_properties
229
+ WHERE project_id = ${input.projectId} AND object_type_id = ${input.objectTypeId}
230
+ AND property_id = ${input.propertyId}
231
+ `
232
+ })
233
+ }
234
+
235
+ async insert(input: {
236
+ readonly projectId: string
237
+ readonly objectTypeId: string
238
+ readonly propertyId: string
239
+ readonly entries: readonly UniqueValueEntry[]
240
+ }): Promise<void> {
241
+ await this.runRootOperation((sql) =>
242
+ claimPgUniqueValues(
243
+ sql,
244
+ input.projectId,
245
+ sql`
246
+ SELECT ${input.objectTypeId}::text AS object_type_id,
247
+ ${input.propertyId}::text AS property_id,
248
+ entry->>'valueKey' AS value_key, entry->>'primaryId' AS primary_id
249
+ FROM jsonb_array_elements(${jsonParameter(sql, input.entries)}::jsonb) AS entry
250
+ `,
251
+ // The values are being prepared: nothing to require yet.
252
+ { requirePrepared: false }
253
+ )
254
+ )
255
+ }
256
+
257
+ async markPrepared(
258
+ input: PreparedUniqueProperty & { readonly projectId: string; readonly preparedAt: string }
259
+ ): Promise<void> {
260
+ await this.runRootOperation(async (sql) => {
261
+ await sql`
262
+ INSERT INTO object_unique_properties (
263
+ project_id, object_type_id, property_id, key_kind, key_version, prepared_at
264
+ ) VALUES (
265
+ ${input.projectId}, ${input.objectTypeId}, ${input.propertyId}, ${input.keyKind},
266
+ ${input.keyVersion}, ${input.preparedAt}::timestamptz
267
+ )
268
+ ON CONFLICT (project_id, object_type_id, property_id)
269
+ DO UPDATE SET key_kind = EXCLUDED.key_kind, key_version = EXCLUDED.key_version,
270
+ prepared_at = EXCLUDED.prepared_at
271
+ `
272
+ })
273
+ }
274
+ }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AiAccountingAggregateFragment,
3
3
  type AiAccountingRecordSetItem,
4
+ aiAccountingModelKind,
4
5
  aiModelCallCostDetails,
5
6
  aiModelCallCostMatchesUsage,
6
7
  buildAiAccountingOverviewFromFragments,
@@ -103,6 +104,7 @@ export class PgAiCostStorage implements AiCostStorage {
103
104
  WITH filtered AS (
104
105
  SELECT usage.provider_id,
105
106
  usage.requested_model_id,
107
+ usage.model_kind,
106
108
  usage.occurred_at,
107
109
  usage.input_tokens,
108
110
  usage.output_tokens,
@@ -112,6 +114,10 @@ export class PgAiCostStorage implements AiCostStorage {
112
114
  usage.text_output_tokens,
113
115
  usage.reasoning_output_tokens,
114
116
  usage.audio_duration_ms,
117
+ usage.image_output_tokens,
118
+ usage.audio_output_tokens,
119
+ usage.output_images,
120
+ usage.input_characters,
115
121
  usage.reporting_status,
116
122
  CASE
117
123
  WHEN direct_agent.kind = 'conversation' THEN 'agent'
@@ -174,6 +180,8 @@ export class PgAiCostStorage implements AiCostStorage {
174
180
  UNION ALL
175
181
  SELECT 'model', provider_id, requested_model_id, NULL, NULL, classified.* FROM classified
176
182
  UNION ALL
183
+ SELECT 'kind', model_kind, NULL, NULL, NULL, classified.* FROM classified
184
+ UNION ALL
177
185
  SELECT 'agent', attribution_agent_kind, attribution_workflow_id, attribution_agent_step_id, NULL, classified.*
178
186
  FROM classified WHERE attribution_agent_kind IS NOT NULL
179
187
  UNION ALL
@@ -206,7 +214,15 @@ export class PgAiCostStorage implements AiCostStorage {
206
214
  COUNT(reasoning_output_tokens)::text AS reasoning_output_tokens_count,
207
215
  SUM(reasoning_output_tokens)::text AS reasoning_output_tokens_sum,
208
216
  COUNT(audio_duration_ms)::text AS audio_duration_ms_count,
217
+ COUNT(image_output_tokens)::text AS image_output_tokens_count,
218
+ COUNT(audio_output_tokens)::text AS audio_output_tokens_count,
219
+ COUNT(output_images)::text AS output_images_count,
220
+ COUNT(input_characters)::text AS input_characters_count,
209
221
  SUM(audio_duration_ms)::text AS audio_duration_ms_sum,
222
+ SUM(image_output_tokens)::text AS image_output_tokens_sum,
223
+ SUM(audio_output_tokens)::text AS audio_output_tokens_sum,
224
+ SUM(output_images)::text AS output_images_sum,
225
+ SUM(input_characters)::text AS input_characters_sum,
210
226
  COUNT(*) FILTER (WHERE valuation_status = 'rated')::text AS rated_call_count,
211
227
  COUNT(*) FILTER (WHERE valuation_status = 'unpriceable')::text
212
228
  AS unpriceable_call_count,
@@ -347,6 +363,10 @@ interface UsageRow {
347
363
  readonly text_output_tokens: number | string | null
348
364
  readonly reasoning_output_tokens: number | string | null
349
365
  readonly audio_duration_ms: number | string | null
366
+ readonly image_output_tokens: number | string | null
367
+ readonly audio_output_tokens: number | string | null
368
+ readonly output_images: number | string | null
369
+ readonly input_characters: number | string | null
350
370
  readonly reporting_status: AiModelCallUsageRecord["usage"]["reportingStatus"]
351
371
  readonly raw_usage: ReadonlyJsonObject | string | null
352
372
  readonly provider_ids: Record<string, unknown> | string | null
@@ -402,7 +422,15 @@ interface AggregateRow {
402
422
  readonly reasoning_output_tokens_count: string
403
423
  readonly reasoning_output_tokens_sum: string | null
404
424
  readonly audio_duration_ms_count: string
425
+ readonly image_output_tokens_count: string
426
+ readonly audio_output_tokens_count: string
427
+ readonly output_images_count: string
428
+ readonly input_characters_count: string
405
429
  readonly audio_duration_ms_sum: string | null
430
+ readonly image_output_tokens_sum: string | null
431
+ readonly audio_output_tokens_sum: string | null
432
+ readonly output_images_sum: string | null
433
+ readonly input_characters_sum: string | null
406
434
  readonly rated_call_count: string
407
435
  readonly unpriceable_call_count: string
408
436
  readonly unvalued_call_count: string
@@ -447,18 +475,20 @@ function aggregateFragmentFromRow(row: AggregateRow): AiAccountingAggregateFragm
447
475
  const dimensionKeys =
448
476
  row.dimension === "model"
449
477
  ? { providerId: required(row.key_1), modelId: required(row.key_2) }
450
- : row.dimension === "agent"
451
- ? {
452
- agentKind: required(row.key_1) as "agent" | "workflowAgent",
453
- ...(row.key_1 === "workflowAgent"
454
- ? { workflowId: required(row.key_2), agentStepId: required(row.key_3) }
455
- : {}),
456
- }
457
- : row.dimension === "workflow"
458
- ? { workflowId: required(row.key_1) }
459
- : row.dimension === "series"
460
- ? { start: new Date(required(row.bucket_start)) }
461
- : {}
478
+ : row.dimension === "kind"
479
+ ? withModelKind(aiAccountingModelKind(row.key_1))
480
+ : row.dimension === "agent"
481
+ ? {
482
+ agentKind: required(row.key_1) as "agent" | "workflowAgent",
483
+ ...(row.key_1 === "workflowAgent"
484
+ ? { workflowId: required(row.key_2), agentStepId: required(row.key_3) }
485
+ : {}),
486
+ }
487
+ : row.dimension === "workflow"
488
+ ? { workflowId: required(row.key_1) }
489
+ : row.dimension === "series"
490
+ ? { start: new Date(required(row.bucket_start)) }
491
+ : {}
462
492
  return {
463
493
  dimension: row.dimension,
464
494
  ...dimensionKeys,
@@ -486,6 +516,10 @@ function aggregateFragmentFromRow(row: AggregateRow): AiAccountingAggregateFragm
486
516
  row.reasoning_output_tokens_sum
487
517
  ),
488
518
  audioDurationMs: meterFragment(row.audio_duration_ms_count, row.audio_duration_ms_sum),
519
+ imageOutputTokens: meterFragment(row.image_output_tokens_count, row.image_output_tokens_sum),
520
+ audioOutputTokens: meterFragment(row.audio_output_tokens_count, row.audio_output_tokens_sum),
521
+ outputImages: meterFragment(row.output_images_count, row.output_images_sum),
522
+ inputCharacters: meterFragment(row.input_characters_count, row.input_characters_sum),
489
523
  },
490
524
  costs: {
491
525
  ...(row.amount_currency === null || row.amount_nanos === null
@@ -587,6 +621,18 @@ function usageFromRow(row: UsageRow, requesterGroupIds: readonly string[]): AiMo
587
621
  ...(number(row.audio_duration_ms) === undefined
588
622
  ? {}
589
623
  : { audioDurationMs: number(row.audio_duration_ms) }),
624
+ ...(number(row.image_output_tokens) === undefined
625
+ ? {}
626
+ : { imageOutputTokens: number(row.image_output_tokens) }),
627
+ ...(number(row.audio_output_tokens) === undefined
628
+ ? {}
629
+ : { audioOutputTokens: number(row.audio_output_tokens) }),
630
+ ...(number(row.output_images) === undefined
631
+ ? {}
632
+ : { outputImages: number(row.output_images) }),
633
+ ...(number(row.input_characters) === undefined
634
+ ? {}
635
+ : { inputCharacters: number(row.input_characters) }),
590
636
  reportingStatus: row.reporting_status,
591
637
  },
592
638
  ...(row.raw_usage === null ? {} : { rawUsage: parseJsonObject(row.raw_usage) }),
@@ -777,3 +823,7 @@ function required<T>(value: T | null | undefined): T {
777
823
  }
778
824
  return value
779
825
  }
826
+
827
+ function withModelKind(modelKind: AiAccountingAggregateFragment["modelKind"]) {
828
+ return modelKind === undefined ? {} : { modelKind }
829
+ }
@@ -1,14 +1,23 @@
1
- import { assertAiUsageExecutionId, normalizeAiModelCallRecord } from "@sixb/core/internal/storage"
1
+ import type { FileRef } from "@sixb/core/blob-storage/server"
2
+ import {
3
+ assertAiUsageExecutionId,
4
+ assertSameCallExecution,
5
+ normalizeAiModelCallFilesRecord,
6
+ normalizeAiModelCallRecord,
7
+ } from "@sixb/core/internal/storage"
2
8
  import { isModelReasoning, normalizeModelProviderIds } from "@sixb/core/models"
3
9
  import type { ReadonlyJsonObject } from "@sixb/core/storage"
4
10
  import {
11
+ type AiModelCallFilesRecord,
5
12
  type AiModelCallUsageInput,
6
13
  type AiModelCallUsageRecord,
7
14
  type AiUsageExecutionSummary,
8
15
  type AiUsageStorage,
9
16
  AiUsageStorageError,
10
17
  aggregateAiModelCallUsage,
18
+ type GetAiModelCallFilesInput,
11
19
  type GetLatestAiModelCallForExecutionInput,
20
+ type RecordAiModelCallFilesInput,
12
21
  type RecordAiModelCallInput,
13
22
  type RecordAiModelCallResult,
14
23
  type SummarizeAiUsageExecutionInput,
@@ -51,6 +60,10 @@ export class PgAiUsageStorage implements AiUsageStorage {
51
60
  text_output_tokens,
52
61
  reasoning_output_tokens,
53
62
  audio_duration_ms,
63
+ image_output_tokens,
64
+ audio_output_tokens,
65
+ output_images,
66
+ input_characters,
54
67
  reporting_status,
55
68
  raw_usage,
56
69
  occurred_at,
@@ -81,6 +94,10 @@ export class PgAiUsageStorage implements AiUsageStorage {
81
94
  ${record.usage.textOutputTokens ?? null},
82
95
  ${record.usage.reasoningOutputTokens ?? null},
83
96
  ${record.usage.audioDurationMs ?? null},
97
+ ${record.usage.imageOutputTokens ?? null},
98
+ ${record.usage.audioOutputTokens ?? null},
99
+ ${record.usage.outputImages ?? null},
100
+ ${record.usage.inputCharacters ?? null},
84
101
  ${record.usage.reportingStatus},
85
102
  ${record.rawUsage === undefined ? null : JSON.stringify(record.rawUsage)}::text::jsonb,
86
103
  ${record.occurredAt},
@@ -235,6 +252,48 @@ export class PgAiUsageStorage implements AiUsageStorage {
235
252
  `
236
253
  }
237
254
 
255
+ async recordModelCallFiles(input: RecordAiModelCallFilesInput): Promise<AiModelCallFilesRecord> {
256
+ const record = normalizeAiModelCallFilesRecord(input)
257
+ return runPgTransaction(this.sql, async (tx) => {
258
+ const existing = await this.findCallFiles(tx, record.projectId, record.callId)
259
+ if (existing) return assertSameCallExecution(existing, record)
260
+ await this.assertExecutionExists(tx, record.projectId, record.executionId)
261
+ const inserted = await tx<CallFilesRow[]>`
262
+ INSERT INTO ai_model_call_files (project_id, call_id, execution_id, files, recorded_at)
263
+ VALUES (
264
+ ${record.projectId},
265
+ ${record.callId},
266
+ ${record.executionId},
267
+ ${JSON.stringify(record.files)}::text::jsonb,
268
+ ${record.recordedAt}
269
+ )
270
+ ON CONFLICT (project_id, call_id) DO NOTHING
271
+ RETURNING *
272
+ `
273
+ if (inserted[0]) return callFilesFromRow(inserted[0])
274
+ // Another writer recorded this call between the read and the insert.
275
+ const concurrent = await this.findCallFiles(tx, record.projectId, record.callId)
276
+ if (!concurrent) throw new Error("[SixbPg] AI model-call files disappeared after a conflict.")
277
+ return assertSameCallExecution(concurrent, record)
278
+ })
279
+ }
280
+
281
+ async getModelCallFiles(input: GetAiModelCallFilesInput): Promise<AiModelCallFilesRecord | null> {
282
+ assertNonBlankProjectId(input.projectId)
283
+ return this.findCallFiles(this.sql, input.projectId, input.callId)
284
+ }
285
+
286
+ private async findCallFiles(
287
+ sql: PgStoreClient,
288
+ projectId: string,
289
+ callId: string
290
+ ): Promise<AiModelCallFilesRecord | null> {
291
+ const [row] = await sql<CallFilesRow[]>`
292
+ SELECT * FROM ai_model_call_files WHERE project_id = ${projectId} AND call_id = ${callId}
293
+ `
294
+ return row ? callFilesFromRow(row) : null
295
+ }
296
+
238
297
  private async assertExecutionExists(
239
298
  sql: PgStoreClient,
240
299
  projectId: string,
@@ -253,6 +312,24 @@ export class PgAiUsageStorage implements AiUsageStorage {
253
312
  }
254
313
  }
255
314
 
315
+ interface CallFilesRow {
316
+ readonly project_id: string
317
+ readonly call_id: string
318
+ readonly execution_id: string
319
+ readonly files: FileRef[] | string
320
+ readonly recorded_at: Date | string
321
+ }
322
+
323
+ function callFilesFromRow(row: CallFilesRow): AiModelCallFilesRecord {
324
+ return {
325
+ projectId: row.project_id,
326
+ executionId: row.execution_id,
327
+ callId: row.call_id,
328
+ files: typeof row.files === "string" ? (JSON.parse(row.files) as FileRef[]) : row.files,
329
+ recordedAt: new Date(row.recorded_at),
330
+ }
331
+ }
332
+
256
333
  interface AiUsageRow {
257
334
  readonly project_id: string
258
335
  readonly id: string
@@ -275,6 +352,10 @@ interface AiUsageRow {
275
352
  readonly text_output_tokens: number | string | null
276
353
  readonly reasoning_output_tokens: number | string | null
277
354
  readonly audio_duration_ms: number | string | null
355
+ readonly image_output_tokens: number | string | null
356
+ readonly audio_output_tokens: number | string | null
357
+ readonly output_images: number | string | null
358
+ readonly input_characters: number | string | null
278
359
  readonly reporting_status: AiModelCallUsageRecord["usage"]["reportingStatus"]
279
360
  readonly raw_usage: ReadonlyJsonObject | string | null
280
361
  readonly occurred_at: Date | string
@@ -319,6 +400,14 @@ function usageFromRow(row: AiUsageRow): AiModelCallUsageInput {
319
400
  ? {}
320
401
  : { reasoningOutputTokens: Number(row.reasoning_output_tokens) }),
321
402
  ...(row.audio_duration_ms === null ? {} : { audioDurationMs: Number(row.audio_duration_ms) }),
403
+ ...(row.image_output_tokens === null
404
+ ? {}
405
+ : { imageOutputTokens: Number(row.image_output_tokens) }),
406
+ ...(row.audio_output_tokens === null
407
+ ? {}
408
+ : { audioOutputTokens: Number(row.audio_output_tokens) }),
409
+ ...(row.output_images === null ? {} : { outputImages: Number(row.output_images) }),
410
+ ...(row.input_characters === null ? {} : { inputCharacters: Number(row.input_characters) }),
322
411
  }
323
412
  }
324
413