@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.
- package/dist/admin/admin.controller.d.ts +13 -2
- package/dist/admin/admin.controller.js +29 -4
- package/dist/agents/agents.service.d.ts +3 -1
- package/dist/agents/agents.service.js +8 -6
- package/dist/agents/from-template.service.js +3 -3
- package/dist/agents/models.controller.d.ts +34 -5
- package/dist/agents/models.controller.js +50 -6
- package/dist/agents/readiness.controller.d.ts +5 -5
- package/dist/agents/readiness.controller.js +6 -6
- package/dist/catalog/marketplaces.service.d.ts +1 -1
- package/dist/config/schema.d.ts +4 -0
- package/dist/config/schema.js +24 -0
- package/dist/conversations/conversations.service.d.ts +4 -3
- package/dist/conversations/conversations.service.js +9 -8
- package/dist/ctx/create-ctx.d.ts +15 -1
- package/dist/ctx/create-ctx.js +23 -2
- package/dist/db/kernel-schema.d.ts +24 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +3 -0
- package/dist/evals/run-replay.js +4 -2
- package/dist/evals/runs.service.d.ts +2 -1
- package/dist/evals/runs.service.js +13 -12
- package/dist/evals/step-replay.d.ts +2 -1
- package/dist/evals/step-replay.js +3 -2
- package/dist/extensions/config-hash.d.ts +10 -0
- package/dist/extensions/config-hash.js +38 -0
- package/dist/extensions/extensions.module.js +6 -12
- package/dist/extensions/host.d.ts +6 -10
- package/dist/extensions/host.js +22 -50
- package/dist/extensions/state.module.d.ts +9 -0
- package/dist/extensions/state.module.js +38 -0
- package/dist/http/api.module.js +2 -0
- package/dist/http/plugin-dispatch.d.ts +4 -1
- package/dist/http/plugin-dispatch.js +14 -2
- package/dist/infra/config/kernel-config.service.js +3 -0
- package/dist/infra/events/kernel-events.d.ts +8 -0
- package/dist/models/embeddings.d.ts +82 -0
- package/dist/models/embeddings.js +326 -0
- package/dist/models/enablement.controller.d.ts +40 -0
- package/dist/models/enablement.controller.js +74 -0
- package/dist/models/enablement.d.ts +99 -0
- package/dist/models/enablement.js +263 -0
- package/dist/models/models.module.d.ts +5 -1
- package/dist/models/models.module.js +16 -4
- package/dist/models/purposes.d.ts +18 -4
- package/dist/models/purposes.js +46 -9
- package/dist/platform/exclusions.js +18 -0
- package/dist/runtime/hook-calls.d.ts +8 -1
- package/dist/runtime/hook-calls.js +40 -9
- package/dist/runtime/model-call.d.ts +8 -3
- package/dist/runtime/model-call.js +28 -5
- package/dist/runtime/providers.d.ts +146 -32
- package/dist/runtime/providers.js +321 -67
- package/dist/runtime/run-worker.d.ts +1 -1
- package/dist/runtime/run-worker.js +4 -4
- package/dist/runtime/subagents/definition.js +3 -3
- package/dist/runtime/usage-report-jobs.d.ts +88 -0
- package/dist/runtime/usage-report-jobs.js +158 -0
- package/dist/runtime/usage-report.d.ts +33 -38
- package/dist/runtime/usage-report.js +152 -84
- package/dist/tools/pipeline.js +6 -0
- package/package.json +3 -3
- 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
|
|
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
|
-
|
|
27
|
-
export
|
|
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
|
|
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(
|
|
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(
|
|
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`.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
|
449
|
-
|
|
512
|
+
const payload = await this.payloadOfJob(j.data);
|
|
513
|
+
// conversation (or org) deleted meanwhile: nothing to report
|
|
450
514
|
if (payload === null)
|
|
451
|
-
return;
|
|
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({
|
|
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,
|
|
720
|
+
imports: [ExtensionsModule, UsageReportJobsModule],
|
|
653
721
|
providers: [UsageReporter],
|
|
654
|
-
exports: [UsageReporter],
|
|
722
|
+
exports: [UsageReporter, UsageReportJobsModule],
|
|
655
723
|
})
|
|
656
724
|
], UsageReportModule);
|
|
657
725
|
export { UsageReportModule };
|
package/dist/tools/pipeline.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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;
|