@sixb/pg 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/agents/rows.d.ts +8 -5
  2. package/dist/agents/rows.d.ts.map +1 -1
  3. package/dist/agents/runs.d.ts +3 -2
  4. package/dist/agents/runs.d.ts.map +1 -1
  5. package/dist/agents/threads.d.ts.map +1 -1
  6. package/dist/ai-cost-groups.d.ts +5 -0
  7. package/dist/ai-cost-groups.d.ts.map +1 -0
  8. package/dist/auth-storage/auth-storage.d.ts +4 -1
  9. package/dist/auth-storage/auth-storage.d.ts.map +1 -1
  10. package/dist/auth-storage/device-authorizations.d.ts +29 -0
  11. package/dist/auth-storage/device-authorizations.d.ts.map +1 -0
  12. package/dist/auth-storage/rows.d.ts +19 -1
  13. package/dist/auth-storage/rows.d.ts.map +1 -1
  14. package/dist/auth-storage/shared.d.ts +1 -1
  15. package/dist/auth-storage/shared.d.ts.map +1 -1
  16. package/dist/index.d.ts +2 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1804 -464
  19. package/dist/index.js.map +19 -16
  20. package/dist/migrations.d.ts.map +1 -1
  21. package/dist/ontology-storage/materialization-state.d.ts +7 -3
  22. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  23. package/dist/pg-ai-cost-storage.d.ts +2 -1
  24. package/dist/pg-ai-cost-storage.d.ts.map +1 -1
  25. package/dist/pg-ai-limit-storage.d.ts +34 -0
  26. package/dist/pg-ai-limit-storage.d.ts.map +1 -0
  27. package/dist/pg-ai-usage-storage.d.ts +28 -0
  28. package/dist/pg-ai-usage-storage.d.ts.map +1 -1
  29. package/package.json +4 -4
  30. package/src/agents/rows.ts +38 -9
  31. package/src/agents/runs.ts +148 -11
  32. package/src/agents/threads.ts +1 -17
  33. package/src/ai-cost-groups.ts +102 -0
  34. package/src/auth-storage/auth-storage.ts +84 -0
  35. package/src/auth-storage/device-authorizations.ts +131 -0
  36. package/src/auth-storage/rows.ts +41 -0
  37. package/src/auth-storage/shared.ts +1 -0
  38. package/src/index.ts +8 -1
  39. package/src/migrations/029-model-accounting.sql +24 -0
  40. package/src/migrations/030-ai-usage-limits.sql +94 -0
  41. package/src/migrations/031-subagent-runs.sql +97 -0
  42. package/src/migrations/032-conversation-run-spec.sql +23 -0
  43. package/src/migrations/033-retire-agent-definitions.sql +28 -0
  44. package/src/migrations/034-device-authorizations.sql +24 -0
  45. package/src/migrations.ts +18 -0
  46. package/src/ontology-storage/materialization-state.ts +45 -29
  47. package/src/ontology-storage/materializations.ts +3 -3
  48. package/src/pg-ai-cost-storage.ts +71 -24
  49. package/src/pg-ai-limit-storage.ts +901 -0
  50. package/src/pg-ai-usage-storage.ts +34 -0
  51. package/src/pg-workflow-run-storage.ts +8 -8
