@capybara-hq/kernel 0.1.0-rc.5 → 0.1.0-rc.6

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 (63) hide show
  1. package/dist/admin/admin.controller.d.ts +13 -2
  2. package/dist/admin/admin.controller.js +29 -4
  3. package/dist/agents/agents.service.d.ts +3 -1
  4. package/dist/agents/agents.service.js +8 -6
  5. package/dist/agents/from-template.service.js +3 -3
  6. package/dist/agents/models.controller.d.ts +34 -5
  7. package/dist/agents/models.controller.js +50 -6
  8. package/dist/agents/readiness.controller.d.ts +5 -5
  9. package/dist/agents/readiness.controller.js +6 -6
  10. package/dist/catalog/marketplaces.service.d.ts +1 -1
  11. package/dist/config/schema.d.ts +4 -0
  12. package/dist/config/schema.js +24 -0
  13. package/dist/conversations/conversations.service.d.ts +4 -3
  14. package/dist/conversations/conversations.service.js +9 -8
  15. package/dist/ctx/create-ctx.d.ts +15 -1
  16. package/dist/ctx/create-ctx.js +23 -2
  17. package/dist/db/kernel-schema.d.ts +24 -0
  18. package/dist/errors.d.ts +3 -0
  19. package/dist/errors.js +3 -0
  20. package/dist/evals/run-replay.js +4 -2
  21. package/dist/evals/runs.service.d.ts +2 -1
  22. package/dist/evals/runs.service.js +13 -12
  23. package/dist/evals/step-replay.d.ts +2 -1
  24. package/dist/evals/step-replay.js +3 -2
  25. package/dist/extensions/config-hash.d.ts +10 -0
  26. package/dist/extensions/config-hash.js +38 -0
  27. package/dist/extensions/extensions.module.js +6 -12
  28. package/dist/extensions/host.d.ts +6 -10
  29. package/dist/extensions/host.js +22 -50
  30. package/dist/extensions/state.module.d.ts +9 -0
  31. package/dist/extensions/state.module.js +38 -0
  32. package/dist/http/api.module.js +2 -0
  33. package/dist/http/plugin-dispatch.d.ts +4 -1
  34. package/dist/http/plugin-dispatch.js +14 -2
  35. package/dist/infra/config/kernel-config.service.js +3 -0
  36. package/dist/infra/events/kernel-events.d.ts +8 -0
  37. package/dist/models/embeddings.d.ts +82 -0
  38. package/dist/models/embeddings.js +326 -0
  39. package/dist/models/enablement.controller.d.ts +40 -0
  40. package/dist/models/enablement.controller.js +74 -0
  41. package/dist/models/enablement.d.ts +99 -0
  42. package/dist/models/enablement.js +263 -0
  43. package/dist/models/models.module.d.ts +5 -1
  44. package/dist/models/models.module.js +16 -4
  45. package/dist/models/purposes.d.ts +18 -4
  46. package/dist/models/purposes.js +46 -9
  47. package/dist/platform/exclusions.js +18 -0
  48. package/dist/runtime/hook-calls.d.ts +8 -1
  49. package/dist/runtime/hook-calls.js +40 -9
  50. package/dist/runtime/model-call.d.ts +8 -3
  51. package/dist/runtime/model-call.js +28 -5
  52. package/dist/runtime/providers.d.ts +146 -32
  53. package/dist/runtime/providers.js +321 -67
  54. package/dist/runtime/run-worker.d.ts +1 -1
  55. package/dist/runtime/run-worker.js +4 -4
  56. package/dist/runtime/subagents/definition.js +3 -3
  57. package/dist/runtime/usage-report-jobs.d.ts +88 -0
  58. package/dist/runtime/usage-report-jobs.js +158 -0
  59. package/dist/runtime/usage-report.d.ts +33 -38
  60. package/dist/runtime/usage-report.js +152 -84
  61. package/dist/tools/pipeline.js +6 -0
  62. package/package.json +3 -3
  63. package/src/migrations/0089_model_kinds_usage.sql +38 -0
