@kindgi/api 0.1.3 → 0.1.4-rc.0

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 (193) hide show
  1. package/dist/agent-binding.d.ts +18 -4
  2. package/dist/agent-binding.d.ts.map +1 -1
  3. package/dist/agent-pins.d.ts +48 -0
  4. package/dist/agent-pins.d.ts.map +1 -0
  5. package/dist/agent-pins.js +102 -0
  6. package/dist/agent-pins.js.map +1 -0
  7. package/dist/app.d.ts +22 -0
  8. package/dist/app.d.ts.map +1 -1
  9. package/dist/app.js +20 -2
  10. package/dist/app.js.map +1 -1
  11. package/dist/block-binding.d.ts +132 -0
  12. package/dist/block-binding.d.ts.map +1 -0
  13. package/dist/block-binding.js +4 -0
  14. package/dist/block-binding.js.map +1 -0
  15. package/dist/block-pins.d.ts +22 -0
  16. package/dist/block-pins.d.ts.map +1 -0
  17. package/dist/block-pins.js +112 -0
  18. package/dist/block-pins.js.map +1 -0
  19. package/dist/cost-binding.d.ts +20 -1
  20. package/dist/cost-binding.d.ts.map +1 -1
  21. package/dist/cost-binding.js +4 -0
  22. package/dist/cost-binding.js.map +1 -1
  23. package/dist/deploy-versions.d.ts +58 -0
  24. package/dist/deploy-versions.d.ts.map +1 -0
  25. package/dist/deploy-versions.js +91 -0
  26. package/dist/deploy-versions.js.map +1 -0
  27. package/dist/deployment-binding.d.ts +24 -3
  28. package/dist/deployment-binding.d.ts.map +1 -1
  29. package/dist/derive-agent-version.d.ts +69 -0
  30. package/dist/derive-agent-version.d.ts.map +1 -0
  31. package/dist/derive-agent-version.js +139 -0
  32. package/dist/derive-agent-version.js.map +1 -0
  33. package/dist/errors.d.ts.map +1 -1
  34. package/dist/errors.js +17 -0
  35. package/dist/errors.js.map +1 -1
  36. package/dist/eval-case-binding.d.ts +65 -0
  37. package/dist/eval-case-binding.d.ts.map +1 -0
  38. package/dist/eval-case-binding.js +4 -0
  39. package/dist/eval-case-binding.js.map +1 -0
  40. package/dist/eval-run-binding.d.ts +30 -0
  41. package/dist/eval-run-binding.d.ts.map +1 -1
  42. package/dist/eval-run-dispatcher.d.ts +38 -3
  43. package/dist/eval-run-dispatcher.d.ts.map +1 -1
  44. package/dist/eval-run-dispatcher.js +21 -15
  45. package/dist/eval-run-dispatcher.js.map +1 -1
  46. package/dist/eval-suite-binding.d.ts +1 -1
  47. package/dist/eval-suite-binding.d.ts.map +1 -1
  48. package/dist/eval-suite-binding.js +2 -0
  49. package/dist/eval-suite-binding.js.map +1 -1
  50. package/dist/flow-binding.d.ts +10 -4
  51. package/dist/flow-binding.d.ts.map +1 -1
  52. package/dist/flow-pins.d.ts +36 -0
  53. package/dist/flow-pins.d.ts.map +1 -0
  54. package/dist/flow-pins.js +81 -0
  55. package/dist/flow-pins.js.map +1 -0
  56. package/dist/index.d.ts +17 -6
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +8 -2
  59. package/dist/index.js.map +1 -1
  60. package/dist/judged-dispatcher.d.ts +134 -0
  61. package/dist/judged-dispatcher.d.ts.map +1 -0
  62. package/dist/judged-dispatcher.js +297 -0
  63. package/dist/judged-dispatcher.js.map +1 -0
  64. package/dist/judged-items.d.ts +86 -0
  65. package/dist/judged-items.d.ts.map +1 -0
  66. package/dist/judged-items.js +184 -0
  67. package/dist/judged-items.js.map +1 -0
  68. package/dist/judgment-binding.d.ts +316 -0
  69. package/dist/judgment-binding.d.ts.map +1 -0
  70. package/dist/judgment-binding.js +19 -0
  71. package/dist/judgment-binding.js.map +1 -0
  72. package/dist/openapi/generate.d.ts.map +1 -1
  73. package/dist/openapi/generate.js +4 -1
  74. package/dist/openapi/generate.js.map +1 -1
  75. package/dist/openapi/operations.d.ts.map +1 -1
  76. package/dist/openapi/operations.js +461 -7
  77. package/dist/openapi/operations.js.map +1 -1
  78. package/dist/openapi/schemas.d.ts +40 -0
  79. package/dist/openapi/schemas.d.ts.map +1 -1
  80. package/dist/openapi/schemas.js +843 -7
  81. package/dist/openapi/schemas.js.map +1 -1
  82. package/dist/provider-binding.d.ts +12 -7
  83. package/dist/provider-binding.d.ts.map +1 -1
  84. package/dist/routes/agents.d.ts +9 -1
  85. package/dist/routes/agents.d.ts.map +1 -1
  86. package/dist/routes/agents.js +175 -11
  87. package/dist/routes/agents.js.map +1 -1
  88. package/dist/routes/blocks.d.ts +19 -0
  89. package/dist/routes/blocks.d.ts.map +1 -0
  90. package/dist/routes/blocks.js +281 -0
  91. package/dist/routes/blocks.js.map +1 -0
  92. package/dist/routes/cost.d.ts.map +1 -1
  93. package/dist/routes/cost.js +47 -2
  94. package/dist/routes/cost.js.map +1 -1
  95. package/dist/routes/deployments.d.ts +3 -0
  96. package/dist/routes/deployments.d.ts.map +1 -1
  97. package/dist/routes/deployments.js +208 -55
  98. package/dist/routes/deployments.js.map +1 -1
  99. package/dist/routes/eval-comparison.d.ts +14 -0
  100. package/dist/routes/eval-comparison.d.ts.map +1 -0
  101. package/dist/routes/eval-comparison.js +87 -0
  102. package/dist/routes/eval-comparison.js.map +1 -0
  103. package/dist/routes/eval-runs.d.ts.map +1 -1
  104. package/dist/routes/eval-runs.js +8 -0
  105. package/dist/routes/eval-runs.js.map +1 -1
  106. package/dist/routes/flows.d.ts +13 -1
  107. package/dist/routes/flows.d.ts.map +1 -1
  108. package/dist/routes/flows.js +44 -3
  109. package/dist/routes/flows.js.map +1 -1
  110. package/dist/routes/hierarchy-errors.d.ts +35 -0
  111. package/dist/routes/hierarchy-errors.d.ts.map +1 -0
  112. package/dist/routes/hierarchy-errors.js +39 -0
  113. package/dist/routes/hierarchy-errors.js.map +1 -0
  114. package/dist/routes/judged-suites.d.ts +20 -0
  115. package/dist/routes/judged-suites.d.ts.map +1 -0
  116. package/dist/routes/judged-suites.js +272 -0
  117. package/dist/routes/judged-suites.js.map +1 -0
  118. package/dist/routes/judgment-context.d.ts +22 -0
  119. package/dist/routes/judgment-context.d.ts.map +1 -0
  120. package/dist/routes/judgment-context.js +88 -0
  121. package/dist/routes/judgment-context.js.map +1 -0
  122. package/dist/routes/judgment-flow-context.d.ts +32 -0
  123. package/dist/routes/judgment-flow-context.d.ts.map +1 -0
  124. package/dist/routes/judgment-flow-context.js +195 -0
  125. package/dist/routes/judgment-flow-context.js.map +1 -0
  126. package/dist/routes/judgments.d.ts +41 -0
  127. package/dist/routes/judgments.d.ts.map +1 -0
  128. package/dist/routes/judgments.js +566 -0
  129. package/dist/routes/judgments.js.map +1 -0
  130. package/dist/routes/orgs.d.ts +5 -2
  131. package/dist/routes/orgs.d.ts.map +1 -1
  132. package/dist/routes/orgs.js +38 -22
  133. package/dist/routes/orgs.js.map +1 -1
  134. package/dist/routes/policies.d.ts.map +1 -1
  135. package/dist/routes/policies.js +12 -1
  136. package/dist/routes/policies.js.map +1 -1
  137. package/dist/routes/projects.d.ts +10 -2
  138. package/dist/routes/projects.d.ts.map +1 -1
  139. package/dist/routes/projects.js +87 -79
  140. package/dist/routes/projects.js.map +1 -1
  141. package/dist/routes/providers.d.ts.map +1 -1
  142. package/dist/routes/providers.js +6 -1
  143. package/dist/routes/providers.js.map +1 -1
  144. package/dist/routes/runs.js +24 -3
  145. package/dist/routes/runs.js.map +1 -1
  146. package/dist/routes/teams.d.ts +6 -2
  147. package/dist/routes/teams.d.ts.map +1 -1
  148. package/dist/routes/teams.js +77 -73
  149. package/dist/routes/teams.js.map +1 -1
  150. package/openapi.json +13545 -10103
  151. package/package.json +21 -21
  152. package/src/agent-binding.ts +19 -4
  153. package/src/agent-pins.ts +147 -0
  154. package/src/app.ts +71 -2
  155. package/src/block-binding.ts +137 -0
  156. package/src/block-pins.ts +148 -0
  157. package/src/cost-binding.ts +21 -1
  158. package/src/deploy-versions.ts +157 -0
  159. package/src/deployment-binding.ts +27 -3
  160. package/src/derive-agent-version.ts +206 -0
  161. package/src/errors.ts +17 -0
  162. package/src/eval-case-binding.ts +71 -0
  163. package/src/eval-run-binding.ts +33 -0
  164. package/src/eval-run-dispatcher.ts +57 -16
  165. package/src/eval-suite-binding.ts +2 -0
  166. package/src/flow-binding.ts +11 -4
  167. package/src/flow-pins.ts +113 -0
  168. package/src/index.ts +85 -2
  169. package/src/judged-dispatcher.ts +507 -0
  170. package/src/judged-items.ts +263 -0
  171. package/src/judgment-binding.ts +349 -0
  172. package/src/openapi/generate.ts +7 -1
  173. package/src/openapi/operations.ts +523 -7
  174. package/src/openapi/schemas.ts +1009 -88
  175. package/src/provider-binding.ts +12 -7
  176. package/src/routes/agents.ts +243 -19
  177. package/src/routes/blocks.ts +362 -0
  178. package/src/routes/cost.ts +55 -1
  179. package/src/routes/deployments.ts +266 -56
  180. package/src/routes/eval-comparison.ts +101 -0
  181. package/src/routes/eval-runs.ts +11 -0
  182. package/src/routes/flows.ts +63 -5
  183. package/src/routes/hierarchy-errors.ts +51 -0
  184. package/src/routes/judged-suites.ts +363 -0
  185. package/src/routes/judgment-context.ts +128 -0
  186. package/src/routes/judgment-flow-context.ts +245 -0
  187. package/src/routes/judgments.ts +743 -0
  188. package/src/routes/orgs.ts +44 -27
  189. package/src/routes/policies.ts +19 -0
  190. package/src/routes/projects.ts +106 -95
  191. package/src/routes/providers.ts +5 -0
  192. package/src/routes/runs.ts +28 -3
  193. package/src/routes/teams.ts +96 -90
