@kindgi/api 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 (91) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +2 -1
  3. package/dist/app.js.map +1 -1
  4. package/dist/cost-binding.d.ts +81 -5
  5. package/dist/cost-binding.d.ts.map +1 -1
  6. package/dist/cost-binding.js +6 -0
  7. package/dist/cost-binding.js.map +1 -1
  8. package/dist/hitl-binding.d.ts +20 -5
  9. package/dist/hitl-binding.d.ts.map +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/middleware/auth.d.ts +3 -2
  14. package/dist/middleware/auth.d.ts.map +1 -1
  15. package/dist/middleware/auth.js.map +1 -1
  16. package/dist/middleware/idempotency.d.ts +5 -1
  17. package/dist/middleware/idempotency.d.ts.map +1 -1
  18. package/dist/middleware/idempotency.js +8 -1
  19. package/dist/middleware/idempotency.js.map +1 -1
  20. package/dist/openapi/operations.d.ts.map +1 -1
  21. package/dist/openapi/operations.js +83 -18
  22. package/dist/openapi/operations.js.map +1 -1
  23. package/dist/openapi/schemas.d.ts +18 -0
  24. package/dist/openapi/schemas.d.ts.map +1 -1
  25. package/dist/openapi/schemas.js +202 -7
  26. package/dist/openapi/schemas.js.map +1 -1
  27. package/dist/provenance-binding.d.ts +27 -1
  28. package/dist/provenance-binding.d.ts.map +1 -1
  29. package/dist/provenance-binding.js.map +1 -1
  30. package/dist/reviewer-binding.d.ts +10 -2
  31. package/dist/reviewer-binding.d.ts.map +1 -1
  32. package/dist/reviewer-role.d.ts +13 -0
  33. package/dist/reviewer-role.d.ts.map +1 -0
  34. package/dist/reviewer-role.js +27 -0
  35. package/dist/reviewer-role.js.map +1 -0
  36. package/dist/routes/approvals.d.ts +5 -4
  37. package/dist/routes/approvals.d.ts.map +1 -1
  38. package/dist/routes/approvals.js +52 -16
  39. package/dist/routes/approvals.js.map +1 -1
  40. package/dist/routes/conversations.d.ts +7 -1
  41. package/dist/routes/conversations.d.ts.map +1 -1
  42. package/dist/routes/conversations.js +41 -1
  43. package/dist/routes/conversations.js.map +1 -1
  44. package/dist/routes/cost.d.ts.map +1 -1
  45. package/dist/routes/cost.js +95 -9
  46. package/dist/routes/cost.js.map +1 -1
  47. package/dist/routes/identity.d.ts +7 -0
  48. package/dist/routes/identity.d.ts.map +1 -1
  49. package/dist/routes/identity.js +3 -2
  50. package/dist/routes/identity.js.map +1 -1
  51. package/dist/routes/provenance.d.ts.map +1 -1
  52. package/dist/routes/provenance.js +32 -1
  53. package/dist/routes/provenance.js.map +1 -1
  54. package/dist/routes/runs.d.ts +0 -7
  55. package/dist/routes/runs.d.ts.map +1 -1
  56. package/dist/routes/runs.js +44 -20
  57. package/dist/routes/runs.js.map +1 -1
  58. package/dist/routes/scope-params.d.ts +16 -1
  59. package/dist/routes/scope-params.d.ts.map +1 -1
  60. package/dist/routes/scope-params.js +28 -0
  61. package/dist/routes/scope-params.js.map +1 -1
  62. package/dist/routes/sse.d.ts +2 -2
  63. package/dist/routes/sse.js +1 -1
  64. package/dist/types.d.ts +4 -3
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/webhook-endpoint-binding.d.ts +11 -0
  67. package/dist/webhook-endpoint-binding.d.ts.map +1 -1
  68. package/dist/webhook-endpoint-binding.js.map +1 -1
  69. package/openapi.json +599 -24
  70. package/package.json +24 -24
  71. package/src/app.ts +5 -1
  72. package/src/cost-binding.ts +96 -5
  73. package/src/hitl-binding.ts +20 -4
  74. package/src/index.ts +3 -0
  75. package/src/middleware/auth.ts +3 -2
  76. package/src/middleware/idempotency.ts +7 -1
  77. package/src/openapi/operations.ts +93 -18
  78. package/src/openapi/schemas.ts +221 -7
  79. package/src/provenance-binding.ts +42 -1
  80. package/src/reviewer-binding.ts +10 -2
  81. package/src/reviewer-role.ts +35 -0
  82. package/src/routes/approvals.ts +70 -19
  83. package/src/routes/conversations.ts +57 -1
  84. package/src/routes/cost.ts +104 -20
  85. package/src/routes/identity.ts +10 -2
  86. package/src/routes/provenance.ts +48 -1
  87. package/src/routes/runs.ts +54 -22
  88. package/src/routes/scope-params.ts +35 -1
  89. package/src/routes/sse.ts +2 -2
  90. package/src/types.ts +4 -3
  91. package/src/webhook-endpoint-binding.ts +11 -0
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { Hono } from 'hono';
5
5
 