@@ -9,9 +9,8 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
9
9
  };
10
10
  import { getQueueToken } from "@nestjs/bullmq";
11
11
  import { Inject, Injectable, Module, Optional } from "@nestjs/common";
12
- import { DelayedError, Job, UnrecoverableError, } from "bullmq";
12
+ import { DelayedError, Job, UnrecoverableError } from "bullmq";
13
13
  import { sql } from "kysely";
14
- import { enabledExtensionsQuery } from "../extensions/enabled.js";
15
14
  import { ExtensionsModule } from "../extensions/extensions.module.js";
16
15
  import { deepFreeze, errMsg, HookNeedsConfigError, } from "../extensions/hooks.js";
17
16
  import { ExtensionHost } from "../extensions/host.js";
@@ -19,31 +18,13 @@ import { STALE_RECONCILE_PERIODS } from "../extensions/lifecycle-topics.js";
19
18
  import { KernelConfigService } from "../infra/config/kernel-config.service.js";
20
19
  import { KernelDb } from "../infra/database/kernel-db.js";
21
20
  import { LockService } from "../infra/lock/lock.service.js";
22
- import { QueueModule } from "../infra/queue/queue.module.js";
23
21
  import { LockedCron } from "../infra/schedule/locked-cron.decorator.js";
24
22
  import { KERNEL_LOGGER } from "../infra/tokens.js";
25
23
  import { deliveryRowOfChildRun } from "./subagents/child.js";
26
- /** Queue of `usage.report` deliveries (no `:` in the name). */
27
- export const USAGE_REPORT_QUEUE = "cap-kernel-usage-report";
28
- /** One job per `usage` event (no `:`: conversation ids are UUIDs, seq is a number). */
29
- export const usageReportJobId = (conversationId, seq) => `usage-${conversationId}-${seq}`;
30
- /** First attempt plus 5 retries (F11). */
31
- export const USAGE_REPORT_ATTEMPTS = 6;
32
- /** Retry backoff: 2 s, doubling (2, 4, 8, 16, 32 s). */
33
- export const USAGE_REPORT_BACKOFF_MS = 2000;
34
- /** Optional provider: the {@link UsageReportRetry} instead of the defaults above (tests). */
35
- export const USAGE_REPORT_RETRY = Symbol("USAGE_REPORT_RETRY");
36
- /** Failed jobs (given up) are kept for inspection, at most {@link KEEP_FAILED_MAX} of them. */
37
- const KEEP_FAILED_S = 7 * 86_400;
38
- /** Upper bound on kept failed jobs (I-2): a long outage of a billing endpoint cannot grow Redis. */
39
- const KEEP_FAILED_MAX = 100_000;
24
+ import { modelUsageJobId, queryUsageSubscribers, USAGE_REPORT_ATTEMPTS, USAGE_REPORT_BACKOFF_MS, USAGE_REPORT_JOB, USAGE_REPORT_QUEUE, USAGE_REPORT_RETRY, UsageReportJobsModule, usageReportJobId, usageReportJobOpts, } from "./usage-report-jobs.js";
25
+ export { ModelUsageJobs, modelUsageJobId, queryUsageSubscribers, USAGE_REPORT_ATTEMPTS, USAGE_REPORT_BACKOFF_MS, USAGE_REPORT_QUEUE, USAGE_REPORT_RETRY, UsageReportJobsModule, usageReportJobId, } from "./usage-report-jobs.js";
40
26
  /** Warnings about a plugin that cannot take deliveries are logged at most once per this window. */
41
27
  const WARN_WINDOW_MS = 60_000;
42
- /**
43
- * Upper bound on kept finished jobs (Redis memory). At the default retention it only bites above
44
- * ~1.1 usage events per second sustained for a day; dedupe needs the jobs only for the sweeper overlap.
45
- */
46
- const KEEP_COMPLETED_MAX = 100_000;
47
28
  /** The sweeper's cron job name (no `:`; every minute, once across replicas). */