@@ -0,0 +1,362 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { type Context, Hono } from 'hono';
5
+
6
+ import {
7
+ BLOCK_KINDS,
8
+ type BlockDefinition,
9
+ type BlockKind,
10
+ settingsSchemaIssues,
11
+ validateBlock,
12
+ } from '@kindgi/agents';
13
+ import { ref } from '@kindgi/authz';
14
+ import type { Cursor, ProjectId, TenantId } from '@kindgi/types';
15
+
16
+ import type { BlockPublishOutcome, BlockRecord, BlockRegistryBinding } from '../block-binding.js';
17
+ import { statusFor, toWireError } from '../errors.js';
18
+ import type { Authorizer } from '../middleware/authorize.js';
19
+ import type { AppEnv } from '../types.js';
20
+ import { clampLimit } from './pagination.js';
21
+ import { parseScopeParams } from './scope-params.js';
22
+
23
+ /**
24
+ * Data blocks: versioned prompts and settings that agent versions pin
25
+ * (`/v1/blocks`).
26
+ *
27
+ * A block belongs to one project and is authorized through it: reading
28
+ * is `read` on the project, publishing, unregistering and reinstating
29
+ * are `write` on it. A block the caller can't read answers 404, as one
30
+ * that doesn't exist.
31
+ *
32
+ * A version never changes, nor does a block's kind. A settings block's
33
+ * values must satisfy the latest version's schema as well as their
34
+ * own, so whatever reads them sees a stable shape.
35
+ */
36
+ export function blocksRouter(binding: BlockRegistryBinding, authorizer?: Authorizer): Hono<AppEnv> {
37
+ const r = new Hono<AppEnv>();
38
+
39
+ async function canProject(
40
+ c: Context<AppEnv>,
41
+ action: 'read' | 'write',
42
+ projectId: ProjectId,
43
+ ): Promise<boolean> {
44
+ return authorizer === undefined || authorizer.can(c, action, ref('project', projectId));
45
+ }
46
+
47
+ function notFound(c: Context<AppEnv>, blockId: string, version?: string) {
48
+ c.status(statusFor('block-not-found') as never);
49
+ return c.json(
50
+ toWireError(
51
+ {
52
+ code: 'block-not-found',
53
+ message: `No block "${blockId}"${version !== undefined ? ` at version "${version}"` : ''}`,
54
+ blockId,
55
+ ...(version !== undefined && { version }),
56
+ },
57
+ c.get('requestId'),
58
+ ),
59
+ );
60
+ }
61
+
62
+ function badInput(c: Context<AppEnv>, message: string) {
63
+ c.status(statusFor('bad-input') as never);
64
+ return c.json(toWireError({ code: 'bad-input', message }, c.get('requestId')));
65
+ }
66
+
67
+ // ---------- GET / (each block's latest version) ----------
68
+ r.get('/', async (c) => {
69
+ const tenantId = c.get('tenantId') as TenantId;
70
+ const kind = c.req.query('kind');
71
+ if (kind !== undefined && kind.length > 0 && !isBlockKind(kind)) {
72
+ return badInput(
73
+ c,
74
+ `Unknown block kind "${kind}". Expected one of: ${BLOCK_KINDS.join(', ')}.`,
75
+ );
76
+ }
77
+ const scope = parseScopeParams(c.req.query(), { tenantId });
78
+ if (scope.kind === 'err') {
79
+ c.status(statusFor('scope-invalid') as never);
80
+ return c.json(
81
+ toWireError({ code: 'scope-invalid', message: scope.message }, c.get('requestId')),
82
+ );
83
+ }
84
+ const cursor = c.req.query('cursor');
85
+ const name = c.req.query('name');
86
+ const page = await binding.list({
87
+ tenantId,
88
+ limit: clampLimit(c.req.query('limit')),
89
+ ...(cursor !== undefined && cursor.length > 0 && { cursor: cursor as Cursor }),
90
+ ...(kind !== undefined && kind.length > 0 && { blockKind: kind as BlockKind }),
91
+ ...(name !== undefined && name.length > 0 && { nameFilter: name }),
92
+ ...(scope.scope !== undefined && { scope: scope.scope }),
93
+ });
94
+ const visible =
95
+ authorizer === undefined
96
+ ? page.data
97
+ : await authorizer.filterByCan(c, 'read', page.data, (b) => ref('project', b.projectId));
98
+ return c.json({
99
+ data: visible.map(serializeBlock),
100
+ hasMore: page.nextCursor !== undefined,
101
+ ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor as unknown as string }),
102
+ });
103
+ });
104
+
105
+ // ---------- GET /:blockId (latest version) ----------
106
+ r.get('/:blockId', async (c) => {
107
+ const blockId = c.req.param('blockId');
108
+ const block = await binding.get({ tenantId: c.get('tenantId') as TenantId, blockId });
109
+ if (block === null || !(await canProject(c, 'read', block.projectId))) {
110
+ return notFound(c, blockId);
111
+ }
112
+ return c.json(serializeBlock(block));
113
+ });
114
+
115
+ // ---------- GET /:blockId/versions ----------
116
+ r.get('/:blockId/versions', async (c) => {
117
+ const tenantId = c.get('tenantId') as TenantId;
118
+ const blockId = c.req.param('blockId');
119
+ const cursor = c.req.query('cursor');
120
+ const page = await binding.listVersions({
121
+ tenantId,
122
+ blockId,
123
+ limit: clampLimit(c.req.query('limit')),
124
+ includeTombstoned: c.req.query('includeTombstoned') === 'true',
125
+ ...(cursor !== undefined && cursor.length > 0 && { cursor: cursor as Cursor }),
126
+ });
127
+ const owner = page.data[0] ?? (await anyVersion(tenantId, blockId));
128
+ if (owner === null || !(await canProject(c, 'read', owner.projectId))) {
129
+ return notFound(c, blockId);
130
+ }
131
+ return c.json({
132
+ data: page.data.map(serializeBlock),
133
+ hasMore: page.nextCursor !== undefined,
134
+ ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor as unknown as string }),
135
+ });
136
+ });
137
+
138
+ // ---------- GET /:blockId/versions/:version ----------
139
+ r.get('/:blockId/versions/:version', async (c) => {
140
+ const blockId = c.req.param('blockId');
141
+ const version = c.req.param('version');
142
+ const block = await binding.getVersion({
143
+ tenantId: c.get('tenantId') as TenantId,
144
+ blockId,
145
+ version,
146
+ });
147
+ if (block === null || !(await canProject(c, 'read', block.projectId))) {
148
+ return notFound(c, blockId, version);
149
+ }
150
+ return c.json(serializeBlock(block));
151
+ });
152
+
153
+ // ---------- POST / (publish a version) ----------
154
+ r.post('/', async (c) => {
155
+ const requestId = c.get('requestId');
156
+ const tenantId = c.get('tenantId') as TenantId;
157
+ let body: unknown;
158
+ try {
159
+ body = await c.req.json();
160
+ } catch {
161
+ return badInput(c, 'Request body must be valid JSON');
162
+ }
163
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
164
+ return badInput(c, 'Request body must be an object');
165
+ }
166
+ const { projectId, ...definition } = body as Record<string, unknown>;
167
+ if (typeof projectId !== 'string' || projectId.length === 0) {
168
+ return badInput(c, '`projectId` is required');
169
+ }
170
+ if (!(await canProject(c, 'write', projectId as ProjectId))) {
171
+ c.status(statusFor('permission-denied') as never);
172
+ return c.json(
173
+ toWireError(
174
+ {
175
+ code: 'permission-denied',
176
+ message: `Publishing a block needs write on project "${projectId}"`,
177
+ },
178
+ requestId,
179
+ ),
180
+ );
181
+ }
182
+ const validated = validateBlock(definition);
183
+ if (validated.kind === 'err')
184
+ return validationFailed(c, validated.error.message, validated.error.issues);
185
+ const block = validated.value;
186
+
187
+ // A block keeps its kind, and a settings block's latest schema holds.
188
+ const latest = await binding.get({ tenantId, blockId: block.id });
189
+ const continuity = continuityIssues(latest, block);
190
+ if (continuity.length > 0) {
191
+ return validationFailed(c, `Block "${block.id}" can't take this version`, continuity);
192
+ }
193
+
194
+ const outcome = await binding.publish({ tenantId, projectId: projectId as ProjectId, block });
195
+ return published(c, outcome);
196
+ });
197
+
198
+ // ---------- POST /:blockId/versions/:version/unregister ----------
199
+ r.post('/:blockId/versions/:version/unregister', async (c) => {
200
+ const tenantId = c.get('tenantId') as TenantId;
201
+ const blockId = c.req.param('blockId');
202
+ const version = c.req.param('version');
203
+ const block = await binding.getVersion({ tenantId, blockId, version });
204
+ if (block === null || !(await canProject(c, 'read', block.projectId))) {
205
+ return notFound(c, blockId, version);
206
+ }
207
+ if (!(await canProject(c, 'write', block.projectId))) return denied(c, block.projectId);
208
+ const outcome = await binding.unregister({ tenantId, blockId, version });
209
+ if (!outcome.unregistered) return notFound(c, blockId, version);
210
+ return c.json({ blockId, version, unregistered: true });
211
+ });
212
+
213
+ // ---------- POST /:blockId/versions/:version/reinstate ----------
214
+ r.post('/:blockId/versions/:version/reinstate', async (c) => {
215
+ const tenantId = c.get('tenantId') as TenantId;
216
+ const blockId = c.req.param('blockId');
217
+ const version = c.req.param('version');
218
+ const block = await binding.getVersion({ tenantId, blockId, version });
219
+ if (block === null || !(await canProject(c, 'read', block.projectId))) {
220
+ return notFound(c, blockId, version);
221
+ }
222
+ if (!(await canProject(c, 'write', block.projectId))) return denied(c, block.projectId);
223
+ const outcome = await binding.reinstateVersion({ tenantId, blockId, version });
224
+ if (outcome.kind === 'not-found') return notFound(c, blockId, version);
225
+ return c.json({ blockId, version, wasTombstoned: outcome.wasTombstoned });
226
+ });
227
+
228
+ /** Any version of a block, unregistered ones included: whose project it is. */
229
+ async function anyVersion(tenantId: TenantId, blockId: string): Promise<BlockRecord | null> {
230
+ const page = await binding.listVersions({
231
+ tenantId,
232
+ blockId,
233
+ limit: 1,
234
+ includeTombstoned: true,
235
+ });
236
+ return page.data[0] ?? null;
237
+ }
238
+
239
+ return r;
240
+ }
241
+
242
+ function isBlockKind(value: string): value is BlockKind {
243
+ return (BLOCK_KINDS as readonly string[]).includes(value);
244
+ }
245
+
246
+ /** A new version against the block's latest: same kind, and a settings block's schema holds. */
247
+ function continuityIssues(
248
+ latest: BlockRecord | null,
249
+ block: BlockDefinition,
250
+ ): { path: string; message: string }[] {
251
+ if (latest === null) return [];
252
+ if (latest.kind !== block.kind) {
253
+ return [
254
+ {
255
+ path: '/kind',
256
+ message: `block "${block.id}" is a ${latest.kind} block; a version can't change its kind`,
257
+ },
258
+ ];
259
+ }
260
+ if (
261
+ latest.kind === 'settings' &&
262
+ block.kind === 'settings' &&
263
+ latest.content.schema !== undefined
264
+ ) {
265
+ return settingsSchemaIssues(block.content.values, latest.content.schema).map((i) => ({
266
+ ...i,
267
+ message: `${i.message} (the schema of version ${latest.version})`,
268
+ }));
269
+ }
270
+ return [];
271
+ }
272
+
273
+ /** The response to a publish outcome. */
274
+ function published(c: Context<AppEnv>, outcome: BlockPublishOutcome) {
275
+ const requestId = c.get('requestId');
276
+ switch (outcome.kind) {
277
+ case 'ok':
278
+ c.status(201);
279
+ return c.json({ blockId: outcome.blockId, version: outcome.version });
280
+ case 'already-registered':
281
+ c.status(statusFor('block-already-registered') as never);
282
+ return c.json(
283
+ toWireError(
284
+ {
285
+ code: 'block-already-registered',
286
+ message: `Block "${outcome.blockId}" version "${outcome.version}" is already published; versions never change, so publish a new one`,
287
+ blockId: outcome.blockId,
288
+ version: outcome.version,
289
+ },
290
+ requestId,
291
+ ),
292
+ );
293
+ case 'project-mismatch':
294
+ c.status(statusFor('block-project-mismatch') as never);
295
+ return c.json(
296
+ toWireError(
297
+ {
298
+ code: 'block-project-mismatch',
299
+ message: `Block "${outcome.blockId}" belongs to project "${outcome.projectId as unknown as string}"; publish its versions there`,
300
+ blockId: outcome.blockId,
301
+ projectId: outcome.projectId as unknown as string,
302
+ },
303
+ requestId,
304
+ ),
305
+ );
306
+ case 'project-not-found':
307
+ c.status(statusFor('bad-input') as never);
308
+ return c.json(
309
+ toWireError(
310
+ {
311
+ code: 'bad-input',
312
+ message: `\`projectId\` "${outcome.projectId as unknown as string}" does not resolve to a project in this tenant`,
313
+ },
314
+ requestId,
315
+ ),
316
+ );
317
+ }
318
+ }
319
+
320
+ function validationFailed(
321
+ c: Context<AppEnv>,
322
+ message: string,
323
+ issues: readonly { readonly path: string; readonly message: string }[],
324
+ ) {
325
+ c.status(statusFor('validation-failed') as never);
326
+ return c.json(
327
+ toWireError(
328
+ {
329
+ code: 'validation-failed',
330
+ message,
331
+ issues: issues as unknown as Record<string, unknown>[],
332
+ },
333
+ c.get('requestId'),
334
+ ),
335
+ );
336
+ }
337
+
338
+ function denied(c: Context<AppEnv>, projectId: ProjectId) {
339
+ c.status(statusFor('permission-denied') as never);
340
+ return c.json(
341
+ toWireError(
342
+ {
343
+ code: 'permission-denied',
344
+ message: `This needs write on the block's project "${projectId as unknown as string}"`,
345
+ },
346
+ c.get('requestId'),
347
+ ),
348
+ );
349
+ }
350
+
351
+ function serializeBlock(b: BlockRecord): Record<string, unknown> {
352
+ return {
353
+ id: b.id,
354
+ version: b.version,
355
+ kind: b.kind,
356
+ ...(b.description !== undefined && { description: b.description }),
357
+ content: b.content,
358
+ projectId: b.projectId as unknown as string,
359
+ publishedAt: b.publishedAt,
360
+ ...(b.unregisteredAt !== undefined && { unregisteredAt: b.unregisteredAt }),
361
+ };
362
+ }
@@ -6,6 +6,8 @@ import { Hono } from 'hono';
6
6
  import type { Cursor, TenantId, Timestamp } from '@kindgi/types';