@@ -0,0 +1,97 @@
1
+ -- Agent executions may either use a managed service account or inherit their parent's exact
2
+ -- credentialed-user / auth-disabled authority.
3
+ ALTER TABLE executions
4
+ DROP CONSTRAINT executions_check3,
5
+ ADD CONSTRAINT executions_agent_authority_check CHECK (
6
+ (
7
+ executor_kind = 'request'
8
+ AND source_kind = 'http'
9
+ AND executor_id = source_id
10
+ AND (
11
+ (
12
+ authority_kind = 'principal'
13
+ AND requested_by_user_id IS NOT DISTINCT FROM authority_user_id
14
+ AND requested_by_service_account_id IS NOT DISTINCT FROM authority_service_account_id
15
+ )
16
+ OR (
17
+ authority_kind = 'disabled'
18
+ AND requested_by_user_id IS NULL
19
+ AND requested_by_service_account_id IS NULL
20
+ )
21
+ )
22
+ )
23
+ OR (
24
+ executor_kind IN ('action', 'pipeline', 'projection', 'rule', 'sync', 'webhook', 'workflow')
25
+ AND authority_kind = 'trustedPrimitive'
26
+ AND executor_kind = authority_primitive_kind
27
+ )
28
+ OR (
29
+ executor_kind = 'agent'
30
+ AND source_kind = 'execution'
31
+ AND (
32
+ (
33
+ authority_kind = 'principal'
34
+ AND authority_service_account_id IS NOT NULL
35
+ AND authority_session_id IS NULL
36
+ AND authority_access_token_id IS NULL
37
+ )
38
+ OR (
39
+ authority_kind = 'principal'
40
+ AND authority_user_id IS NOT NULL
41
+ AND num_nonnulls(authority_session_id, authority_access_token_id) = 1
42
+ )
43
+ OR authority_kind = 'disabled'
44
+ )
45
+ )
46
+ OR (executor_kind = 'kernel' AND authority_kind = 'kernel')
47
+ );
48
+
49
+ ALTER TABLE agent_runs
50
+ ADD COLUMN kind TEXT NOT NULL DEFAULT 'conversation'
51
+ CHECK (kind IN ('conversation', 'subagent')),
52
+ ADD COLUMN parent_run_id TEXT,
53
+ ADD COLUMN spawn_key TEXT,
54
+ ADD COLUMN spec JSONB,
55
+ ADD COLUMN result JSONB,
56
+ ALTER COLUMN thread_id DROP NOT NULL,
57
+ ALTER COLUMN agent_id DROP NOT NULL,
58
+ ALTER COLUMN trigger_message_id DROP NOT NULL,
59
+ ADD CONSTRAINT fk_agent_runs_parent
60
+ FOREIGN KEY (project_id, parent_run_id)
61
+ REFERENCES agent_runs (project_id, id)
62
+ ON DELETE RESTRICT,
63
+ ADD CONSTRAINT agent_runs_kind_fields CHECK (
64
+ (
65
+ kind = 'conversation'
66
+ AND thread_id IS NOT NULL
67
+ AND agent_id IS NOT NULL
68
+ AND trigger_message_id IS NOT NULL
69
+ AND parent_run_id IS NULL
70
+ AND spawn_key IS NULL
71
+ AND spec IS NULL
72
+ AND result IS NULL
73
+ )
74
+ OR
75
+ (
76
+ kind = 'subagent'
77
+ AND thread_id IS NULL
78
+ AND agent_id IS NULL
79
+ AND trigger_message_id IS NULL
80
+ AND parent_run_id IS NOT NULL
81
+ AND spawn_key IS NOT NULL
82
+ AND spec IS NOT NULL
83
+ )
84
+ ),
85
+ ADD CONSTRAINT agent_runs_subagent_result CHECK (
86
+ kind <> 'subagent' OR status <> 'succeeded' OR result IS NOT NULL
87
+ ),
88
+ ADD CONSTRAINT uq_agent_runs_parent_spawn_key UNIQUE (project_id, parent_run_id, spawn_key);
89
+
90
+ CREATE INDEX idx_agent_runs_project_parent_started
91
+ ON agent_runs (
92
+ project_id,
93
+ parent_run_id,
94
+ status,
95
+ COALESCE(started_at, created_at) DESC,
96
+ id DESC
97
+ );
@@ -0,0 +1,23 @@
1
+ ALTER TABLE agent_runs
2
+ DROP CONSTRAINT agent_runs_kind_fields,
3
+ ADD CONSTRAINT agent_runs_kind_fields CHECK (
4
+ (
5
+ kind = 'conversation'
6
+ AND thread_id IS NOT NULL
7
+ AND agent_id IS NOT NULL
8
+ AND trigger_message_id IS NOT NULL
9
+ AND parent_run_id IS NULL
10
+ AND spawn_key IS NULL
11
+ AND result IS NULL
12
+ )
13
+ OR
14
+ (
15
+ kind = 'subagent'
16
+ AND thread_id IS NULL
17
+ AND agent_id IS NULL
18
+ AND trigger_message_id IS NULL
19
+ AND parent_run_id IS NOT NULL
20
+ AND spawn_key IS NOT NULL
21
+ AND spec IS NOT NULL
22
+ )
23
+ );
@@ -0,0 +1,28 @@
1
+ DROP INDEX IF EXISTS idx_agent_threads_project_agent_activity;
2
+ ALTER TABLE workflow_agent_node_runs RENAME COLUMN agent_id TO actor_id;
3
+ DROP INDEX IF EXISTS idx_agent_runs_project_agent_started;
4
+
5
+ ALTER TABLE agent_threads DROP COLUMN agent_id;
6
+
7
+ ALTER TABLE agent_runs
8
+ DROP CONSTRAINT agent_runs_kind_fields,
9
+ DROP COLUMN agent_id,
10
+ ADD CONSTRAINT agent_runs_kind_fields CHECK (
11
+ (
12
+ kind = 'conversation'
13
+ AND thread_id IS NOT NULL
14
+ AND trigger_message_id IS NOT NULL
15
+ AND parent_run_id IS NULL
16
+ AND spawn_key IS NULL
17
+ AND result IS NULL
18
+ )
19
+ OR
20
+ (
21
+ kind = 'subagent'
22
+ AND thread_id IS NULL
23
+ AND trigger_message_id IS NULL
24
+ AND parent_run_id IS NOT NULL
25
+ AND spawn_key IS NOT NULL
26
+ AND spec IS NOT NULL
27
+ )
28
+ );
@@ -0,0 +1,24 @@
1
+ CREATE TABLE auth_device_authorizations (
2
+ project_id TEXT NOT NULL,
3
+ id TEXT NOT NULL,
4
+ device_code_hash TEXT NOT NULL,
5
+ user_code TEXT NOT NULL,
6
+ client_name TEXT NOT NULL,
7
+ token_name TEXT NOT NULL,
8
+ token_expires_at TIMESTAMPTZ NOT NULL,
9
+ status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'denied', 'consumed')),
10
+ approved_user_id TEXT,
11
+ approved_session_id TEXT,
12
+ created_at TIMESTAMPTZ NOT NULL,
13
+ expires_at TIMESTAMPTZ NOT NULL,
14
+ approved_at TIMESTAMPTZ,
15
+ denied_at TIMESTAMPTZ,
16
+ consumed_at TIMESTAMPTZ,
17
+ PRIMARY KEY (project_id, id),
18
+ UNIQUE (project_id, user_code),
19
+ FOREIGN KEY (project_id, approved_user_id) REFERENCES auth_users (project_id, id),
20
+ FOREIGN KEY (project_id, approved_session_id) REFERENCES auth_sessions (project_id, id)
21
+ );
22
+
23
+ CREATE INDEX idx_auth_device_authorizations_pending
24
+ ON auth_device_authorizations (project_id, status, expires_at DESC);
package/src/migrations.ts CHANGED
@@ -73,6 +73,18 @@ import agentContextCheckpointsSql from "./migrations/027-agent-context-checkpoin
73
73
  import objectOverrideEditTimesSql from "./migrations/028-object-override-edit-times.sql" with {
74
74
  type: "text",
75
75
  }