48
29
  export const USAGE_SWEEP_JOB = "usage-report-sweep";
49
30
  /** The maintenance scheduler the sweep ran on before it became a `@LockedCron` (retired). */
@@ -57,38 +38,6 @@ export const USAGE_SWEEP_LAG_MS = 30_000;
57
38
  */
58
39
  export const USAGE_SWEEP_OVERLAP_MS = 10 * 60_000;
59
40
  const SWEEP_PAGE = 500;
60
- const JOB = "usage-report";
61
- /**
62
- * Plugins that subscribe to `usage.report` according to deployment state: an approved permission
63
- * snapshot that declares the `usage.report` hook, for a plugin the deployment still knows: an enabled
64
- * install, or a config-declared plugin with a replica status row that is not `unloaded`. With `orgId`, only plugins
65
- * the organization enabled (org config rows and required bootstrap plugins).
66
- */
67
- export async function queryUsageSubscribers(db, orgId) {
68
- let q = db
69
- .selectFrom("extension_permission_snapshots as s")
70
- .select("s.plugin")
71
- .where(sql `s.permissions -> 'permissions' -> 'hooks' ? 'usage.report'`)
72
- .where((eb) => eb.or([
73
- eb.exists(eb
74
- .selectFrom("extension_installs as i")
75
- .select(sql `1`.as("one"))
76
- .whereRef("i.name", "=", "s.plugin")
77
- .where("i.desired_state", "=", "enabled")),
78
- // config-declared plugins still declared (and not unloaded) on some replica; installs are
79
- // covered by desired_state above (their replica rows linger as `unloaded` when disabled)
80
- eb.exists(eb
81
- .selectFrom("extension_replica_status as r")
82
- .select(sql `1`.as("one"))
83
- .whereRef("r.plugin", "=", "s.plugin")
84
- .where("r.source", "=", "config")
85
- .where("r.state", "<>", "unloaded")),
86
- ]));
87
- if (orgId !== undefined) {
88
- q = q.where("s.plugin", "in", enabledExtensionsQuery(db, orgId));
89
- }
90
- return new Set((await q.execute()).map((r) => r.plugin));
91
- }
92
41
  /**
93
42
  * Of `plugins`, those that are **definitely** unservable right now (I-2, N-3): live replicas report
94
43
  * them (a `extension_replica_status` row with a heartbeat newer than `staleSeconds`), no live row is
@@ -128,6 +77,10 @@ class Undeliverable extends Error {
128
77
  this.name = "Undeliverable";
129
78
  }
130
79
  }
80
+ /** A job's identity: its `usage` event or its `model_usage` row. */
81
+ const keyOf = (d) => d.usageId !== undefined
82
+ ? { usageId: d.usageId }
83
+ : { conversationId: d.conversationId, seq: d.seq };
131
84
  /**
132
85
  * The next `pending` set (m-2 fence): plugins that failed now, narrowed to what the stored job data
133
86
  * still lists. A plugin another (stalled-over) execution already delivered never comes back.
@@ -159,11 +112,18 @@ export function narrowPending(stored, failed) {
159
112
  * most 100 000. Each call runs through the instance's `inflight.run`, limited by `hooks.timeoutMs`;
160
113
  * when this replica stops, in-flight calls are aborted and the job goes back to the queue without
161
114
  * using up an attempt.
162
- * Handlers must be idempotent by `eventId` (`<conversationId>/<seq>`). Nothing is written to the event
163
- * log (never model-visible). Compaction calls log usage too (`purpose: "compaction"`) and are reported,
115
+ * Handlers must be idempotent by `eventId` (`<conversationId>/<seq>`, or a `model_usage` row id).
116
+ * Nothing is written to the event log (never model-visible). Compaction calls log usage too (`purpose: "compaction"`) and are reported,
164
117
  * as are conversation title calls (`purpose: "title"`, no run: `runId` is "") and `WebFetch`
165
118
  * extraction calls (`purpose: "web_fetch"`).
166
119
  * Single-step eval replays and judge requests log no usage: not reported (M7).
120
+ * - **Model usage outside conversations** (spec B §5.4): each `ctx.models.embed` call writes a
121
+ * `model_usage` row; on its `model_usage.recorded` event `ModelUsageJobs` (`usage-report-jobs.ts`)
122
+ * enqueues job `usage-model-<rowId>` (only while some plugin subscribes); the payload
123
+ * (`purpose: "embedding"`, `eventId` = the row id, the calling `extension`, no conversation fields)
124
+ * is read from the row when the job runs, and the row's `reported_at` is set once nothing is left
125
+ * to deliver; a job that gives up after its last attempt sets `report_failed_at`. The sweep
126
+ * re-enqueues recent rows that are neither (see {@link sweep}).
167
127
  */