7
7
 
8
8
  import {
9
+ COST_AGGREGATE_DEFAULT_LIMIT,
10
+ COST_AGGREGATE_MAX_LIMIT,
9
11
  COST_GROUP_DIMENSIONS,
10
12
  type CostAggregateGroup,
11
13
  type CostBinding,
@@ -169,6 +171,21 @@ export function costRouter(binding: CostBinding): Hono<AppEnv> {
169
171
  }
170
172
  }
171
173
 
174
+ // limit — the most groups to return, the most expensive first.
175
+ const limit = parseAggregateLimit(query.limit);
176
+ if (limit === null) {
177
+ c.status(statusFor('bad-input') as never);
178
+ return c.json(
179
+ toWireError(
180
+ {
181
+ code: 'bad-input',
182
+ message: `\`limit\` must be an integer from 1 to ${COST_AGGREGATE_MAX_LIMIT} (default ${COST_AGGREGATE_DEFAULT_LIMIT})`,
183
+ },
184
+ requestId,
185
+ ),
186
+ );
187
+ }
188
+
172
189
  // Time range — required. Default to last 30 days when both absent
173
190
  // (documented in the response `timeRange`). If only one endpoint is
174
191
  // supplied, reject — half-open defaults invite confusion.
@@ -240,10 +257,18 @@ export function costRouter(binding: CostBinding): Hono<AppEnv> {
240
257
  ...(Object.keys(filter.value).length > 0 && { filter: filter.value }),
241
258
  ...(scopeParsed.scope !== undefined && { scope: scopeParsed.scope }),
242
259
  ...(scopeParsed.inherit !== undefined && { inherit: scopeParsed.inherit }),
260
+ limit,
243
261
  });