76
+ import modelAccountingSql from "./migrations/029-model-accounting.sql" with { type: "text" }
77
+ import aiUsageLimitsSql from "./migrations/030-ai-usage-limits.sql" with { type: "text" }
78
+ import subagentRunsSql from "./migrations/031-subagent-runs.sql" with { type: "text" }
79
+ import conversationRunSpecSql from "./migrations/032-conversation-run-spec.sql" with {
80
+ type: "text",
81
+ }
82
+ import retireAgentDefinitionsSql from "./migrations/033-retire-agent-definitions.sql" with {
83
+ type: "text",
84
+ }
85
+ import deviceAuthorizationsSql from "./migrations/034-device-authorizations.sql" with {
86
+ type: "text",
87
+ }
76
88
  import type { SQL, SQLClient } from "./pg-client"
77
89
 
78
90
  export interface PostgresMigrationContext {
@@ -354,6 +366,12 @@ export const postgresStorageMigrations = defineMigrations<PostgresMigrationConte
354
366
  pgSql("026-ai-cost-accounting", aiCostAccountingSql),
355
367
  pgSql("027-agent-context-checkpoints", agentContextCheckpointsSql),
356
368
  pgSql("028-object-override-edit-times", objectOverrideEditTimesSql),
369
+ pgSql("029-model-accounting", modelAccountingSql),
370
+ pgSql("030-ai-usage-limits", aiUsageLimitsSql),
371
+ pgSql("031-subagent-runs", subagentRunsSql),
372
+ pgSql("032-conversation-run-spec", conversationRunSpecSql),
373
+ pgSql("033-retire-agent-definitions", retireAgentDefinitionsSql),
374
+ pgSql("034-device-authorizations", deviceAuthorizationsSql),
357
375
  ],
