@schlessera/brain-ui-server 0.19.0 → 0.21.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.
- package/README.md +5 -1
- package/data/model-prices.json +63 -0
- package/dist/activity/digest.d.ts.map +1 -1
- package/dist/activity/digest.js +30 -2
- package/dist/activity/digest.js.map +1 -1
- package/dist/activity/notify.d.ts.map +1 -1
- package/dist/activity/notify.js +26 -4
- package/dist/activity/notify.js.map +1 -1
- package/dist/activity/query.d.ts.map +1 -1
- package/dist/activity/query.js +28 -1
- package/dist/activity/query.js.map +1 -1
- package/dist/activity/recorder.d.ts +5 -0
- package/dist/activity/recorder.d.ts.map +1 -1
- package/dist/activity/recorder.js +37 -4
- package/dist/activity/recorder.js.map +1 -1
- package/dist/activity/runtime.d.ts +7 -1
- package/dist/activity/runtime.d.ts.map +1 -1
- package/dist/activity/runtime.js +28 -10
- package/dist/activity/runtime.js.map +1 -1
- package/dist/activity/store.d.ts +51 -2
- package/dist/activity/store.d.ts.map +1 -1
- package/dist/activity/store.js +236 -19
- package/dist/activity/store.js.map +1 -1
- package/dist/activity/stream.d.ts +6 -2
- package/dist/activity/stream.d.ts.map +1 -1
- package/dist/activity/stream.js +42 -1
- package/dist/activity/stream.js.map +1 -1
- package/dist/agent/backend.d.ts +13 -4
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +68 -5
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +18 -3
- package/dist/app.js.map +1 -1
- package/dist/config/env.d.ts +36 -0
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +66 -2
- package/dist/config/env.js.map +1 -1
- package/dist/db/settings.d.ts +15 -0
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +43 -0
- package/dist/db/settings.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pricing/model-pricing.d.ts +61 -0
- package/dist/pricing/model-pricing.d.ts.map +1 -0
- package/dist/pricing/model-pricing.js +379 -0
- package/dist/pricing/model-pricing.js.map +1 -0
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/activity.js +40 -32
- package/dist/routes/activity.js.map +1 -1
- package/dist/routes/models.d.ts +6 -0
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +38 -3
- package/dist/routes/models.js.map +1 -1
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +36 -2
- package/dist/ws/run-session.js.map +1 -1
- package/migrations/009_activity_followups.sql +22 -0
- package/migrations/010_effective_cost.sql +21 -0
- package/package.json +4 -3
- package/src/activity/digest.ts +30 -2
- package/src/activity/notify.ts +30 -8
- package/src/activity/query.ts +28 -1
- package/src/activity/recorder.ts +57 -5
- package/src/activity/runtime.ts +41 -13
- package/src/activity/store.ts +316 -21
- package/src/activity/stream.ts +39 -9
- package/src/agent/backend.ts +91 -10
- package/src/app.ts +19 -3
- package/src/config/env.ts +90 -2
- package/src/db/settings.ts +56 -0
- package/src/index.ts +1 -0
- package/src/pricing/model-pricing.ts +497 -0
- package/src/routes/activity.ts +41 -33
- package/src/routes/models.ts +57 -6
- package/src/ws/run-session.ts +45 -3
package/src/activity/runtime.ts
CHANGED
|
@@ -13,12 +13,13 @@ import type { Database } from "bun:sqlite";
|
|
|
13
13
|
import type { Logger } from "@opentelemetry/api-logs";
|
|
14
14
|
import type { ActivityQuery, ActivityQueryResult } from "@schlessera/brain-ui-sdk/server";
|
|
15
15
|
|
|
16
|
-
import { createActivityStore, type ActivityStore } from "./store.js";
|
|
16
|
+
import { createActivityStore, type ActivityStore, type RollupPricing } from "./store.js";
|
|
17
17
|
import { createActivityStream, type ActivityStream } from "./stream.js";
|
|
18
18
|
import { createActivityNotifier, type ActivityNotifier } from "./notify.js";
|
|
19
19
|
import { createPushSender, type PushSender } from "./push-sender.js";
|
|
20
20
|
import { digestRetentionFloor } from "./digest.js";
|
|
21
21
|
import { runActivityQuery } from "./query.js";
|
|
22
|
+
import { getDetailRetentionDays } from "../db/settings.js";
|
|
22
23
|
|
|
23
24
|
// Activity lifecycle cadence. The stale threshold must comfortably exceed
|
|
24
25
|
// the cron wrapper's heartbeat interval (~30s) so a live writer is never
|
|
@@ -43,12 +44,33 @@ export interface ActivityRuntime {
|
|
|
43
44
|
|
|
44
45
|
export function createActivityRuntime(
|
|
45
46
|
db: Database,
|
|
46
|
-
deps: {
|
|
47
|
+
deps: {
|
|
48
|
+
log: Logger;
|
|
49
|
+
/**
|
|
50
|
+
* The app's shared model-pricing instance, for rollup-time effective
|
|
51
|
+
* cost. Optional so embedders without one fall back to the store's own
|
|
52
|
+
* env-derived default (the same instance shape, just not shared).
|
|
53
|
+
*/
|
|
54
|
+
pricing?: RollupPricing;
|
|
55
|
+
}
|
|
47
56
|
): ActivityRuntime {
|
|
48
57
|
const { log } = deps;
|
|
49
|
-
const store = createActivityStore(db);
|
|
58
|
+
const store = createActivityStore(db, deps.pricing ? { pricing: deps.pricing } : {});
|
|
50
59
|
const stream = createActivityStream(store, log);
|
|
51
60
|
|
|
61
|
+
// The notifier is created BEFORE the boot sweep: its change cursor starts
|
|
62
|
+
// at the current head, so the terminal changes the sweep writes are above
|
|
63
|
+
// it and the first tick turns restart-interrupted runs into failure
|
|
64
|
+
// intents. Created after, the sweep's changes would sit below the cursor
|
|
65
|
+
// and a turn killed by a restart would never be notified.
|
|
66
|
+
const notifier = createActivityNotifier({
|
|
67
|
+
db,
|
|
68
|
+
store,
|
|
69
|
+
isWatched: (scope) => stream.isWatched(scope),
|
|
70
|
+
log,
|
|
71
|
+
});
|
|
72
|
+
const pushSender = createPushSender(db, { log });
|
|
73
|
+
|
|
52
74
|
// Boot sweep: close this server's orphans from a previous life (interrupted).
|
|
53
75
|
try {
|
|
54
76
|
const orphans = store.sweepOwnOrphans();
|
|
@@ -67,14 +89,6 @@ export function createActivityRuntime(
|
|
|
67
89
|
});
|
|
68
90
|
}
|
|
69
91
|
|
|
70
|
-
const notifier = createActivityNotifier({
|
|
71
|
-
db,
|
|
72
|
-
store,
|
|
73
|
-
isWatched: (scope) => stream.isWatched(scope),
|
|
74
|
-
log,
|
|
75
|
-
});
|
|
76
|
-
const pushSender = createPushSender(db, { log });
|
|
77
|
-
|
|
78
92
|
let delivering = false;
|
|
79
93
|
let lastPrune = 0;
|
|
80
94
|
const tick = setInterval(() => {
|
|
@@ -108,10 +122,24 @@ export function createActivityRuntime(
|
|
|
108
122
|
}
|
|
109
123
|
if (Date.now() - lastPrune > ACTIVITY_PRUNE_INTERVAL_MS) {
|
|
110
124
|
lastPrune = Date.now();
|
|
125
|
+
// Self-heal the pricing table on server traffic: without this, a
|
|
126
|
+
// long-lived server rolls up from whatever the boot-time refresh
|
|
127
|
+
// fetched. Single-flight + TTL inside ensureFresh make the hourly
|
|
128
|
+
// call free when fresh. The rollup path itself stays synchronous —
|
|
129
|
+
// never refresh from inside the store.
|
|
130
|
+
const pricing = deps.pricing as
|
|
131
|
+
| { ensureFresh?: () => Promise<void> }
|
|
132
|
+
| undefined;
|
|
133
|
+
if (typeof pricing?.ensureFresh === "function") {
|
|
134
|
+
void pricing.ensureFresh().catch(() => {});
|
|
135
|
+
}
|
|
111
136
|
store.prune({
|
|
112
|
-
// Full detail survives until the digest has covered it
|
|
113
|
-
//
|
|
137
|
+
// Full detail survives until the digest has covered it AND the
|
|
138
|
+
// retention window has passed; the hard ceiling bounds growth even
|
|
139
|
+
// if the digest job silently dies. The setting is read every pass
|
|
140
|
+
// so a change applies without a restart.
|
|
114
141
|
digestFloorAt: digestRetentionFloor(db),
|
|
142
|
+
detailRetentionMs: getDetailRetentionDays(db, log) * 24 * 60 * 60 * 1000,
|
|
115
143
|
hardCeilingMs: ACTIVITY_HARD_CEILING_MS,
|
|
116
144
|
});
|
|
117
145
|
notifier.pruneAcknowledged(INTENT_RETENTION_MS);
|
package/src/activity/store.ts
CHANGED
|
@@ -31,6 +31,13 @@
|
|
|
31
31
|
* loud one.
|
|
32
32
|
*/
|
|
33
33
|
import type { Database } from "bun:sqlite";
|
|
34
|
+
import { isBillingMode, type BillingMode } from "@schlessera/brain-ui-sdk/protocol";
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
resolveAmbientBillingMode,
|
|
38
|
+
resolveStandalonePricingConfig,
|
|
39
|
+
} from "../config/env.js";
|
|
40
|
+
import { createModelPricing, type PricingRates } from "../pricing/model-pricing.js";
|
|
34
41
|
|
|
35
42
|
export const SPAN_OUTCOMES = [
|
|
36
43
|
"success",
|
|
@@ -101,6 +108,16 @@ export interface RunRollupRow {
|
|
|
101
108
|
cacheReadTokens: number | null;
|
|
102
109
|
cacheCreationTokens: number | null;
|
|
103
110
|
costUsd: number | null;
|
|
111
|
+
/**
|
|
112
|
+
* What the run actually cost, frozen at first computation (migration 010):
|
|
113
|
+
* 0 for subscription-billed work regardless of tokens, priced usage for
|
|
114
|
+
* api-billed. NULL = unknown, 0 = genuinely free.
|
|
115
|
+
*/
|
|
116
|
+
effectiveCostUsd: number | null;
|
|
117
|
+
/** Billing classification behind `effectiveCostUsd`; NULL = unknown. */
|
|
118
|
+
billingMode: BillingMode | null;
|
|
119
|
+
/** True when the effective cost was computed from estimated rates. */
|
|
120
|
+
pricingEstimate: boolean | null;
|
|
104
121
|
failureReason: string | null;
|
|
105
122
|
detailPruned: boolean;
|
|
106
123
|
}
|
|
@@ -123,11 +140,34 @@ export function rowToRunRollup(r: any): RunRollupRow {
|
|
|
123
140
|
cacheReadTokens: r.cache_read_tokens,
|
|
124
141
|
cacheCreationTokens: r.cache_creation_tokens,
|
|
125
142
|
costUsd: r.cost_usd,
|
|
143
|
+
effectiveCostUsd: r.effective_cost_usd ?? null,
|
|
144
|
+
// Migration 010 CHECKs this column, but a row written around them (older
|
|
145
|
+
// binary, manual edit) must degrade to unknown, never to a wrong mode.
|
|
146
|
+
billingMode: isBillingMode(r.billing_mode) ? r.billing_mode : null,
|
|
147
|
+
pricingEstimate: r.pricing_estimate == null ? null : r.pricing_estimate === 1,
|
|
126
148
|
failureReason: r.failure_reason,
|
|
127
149
|
detailPruned: r.detail_pruned === 1,
|
|
128
150
|
};
|
|
129
151
|
}
|
|
130
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Sum-of-KNOWNS over effective costs: unknown (NULL) rows contribute nothing
|
|
155
|
+
* to the sum and are counted instead, so no aggregate can pass an unknown off
|
|
156
|
+
* as $0 (AE3). One definition for every reader that folds rollup rows.
|
|
157
|
+
*/
|
|
158
|
+
export function sumEffectiveCost(rows: Array<{ effectiveCostUsd: number | null }>): {
|
|
159
|
+
effectiveCostUsd: number;
|
|
160
|
+
unpricedRuns: number;
|
|
161
|
+
} {
|
|
162
|
+
let effectiveCostUsd = 0;
|
|
163
|
+
let unpricedRuns = 0;
|
|
164
|
+
for (const row of rows) {
|
|
165
|
+
if (row.effectiveCostUsd === null) unpricedRuns += 1;
|
|
166
|
+
else effectiveCostUsd += row.effectiveCostUsd;
|
|
167
|
+
}
|
|
168
|
+
return { effectiveCostUsd, unpricedRuns };
|
|
169
|
+
}
|
|
170
|
+
|
|
131
171
|
/** One committed write, as the delta stream sees it. */
|
|
132
172
|
export type ActivityChange =
|
|
133
173
|
| { changeId: number; runId: string; seq: number; kind: "span"; span: SpanRow }
|
|
@@ -169,6 +209,10 @@ export interface PruneOptions {
|
|
|
169
209
|
/** Spans of runs that ended before this are prunable (digest floor).
|
|
170
210
|
* Absolute epoch ms, not an offset. */
|
|
171
211
|
digestFloorAt: number;
|
|
212
|
+
/** Minimum age (ms since ended) a run must reach before the digest floor
|
|
213
|
+
* may prune its detail. 0 reproduces floor-only pruning. The hard
|
|
214
|
+
* ceiling ignores it. */
|
|
215
|
+
detailRetentionMs: number;
|
|
172
216
|
/** Runs older than this are pruned REGARDLESS of the digest floor. */
|
|
173
217
|
hardCeilingMs: number;
|
|
174
218
|
now?: number;
|
|
@@ -178,7 +222,6 @@ export interface PruneOptions {
|
|
|
178
222
|
|
|
179
223
|
/** A single event payload is capped so no delta can approach the WS frame cap. */
|
|
180
224
|
export const MAX_EVENT_PAYLOAD_BYTES = 16_384;
|
|
181
|
-
export const TRUNCATION_MARKER = "…[truncated]";
|
|
182
225
|
|
|
183
226
|
export interface ActivityStore {
|
|
184
227
|
readonly writer: string;
|
|
@@ -195,7 +238,19 @@ export interface ActivityStore {
|
|
|
195
238
|
spanId: string,
|
|
196
239
|
patch: { attrs?: Record<string, unknown>; usage?: SpanUsage; waitUntil?: number }
|
|
197
240
|
): boolean;
|
|
198
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Append one event; null (nothing written) for an unknown span. `cap`
|
|
243
|
+
* lowers the payload size cap for this call — the store's 16 KB invariant
|
|
244
|
+
* is the ceiling regardless. A capped payload is clipped and flagged
|
|
245
|
+
* `truncated`.
|
|
246
|
+
*/
|
|
247
|
+
appendEvent(
|
|
248
|
+
spanId: string,
|
|
249
|
+
eventType: string,
|
|
250
|
+
payload: unknown,
|
|
251
|
+
ts?: number,
|
|
252
|
+
cap?: number
|
|
253
|
+
): SpanEventRow | null;
|
|
199
254
|
/** External writers touch their root span so staleness is heartbeat-age based. */
|
|
200
255
|
heartbeat(spanId: string, at?: number): void;
|
|
201
256
|
/** Close every open span of a run as `outcome` (children first), reason on all. */
|
|
@@ -227,9 +282,27 @@ export interface ActivityStore {
|
|
|
227
282
|
prune(options: PruneOptions): { runsPruned: number; spansDeleted: number };
|
|
228
283
|
}
|
|
229
284
|
|
|
285
|
+
/**
|
|
286
|
+
* The pricing dependency rollups price through. `resolve` MUST be synchronous
|
|
287
|
+
* — it is called inside the rollup write transaction, which never awaits.
|
|
288
|
+
*/
|
|
289
|
+
export interface RollupPricing {
|
|
290
|
+
resolve(modelId: string): PricingRates | null;
|
|
291
|
+
}
|
|
292
|
+
|
|
230
293
|
export interface CreateActivityStoreOptions {
|
|
231
294
|
/** Process identity stamped on every span this store writes. */
|
|
232
295
|
writer?: string;
|
|
296
|
+
/**
|
|
297
|
+
* Pricing seam for rollup-time effective cost. Defaults to an env-derived
|
|
298
|
+
* `createModelPricing` instance (the `$BRAIN_PATH` cache and the bundled
|
|
299
|
+
* snapshot are both synchronous reads), constructed on first rollup — so
|
|
300
|
+
* the cron wrapper's bare `createActivityStore(db)` prices identically to
|
|
301
|
+
* the server with no wiring of its own. Tests inject a fake to stay
|
|
302
|
+
* network- and disk-free (the default is also disabled under NODE_ENV=test,
|
|
303
|
+
* mirroring the discovery flag's default).
|
|
304
|
+
*/
|
|
305
|
+
pricing?: RollupPricing;
|
|
233
306
|
}
|
|
234
307
|
|
|
235
308
|
export function createActivityStore(
|
|
@@ -238,6 +311,16 @@ export function createActivityStore(
|
|
|
238
311
|
): ActivityStore {
|
|
239
312
|
const writer = options.writer ?? `pid:${process.pid}:${Date.now()}`;
|
|
240
313
|
|
|
314
|
+
// Lazy so a store that never rolls up (span-only paths, most tests)
|
|
315
|
+
// touches neither the env nor the disk. The env reads live in
|
|
316
|
+
// config/env.ts — the one chokepoint the env-access gate allows. Every
|
|
317
|
+
// wrapper whose transaction can reach rollupRunInTx calls this BEFORE
|
|
318
|
+
// entering `inWrite`, so the SQLite write lock never covers the two
|
|
319
|
+
// synchronous JSON reads construction costs.
|
|
320
|
+
let pricing: RollupPricing | undefined = options.pricing;
|
|
321
|
+
const getPricing = (): RollupPricing =>
|
|
322
|
+
(pricing ??= createModelPricing(resolveStandalonePricingConfig()));
|
|
323
|
+
|
|
241
324
|
// bun:sqlite transactions: `.immediate` takes the write lock up front, so a
|
|
242
325
|
// concurrent writer waits (busy_timeout) instead of failing mid-upgrade.
|
|
243
326
|
const inWrite = <T>(fn: () => T): T => db.transaction(fn).immediate();
|
|
@@ -375,19 +458,95 @@ export function createActivityStore(
|
|
|
375
458
|
spans.find(
|
|
376
459
|
(s) => (s.outcome === "error" || s.outcome === "timeout") && s.outcomeReason != null
|
|
377
460
|
)?.outcomeReason ?? (root.outcome === "interrupted" ? "interrupted" : null);
|
|
461
|
+
|
|
462
|
+
// Billing rides the root span when the recorder knew it at run start
|
|
463
|
+
// (session runs, U3). A root without the attr classifies from THIS
|
|
464
|
+
// process's env ONLY for non-session origins: for cron rollups the
|
|
465
|
+
// executing process is the wrapper itself — exactly the credential set
|
|
466
|
+
// the job's agent authenticated under — so classification and reality
|
|
467
|
+
// move together. A SESSION root without the attr (a custom backend the
|
|
468
|
+
// registry could not classify, or a failed profile resolution) stays
|
|
469
|
+
// unknown instead: this process's ambient credentials say nothing about
|
|
470
|
+
// whichever backend ran the turn, and a wrong subscription-$0 would
|
|
471
|
+
// freeze forever where unknown stays honestly unpriced.
|
|
472
|
+
const attrBilling = root.attrs["brain.billing_mode"];
|
|
473
|
+
const billingMode: BillingMode | null = isBillingMode(attrBilling)
|
|
474
|
+
? attrBilling
|
|
475
|
+
: root.origin === "session"
|
|
476
|
+
? null
|
|
477
|
+
: resolveAmbientBillingMode();
|
|
478
|
+
|
|
479
|
+
// List-price math runs regardless of billing mode: it gap-fills a
|
|
480
|
+
// missing backend cost_usd (pi without snapshots, cron) AND provides the
|
|
481
|
+
// api-billed effective number. resolve() is synchronous by contract —
|
|
482
|
+
// nothing here may await inside the write transaction.
|
|
483
|
+
const usage = usageForPricing(root, spans);
|
|
484
|
+
const priced =
|
|
485
|
+
usage.kind === "usage" ? priceUsage(usage.byModel, getPricing()) : null;
|
|
486
|
+
|
|
487
|
+
// Effective-cost semantics (NULL = unknown, 0 = genuinely free):
|
|
488
|
+
// subscription → 0 regardless of tokens (AE1); api → the priced sum —
|
|
489
|
+
// 0 when the run verifiably consumed nothing, NULL when usage was never
|
|
490
|
+
// recorded (denied before inference) or any contributing model/rate is
|
|
491
|
+
// missing. The estimate flag qualifies the effective number, so it is
|
|
492
|
+
// NULL exactly when that is, and 0 for a subscription $0 (exact, not
|
|
493
|
+
// estimated).
|
|
494
|
+
// An unclassified run (billingMode null — session root without the
|
|
495
|
+
// attr) prices as unknown: no subscription-zero, no api pricing.
|
|
496
|
+
const effectiveCostUsd =
|
|
497
|
+
billingMode === null ? null : billingMode === "subscription" ? 0 : (priced?.costUsd ?? null);
|
|
498
|
+
const pricingEstimate =
|
|
499
|
+
billingMode === null
|
|
500
|
+
? null
|
|
501
|
+
: billingMode === "subscription"
|
|
502
|
+
? 0
|
|
503
|
+
: priced
|
|
504
|
+
? (priced.estimate ? 1 : 0)
|
|
505
|
+
: null;
|
|
506
|
+
const costUsd = root.usage.costUsd ?? priced?.costUsd ?? null;
|
|
507
|
+
|
|
378
508
|
db.query(
|
|
379
509
|
`INSERT INTO activity_run_rollups
|
|
380
510
|
(run_id, origin, name, session_id, job_name, started_at, ended_at, outcome,
|
|
381
511
|
duration_ms, span_count, input_tokens, output_tokens, cache_read_tokens,
|
|
382
|
-
cache_creation_tokens, cost_usd,
|
|
383
|
-
|
|
512
|
+
cache_creation_tokens, cost_usd, effective_cost_usd, billing_mode,
|
|
513
|
+
pricing_estimate, failure_reason, detail_pruned)
|
|
514
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
|
384
515
|
ON CONFLICT(run_id) DO UPDATE SET
|
|
385
516
|
ended_at = excluded.ended_at, outcome = excluded.outcome,
|
|
386
517
|
duration_ms = excluded.duration_ms, span_count = excluded.span_count,
|
|
387
518
|
input_tokens = excluded.input_tokens, output_tokens = excluded.output_tokens,
|
|
388
519
|
cache_read_tokens = excluded.cache_read_tokens,
|
|
389
520
|
cache_creation_tokens = excluded.cache_creation_tokens,
|
|
390
|
-
cost_usd =
|
|
521
|
+
cost_usd = COALESCE(activity_run_rollups.cost_usd, excluded.cost_usd),
|
|
522
|
+
effective_cost_usd = CASE
|
|
523
|
+
WHEN activity_run_rollups.effective_cost_usd IS NOT NULL
|
|
524
|
+
THEN activity_run_rollups.effective_cost_usd
|
|
525
|
+
WHEN activity_run_rollups.billing_mode IS NULL
|
|
526
|
+
OR activity_run_rollups.billing_mode = excluded.billing_mode
|
|
527
|
+
THEN excluded.effective_cost_usd
|
|
528
|
+
ELSE NULL
|
|
529
|
+
END,
|
|
530
|
+
billing_mode = COALESCE(activity_run_rollups.billing_mode, excluded.billing_mode),
|
|
531
|
+
pricing_estimate = CASE
|
|
532
|
+
WHEN activity_run_rollups.pricing_estimate IS NOT NULL
|
|
533
|
+
THEN activity_run_rollups.pricing_estimate
|
|
534
|
+
WHEN activity_run_rollups.billing_mode IS NULL
|
|
535
|
+
OR activity_run_rollups.billing_mode = excluded.billing_mode
|
|
536
|
+
THEN excluded.pricing_estimate
|
|
537
|
+
ELSE NULL
|
|
538
|
+
END,
|
|
539
|
+
failure_reason = excluded.failure_reason`
|
|
540
|
+
// Every cost column is FROZEN at first non-NULL write (AE5): a re-rollup
|
|
541
|
+
// after a pricing refresh must not silently reprice history — only a
|
|
542
|
+
// still-NULL slot may be filled by a later computation. cost_usd gets
|
|
543
|
+
// the plain first-write-wins COALESCE (the terminal endSpan already
|
|
544
|
+
// merged the backend's authoritative number before the first rollup).
|
|
545
|
+
// The effective/estimate pair additionally requires the LATER fill to
|
|
546
|
+
// agree with the frozen classification: billing_mode is first-write-wins,
|
|
547
|
+
// and a slot left NULL under one classification must never be filled by
|
|
548
|
+
// a number computed under a different one (a subscription $0 landing on
|
|
549
|
+
// an api-classified row would fabricate a cross-classified price).
|
|
391
550
|
).run(
|
|
392
551
|
runId,
|
|
393
552
|
root.origin,
|
|
@@ -403,7 +562,10 @@ export function createActivityStore(
|
|
|
403
562
|
root.usage.outputTokens ?? null,
|
|
404
563
|
root.usage.cacheReadTokens ?? null,
|
|
405
564
|
root.usage.cacheCreationTokens ?? null,
|
|
406
|
-
|
|
565
|
+
costUsd,
|
|
566
|
+
effectiveCostUsd,
|
|
567
|
+
billingMode,
|
|
568
|
+
pricingEstimate,
|
|
407
569
|
failure
|
|
408
570
|
);
|
|
409
571
|
}
|
|
@@ -477,7 +639,7 @@ export function createActivityStore(
|
|
|
477
639
|
});
|
|
478
640
|
},
|
|
479
641
|
|
|
480
|
-
appendEvent(spanId, eventType, payload, ts) {
|
|
642
|
+
appendEvent(spanId, eventType, payload, ts, cap) {
|
|
481
643
|
return inWrite(() => {
|
|
482
644
|
const span = db
|
|
483
645
|
.query("SELECT run_id FROM activity_spans WHERE span_id = ?")
|
|
@@ -488,15 +650,21 @@ export function createActivityStore(
|
|
|
488
650
|
"SELECT COALESCE(MAX(event_index), -1) + 1 AS idx FROM activity_events WHERE span_id = ?"
|
|
489
651
|
)
|
|
490
652
|
.get(spanId) as { idx: number };
|
|
491
|
-
const stored = capPayload(payload);
|
|
653
|
+
const stored = capPayload(payload, cap);
|
|
654
|
+
const at = ts ?? Date.now();
|
|
492
655
|
db.query(
|
|
493
656
|
"INSERT INTO activity_events (span_id, event_index, ts, event_type, payload) VALUES (?, ?, ?, ?, ?)"
|
|
494
|
-
).run(spanId, next.idx,
|
|
657
|
+
).run(spanId, next.idx, at, eventType, JSON.stringify(stored));
|
|
495
658
|
logChange(span.run_id, nextSeq(span.run_id), spanId, next.idx);
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
659
|
+
// Built from the values just written — no read-back needed.
|
|
660
|
+
return {
|
|
661
|
+
spanId,
|
|
662
|
+
eventIndex: next.idx,
|
|
663
|
+
ts: at,
|
|
664
|
+
eventType,
|
|
665
|
+
payload: stored.v,
|
|
666
|
+
truncated: stored.truncated === true,
|
|
667
|
+
};
|
|
500
668
|
});
|
|
501
669
|
},
|
|
502
670
|
|
|
@@ -511,6 +679,7 @@ export function createActivityStore(
|
|
|
511
679
|
},
|
|
512
680
|
|
|
513
681
|
cascadeClose(runId, outcome, reason) {
|
|
682
|
+
getPricing(); // construct outside the write lock; rollup reuses it
|
|
514
683
|
return inWrite(() => closeRunInTx(runId, outcome, reason));
|
|
515
684
|
},
|
|
516
685
|
|
|
@@ -533,6 +702,7 @@ export function createActivityStore(
|
|
|
533
702
|
)
|
|
534
703
|
.all() as Array<{ run_id: string }>;
|
|
535
704
|
if (open.length === 0) return 0;
|
|
705
|
+
getPricing(); // construct outside the write lock; rollup reuses it
|
|
536
706
|
return inWrite(() => {
|
|
537
707
|
let closed = 0;
|
|
538
708
|
for (const { run_id } of open) {
|
|
@@ -555,6 +725,7 @@ export function createActivityStore(
|
|
|
555
725
|
)
|
|
556
726
|
.all(writer, cutoff) as Array<{ span_id: string; run_id: string }>;
|
|
557
727
|
if (staleRoots.length === 0) return 0;
|
|
728
|
+
getPricing(); // construct outside the write lock; rollup reuses it
|
|
558
729
|
return inWrite(() => {
|
|
559
730
|
let closed = 0;
|
|
560
731
|
for (const { run_id } of staleRoots) {
|
|
@@ -692,6 +863,7 @@ export function createActivityStore(
|
|
|
692
863
|
},
|
|
693
864
|
|
|
694
865
|
rollupRun(runId) {
|
|
866
|
+
getPricing(); // construct outside the write lock; rollup reuses it
|
|
695
867
|
inWrite(() => rollupRunInTx(runId));
|
|
696
868
|
},
|
|
697
869
|
|
|
@@ -700,8 +872,15 @@ export function createActivityStore(
|
|
|
700
872
|
const batch = options.batch ?? 50;
|
|
701
873
|
// The digest floor gates pruning, but a dead digest job must not freeze
|
|
702
874
|
// it forever: the hard ceiling prunes regardless (marking the rollup so
|
|
703
|
-
// the coverage gap is visible).
|
|
875
|
+
// the coverage gap is visible). The floor alone is NOT enough: it meant
|
|
876
|
+
// "safe to prune once summarized", but drill-in debugging is a second
|
|
877
|
+
// reader of detail with a different clock — nightly cron runs end
|
|
878
|
+
// before the morning digest, day sessions after, so floor-only pruning
|
|
879
|
+
// took cron span trees within the hour while sessions kept theirs (it
|
|
880
|
+
// looked cron-specific; it was clock skew). The retention cutoff ANDs
|
|
881
|
+
// with the floor so covered runs still keep detail for a minimum window.
|
|
704
882
|
const floor = Math.max(options.digestFloorAt, 0);
|
|
883
|
+
const retentionCutoff = now - options.detailRetentionMs;
|
|
705
884
|
const ceiling = now - options.hardCeilingMs;
|
|
706
885
|
return inWrite(() => {
|
|
707
886
|
// Candidates come from the rollups (they exist for every finished
|
|
@@ -711,14 +890,14 @@ export function createActivityStore(
|
|
|
711
890
|
.query(
|
|
712
891
|
`SELECT run_id, ended_at AS ended FROM activity_run_rollups r
|
|
713
892
|
WHERE detail_pruned = 0 AND ended_at IS NOT NULL
|
|
714
|
-
AND (ended_at < ? OR ended_at < ?)
|
|
893
|
+
AND ((ended_at < ? AND ended_at < ?) OR ended_at < ?)
|
|
715
894
|
AND NOT EXISTS (
|
|
716
895
|
SELECT 1 FROM activity_spans s
|
|
717
896
|
WHERE s.run_id = r.run_id AND s.outcome IS NULL
|
|
718
897
|
)
|
|
719
898
|
LIMIT ?`
|
|
720
899
|
)
|
|
721
|
-
.all(floor, ceiling, batch) as Array<{ run_id: string; ended: number }>;
|
|
900
|
+
.all(floor, retentionCutoff, ceiling, batch) as Array<{ run_id: string; ended: number }>;
|
|
722
901
|
let spansDeleted = 0;
|
|
723
902
|
for (const { run_id, ended } of candidates) {
|
|
724
903
|
db.query(
|
|
@@ -754,6 +933,120 @@ export function createActivityStore(
|
|
|
754
933
|
};
|
|
755
934
|
}
|
|
756
935
|
|
|
936
|
+
/** Per-model token counts, as priced; absent fields count as zero consumed. */
|
|
937
|
+
interface PricedTokens {
|
|
938
|
+
inputTokens?: number;
|
|
939
|
+
outputTokens?: number;
|
|
940
|
+
cacheReadTokens?: number;
|
|
941
|
+
cacheCreationTokens?: number;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
type PricingUsage =
|
|
945
|
+
/** Nothing recorded at all (denied before inference, pre-feature spans) → unknown. */
|
|
946
|
+
| { kind: "none" }
|
|
947
|
+
/** Usage exists but cannot be attributed to a model → whole run unknown. */
|
|
948
|
+
| { kind: "unpriceable" }
|
|
949
|
+
| { kind: "usage"; byModel: Record<string, PricedTokens> };
|
|
950
|
+
|
|
951
|
+
function tokenCount(value: unknown): number | undefined {
|
|
952
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* The usage a run is priced from. Session runs carry the SDK's per-model
|
|
957
|
+
* breakdown as a root attr (result-level accounting, subagents included —
|
|
958
|
+
* the same root-only scope the token rollup uses). Cron runs have no root
|
|
959
|
+
* aggregate: the wrapper's children carry the usage, summed per model here
|
|
960
|
+
* — the sanctioned origin-scoped exception to root-only aggregation, safe
|
|
961
|
+
* because sink children are not double-counted in their root.
|
|
962
|
+
*/
|
|
963
|
+
function usageForPricing(root: SpanRow, spans: SpanRow[]): PricingUsage {
|
|
964
|
+
if (root.origin === "cron") {
|
|
965
|
+
// Null-prototype: model ids are foreign strings — an id like "__proto__"
|
|
966
|
+
// must be an ordinary key, never a prototype write.
|
|
967
|
+
const byModel: Record<string, PricedTokens> = Object.create(null);
|
|
968
|
+
let sawUsage = false;
|
|
969
|
+
for (const span of spans) {
|
|
970
|
+
if (span.spanId === root.spanId) continue;
|
|
971
|
+
const u = span.usage;
|
|
972
|
+
if (
|
|
973
|
+
u.inputTokens == null &&
|
|
974
|
+
u.outputTokens == null &&
|
|
975
|
+
u.cacheReadTokens == null &&
|
|
976
|
+
u.cacheCreationTokens == null
|
|
977
|
+
) {
|
|
978
|
+
continue; // no inference on this span (tool spans etc.) — not "unknown"
|
|
979
|
+
}
|
|
980
|
+
sawUsage = true;
|
|
981
|
+
// Tokens without a model cannot be priced at any rate — guessing one
|
|
982
|
+
// would silently misprice, so the whole run goes unknown.
|
|
983
|
+
if (!u.model) return { kind: "unpriceable" };
|
|
984
|
+
const agg = (byModel[u.model] ??= {});
|
|
985
|
+
agg.inputTokens = (agg.inputTokens ?? 0) + (u.inputTokens ?? 0);
|
|
986
|
+
agg.outputTokens = (agg.outputTokens ?? 0) + (u.outputTokens ?? 0);
|
|
987
|
+
agg.cacheReadTokens = (agg.cacheReadTokens ?? 0) + (u.cacheReadTokens ?? 0);
|
|
988
|
+
agg.cacheCreationTokens = (agg.cacheCreationTokens ?? 0) + (u.cacheCreationTokens ?? 0);
|
|
989
|
+
}
|
|
990
|
+
return sawUsage ? { kind: "usage", byModel } : { kind: "none" };
|
|
991
|
+
}
|
|
992
|
+
const perModel = root.attrs["gen_ai.usage.per_model"];
|
|
993
|
+
if (typeof perModel !== "object" || perModel === null || Array.isArray(perModel)) {
|
|
994
|
+
return { kind: "none" };
|
|
995
|
+
}
|
|
996
|
+
const byModel: Record<string, PricedTokens> = Object.create(null);
|
|
997
|
+
for (const [model, value] of Object.entries(perModel as Record<string, unknown>)) {
|
|
998
|
+
if (typeof value !== "object" || value === null) continue;
|
|
999
|
+
const v = value as Record<string, unknown>;
|
|
1000
|
+
byModel[model] = {
|
|
1001
|
+
inputTokens: tokenCount(v.inputTokens) ?? 0,
|
|
1002
|
+
outputTokens: tokenCount(v.outputTokens) ?? 0,
|
|
1003
|
+
cacheReadTokens: tokenCount(v.cacheReadTokens) ?? 0,
|
|
1004
|
+
cacheCreationTokens: tokenCount(v.cacheCreationTokens) ?? 0,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
return Object.keys(byModel).length > 0 ? { kind: "usage", byModel } : { kind: "none" };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Price one run's per-model usage at list rates. Null = unknown: an
|
|
1012
|
+
* unresolvable model with consumed tokens, or a consumed token class with no
|
|
1013
|
+
* rate, poisons the WHOLE run — cache reads dominate Claude usage, so
|
|
1014
|
+
* partial pricing would systematically understate (the binding
|
|
1015
|
+
* missing-cache-rate decision). A model with zero consumption contributes
|
|
1016
|
+
* nothing and needs no rate. Variant/snapshot fallbacks are the pricing
|
|
1017
|
+
* service's job (`resolve()`); the store only propagates the estimate flag.
|
|
1018
|
+
*/
|
|
1019
|
+
function priceUsage(
|
|
1020
|
+
byModel: Record<string, PricedTokens>,
|
|
1021
|
+
pricing: RollupPricing
|
|
1022
|
+
): { costUsd: number; estimate: boolean } | null {
|
|
1023
|
+
let costUsd = 0;
|
|
1024
|
+
let estimate = false;
|
|
1025
|
+
for (const [model, tokens] of Object.entries(byModel)) {
|
|
1026
|
+
const classes: Array<
|
|
1027
|
+
[count: number, rate: (r: PricingRates) => number | null]
|
|
1028
|
+
> = [
|
|
1029
|
+
[tokens.inputTokens ?? 0, (r) => r.input],
|
|
1030
|
+
[tokens.outputTokens ?? 0, (r) => r.output],
|
|
1031
|
+
[tokens.cacheReadTokens ?? 0, (r) => r.cacheRead],
|
|
1032
|
+
[tokens.cacheCreationTokens ?? 0, (r) => r.cacheWrite],
|
|
1033
|
+
];
|
|
1034
|
+
if (!classes.some(([count]) => count > 0)) continue;
|
|
1035
|
+
const rates = pricing.resolve(model);
|
|
1036
|
+
if (!rates) return null;
|
|
1037
|
+
for (const [count, rateOf] of classes) {
|
|
1038
|
+
if (count <= 0) continue;
|
|
1039
|
+
const perToken = rateOf(rates);
|
|
1040
|
+
if (perToken === null) return null;
|
|
1041
|
+
costUsd += count * perToken;
|
|
1042
|
+
}
|
|
1043
|
+
if (rates.estimate) estimate = true;
|
|
1044
|
+
}
|
|
1045
|
+
// A non-finite sum (overflowed or poisoned rates) must surface as unknown —
|
|
1046
|
+
// once frozen into the rollup it would render as an exact number forever.
|
|
1047
|
+
return Number.isFinite(costUsd) ? { costUsd, estimate } : null;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
757
1050
|
function safeParse(text: string | null): Record<string, unknown> | undefined {
|
|
758
1051
|
if (!text) return undefined;
|
|
759
1052
|
try {
|
|
@@ -763,12 +1056,14 @@ function safeParse(text: string | null): Record<string, unknown> | undefined {
|
|
|
763
1056
|
}
|
|
764
1057
|
}
|
|
765
1058
|
|
|
766
|
-
/** Cap a payload's serialized size
|
|
767
|
-
|
|
1059
|
+
/** Cap a payload's serialized size; a clipped payload carries the `truncated`
|
|
1060
|
+
* flag (which rides the wire), so no in-text marker is stored. */
|
|
1061
|
+
function capPayload(payload: unknown, cap?: number): { v: unknown; truncated?: boolean } {
|
|
1062
|
+
const limit = Math.min(cap ?? MAX_EVENT_PAYLOAD_BYTES, MAX_EVENT_PAYLOAD_BYTES);
|
|
768
1063
|
const json = JSON.stringify(payload ?? null);
|
|
769
|
-
if (json.length <=
|
|
1064
|
+
if (json.length <= limit) return { v: payload ?? null };
|
|
770
1065
|
if (typeof payload === "string") {
|
|
771
|
-
return { v: payload.slice(0,
|
|
1066
|
+
return { v: payload.slice(0, limit), truncated: true };
|
|
772
1067
|
}
|
|
773
|
-
return { v: json.slice(0,
|
|
1068
|
+
return { v: json.slice(0, limit), truncated: true };
|
|
774
1069
|
}
|
package/src/activity/stream.ts
CHANGED
|
@@ -17,19 +17,27 @@
|
|
|
17
17
|
* This is deliberately the only place broadcast is per-connection filtered;
|
|
18
18
|
* every other frame keeps the host's broadcast-to-all semantics.
|
|
19
19
|
*/
|
|
20
|
-
import
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
import {
|
|
21
|
+
SPAN_TOOL_NAME_PREFIX,
|
|
22
|
+
type ActivityRunRollup,
|
|
23
|
+
type ActivitySpan,
|
|
24
|
+
type ActivitySpanEvent,
|
|
25
|
+
type ClientActivitySubscribe,
|
|
26
|
+
type ClientActivityUnsubscribe,
|
|
27
|
+
type ServerActivityDelta,
|
|
28
|
+
type ServerActivitySnapshot,
|
|
27
29
|
} from "@schlessera/brain-ui-sdk/protocol";
|
|
28
30
|
import type { Logger } from "@opentelemetry/api-logs";
|
|
29
31
|
|
|
30
32
|
import type { WSContext } from "../ws/clients.js";
|
|
31
33
|
import { sendTo } from "../ws/clients.js";
|
|
32
|
-
import type {
|
|
34
|
+
import type {
|
|
35
|
+
ActivityChange,
|
|
36
|
+
ActivityStore,
|
|
37
|
+
RunRollupRow,
|
|
38
|
+
SpanEventRow,
|
|
39
|
+
SpanRow,
|
|
40
|
+
} from "./store.js";
|
|
33
41
|
|
|
34
42
|
/** Events per snapshot frame — keeps each frame far below the WS size cap. */
|
|
35
43
|
const SNAPSHOT_EVENT_CHUNK = 100;
|
|
@@ -38,7 +46,7 @@ const SNAPSHOT_SPAN_CHUNK = 100;
|
|
|
38
46
|
/** Fast-poll cadence while subscriptions exist (foreign-writer liveness). */
|
|
39
47
|
const POLL_INTERVAL_MS = 1500;
|
|
40
48
|
|
|
41
|
-
const TOOL_NAME_PREFIX =
|
|
49
|
+
const TOOL_NAME_PREFIX = SPAN_TOOL_NAME_PREFIX;
|
|
42
50
|
|
|
43
51
|
interface Subscription {
|
|
44
52
|
view: "index" | "session" | "run";
|
|
@@ -105,6 +113,28 @@ export function toWireEvent(event: SpanEventRow): ActivitySpanEvent {
|
|
|
105
113
|
};
|
|
106
114
|
}
|
|
107
115
|
|
|
116
|
+
/** Store rollup → wire rollup: nullable columns become omitted-when-null per
|
|
117
|
+
* the optional wire contract (effectiveCostUsd stays explicit — null means
|
|
118
|
+
* unknown, and omitting it would let a client mistake unknown for absent). */
|
|
119
|
+
export function toWireRollup(r: RunRollupRow): ActivityRunRollup {
|
|
120
|
+
return {
|
|
121
|
+
origin: r.origin,
|
|
122
|
+
name: r.name,
|
|
123
|
+
sessionId: r.sessionId,
|
|
124
|
+
jobName: r.jobName,
|
|
125
|
+
startedAt: r.startedAt,
|
|
126
|
+
endedAt: r.endedAt,
|
|
127
|
+
outcome: r.outcome,
|
|
128
|
+
durationMs: r.durationMs,
|
|
129
|
+
spanCount: r.spanCount,
|
|
130
|
+
costUsd: r.costUsd,
|
|
131
|
+
effectiveCostUsd: r.effectiveCostUsd,
|
|
132
|
+
billingMode: r.billingMode ?? undefined,
|
|
133
|
+
pricingEstimate: r.pricingEstimate ?? undefined,
|
|
134
|
+
failureReason: r.failureReason,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
108
138
|
export interface ActivityStream {
|
|
109
139
|
handleSubscribe(ws: WSContext, msg: ClientActivitySubscribe): void;
|
|
110
140
|
handleUnsubscribe(ws: WSContext, msg: ClientActivityUnsubscribe): void;
|