168
128
  let UsageReporter = class UsageReporter {
169
129
  kdb;
@@ -226,17 +186,8 @@ let UsageReporter = class UsageReporter {
226
186
  subscribers(orgId) {
227
187
  return queryUsageSubscribers(this.db, orgId);
228
188
  }
229
- jobOpts(conversationId, seq) {
230
- return {
231
- jobId: usageReportJobId(conversationId, seq),
232
- attempts: this.retry.attempts,
233
- backoff: { type: "exponential", delay: this.retry.backoffMs },
234
- removeOnComplete: {
235
- age: this.config.get().hooks.usageReportKeepHours * 3600,
236
- count: KEEP_COMPLETED_MAX,
237
- },
238
- removeOnFail: { age: KEEP_FAILED_S, count: KEEP_FAILED_MAX },
239
- };
189
+ jobOpts(jobId) {
190
+ return usageReportJobOpts(jobId, this.retry, this.config.get().hooks.usageReportKeepHours);
240
191
  }
241
192
  /**
242
193
  * Enqueues the delivery of one `usage` event. Returns without a job when no plugin subscribes.
@@ -252,7 +203,7 @@ let UsageReporter = class UsageReporter {
252
203
  try {
253
204
  if ((await this.subscribers()).size === 0)
254
205
  return;
255
- await this.queue.add(JOB, { conversationId, seq }, this.jobOpts(conversationId, seq));
206
+ await this.queue.add(USAGE_REPORT_JOB, { conversationId, seq }, this.jobOpts(usageReportJobId(conversationId, seq)));
256
207
  }
257
208
  catch (err) {
258
209
  this.log.warn("cannot enqueue usage report", { conversationId, seq, err });
@@ -261,7 +212,10 @@ let UsageReporter = class UsageReporter {
261
212
  /**
262
213
  * One sweep (R13): re-enqueues the `usage` events created in `[watermark - overlap, now - lag]` in
263
214
  * pages, then advances the watermark to `now - lag`. With no subscriber it only advances. The first
264
- * sweep starts at `now - lag - overlap`. Returns the number of events offered to the queue.
215
+ * sweep starts at `now - lag - overlap`. Also re-enqueues the `model_usage` rows of the same
216
+ * window that are neither reported nor given up (spec B §5.4),
217
+ * or, with no subscriber, marks those rows reported (nobody to deliver to). Returns the number of
218
+ * events and rows offered to the queue.
265
219
  */
266
220
  async sweep(opts = {}) {
267
221
  // bounds and keyset as full-precision timestamp text (microseconds), computed with the DB clock
@@ -278,7 +232,8 @@ let UsageReporter = class UsageReporter {
278
232
  .executeTakeFirst();
279
233
  const watermark = row?.watermark ?? to;
280
234
  let offered = 0;
281
- if ((await this.subscribers()).size > 0) {
235
+ const subscribed = (await this.subscribers()).size > 0;
236
+ if (subscribed) {
282
237
  let after;
283
238
  for (;;) {
284
239
  let q = this.db
@@ -298,9 +253,9 @@ let UsageReporter = class UsageReporter {
298
253
  .execute();
299
254
  if (page.length > 0) {
300
255
  await this.queue.addBulk(page.map((r) => ({
301
- name: JOB,
256
+ name: USAGE_REPORT_JOB,
302
257
  data: { conversationId: r.conversation_id, seq: Number(r.seq) },
303
- opts: this.jobOpts(r.conversation_id, Number(r.seq)),
258
+ opts: this.jobOpts(usageReportJobId(r.conversation_id, Number(r.seq))),
304
259
  })));
305
260
  offered += page.length;
306
261
  }
@@ -310,6 +265,7 @@ let UsageReporter = class UsageReporter {
310
265
  after = { at: last.at, conv: last.conversation_id, seq: String(last.seq) };
311
266
  }
312
267
  }
268
+ offered += await this.sweepModelUsage(watermark, to, subscribed);
313
269
  await this.db
314
270
  .insertInto("usage_report_sweep")
315
271
  .values({ id: 1, watermark: sql `${to}::timestamptz` })
@@ -320,6 +276,51 @@ let UsageReporter = class UsageReporter {
320
276
  .execute();
321
277
  return offered;
322
278
  }
279
+ /**
280
+ * The `model_usage` half of {@link sweep}: rows neither reported nor given up that occurred in
281
+ * the same window as the events (`[watermark - overlap, to]`), oldest first. Bounded like the
282
+ * event sweep, so a row whose job failed for good is never offered again, even after its failed
283
+ * job left Redis (no repeated backlog, no second delivery to plugins already served).
284
+ */
285
+ async sweepModelUsage(watermark, to, subscribed) {
286
+ if (!subscribed) {
287
+ await this.db
288
+ .updateTable("model_usage")
289
+ .set({ reported_at: sql `now()` })
290
+ .where("reported_at", "is", null)
291
+ .where(sql `occurred_at <= ${to}::timestamptz`)
292
+ .execute();
293
+ return 0;
294
+ }
295
+ let offered = 0;
296
+ let after;
297
+ for (;;) {
298
+ let q = this.db
299
+ .selectFrom("model_usage")
300
+ .select(["id", sql `occurred_at::text`.as("at")])
301
+ .where("reported_at", "is", null)
302
+ .where("report_failed_at", "is", null)
303
+ .where(sql `occurred_at >= ${watermark}::timestamptz - make_interval(secs => ${USAGE_SWEEP_OVERLAP_MS / 1000})`)
304
+ .where(sql `occurred_at <= ${to}::timestamptz`);
305
+ if (after) {
306
+ q = q.where(sql `(occurred_at, id) > (${after.at}::timestamptz, ${after.id}::uuid)`);
307
+ }
308
+ const page = await q.orderBy("occurred_at").orderBy("id").limit(SWEEP_PAGE).execute();
309
+ if (page.length > 0) {
310
+ await this.queue.addBulk(page.map((r) => ({
311
+ name: USAGE_REPORT_JOB,
312
+ data: { usageId: r.id },
313
+ opts: this.jobOpts(modelUsageJobId(r.id)),
314
+ })));
315
+ offered += page.length;
316
+ }
317
+ const last = page.at(-1);
318
+ if (!last || page.length < SWEEP_PAGE)
319
+ break;
320
+ after = { at: last.at, id: last.id };
321
+ }
322
+ return offered;
323
+ }
323
324
  /**
324
325
  * Starts consuming (worker role only): `worker` is the `@Processor` worker of
325
326
  * {@link USAGE_REPORT_QUEUE} (built with the run queue's job lock and stalled detection).
@@ -344,14 +345,17 @@ let UsageReporter = class UsageReporter {
344
345
  const final = j.attemptsMade >= (j.opts.attempts ?? 1);
345
346
  const fields = {
346
347
  jobId: j.id,
347
- conversationId: j.data.conversationId,
348
- seq: j.data.seq,
348
+ ...(j.data.usageId !== undefined
349
+ ? { usageId: j.data.usageId }
350
+ : { conversationId: j.data.conversationId, seq: j.data.seq }),
349
351
  pending: j.data.pending,
350
352
  attempt: j.attemptsMade,
351
353
  error: err.message,
352
354
  };
353
- if (final)
355
+ if (final) {
354
356
  this.log.error("usage report given up after retries", fields);
357
+ void this.markFailed(j.data);
358
+ }
355
359
  else
356
360
  this.log.warn("usage report attempt failed; retrying", fields);
357
361
  }
@@ -366,6 +370,67 @@ let UsageReporter = class UsageReporter {
366
370
  this.worker = undefined;
367
371
  await w?.close().catch(() => { });
368
372
  }
373
+ /** The payload of a job: a `usage` event's or a `model_usage` row's; null when it is gone. */
374
+ payloadOfJob(data) {
375
+ return data.usageId !== undefined
376
+ ? this.payloadOfUsage(data.usageId)
377
+ : this.payloadOf(data.conversationId, data.seq);
378
+ }
379
+ /**
380
+ * The payload of one `model_usage` row (spec B §5.4): purpose `embedding`, the calling extension,
381
+ * no conversation fields; `eventId` is the row id. Null when the row no longer exists.
382
+ */
383
+ async payloadOfUsage(id) {
384
+ const row = await this.db
385
+ .selectFrom("model_usage")
386
+ .select(["id", "org_id", "extension", "provider", "model", "input_tokens", "occurred_at"])
387
+ .where("id", "=", id)
388
+ .executeTakeFirst();
389
+ if (!row)
390
+ return null;
391
+ return {
392
+ eventId: row.id,
393
+ purpose: "embedding",
394
+ orgId: row.org_id,
395
+ extension: row.extension,
396
+ provider: row.provider,
397
+ model: row.model,
398
+ usage: {
399
+ inputTokens: row.input_tokens,
400
+ outputTokens: 0,
401
+ cacheReadTokens: 0,
402
+ cacheWriteTokens: 0,
403
+ },
404
+ occurredAt: new Date(row.occurred_at).toISOString(),
405
+ };
406
+ }
407
+ /** A `model_usage` job is done (delivered, or nothing left to deliver to): marks the row. */
408
+ async markReported(data) {
409
+ if (data.usageId === undefined)
410
+ return;
411
+ await this.db
412
+ .updateTable("model_usage")
413
+ .set({ reported_at: sql `now()` })
414
+ .where("id", "=", data.usageId)
415
+ .where("reported_at", "is", null)
416
+ .execute();
417
+ }
418
+ /** A `model_usage` job gave up after its last attempt: the sweep never offers the row again. */
419
+ async markFailed(data) {
420
+ if (data.usageId === undefined)
421
+ return;
422
+ try {
423
+ await this.db
424
+ .updateTable("model_usage")
425
+ .set({ report_failed_at: sql `now()` })
426
+ .where("id", "=", data.usageId)
427
+ .where("reported_at", "is", null)
428
+ .execute();
429
+ }
430
+ catch (err) {
431
+ this.log.warn("cannot mark a model usage report as given up", { usageId: data.usageId, err });
432
+ }
433
+ }
369
434
  /** The payload of one usage event, read from the log; null when the event no longer exists. */
370
435
  async payloadOf(conversationId, seq) {
371
436
  const row = await this.db
@@ -435,8 +500,7 @@ let UsageReporter = class UsageReporter {
435
500
  const allowed = stored === undefined ? undefined : [...stored, ...Object.keys(storedDropped)];
436
501
  const keep = [...dropped].filter(([p]) => allowed === undefined || allowed.includes(p));
437
502
  const out = {
438
- conversationId: j.data.conversationId,
439
- seq: j.data.seq,
503
+ ...keyOf(j.data),
440
504
  pending: narrowPending(allowed, next),
441
505
  ...(keep.length > 0 && { dropped: Object.fromEntries(keep) }),
442
506
  };
@@ -445,22 +509,24 @@ let UsageReporter = class UsageReporter {
445
509
  }
446
510
  /** One delivery job (the `@Processor` of {@link USAGE_REPORT_QUEUE} calls it). */
447
511
  async process(j, token) {
448
- const { conversationId, seq } = j.data;
449
- const payload = await this.payloadOf(conversationId, seq);
512
+ const payload = await this.payloadOfJob(j.data);
513
+ // conversation (or org) deleted meanwhile: nothing to report
450
514
  if (payload === null)
451
- return; // conversation deleted meanwhile: nothing to report
515
+ return;
452
516
  const subscribers = await this.subscribers(payload.orgId);
453
517
  if (j.data.pending === undefined) {
454
518
  // first attempt: the targets come from deployment state, recorded so later attempts (on any
455
519
  // replica) deliver exactly the plugins that have not succeeded yet
456
520
  const targets = [...subscribers];
457
- await j.updateData({ conversationId, seq, pending: targets });
521
+ await j.updateData({ ...keyOf(j.data), pending: targets });
458
522
  j.data.pending = targets;
459
523
  }
460
524
  // plugins the organization disabled meanwhile are dropped (not a failure)
461
525
  const targets = (j.data.pending ?? []).filter((p) => subscribers.has(p));
462
- if (targets.length === 0)
526
+ if (targets.length === 0) {
527
+ await this.markReported(j.data);
463
528
  return;
529
+ }
464
530
  const byPlugin = new Map();
465
531
  for (const h of this.hooks.handlers("usage.report")) {
466
532
  byPlugin.set(h.plugin, [...(byPlugin.get(h.plugin) ?? []), h.handler]);
@@ -539,6 +605,8 @@ let UsageReporter = class UsageReporter {
539
605
  // m-1: pending = the dropped plugins only, so a manual retry reaches them and never the
540
606
  // plugins already served
541
607
  await this.writePending(j, [...allDropped.keys()], allDropped);
608
+ // delivered to every target still servable (a definite drop is final too): the row is done
609
+ await this.markReported(j.data);
542
610
  const traced = new Map([...allDropped].filter(([p]) => j.data.dropped?.[p] !== undefined));
543
611
  if (traced.size === 0)
544
612
  return;
@@ -649,9 +717,9 @@ let UsageReportModule = class UsageReportModule {
649
717
  };
650
718
  UsageReportModule = __decorate([
651
719
  Module({
652
- imports: [ExtensionsModule, QueueModule.registerQueue(USAGE_REPORT_QUEUE)],
720
+ imports: [ExtensionsModule, UsageReportJobsModule],
653
721
  providers: [UsageReporter],
654
- exports: [UsageReporter],
722
+ exports: [UsageReporter, UsageReportJobsModule],
655
723
  })
656
724
  ], UsageReportModule);
657
725
  export { UsageReportModule };
@@ -11,6 +11,7 @@ import { parseToolKey, ToolError, validateInputForm, validateToolDisplay, } from
11
11
  import { Inject, Injectable, Optional } from "@nestjs/common";
12
12
  import { EventLog } from "../conversations/event-log.js";
13
13
  import { ToolViewSchema } from "../conversations/events.js";
14
+ import { ApiError } from "../errors.js";
14
15
  import { EffectiveConfigs, NEEDS_CONFIG_REASON } from "../extensions/effective-config.js";
15
16
  import { clipText } from "../extensions/hooks.js";
16
17
  import { ExtensionHost } from "../extensions/host.js";
@@ -1012,6 +1013,11 @@ let ToolPipeline = class ToolPipeline {
1012
1013
  thrown(key, e) {
1013
1014
  if (e instanceof ToolError)
1014
1015
  return { kind: "error", message: e.message };
1016
+ // an extension tool's ctx.models.embed denied by a policy hook (spec B §5.4): the model sees the
1017
+ // deny text (sanitized reason), as for a denied tool call, not a generic failure
1018
+ if (e instanceof ApiError && e.code === "model.hook_denied") {
1019
+ return { kind: "error", message: e.message };
1020
+ }
1015
1021
  // 提供工具的插件实例在调用进行中被撤销(停用 / 升级),排空时限到后中止(C17)
1016
1022
  if (e instanceof ExtensionUnloadedError) {
1017
1023
  this.deps.log.warn("tool call aborted: plugin unloaded", { toolKey: key, plugin: e.plugin });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capybara-hq/kernel",
3
- "version": "0.1.0-rc.5",
3
+ "version": "0.1.0-rc.6",
4
4
  "description": "Capybara kernel: config, database, plugin host and HTTP API of the Capybara agent platform",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -64,14 +64,14 @@
64
64
  "yaml": "^2.9.1",
65
65
  "yauzl": "3.4.0",
66
66
  "zod": "^4.6.7",
67
- "@capybara-hq/plugin-sdk": "^0.1.0-rc.3"
67
+ "@capybara-hq/plugin-sdk": "^0.1.0-rc.4"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@langchain/langgraph-checkpoint-validation": "^1.2.0",
71
71
  "@types/pg": "^8.23.1",
72
72
  "@types/semver": "^7.8.0",
73
73
  "@types/yauzl": "3.4.0",
74
- "@capybara-hq/testing": "0.1.0-rc.6"
74
+ "@capybara-hq/testing": "0.1.0-rc.7"
75
75
  },
76
76
  "scripts": {
77
77
  "build": "tsc -b"
@@ -0,0 +1,38 @@
1
+ -- Model platform providers (spec 2026-11-06-model-platform-providers-design.md). Expand-only: two
2
+ -- new tables, nothing existing changes.
3
+
4
+ -- 0089, spec §5.3: models disabled on two layers. `org_id` null is the platform's list (the super
5
+ -- admin; such a model is invisible to every org), otherwise the org's own list (an org admin, among
6
+ -- the models the platform left on). No row means the model is enabled; a model a provider lists
7
+ -- later is enabled everywhere until someone disables it. Keyed on the model id, so a row applies to
8
+ -- whichever list the org sees (the platform's config or the org's own copy); an id the list does
9
+ -- not contain is kept harmlessly.
10
+ CREATE TABLE IF NOT EXISTS model_disabled (
11
+ org_id text NULL REFERENCES orgs(id) ON DELETE CASCADE,
12
+ provider text NOT NULL,
13
+ model text NOT NULL,
14
+ disabled_by text REFERENCES accounts(id) ON DELETE SET NULL,
15
+ disabled_at timestamptz NOT NULL DEFAULT now()
16
+ );
17
+ CREATE UNIQUE INDEX IF NOT EXISTS model_disabled_scope_key
18
+ ON model_disabled ((coalesce(org_id, '')), provider, model);
19
+
20
+ -- 0089, spec §5.4: model usage outside conversations (an extension's `ctx.models.embed` call). One
21
+ -- row per call; `reported_at` is set once the `usage.report` job for the row (eventId = id) has
22
+ -- nothing left to deliver (delivered, no subscriber, or a definite drop); `report_failed_at` once
23
+ -- the job gave up after its last attempt. The sweeper re-enqueues recent rows where both are null.
24
+ CREATE TABLE IF NOT EXISTS model_usage (
25
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
26
+ org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
27
+ extension text NOT NULL,
28
+ purpose text NOT NULL,
29
+ provider text NOT NULL,
30
+ model text NOT NULL,
31
+ input_tokens integer NOT NULL,
32
+ occurred_at timestamptz NOT NULL DEFAULT now(),
33
+ reported_at timestamptz NULL,
34
+ report_failed_at timestamptz NULL
35
+ );
36
+ CREATE INDEX IF NOT EXISTS model_usage_org_time ON model_usage (org_id, occurred_at DESC);
37
+ CREATE INDEX IF NOT EXISTS model_usage_unreported ON model_usage (occurred_at)
38
+ WHERE reported_at IS NULL AND report_failed_at IS NULL;