358
376
  })
359
377
 
@@ -22,6 +22,7 @@ import {
22
22
  startScopeAccumulator,
23
23
  } from "@sixb/core/internal/ontology-storage-provider"
24
24
  import type {
25
+ MaterializationLinkScopeRevision,
25
26
  MaterializationLinkScopeState,
26
27
  MaterializationLinkState,
27
28
  MaterializationObjectState,
@@ -407,7 +408,14 @@ export class PgMaterializationStateReader {
407
408
  })
408
409
  }
409
410
 
410
- async linkScopes(
411
+ async linkScopeRevisions(
412
+ scopes: readonly { readonly source: OntologyObjectRef; readonly linkId: string }[]
413
+ ): Promise<readonly MaterializationLinkScopeRevision[]> {
414
+ if (scopes.length === 0) return []
415
+ return (await this.readEffectiveLinkScopes(scopes)).revisions
416
+ }
417
+
418
+ async linkSlotStates(
411
419
  scopes: readonly { readonly source: OntologyObjectRef; readonly linkId: string }[]
412
420
  ): Promise<readonly MaterializationLinkScopeState[]> {
413
421
  if (scopes.length === 0) return []
@@ -417,12 +425,6 @@ export class PgMaterializationStateReader {
417
425
  source_id: source.primaryId,
418
426
  link_id: linkId,
419
427
  }))
420
- const accumulators = new Map(
421
- scopes.map(({ source, linkId }) => {
422
- const key = linkScopeSortKey(source, linkId)
423
- return [key, startScopeAccumulator(source, linkId)] as const
424
- })
425
- )
426
428
  const requestedParameter = jsonParameter(this.sql, requested)