244
262
 
263
+ // The most expensive groups, capped here too: a binding may return
264
+ // every group. The window's totals stay over every record.
265
+ const groups = [...result.groups].sort((a, b) => compareGroups(a, b, groupBy)).slice(0, limit);
266
+ const totalGroups = Math.max(result.totalGroups ?? 0, result.groups.length);
267
+
245
268
  return c.json({
246
- groups: result.groups.map(serializeGroup),
269
+ groups: groups.map(serializeGroup),
270
+ totalGroups,
271
+ truncated: totalGroups > groups.length,
247
272
  totalUsd: result.totalUsd,
248
273
  totalRecords: result.totalRecords,
249
274
  tokens: serializeTokens(result.tokens),
@@ -388,6 +413,35 @@ function pick(rec: CostRecord, keys: readonly (keyof CostRecord)[]): Record<stri
388
413
  return out;
389
414
  }
390
415
 
416
+ /** `?limit=` on the aggregate: the default when absent, `null` when it isn't 1..max. */
417
+ function parseAggregateLimit(raw: string | undefined): number | null {
418
+ if (raw === undefined) return COST_AGGREGATE_DEFAULT_LIMIT;
419
+ if (!/^\d+$/.test(raw)) return null;
420
+ const n = Number(raw);
421
+ return n >= 1 && n <= COST_AGGREGATE_MAX_LIMIT ? n : null;
422
+ }
423
+
424
+ /**
425
+ * The aggregate's order: `totalUsd` descending, then the key, dimension
426
+ * by dimension in `groupBy` order (code-point order; `null` last).
427
+ */
428
+ function compareGroups(
429
+ a: CostAggregateGroup,
430
+ b: CostAggregateGroup,
431
+ groupBy: readonly CostGroupDimension[],
432
+ ): number {
433
+ if (a.totalUsd !== b.totalUsd) return b.totalUsd - a.totalUsd;
434
+ for (const dim of groupBy) {
435
+ const x = a.key[dim] ?? null;
436
+ const y = b.key[dim] ?? null;
437
+ if (x === y) continue;
438
+ if (x === null) return 1;
439
+ if (y === null) return -1;
440
+ return x < y ? -1 : 1;
441
+ }
442
+ return 0;
443
+ }
444
+
391
445
  function serializeGroup(g: CostAggregateGroup): Record<string, unknown> {
392
446
  return {
393
447
  key: g.key,