@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.
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +2 -1
- package/dist/app.js.map +1 -1
- package/dist/cost-binding.d.ts +81 -5
- package/dist/cost-binding.d.ts.map +1 -1
- package/dist/cost-binding.js +6 -0
- package/dist/cost-binding.js.map +1 -1
- package/dist/hitl-binding.d.ts +20 -5
- package/dist/hitl-binding.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +3 -2
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/idempotency.d.ts +5 -1
- package/dist/middleware/idempotency.d.ts.map +1 -1
- package/dist/middleware/idempotency.js +8 -1
- package/dist/middleware/idempotency.js.map +1 -1
- package/dist/openapi/operations.d.ts.map +1 -1
- package/dist/openapi/operations.js +83 -18
- package/dist/openapi/operations.js.map +1 -1
- package/dist/openapi/schemas.d.ts +18 -0
- package/dist/openapi/schemas.d.ts.map +1 -1
- package/dist/openapi/schemas.js +202 -7
- package/dist/openapi/schemas.js.map +1 -1
- package/dist/provenance-binding.d.ts +27 -1
- package/dist/provenance-binding.d.ts.map +1 -1
- package/dist/provenance-binding.js.map +1 -1
- package/dist/reviewer-binding.d.ts +10 -2
- package/dist/reviewer-binding.d.ts.map +1 -1
- package/dist/reviewer-role.d.ts +13 -0
- package/dist/reviewer-role.d.ts.map +1 -0
- package/dist/reviewer-role.js +27 -0
- package/dist/reviewer-role.js.map +1 -0
- package/dist/routes/approvals.d.ts +5 -4
- package/dist/routes/approvals.d.ts.map +1 -1
- package/dist/routes/approvals.js +52 -16
- package/dist/routes/approvals.js.map +1 -1
- package/dist/routes/conversations.d.ts +7 -1
- package/dist/routes/conversations.d.ts.map +1 -1
- package/dist/routes/conversations.js +41 -1
- package/dist/routes/conversations.js.map +1 -1
- package/dist/routes/cost.d.ts.map +1 -1
- package/dist/routes/cost.js +95 -9
- package/dist/routes/cost.js.map +1 -1
- package/dist/routes/identity.d.ts +7 -0
- package/dist/routes/identity.d.ts.map +1 -1
- package/dist/routes/identity.js +3 -2
- package/dist/routes/identity.js.map +1 -1
- package/dist/routes/provenance.d.ts.map +1 -1
- package/dist/routes/provenance.js +32 -1
- package/dist/routes/provenance.js.map +1 -1
- package/dist/routes/runs.d.ts +0 -7
- package/dist/routes/runs.d.ts.map +1 -1
- package/dist/routes/runs.js +44 -20
- package/dist/routes/runs.js.map +1 -1
- package/dist/routes/scope-params.d.ts +16 -1
- package/dist/routes/scope-params.d.ts.map +1 -1
- package/dist/routes/scope-params.js +28 -0
- package/dist/routes/scope-params.js.map +1 -1
- package/dist/routes/sse.d.ts +2 -2
- package/dist/routes/sse.js +1 -1
- package/dist/types.d.ts +4 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/webhook-endpoint-binding.d.ts +11 -0
- package/dist/webhook-endpoint-binding.d.ts.map +1 -1
- package/dist/webhook-endpoint-binding.js.map +1 -1
- package/openapi.json +599 -24
- package/package.json +24 -24
- package/src/app.ts +5 -1
- package/src/cost-binding.ts +96 -5
- package/src/hitl-binding.ts +20 -4
- package/src/index.ts +3 -0
- package/src/middleware/auth.ts +3 -2
- package/src/middleware/idempotency.ts +7 -1
- package/src/openapi/operations.ts +93 -18
- package/src/openapi/schemas.ts +221 -7
- package/src/provenance-binding.ts +42 -1
- package/src/reviewer-binding.ts +10 -2
- package/src/reviewer-role.ts +35 -0
- package/src/routes/approvals.ts +70 -19
- package/src/routes/conversations.ts +57 -1
- package/src/routes/cost.ts +104 -20
- package/src/routes/identity.ts +10 -2
- package/src/routes/provenance.ts +48 -1
- package/src/routes/runs.ts +54 -22
- package/src/routes/scope-params.ts +35 -1
- package/src/routes/sse.ts +2 -2
- package/src/types.ts +4 -3
- package/src/webhook-endpoint-binding.ts +11 -0
package/src/routes/approvals.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
5
|
|
|
6
|
-
import
|
|
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 {
|
|
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
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* approvals route — this surface is
|
|
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
|
|
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
|
-
'
|
|
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
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
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
|
|
376
|
-
parsed.value.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
},
|
package/src/routes/cost.ts
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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[
|
|
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
|
|
package/src/routes/identity.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/routes/provenance.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|