427
429
  const [sourceRows, slotOverrideRows] = await Promise.all([
428
430
  this.sql<PgOntologySourceAssertionRow[]>`
@@ -470,6 +472,37 @@ export class PgMaterializationStateReader {
470
472
  return [linkScopeSortKey(stored.ref.source, stored.ref.linkId), stored] as const
471
473
  })
472
474
  )
475
+ const { revisions, effective } = await this.readEffectiveLinkScopes(scopes)
476
+ return scopes.map(({ source, linkId }, index) => {
477
+ const key = linkScopeSortKey(source, linkId)
478
+ return {
479
+ ...revisions[index]!,
480
+ sourceAssertion: sources.get(key) ?? null,
481
+ override: overrides.get(key) ?? null,
482
+ effective: effective.get(key) ?? null,
483
+ }
484
+ })
485
+ }
486
+
487
+ private async readEffectiveLinkScopes(
488
+ scopes: readonly { readonly source: OntologyObjectRef; readonly linkId: string }[]
489
+ ): Promise<{
490
+ readonly revisions: readonly MaterializationLinkScopeRevision[]
491
+ readonly effective: ReadonlyMap<string, EffectiveLinkSnapshot | null>
492
+ }> {
493
+ const requested = scopes.map(({ source, linkId }) => ({
494
+ scope_sort_key: linkScopeSortKey(source, linkId),
495
+ source_type_id: source.objectTypeId,
496
+ source_id: source.primaryId,
497
+ link_id: linkId,
498
+ }))
499
+ const requestedParameter = jsonParameter(this.sql, requested)
500
+ const accumulators = new Map(
501
+ scopes.map(({ source, linkId }) => {
502
+ const key = linkScopeSortKey(source, linkId)
503
+ return [key, startScopeAccumulator(source, linkId)] as const
504
+ })
505
+ )
473
506
  const effective = new Map<string, EffectiveLinkSnapshot | null>()
474
507
  let scopeCursor: string | null = null
475
508
  let linkCursor: string | null = null
@@ -510,29 +543,12 @@ export class PgMaterializationStateReader {
510
543
  scopeCursor = last.scope_sort_key
511
544
  linkCursor = last.link_sort_key
512
545
  }
513
- return scopes.map(({ source, linkId }) => {
514
- const key = linkScopeSortKey(source, linkId)
515
- return {
516
- ...finishScopeAccumulator(accumulators.get(key)!),
517
- sourceAssertion: sources.get(key) ?? null,
518
- override: overrides.get(key) ?? null,
519
- effective: effective.get(key) ?? null,
520
- }
521
- })
522
- }
523
-
524
- async linkScope(
525
- source: OntologyObjectRef,
526
- linkId: string
527
- ): Promise<MaterializationLinkScopeState> {
528
- const [scope] = await this.linkScopes([{ source, linkId }])
529
- if (!scope) {
530
- throw new MaterializationConflictError(
531
- "effective-state",
532
- "Link scope lookup returned no row."
533
- )
546
+ return {
547
+ revisions: scopes.map(({ source, linkId }) =>
548
+ finishScopeAccumulator(accumulators.get(linkScopeSortKey(source, linkId))!)
549
+ ),
550
+ effective,
534
551
  }
535
- return scope
536
552
  }
537
553
 
538
554
  async *incidentLinks(
@@ -121,9 +121,9 @@ export class PgOntologyMaterializationStorage implements OntologyMaterialization
121
121
  for (const expected of input.expected.links) {
122
122
  this.assertLink(linkRevisions.get(linkRefKey(expected.ref)), expected)
123
123
  }
124
- const linkScopes = await reader.linkScopes(input.expected.linkScopes)
124
+ const linkScopeRevisions = await reader.linkScopeRevisions(input.expected.linkScopes)
125
125
  for (const [index, expected] of input.expected.linkScopes.entries()) {
126
- if (linkScopes[index]?.fingerprint !== expected.fingerprint) {
126
+ if (linkScopeRevisions[index]?.fingerprint !== expected.fingerprint) {
127
127
  throw new MaterializationConflictError(
128
128
  "effective-state",
129
129
  `Expected link scope changed for ${expected.source.objectTypeId}:${expected.source.primaryId}.${expected.linkId}.`
@@ -191,7 +191,7 @@ export class PgOntologyMaterializationStorage implements OntologyMaterialization
191
191
  yield {
192
192
  objects: [],
193
193
  links: [],
194
- linkScopes: await reader.linkScopes(scopes.slice(offset, offset + input.pageRows)),
194
+ linkScopes: await reader.linkSlotStates(scopes.slice(offset, offset + input.pageRows)),
195
195
  points: [],
196
196
  }
197
197
  }
@@ -10,6 +10,7 @@ import {
10
10
  parseAiModelCallCostDetails,
11
11
  toAccountingItem,
12
12
  } from "@sixb/core/internal/ai-cost-storage-provider"
13
+ import { normalizeModelProviderIds } from "@sixb/core/models"
13
14
  import type { ReadonlyJsonObject } from "@sixb/core/storage"
14
15
  import {
15
16
  type AiAccountingOverview,
@@ -20,9 +21,13 @@ import {
20
21
  type AiModelCallUsageRecord,
21
22
  type ListAiModelCallAccountingInput,
22
23
  type ListAiModelCallAccountingResult,
24
+ type ListAiModelCallGroupsInput,
25
+ type ListAiModelCallGroupsResult,
23
26
  type QueryAiAccountingOverviewInput,
24
27
  type SummarizeAiCostExecutionsInput,
25
28
  } from "@sixb/core/storage"
29
+ import { listAiModelCallGroups } from "./ai-cost-groups"
30
+ import { requestedReasoningFromRow } from "./pg-ai-usage-storage"
26
31
  import type { PgStoreClient } from "./transactions"
27
32
  import { runPgTransaction } from "./transactions"
28
33
 
@@ -30,6 +35,12 @@ import { runPgTransaction } from "./transactions"
30
35
  export class PgAiCostStorage implements AiCostStorage {
31
36
  constructor(private readonly sql: PgStoreClient) {}
32
37
 
38
+ async listModelCallGroups(
39
+ input: ListAiModelCallGroupsInput
40
+ ): Promise<ListAiModelCallGroupsResult> {
41
+ return listAiModelCallGroups(this.sql, input)
42
+ }
43
+
33
44
  async recordModelCallCost(input: AiModelCallCostRecord): Promise<void> {
34
45
  const record = normalizeAiModelCallCostRecord(input)
35
46
  await runPgTransaction(this.sql, async (tx) => {
@@ -100,7 +111,12 @@ export class PgAiCostStorage implements AiCostStorage {
100
111
  usage.text_output_tokens,
101
112
  usage.reasoning_output_tokens,
102
113
  usage.reporting_status,
103
- COALESCE(direct_agent.agent_id, workflow_agent.agent_id) AS attribution_agent_id,
114
+ CASE
115
+ WHEN direct_agent.kind = 'conversation' THEN 'agent'
116
+ WHEN workflow_agent.node_run_id IS NOT NULL THEN 'workflowAgent'
117
+ ELSE NULL
118
+ END AS attribution_agent_kind,
119
+ workflow_node.node_id AS attribution_agent_step_id,
104
120
  workflow_node.workflow_id AS attribution_workflow_id,
105
121
  cost.status AS cost_status,
106
122
  cost.currency AS cost_currency,
@@ -135,11 +151,12 @@ export class PgAiCostStorage implements AiCostStorage {
135
151
  SELECT 'totals'::text AS dimension,
136
152
  NULL::text AS key_1,
137
153
  NULL::text AS key_2,
154
+ NULL::text AS key_3,
138
155
  NULL::timestamptz AS bucket_start,
139
156
  classified.*
140
157
  FROM classified
141
158
  UNION ALL
142
- SELECT 'series', NULL, NULL,
159
+ SELECT 'series', NULL, NULL, NULL,
143
160
  CASE ${input.bucket}::text
144
161
  WHEN 'hour' THEN
145
162
  date_trunc('hour', occurred_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'
@@ -150,17 +167,18 @@ export class PgAiCostStorage implements AiCostStorage {
150
167
  classified.*
151
168
  FROM classified
152
169
  UNION ALL
153
- SELECT 'model', provider_id, requested_model_id, NULL, classified.* FROM classified
170
+ SELECT 'model', provider_id, requested_model_id, NULL, NULL, classified.* FROM classified
154
171
  UNION ALL
155
- SELECT 'agent', attribution_agent_id, NULL, NULL, classified.*
156
- FROM classified WHERE attribution_agent_id IS NOT NULL
172
+ SELECT 'agent', attribution_agent_kind, attribution_workflow_id, attribution_agent_step_id, NULL, classified.*
173
+ FROM classified WHERE attribution_agent_kind IS NOT NULL
157
174
  UNION ALL
158
- SELECT 'workflow', attribution_workflow_id, NULL, NULL, classified.*
175
+ SELECT 'workflow', attribution_workflow_id, NULL, NULL, NULL, classified.*
159
176
  FROM classified WHERE attribution_workflow_id IS NOT NULL
160
177
  )
161
178
  SELECT dimension,
162
179
  key_1,
163
180
  key_2,
181
+ key_3,
164
182
  bucket_start,
165
183
  amount_currency,
166
184
  COUNT(*)::text AS model_call_count,
@@ -187,7 +205,7 @@ export class PgAiCostStorage implements AiCostStorage {
187
205
  AS unvalued_call_count,
188
206
  SUM(amount_nanos)::text AS amount_nanos
189
207
  FROM expanded
190
- GROUP BY dimension, key_1, key_2, bucket_start, amount_currency
208
+ GROUP BY dimension, key_1, key_2, key_3, bucket_start, amount_currency
191
209
  `
192
210
  return buildAiAccountingOverviewFromFragments(input, rows.map(aggregateFragmentFromRow))
193
211
  }
@@ -221,13 +239,15 @@ export class PgAiCostStorage implements AiCostStorage {
221
239
  const rows = await this.sql<JoinedRow[]>`
222
240
  SELECT usage.*,
223
241
  CASE
224
- WHEN direct_agent.id IS NOT NULL THEN 'agent'
242
+ WHEN direct_agent.kind = 'conversation' THEN 'agent'
243
+ WHEN direct_agent.kind = 'subagent' THEN 'subagent'
225
244
  WHEN workflow_agent.node_run_id IS NOT NULL THEN 'workflowAgent'
226
245
  ELSE NULL
227
246
  END AS attribution_kind,
228
- COALESCE(direct_agent.agent_id, workflow_agent.agent_id) AS attribution_agent_id,
247
+ workflow_node.node_id AS attribution_agent_step_id,
229
248
  direct_agent.id AS attribution_agent_run_id,
230
249
  direct_agent.thread_id AS attribution_thread_id,
250
+ direct_agent.parent_run_id AS attribution_parent_run_id,
231
251
  workflow_agent.node_run_id AS attribution_node_run_id,
232
252
  workflow_node.workflow_id AS attribution_workflow_id,
233
253
  workflow_node.workflow_run_id AS attribution_workflow_run_id,
@@ -303,6 +323,7 @@ interface UsageRow {
303
323
  readonly call_id: string
304
324
  readonly provider_id: string
305
325
  readonly requested_model_id: string
326
+ readonly requested_reasoning: string | Record<string, unknown> | null
306
327
  readonly response_model_id: string | null
307
328
  readonly response_id: string
308
329
  readonly input_tokens: number | string | null
@@ -315,15 +336,17 @@ interface UsageRow {
315
336
  readonly reasoning_output_tokens: number | string | null
316
337
  readonly reporting_status: AiModelCallUsageRecord["usage"]["reportingStatus"]
317
338
  readonly raw_usage: ReadonlyJsonObject | string | null
339
+ readonly provider_ids: Record<string, unknown> | string | null
318
340
  readonly occurred_at: Date | string
319
341
  readonly recorded_at: Date | string
320
342
  }
321
343
 
322
344
  interface JoinedRow extends UsageRow {
323
- readonly attribution_kind: "agent" | "workflowAgent" | null
324
- readonly attribution_agent_id: string | null
345
+ readonly attribution_kind: "agent" | "subagent" | "workflowAgent" | null
346
+ readonly attribution_agent_step_id: string | null
325
347
  readonly attribution_agent_run_id: string | null
326
348
  readonly attribution_thread_id: string | null
349
+ readonly attribution_parent_run_id: string | null
327
350
  readonly attribution_node_run_id: string | null
328
351
  readonly attribution_workflow_id: string | null
329
352
  readonly attribution_workflow_run_id: string | null
@@ -342,6 +365,7 @@ interface AggregateRow {
342
365
  readonly dimension: AiAccountingAggregateFragment["dimension"]
343
366
  readonly key_1: string | null
344
367
  readonly key_2: string | null
368
+ readonly key_3: string | null
345
369
  readonly bucket_start: Date | string | null
346
370
  readonly amount_currency: string | null
347
371
  readonly model_call_count: string
@@ -405,7 +429,12 @@ function aggregateFragmentFromRow(row: AggregateRow): AiAccountingAggregateFragm
405
429
  row.dimension === "model"
406
430
  ? { providerId: required(row.key_1), modelId: required(row.key_2) }
407
431
  : row.dimension === "agent"
408
- ? { agentId: required(row.key_1) }
432
+ ? {
433
+ agentKind: required(row.key_1) as "agent" | "workflowAgent",
434
+ ...(row.key_1 === "workflowAgent"
435
+ ? { workflowId: required(row.key_2), agentStepId: required(row.key_3) }
436
+ : {}),
437
+ }
409
438
  : row.dimension === "workflow"
410
439
  ? { workflowId: required(row.key_1) }
411
440
  : row.dimension === "series"
@@ -466,17 +495,22 @@ function accountingItemFromRow(row: JoinedRow): AiAccountingRecordSetItem {
466
495
  : row.attribution_kind === "agent"
467
496
  ? {
468
497
  kind: "agent" as const,
469
- agentId: required(row.attribution_agent_id),
470
498
  agentRunId: required(row.attribution_agent_run_id),
471
499
  threadId: required(row.attribution_thread_id),
472
500
  }
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
- }
501
+ : row.attribution_kind === "subagent"
502
+ ? {
503
+ kind: "subagent" as const,
504
+ subagentRunId: required(row.attribution_agent_run_id),
505
+ parentRunId: required(row.attribution_parent_run_id),
506
+ }
507
+ : {
508
+ kind: "workflowAgent" as const,
509
+ agentStepId: required(row.attribution_agent_step_id),
510
+ nodeRunId: required(row.attribution_node_run_id),
511
+ workflowId: required(row.attribution_workflow_id),
512
+ workflowRunId: required(row.attribution_workflow_run_id),
513
+ }
480
514
  return {
481
515
  usage,
482
516
  ...(attribution ? { attribution } : {}),
@@ -494,7 +528,17 @@ function usageFromRow(row: UsageRow, requesterGroupIds: readonly string[]): AiMo
494
528
  callId: row.call_id,
495
529
  requesterGroupIds,
496
530
  providerId: row.provider_id,
531
+ ...(row.provider_ids === null
532
+ ? {}
533
+ : {
534
+ providerIds: normalizeModelProviderIds(
535
+ typeof row.provider_ids === "string" ? JSON.parse(row.provider_ids) : row.provider_ids
536
+ ),
537
+ }),
497
538
  requestedModelId: row.requested_model_id,
539
+ ...(row.requested_reasoning === null
540
+ ? {}
541
+ : { requestedReasoning: requestedReasoningFromRow(row.requested_reasoning) }),
498
542
  ...(row.response_model_id === null ? {} : { responseModelId: row.response_model_id }),
499
543
  responseId: row.response_id,
500
544
  usage: {
@@ -569,14 +613,15 @@ function costRecord(input: {
569
613
  ratedAt: Date | string
570
614
  }): AiModelCallCostRecord {
571
615
  const details = parseAiModelCallCostDetails(input.details)
616
+ const priceSource =
617
+ details.priceSource === undefined
618
+ ? undefined
619
+ : { ...details.priceSource, observedAt: new Date(details.priceSource.observedAt) }
572
620
  const base = {
621
+ ...(details.estimate === undefined ? {} : { estimate: details.estimate }),
573
622
  projectId: input.projectId,
574
623
  usageRecordId: input.usageId,
575
624
  pricingContext: details.pricingContext,
576
- priceSource: {
577
- ...details.priceSource,
578
- observedAt: new Date(details.priceSource.observedAt),
579
- },
580
625
  ratedAt: new Date(input.ratedAt),
581
626
  }
582
627
  if (input.status === "rated") {
@@ -584,6 +629,7 @@ function costRecord(input: {
584
629
  ...base,
585
630
  status: "rated",
586
631
  billingIdentity: { providerId: required(input.providerId), modelId: required(input.modelId) },
632
+ priceSource: required(priceSource),
587
633
  money: { currency: required(input.currency), amountNanos: required(input.amountNanos) },
588
634
  components: required(details.components),
589
635
  })
@@ -594,6 +640,7 @@ function costRecord(input: {
594
640
  ...(input.providerId && input.modelId
595
641
  ? { billingIdentity: { providerId: input.providerId, modelId: input.modelId } }
596
642
  : {}),
643
+ ...(priceSource === undefined ? {} : { priceSource }),
597
644
  reason: required(input.reason) as Extract<
598
645
  AiModelCallCostRecord,
599
646
  { status: "unpriceable" }