6
- import type { ConversationBinding } from '@kindgi/agents';
6
+ import { AGENT_GATE_SUBJECTS, TOOL_CALL_GATE_SUBJECT } from '@kindgi/agents';
7
+ import type { ConversationBinding, GateDecisionValue } from '@kindgi/agents';
7
8
  import { REVIEWER_ROLE_RANK, type ReviewerRole } from '@kindgi/authz';
8
9
  import { serializePublicKeyPem, signEd25519 } from '@kindgi/crypto';
9
10
  import type { SigningKeyBinding } from '@kindgi/crypto';
@@ -23,10 +24,18 @@ import type {
23
24
 
24
25
  import { statusFor, toWireError } from '../errors.js';
25
26
  import type { RunHandlerBinding } from '../handler-binding.js';
26
- import type { Approval, ApprovalStatus, HitlBinding, ReviewDecisionKind } from '../hitl-binding.js';
27
+ import type {
28
+ Approval,
29
+ ApprovalStatus,
30
+ HitlBinding,
31
+ ReviewDecisionKind,
32
+ ReviewDecisionRecord,
33
+ } from '../hitl-binding.js';
27
34
  import type { ReviewerBinding } from '../reviewer-binding.js';
35
+ import { callerReviewerRole } from '../reviewer-role.js';
28
36
  import type { AppEnv } from '../types.js';
29
37
  import { clampLimit } from './pagination.js';
38
+ import { parseListScope } from './scope-params.js';
30
39
 
31
40
  const APPROVAL_STATUSES: ReadonlySet<ApprovalStatus> = new Set([
32
41
  'pending',
@@ -80,10 +89,11 @@ export interface ApprovalsRouterOptions {
80
89
  /**
81
90
  * Approvals resource routes.
82
91
  *
83
- * Reviewer-role scoping: routes require `c.get('reviewerRole')` to be
84
- * set by the auth middleware (see `TokenResolution.reviewerRole`).
85
- * Tokens without a reviewer role get `403 permission-denied` on every
86
- * approvals route — this surface is reviewer-only.
92
+ * Reviewer-role scoping: routes require a reviewer role — the token's
93
+ * (`TokenResolution.reviewerRole`), or its user's in the reviewer roster
94
+ * (`ReviewerBinding.resolveReviewerRole`). A caller with neither gets
95
+ * `403 permission-denied` on every approvals route — this surface is
96
+ * reviewer-only.
87
97
  *
88
98
  * Visibility filter: a caller with role `R` sees approvals whose
89
99
  * `requiredRole` rank ≤ their rank (standard < senior < admin). The
@@ -103,9 +113,11 @@ export function approvalsRouter(
103
113
  const runHandler = options.runHandler;
104
114
 
105
115
  // ---------- role gate for the whole resource ----------
116
+ // A reviewer: a token that carries a role, or whose user the roster
117
+ // names (a session or API key of a registered reviewer).
106
118
  r.use('*', async (c, next) => {
107
119
  const requestId = c.get('requestId');
108
- const role = c.get('reviewerRole');
120
+ const role = await callerReviewerRole(c, reviewerBinding);
109
121
  if (role === undefined) {
110
122
  c.status(statusFor('permission-denied') as never);
111
123
  return c.json(
@@ -113,7 +125,7 @@ export function approvalsRouter(
113
125
  {
114
126
  code: 'permission-denied',
115
127
  message:
116
- 'This token was not provisioned with a reviewer role; approvals surface is reviewer-only.',
128
+ "The caller isn't a reviewer: its token carries no reviewer role, and its user isn't registered as one (`kindgi reviewers register`). The approvals surface is reviewer-only.",
117
129
  },
118
130
  requestId,
119
131
  ),
@@ -130,6 +142,14 @@ export function approvalsRouter(
130
142
  const role = c.get('reviewerRole') as ReviewerRole;
131
143
  const limit = clampLimit(c.req.query('limit'));
132
144
 
145
+ const scopeParsed = parseListScope(c.req.query(), { tenantId });
146
+ if (scopeParsed.kind === 'err') {
147
+ c.status(statusFor('scope-invalid') as never);
148
+ return c.json(
149
+ toWireError({ code: 'scope-invalid', message: scopeParsed.message }, requestId),
150
+ );
151
+ }
152
+
133
153
  const statusRaw = c.req.query('status');
134
154
  let statusFilter: ApprovalStatus | undefined;
135
155
  if (statusRaw !== undefined && statusRaw.length > 0) {
@@ -213,6 +233,7 @@ export function approvalsRouter(
213
233
  const listInput = {
214
234
  tenantId,
215
235
  limit: HITL_LIMIT_CAP,
236
+ ...(scopeParsed.scope !== undefined && { scope: scopeParsed.scope }),
216
237
  ...(statusFilter !== undefined && { status: statusFilter }),
217
238
  ...(requiredRoleFilter !== undefined && { requiredRole: requiredRoleFilter }),
218
239
  ...(createdAfterIso !== undefined && { since: createdAfterIso as unknown as Timestamp }),
@@ -327,6 +348,22 @@ export function approvalsRouter(
327
348
  );
328
349
  }
329
350
 
351
+ // An agent's tool-call and session gates resume on the decision alone
352
+ // (`readGateDecision` fails closed on anything else), so a `value`
353
+ // can't stand in for it: refused before anything is recorded.
354
+ if (parsed.value.value !== undefined && AGENT_GATE_SUBJECTS.has(approval.subjectKind)) {
355
+ c.status(statusFor('bad-input') as never);
356
+ return c.json(
357
+ toWireError(
358
+ {
359
+ code: 'bad-input',
360
+ message: `An approval of an agent's ${approval.subjectKind === TOOL_CALL_GATE_SUBJECT ? 'tool call' : 'session'} takes a decision (approve or reject) and an optional rationale, not a \`value\`: the turn resumes on the decision alone.`,
361
+ },
362
+ requestId,
363
+ ),
364
+ );
365
+ }
366
+
330
367
  const reviewerId = await reviewerBinding.resolveReviewer({
331
368
  tenantId,
332
369
  userId: userId as UserId,
@@ -362,23 +399,25 @@ export function approvalsRouter(
362
399
  // stays suspended until the new (escalated) approval terminates,
363
400
  // or the run is cancelled explicitly.
364
401
  //
365
- // Resume value shape: `{ decided, rationale? }` — the shape the
366
- // agent's approval gate consumes. The caller CAN supply an explicit
367
- // `value` in the wire body to override it, when the resume payload
368
- // must carry more than the decision.
402
+ // Resume value shape: `GateDecisionValue`, `{ decided, rationale?,
403
+ // decidedBy, approvalId }`, the shape the agent's approval gate
404
+ // consumes; the run's journal keeps who decided which approval. For
405
+ // another subject, the caller CAN supply an explicit `value` to
406
+ // override it, when the resume payload must carry more than the
407
+ // decision (an agent gate refuses one, above).
369
408
  let waitpointResolved = false;
370
409
  if (
371
410
  approval.waitTokenId !== undefined &&
372
411
  approval.provenanceRef?.runId !== undefined &&
373
412
  (parsed.value.decision === 'approve' || parsed.value.decision === 'reject')
374
413
  ) {
375
- const resumeValue =
376
- parsed.value.value !== undefined
377
- ? parsed.value.value
378
- : {
379
- decided: parsed.value.decision as 'approve' | 'reject',
380
- ...(parsed.value.rationale !== undefined && { rationale: parsed.value.rationale }),
381
- };
414
+ const decided: GateDecisionValue = {
415
+ decided: parsed.value.decision,
416
+ ...(parsed.value.rationale !== undefined && { rationale: parsed.value.rationale }),
417
+ decidedBy: `user:${userId as unknown as string}`,
418
+ approvalId: approval.id as unknown as string,
419
+ };
420
+ const resumeValue = parsed.value.value !== undefined ? parsed.value.value : decided;
382
421
  const resolved = await runBinding.completeToken(
383
422
  tenantId,
384
423
  approval.provenanceRef.runId as RunId,
@@ -661,6 +700,18 @@ function serializeApproval(a: Approval): Record<string, unknown> {
661
700
  updatedAt: a.updatedAt,
662
701
  decidedAt: a.decidedAt,
663
702
  expiresAt: a.expiresAt,
703
+ decision: a.decision === undefined ? undefined : serializeApprovalDecision(a.decision),
704
+ };
705
+ }
706
+
707
+ function serializeApprovalDecision(d: ReviewDecisionRecord): Record<string, unknown> {
708
+ return {
709
+ decision: d.decision,
710
+ ...(d.decidedBy !== undefined && { decidedBy: d.decidedBy }),
711
+ reviewerId: d.reviewerId,
712
+ reviewerRoleAtDecision: d.reviewerRoleAtDecision,
713
+ decidedAt: d.decidedAt,
714
+ ...(d.rationale !== undefined && { rationale: d.rationale }),
664
715
  };
665
716
  }
666
717
 
@@ -10,12 +10,16 @@ import type {
10
10
  ConversationMessage,
11
11
  ConversationPageCursor,
12
12
  } from '@kindgi/agents';
13
+ import type { ProjectBinding } from '@kindgi/platform';
13
14
  import type { RunBinding } from '@kindgi/runtime';
14
- import type { AgentId, Semver, TenantId } from '@kindgi/types';
15
+ import type { AgentId, ProjectId, Semver, TenantId } from '@kindgi/types';
15
16
 
16
17
  import { statusFor, toWireError } from '../errors.js';
17
18
  import type { AppEnv } from '../types.js';
18
19
  import { clampLimit, decodeCursor, encodeCursor } from './pagination.js';
20
+ import { parseListScope } from './scope-params.js';
21
+
22
+ const PROJECT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19
23
 
20
24
  /**
21
25
  * Conversations resource routes. All storage access goes through the
@@ -27,6 +31,11 @@ import { clampLimit, decodeCursor, encodeCursor } from './pagination.js';
27
31
  export function conversationsRouter(
28
32
  conversationBinding: ConversationBinding,
29
33
  runBinding: RunBinding,
34
+ /**
35
+ * When wired, a `projectId` given to `POST /` must be one of the
36
+ * tenant's projects, and an omitted one is the tenant's Default project.
37
+ */
38
+ projectBinding?: ProjectBinding,
30
39
  ): Hono<AppEnv> {
31
40
  const r = new Hono<AppEnv>();
32
41
 
@@ -53,6 +62,14 @@ export function conversationsRouter(
53
62
 
54
63
  const agentIdRaw = c.req.query('agentId');
55
64
 
65
+ const scopeParsed = parseListScope(c.req.query(), { tenantId });
66
+ if (scopeParsed.kind === 'err') {
67
+ c.status(statusFor('scope-invalid') as never);
68
+ return c.json(
69
+ toWireError({ code: 'scope-invalid', message: scopeParsed.message }, requestId),
70
+ );
71
+ }
72
+
56
73
  let before: ConversationPageCursor | undefined;
57
74
  const rawCursor = c.req.query('cursor');
58
75
  if (rawCursor !== undefined && rawCursor.length > 0) {
@@ -68,6 +85,7 @@ export function conversationsRouter(
68
85
 
69
86
  const listResult = await conversationBinding.listConversationsPage({
70
87
  tenantId,
88
+ ...(scopeParsed.scope !== undefined && { scope: scopeParsed.scope }),
71
89
  ...(agentIdRaw !== undefined && agentIdRaw.length > 0 && { agentId: agentIdRaw as AgentId }),
72
90
  ...(statusFilter !== undefined && { status: statusFilter }),
73
91
  ...(before !== undefined && { before }),
@@ -126,9 +144,30 @@ export function conversationsRouter(
126
144
  c.status(statusFor(parsed.error.code) as never);
127
145
  return c.json(toWireError(parsed.error, requestId));
128
146
  }
147
+ const supplied = parsed.value.projectId;
148
+ if (
149
+ supplied !== undefined &&
150
+ projectBinding !== undefined &&
151
+ (await projectBinding.get(tenantId, supplied)) === undefined
152
+ ) {
153
+ c.status(statusFor('bad-input') as never);
154
+ return c.json(
155
+ toWireError(
156
+ {
157
+ code: 'bad-input',
158
+ message: `\`projectId\` "${supplied as unknown as string}" does not resolve to a project in this tenant`,
159
+ },
160
+ requestId,
161
+ ),
162
+ );
163
+ }
164
+ // Omitted: the tenant's Default project, as for a run. No Default (or
165
+ // no project binding): no project, never a refusal.
166
+ const projectId = supplied ?? (await projectBinding?.getDefault(tenantId))?.id;
129
167
 
130
168
  const opened = await conversationBinding.openConversation({
131
169
  tenantId,
170
+ ...(projectId !== undefined && { projectId }),
132
171
  agentId: parsed.value.agentId,
133
172
  agentVersion: parsed.value.agentVersion,
134
173
  title: parsed.value.title,
@@ -232,6 +271,7 @@ function serializeConversation(c: Conversation): Record<string, unknown> {
232
271
  agentVersion: c.agentVersion as unknown as string,
233
272
  title: c.title,
234
273
  ...(c.participantId !== undefined && { participantId: c.participantId }),
274
+ ...(c.projectId !== undefined && { projectId: c.projectId as unknown as string }),
235
275
  scope: c.scope,
236
276
  status: (c.closedAt === undefined ? 'open' : 'closed') as 'open' | 'closed',
237
277
  openedAt: c.openedAt as unknown as string,
@@ -283,6 +323,7 @@ interface ParsedOpenBody {
283
323
  readonly agentVersion: Semver;
284
324
  readonly title: string;
285
325
  readonly scope: Readonly<Record<string, unknown>>;
326
+ readonly projectId?: ProjectId;
286
327
  readonly participantId?: string;
287
328
  readonly metadata?: Readonly<Record<string, unknown>>;
288
329
  }
@@ -335,6 +376,20 @@ function parseOpenBody(
335
376
  scope = scopeRaw as Readonly<Record<string, unknown>>;
336
377
  }
337
378
 
379
+ const projectIdRaw = b.projectId;
380
+ if (
381
+ projectIdRaw !== undefined &&
382
+ (typeof projectIdRaw !== 'string' || !PROJECT_ID_RE.test(projectIdRaw))
383
+ ) {
384
+ return {
385
+ kind: 'err',
386
+ error: {
387
+ code: 'bad-input',
388
+ message: '`projectId` must be a project id (a UUID) when supplied',
389
+ },
390
+ };
391
+ }
392
+
338
393
  const participantIdRaw = b.participantId;
339
394
  if (participantIdRaw !== undefined && typeof participantIdRaw !== 'string') {
340
395
  return {
@@ -362,6 +417,7 @@ function parseOpenBody(
362
417
  agentVersion: agentVersion as Semver,
363
418
  title,
364
419
  scope,
420
+ ...(projectIdRaw !== undefined && { projectId: projectIdRaw as ProjectId }),
365
421
  ...(participantIdRaw !== undefined && { participantId: participantIdRaw }),
366
422
  ...(metadata !== undefined && { metadata }),
367
423
  },
@@ -12,6 +12,7 @@ import {
12
12
  type CostGroupDimension,
13
13
  type CostRecord,
14
14
  type CostRecordFilter,
15
+ type CostTokenTotals,
15
16
  } from '../cost-binding.js';
16
17
  import { statusFor, toWireError } from '../errors.js';
17
18
  import type { AppEnv } from '../types.js';
@@ -52,6 +53,11 @@ export function costRouter(binding: CostBinding): Hono<AppEnv> {
52
53
  c.status(statusFor('bad-input') as never);
53
54
  return c.json(toWireError({ code: 'bad-input', message: filter.error }, requestId));
54
55
  }
56
+ const include = parseInclude(c.req.query('include'));
57
+ if (include.kind === 'err') {
58
+ c.status(statusFor('bad-input') as never);
59
+ return c.json(toWireError({ code: 'bad-input', message: include.error }, requestId));
60
+ }
55
61
 
56
62
  // Thread the ?scopeKind + ?scopeId + ?inherit
57
63
  // triplet as a sibling of `filter` (matches the binding's shape:
@@ -71,6 +77,7 @@ export function costRouter(binding: CostBinding): Hono<AppEnv> {
71
77
  filter: filter.value,
72
78
  ...(scopeParsed.scope !== undefined && { scope: scopeParsed.scope }),
73
79
  ...(scopeParsed.inherit !== undefined && { inherit: scopeParsed.inherit }),
80
+ ...(include.rawUsage && { includeRawUsage: true }),
74
81
  });
75
82
  return c.json({
76
83
  data: page.data.map(serializeRecord),
@@ -84,8 +91,17 @@ export function costRouter(binding: CostBinding): Hono<AppEnv> {
84
91
  const requestId = c.get('requestId');
85
92
  const tenantId = c.get('tenantId') as TenantId;
86
93
  const recordId = c.req.param('recordId');
94
+ const include = parseInclude(c.req.query('include'));
95
+ if (include.kind === 'err') {
96
+ c.status(statusFor('bad-input') as never);
97
+ return c.json(toWireError({ code: 'bad-input', message: include.error }, requestId));
98
+ }
87
99
 
88
- const record = await binding.getRecord({ tenantId, recordId });
100
+ const record = await binding.getRecord({
101
+ tenantId,
102
+ recordId,
103
+ ...(include.rawUsage && { includeRawUsage: true }),
104
+ });
89
105
  if (record === null) {
90
106
  c.status(statusFor('cost-record-not-found') as never);
91
107
  return c.json(
@@ -230,6 +246,7 @@ export function costRouter(binding: CostBinding): Hono<AppEnv> {
230
246
  groups: result.groups.map(serializeGroup),
231
247
  totalUsd: result.totalUsd,
232
248
  totalRecords: result.totalRecords,
249
+ tokens: serializeTokens(result.tokens),
233
250
  timeRange: {
234
251
  from: result.timeRange.from,
235
252
  to: result.timeRange.to,
@@ -248,27 +265,34 @@ function parseRecordFilter(
248
265
  opts: { skipTime?: boolean } = {},
249
266
  ): { kind: 'ok'; value: CostRecordFilter } | { kind: 'err'; error: string } {
250
267
  const out: {
251
- runId?: string;
252
- agentId?: string;
253
- conversationId?: string;
254
- category?: string;
255
- providerId?: string;
256
- from?: Date;
257
- to?: Date;
268
+ -readonly [K in keyof CostRecordFilter]: CostRecordFilter[K];
258
269
  } = {};
259
- const strKeys: Array<
260
- ['runId' | 'agentId' | 'conversationId' | 'category' | 'providerId', string]
261
- > = [
262
- ['runId', 'runId'],
263
- ['agentId', 'agentId'],
264
- ['conversationId', 'conversationId'],
265
- ['category', 'category'],
266
- ['providerId', 'providerId'],
267
- ];
268
- for (const [outKey, qKey] of strKeys) {
269
- const raw = query[qKey];
270
+ const strKeys = [
271
+ 'runId',
272
+ 'agentId',
273
+ 'conversationId',
274
+ 'category',
275
+ 'providerId',
276
+ 'model',
277
+ 'servedModel',
278
+ 'rootRunId',
279
+ ] as const;
280
+ for (const key of strKeys) {
281
+ const raw = query[key];
270
282
  if (raw !== undefined && raw.length > 0) {
271
- out[outKey] = raw;
283
+ out[key] = raw;
284
+ }
285
+ }
286
+ const descendants = query.includeDescendants;
287
+ if (descendants !== undefined && descendants.length > 0) {
288
+ if (descendants !== 'true' && descendants !== 'false') {
289
+ return { kind: 'err', error: '`includeDescendants` must be `true` or `false`' };
290
+ }
291
+ if (descendants === 'true') {
292
+ if (out.runId === undefined) {
293
+ return { kind: 'err', error: '`includeDescendants` needs a `runId`' };
294
+ }
295
+ out.includeDescendants = true;
272
296
  }
273
297
  }
274
298
  if (opts.skipTime !== true) {
@@ -291,6 +315,22 @@ function parseRecordFilter(
291
315
  return { kind: 'ok', value: out };
292
316
  }
293
317
 
318
+ /** The `include` query: optional extra fields, comma-separated. Only `rawUsage` today. */
319
+ function parseInclude(
320
+ raw: string | undefined,
321
+ ): { kind: 'ok'; rawUsage: boolean } | { kind: 'err'; error: string } {
322
+ const fields = (raw ?? '')
323
+ .split(',')
324
+ .map((f) => f.trim())
325
+ .filter((f) => f.length > 0);
326
+ for (const field of fields) {
327
+ if (field !== 'rawUsage') {
328
+ return { kind: 'err', error: `\`include\` value "${field}" is not known (one of: rawUsage)` };
329
+ }
330
+ }
331
+ return { kind: 'ok', rawUsage: fields.includes('rawUsage') };
332
+ }
333
+
294
334
  function parseIsoDate(raw: string): Date | null {
295
335
  const t = Date.parse(raw);
296
336
  if (!Number.isFinite(t)) return null;
@@ -312,14 +352,58 @@ function serializeRecord(rec: CostRecord): Record<string, unknown> {
312
352
  occurredAt: rec.occurredAt as unknown as string,
313
353
  ...(rec.metrics !== undefined && { metrics: rec.metrics }),
314
354
  ...(rec.attributes !== undefined && { attributes: rec.attributes }),
355
+ ...pick(rec, MODEL_CALL_FIELDS),
315
356
  };
316
357
  }
317
358
 
359
+ /** A model call's fields on the wire, as the binding gives them (all optional). */
360
+ const MODEL_CALL_FIELDS = [
361
+ 'callId',
362
+ 'projectId',
363
+ 'rootRunId',
364
+ 'parentRunId',
365
+ 'agentVersion',
366
+ 'flowId',
367
+ 'nodeId',
368
+ 'step',
369
+ 'purpose',
370
+ 'model',
371
+ 'servedModel',
372
+ 'fallback',
373
+ 'status',
374
+ 'usage',
375
+ 'durationMs',
376
+ 'finishReason',
377
+ 'providerRequestId',
378
+ 'attempts',
379
+ 'error',
380
+ 'rawUsage',
381
+ ] as const satisfies readonly (keyof CostRecord)[];
382
+
383
+ function pick(rec: CostRecord, keys: readonly (keyof CostRecord)[]): Record<string, unknown> {
384
+ const out: Record<string, unknown> = {};
385
+ for (const key of keys) {
386
+ if (rec[key] !== undefined) out[key] = rec[key];
387
+ }
388
+ return out;
389
+ }
390
+
318
391
  function serializeGroup(g: CostAggregateGroup): Record<string, unknown> {
319
392
  return {
320
393
  key: g.key,
321
394
  count: g.count,
322
395
  totalUsd: g.totalUsd,
396
+ tokens: serializeTokens(g.tokens),
397
+ };
398
+ }
399
+
400
+ function serializeTokens(t: CostTokenTotals): CostTokenTotals {
401
+ return {
402
+ prompt: t.prompt,
403
+ completion: t.completion,
404
+ cacheRead: t.cacheRead,
405
+ cacheWrite: t.cacheWrite,
406
+ reasoning: t.reasoning,
323
407
  };
324
408
  }
325
409
 
@@ -11,6 +11,8 @@ import type {
11
11
  SessionSummary,
12
12
  UserRecord,
13
13
  } from '../identity-directory-binding.js';
14
+ import type { ReviewerBinding } from '../reviewer-binding.js';
15
+ import { callerReviewerRole } from '../reviewer-role.js';
14
16
  import type { SessionStoreBinding } from '../session-store-binding.js';
15
17
  import type { AppEnv } from '../types.js';
16
18
  import { clampLimit } from './pagination.js';
@@ -47,10 +49,16 @@ export interface IdentityRouterOptions {
47
49
  * deployments that don't wire an OAuth surface.
48
50
  */
49
51
  readonly sessionStore?: SessionStoreBinding;
52
+ /**
53
+ * Optional. When present, `whoami` reports the reviewer role of a
54
+ * caller whose token carries none from the roster, as the approvals
55
+ * routes resolve it.
56
+ */
57
+ readonly reviewerBinding?: ReviewerBinding;
50
58
  }
51
59
 
52
60
  export function identityRouter(options: IdentityRouterOptions = {}): Hono<AppEnv> {
53
- const { directory, sessionStore } = options;
61
+ const { directory, sessionStore, reviewerBinding } = options;
54
62
  const r = new Hono<AppEnv>();
55
63
 
56
64
  // ---------- GET / whoami ----------
@@ -62,7 +70,7 @@ export function identityRouter(options: IdentityRouterOptions = {}): Hono<AppEnv
62
70
  const userId = c.get('userId') as UserId | undefined;
63
71
  const providerId = c.get('providerId') as string | undefined;
64
72
  const scopes = (c.get('scopes') as readonly string[] | undefined) ?? [];
65
- const reviewerRole = c.get('reviewerRole') as string | undefined;
73
+ const reviewerRole = await callerReviewerRole(c, reviewerBinding);
66
74
 
67
75
  const body: Record<string, unknown> = { tenantId };
68
76
  if (sessionId !== undefined) body.sessionId = sessionId;
@@ -12,12 +12,15 @@ import type { ConversationId, RunId, SigningKeyId, TenantId, Timestamp } from '@
12
12
  import { statusFor, toWireError } from '../errors.js';
13
13
 
14
14
  import type {
15
+ CallUsageByCallId,
15
16
  ProvenanceBinding,
17
+ ProvenanceBindingError,
16
18
  ProvenanceListCursor,
17
19
  ProvenanceRecordSummary,
18
20
  } from '../provenance-binding.js';
19
21
  import type { AppEnv } from '../types.js';
20
22
  import { clampLimit, decodeCursor, encodeCursor } from './pagination.js';
23
+ import { parseListScope } from './scope-params.js';
21
24
 
22
25
  /**
23
26
  * Provenance resource routes.
@@ -48,7 +51,8 @@ export interface ProvenanceRouterOptions {
48
51
  }
49
52
 
50
53
  /** Bundle schema version — bump when the wire shape of `bundle.body` changes. */
51
- const BUNDLE_SCHEMA_VERSION = '1.0.0';
54
+ /** 1.1.0 adds `callUsage`: the model calls' usage from the cost ledger, when the run has any. */
55
+ const BUNDLE_SCHEMA_VERSION = '1.1.0';
52
56
 
53
57
  export function provenanceRouter(
54
58
  binding: ProvenanceBinding,
@@ -76,6 +80,14 @@ export function provenanceRouter(
76
80
  cursorFilter = { createdAt: decoded.createdAt, id: decoded.id };
77
81
  }
78
82
 
83
+ const scopeParsed = parseListScope(c.req.query(), { tenantId });
84
+ if (scopeParsed.kind === 'err') {
85
+ c.status(statusFor('scope-invalid') as never);
86
+ return c.json(
87
+ toWireError({ code: 'scope-invalid', message: scopeParsed.message }, requestId),
88
+ );
89
+ }
90
+
79
91
  const runIdFilter = c.req.query('runId');
80
92
  const agentIdFilter = c.req.query('agentId');
81
93
  const createdAfterRaw = c.req.query('createdAfter');
@@ -98,6 +110,7 @@ export function provenanceRouter(
98
110
  const result = await binding.listRecords({
99
111
  tenantId,
100
112
  limit,
113
+ ...(scopeParsed.scope !== undefined && { scope: scopeParsed.scope }),
101
114
  ...(runIdFilter !== undefined && runIdFilter.length > 0 && { runId: runIdFilter as RunId }),
102
115
  ...(agentIdFilter !== undefined && agentIdFilter.length > 0 && { agentId: agentIdFilter }),
103
116
  ...(createdAfter !== undefined && { createdAfter }),
@@ -148,6 +161,13 @@ export function provenanceRouter(
148
161
  );
149
162
  }
150
163
  const provenance = result.value;
164
+ const callUsage = await readCallUsage(binding, tenantId, runId);
165
+ if (callUsage.kind === 'err') {
166
+ c.status(statusFor(callUsage.error.code) as never);
167
+ return c.json(
168
+ toWireError({ code: callUsage.error.code, message: callUsage.error.message }, requestId),
169
+ );
170
+ }
151
171
  return c.json({
152
172
  id: provenance.id as unknown as string,
153
173
  runId: provenance.runId as unknown as string,
@@ -165,6 +185,7 @@ export function provenanceRouter(
165
185
  edges: provenance.edges,
166
186
  },
167
187
  ...(provenance.signature !== undefined && { signature: provenance.signature }),
188
+ ...(callUsage.value !== undefined && { callUsage: callUsage.value }),
168
189
  });
169
190
  });
170
191
 
@@ -232,6 +253,14 @@ export function provenanceRouter(
232
253
  );
233
254
  }
234
255
  const provenance = loaded.value;
256
+ // The calls' usage as it stands now: the signature covers it.
257
+ const callUsage = await readCallUsage(binding, tenantId, runId);
258
+ if (callUsage.kind === 'err') {
259
+ c.status(statusFor(callUsage.error.code) as never);
260
+ return c.json(
261
+ toWireError({ code: callUsage.error.code, message: callUsage.error.message }, requestId),
262
+ );
263
+ }
235
264
 
236
265
  // 2. Optionally hydrate conversation messages. Agent turns use the
237
266
  // conversation id as the run id; for flow-only runs no
@@ -306,6 +335,7 @@ export function provenanceRouter(
306
335
  edges: provenance.edges,
307
336
  },
308
337
  ...(messages !== undefined && { messages }),
338
+ ...(callUsage.value !== undefined && { callUsage: callUsage.value }),
309
339
  };
310
340
  const canonicalBundleBytes = new TextEncoder().encode(canonicalize(bundleBody));
311
341
 
@@ -355,6 +385,7 @@ interface RecordMetadata {
355
385
  readonly createdAt: string;
356
386
  readonly flowRef?: { readonly id: string; readonly version: string };
357
387
  readonly signed: boolean;
388
+ readonly projectId?: string;
358
389
  }
359
390
 
360
391
  function serializeRecordMetadata(row: ProvenanceRecordSummary): RecordMetadata {
@@ -371,6 +402,7 @@ function serializeRecordMetadata(row: ProvenanceRecordSummary): RecordMetadata {
371
402
  },
372
403
  }),
373
404
  signed: row.signed,
405
+ ...(row.projectId !== undefined && { projectId: row.projectId as unknown as string }),
374
406
  };
375
407
  }
376
408
 
@@ -404,3 +436,18 @@ function parseExportBody(body: Record<string, unknown>): ParseResult<ValidatedEx
404
436
  },
405
437
  };
406
438
  }
439
+
440
+ /** The run's call usage, when the binding has a cost ledger and the run made calls. */
441
+ async function readCallUsage(
442
+ binding: ProvenanceBinding,
443
+ tenantId: TenantId,
444
+ runId: RunId,
445
+ ): Promise<
446
+ | { readonly kind: 'ok'; readonly value: CallUsageByCallId | undefined }
447
+ | { readonly kind: 'err'; readonly error: ProvenanceBindingError }
448
+ > {
449
+ if (binding.getCallUsage === undefined) return { kind: 'ok', value: undefined };
450
+ const read = await binding.getCallUsage(tenantId, runId);
451
+ if (read.kind === 'err') return read;
452
+ return { kind: 'ok', value: Object.keys(read.value).length > 0 ? read.value : undefined };
453
+ }