@matatbread/matbot-core 0.4.1 → 0.4.4
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/package.json +2 -2
- package/src/index.ts +4 -2
- package/src/registry.ts +3 -9
- package/src/runner.ts +62 -16
- package/src/session-runner.ts +100 -9
- package/src/session.ts +1 -3
- package/src/storage-base/caching-backend.ts +13 -0
- package/src/storage-base/index.ts +1 -0
- package/src/storage-base/medium-guard.ts +114 -0
- package/src/storage-base/query/execute.ts +4 -1
- package/src/usage.ts +63 -14
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.4",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24.0.0"
|
|
10
10
|
},
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@matatbread/matbot-plugin-api": "^0.4.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.4.4"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/index.ts
CHANGED
|
@@ -28,11 +28,13 @@ export {
|
|
|
28
28
|
unifyServices, forwardingProxy, makeSwappable, singleTurnRequest, createMountTable,
|
|
29
29
|
createBroadcaster, subscribable, createNotifier, scopedNotifier,
|
|
30
30
|
installPrincipalCarrier, enterPrincipal, createConstantPrincipalCarrier,
|
|
31
|
-
contextSwitch, onContextQuiesce, flushIfQuiescent,
|
|
32
|
-
installUsageCarrier, createSerialUsageCarrier, recordUsage,
|
|
31
|
+
contextSwitch, machineBusy, quiesced, onContextQuiesce, flushIfQuiescent,
|
|
32
|
+
installUsageCarrier, createSerialUsageCarrier, recordUsage, recordSpan, currentUsageSink,
|
|
33
|
+
currentUsageScope, currentUsageSite, withUsageScope, withUsageSite,
|
|
33
34
|
} from '@matatbread/matbot-plugin-api/host';
|
|
34
35
|
export type {
|
|
35
36
|
Subscribable, Broadcaster, Routed, RoutedFilter, SwapFn, MountTable, PrincipalCarrier, UsageCarrier,
|
|
37
|
+
UsageScope, Quiescer,
|
|
36
38
|
} from '@matatbread/matbot-plugin-api/host';
|
|
37
39
|
export * from './session.js';
|
|
38
40
|
export * from './usage.js';
|
package/src/registry.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Tool, ToolRegistry, Hook, PromptFn, FormField, FrontendInfo, ProviderAdapter, ProviderConfig } from './types.js';
|
|
1
|
+
import type { Tool, ToolRegistry, Hook, PromptFn, FormField, FrontendInfo, ProviderAdapter, ProviderConfig, FailedPlugin } from './types.js';
|
|
2
2
|
import { RegistryChangeKind } from '@matatbread/matbot-plugin-api';
|
|
3
3
|
import { scopedNotifier } from '@matatbread/matbot-plugin-api/host';
|
|
4
4
|
import type {
|
|
@@ -11,14 +11,8 @@ import type { SettingsDoc } from './settings.js';
|
|
|
11
11
|
|
|
12
12
|
// ── Internal state ────────────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* module that is not plugin-shaped, or a setup() throw. Recorded (not discarded) so the failure is
|
|
17
|
-
* *graceful but not silent*: the `plugin` tool and web UI can show which configured plugins failed
|
|
18
|
-
* and why, instead of the failure vanishing into a boot-time console line. `error` is pre-stringified
|
|
19
|
-
* (sidesteps the `unknown` catch-var); `name` is present only when known before the failure.
|
|
20
|
-
*/
|
|
21
|
-
export type FailedPlugin = { specifier: string; name?: string; error: string };
|
|
14
|
+
// `FailedPlugin` now lives in plugin-api: `plugin list` reports it, so it is part of that tool's
|
|
15
|
+
// contract, and the contract has to be declarable from a package this one can reach.
|
|
22
16
|
|
|
23
17
|
// Mutable arrays/maps held in a single object to make _resetRegistry() simple.
|
|
24
18
|
const state = {
|
package/src/runner.ts
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
Session, Message, MessageContent, ModelContent, Usage, ProviderMeta, TruncatedToolResult,
|
|
2
|
+
Session, Message, MessageContent, ModelContent, Usage, UsageSite, ProviderMeta, TruncatedToolResult,
|
|
3
3
|
TurnEvent, RunConfig, ProviderAdapter, ProviderConfig,
|
|
4
4
|
Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
|
|
5
5
|
} from './types.js';
|
|
6
6
|
import type { MatbotPlugin } from './plugin.js';
|
|
7
7
|
import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
8
|
-
import {
|
|
8
|
+
import { recordSpan, recordUsage, withUsageSite } from '@matatbread/matbot-plugin-api/host';
|
|
9
9
|
import { HookRegistry } from './hooks.js';
|
|
10
10
|
import { appendMessage, createMessage } from './session.js';
|
|
11
11
|
import { foldOntoUserTurn, bindPluginOps } from '@matatbread/matbot-plugin-api';
|
|
12
12
|
import { addUsage } from './usage.js';
|
|
13
13
|
|
|
14
|
+
// Establish a usage call site across an async iteration. Wrapping the *loop* would not work: this
|
|
15
|
+
// runner is itself an async generator, so it suspends at every `yield` and resumes under the consumer's
|
|
16
|
+
// context, losing any scope entered around the loop body. Scoping each `next()` instead means the
|
|
17
|
+
// tool's body — and anything it kicks off while running — always resumes under its own site.
|
|
18
|
+
function siteScoped<T>(site: UsageSite, src: AsyncIterable<T>): AsyncIterable<T> {
|
|
19
|
+
return {
|
|
20
|
+
[Symbol.asyncIterator]: () => {
|
|
21
|
+
const it = src[Symbol.asyncIterator]();
|
|
22
|
+
return {
|
|
23
|
+
next: () => withUsageSite(site, () => it.next()),
|
|
24
|
+
...(it.return ? { return: (v?: never) => withUsageSite(site, () => it.return!(v)) } : {}),
|
|
25
|
+
...(it.throw ? { throw: (e?: unknown) => withUsageSite(site, () => it.throw!(e)) } : {}),
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
// Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
|
|
15
32
|
// the tool was cut off, not genuinely faulty, and the raw abort reason ("Error: steer") is a leaked
|
|
16
33
|
// internal token that reads as a real failure. A steer's continuation turn is the one place a model
|
|
@@ -239,6 +256,22 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
239
256
|
const assistantParts: MessageContent[] = [];
|
|
240
257
|
let textAcc = '';
|
|
241
258
|
let turnUsage: Usage | undefined;
|
|
259
|
+
// The round's bracket — set where the provider call is actually issued, below, so it measures the
|
|
260
|
+
// call rather than the screen/contribute work that precedes it.
|
|
261
|
+
let roundStartedAt = Date.now();
|
|
262
|
+
// Book the round's own spend as an entry the moment the stream settles — including when the response
|
|
263
|
+
// is then thrown away (an in-situ restart) or the call died part-way. The tokens were billed either
|
|
264
|
+
// way; tying the record to whether the *answer* survived is what made a restarted round free. Clears
|
|
265
|
+
// as it books, so no exit path can double-count it.
|
|
266
|
+
const bookRound = (): void => {
|
|
267
|
+
const u = turnUsage;
|
|
268
|
+
if (u === undefined) return;
|
|
269
|
+
turnUsage = undefined;
|
|
270
|
+
withUsageSite({ kind: 'round', round }, () => recordUsage(config.provider, u, {
|
|
271
|
+
startedAt: new Date(roundStartedAt).toISOString(),
|
|
272
|
+
durationMs: Date.now() - roundStartedAt,
|
|
273
|
+
}));
|
|
274
|
+
};
|
|
242
275
|
let truncation: { reason: 'max-tokens' | 'stream-end'; raw?: string } | undefined;
|
|
243
276
|
// Set if a raced verdict fires mid-generation: discard this response and re-run the loop (below).
|
|
244
277
|
let restart = false;
|
|
@@ -264,14 +297,18 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
264
297
|
// Tools advertised for THIS call. A presenter (optional) may present a subset of the snapshot and
|
|
265
298
|
// grow it across iterations as the model discovers tools; absent ⇒ the whole snapshot. Withheld
|
|
266
299
|
// tools stay callable — execution resolves against the live registry (opts.toolRegistry), not this.
|
|
300
|
+
// Scoped to the round: a presenter may itself run completions (deriving a tool's search terms), and
|
|
301
|
+
// that spend belongs to the round that asked for it — it is neither a tool call nor a hook.
|
|
267
302
|
const advertised = opts.toolPresenter
|
|
268
|
-
? await
|
|
303
|
+
? await withUsageSite({ kind: 'round', round },
|
|
304
|
+
() => opts.toolPresenter!.present([...tools.values()], { session, provider: config.provider }))
|
|
269
305
|
: [...tools.values()];
|
|
270
306
|
// Per-call signal linked to the turn signal: an in-situ restart aborts THIS provider call (callAc)
|
|
271
307
|
// to cancel the in-flight request and stop backend generation, without aborting the whole turn.
|
|
272
308
|
const callAc = new AbortController();
|
|
273
309
|
const callSignal = AbortSignal.any([signal, callAc.signal]);
|
|
274
310
|
try {
|
|
311
|
+
roundStartedAt = Date.now();
|
|
275
312
|
for await (const ev of provider.complete(outgoing, providerConfig, advertised, callSignal)) {
|
|
276
313
|
// A raced verdict firing mid-generation changes the premise: discard this doomed response and
|
|
277
314
|
// restart the loop with the correction folded in. Polled BEFORE the event is emitted, so a
|
|
@@ -308,9 +345,9 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
308
345
|
case 'usage':
|
|
309
346
|
turnUsage = addUsage(turnUsage, ev);
|
|
310
347
|
yield { type: 'usage', inputTokens: ev.inputTokens, outputTokens: ev.outputTokens, traceId,
|
|
311
|
-
...(ev.costUsd !== undefined ? { costUsd: ev.costUsd } : {}),
|
|
312
348
|
...(ev.cacheReadTokens !== undefined ? { cacheReadTokens: ev.cacheReadTokens } : {}),
|
|
313
|
-
...(ev.cacheCreationTokens !== undefined ? { cacheCreationTokens: ev.cacheCreationTokens } : {})
|
|
349
|
+
...(ev.cacheCreationTokens !== undefined ? { cacheCreationTokens: ev.cacheCreationTokens } : {}),
|
|
350
|
+
...(ev.reported !== undefined ? { reported: ev.reported } : {}) };
|
|
314
351
|
break;
|
|
315
352
|
case 'done':
|
|
316
353
|
break;
|
|
@@ -322,12 +359,12 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
322
359
|
if (restart || (callAc.signal.aborted && !signal.aborted)) {
|
|
323
360
|
restart = true;
|
|
324
361
|
} else if (signal.aborted) {
|
|
362
|
+
bookRound();
|
|
325
363
|
// Save whatever the LLM streamed before the abort hit.
|
|
326
364
|
if (textAcc) assistantParts.push({ type: 'text', text: textAcc });
|
|
327
365
|
if (assistantParts.length > 0) {
|
|
328
366
|
session = appendMessage(session, createMessage({
|
|
329
367
|
role: 'assistant', content: assistantParts, traceId, providerName: config.provider,
|
|
330
|
-
...(turnUsage !== undefined ? { usage: turnUsage } : {}),
|
|
331
368
|
}));
|
|
332
369
|
}
|
|
333
370
|
yield* end({ type: 'aborted', reason: abortReason(signal), session, traceId });
|
|
@@ -343,10 +380,12 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
343
380
|
// belong to the call that failed, and the model never finished the thought. The `error` event
|
|
344
381
|
// carries no session (a failure is not a transcript), so this yields the terminal itself rather
|
|
345
382
|
// than the session — but it commits first, exactly like every other exit.
|
|
383
|
+
bookRound();
|
|
346
384
|
yield* end({ type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId });
|
|
347
385
|
return;
|
|
348
386
|
}
|
|
349
387
|
}
|
|
388
|
+
bookRound();
|
|
350
389
|
|
|
351
390
|
// In-situ restart: discard this iteration's (uncommitted) response and re-run the loop with the
|
|
352
391
|
// raced correction now folded into `ephemeral`. No store write, no retraction marker — the clean
|
|
@@ -370,7 +409,6 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
370
409
|
content: assistantParts,
|
|
371
410
|
traceId,
|
|
372
411
|
providerName: config.provider,
|
|
373
|
-
...(turnUsage !== undefined ? { usage: turnUsage } : {}),
|
|
374
412
|
});
|
|
375
413
|
session = appendMessage(session, assistantMsg);
|
|
376
414
|
}
|
|
@@ -487,9 +525,10 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
487
525
|
let result: unknown;
|
|
488
526
|
let isError = false;
|
|
489
527
|
// Token accounting for completions this tool runs (via singleTurn/complete) accrues into the
|
|
490
|
-
// ambient turn
|
|
491
|
-
|
|
492
|
-
|
|
528
|
+
// ambient turn scope, stamped with the site established below — never attached to this message.
|
|
529
|
+
// A tool message is popped wholesale by a retract-and-rerun, which would take its accounting
|
|
530
|
+
// somewhere no reduction over the session can reach; the entry lives on the turn head instead.
|
|
531
|
+
const site: UsageSite = { kind: 'tool', callId: tc.id, tool: tc.name };
|
|
493
532
|
const startedAt = Date.now();
|
|
494
533
|
|
|
495
534
|
const toolCtx: ToolContext = {
|
|
@@ -503,7 +542,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
503
542
|
};
|
|
504
543
|
|
|
505
544
|
try {
|
|
506
|
-
for await (const toolEv of tool.executor.execute(tc.input, toolCtx)) {
|
|
545
|
+
for await (const toolEv of siteScoped(site, tool.executor.execute(tc.input, toolCtx))) {
|
|
507
546
|
switch (toolEv.type) {
|
|
508
547
|
case 'stdout': yield { type: 'tool:stdout', callId: tc.id, chunk: toolEv.chunk, traceId }; break;
|
|
509
548
|
case 'stderr': yield { type: 'tool:stderr', callId: tc.id, chunk: toolEv.chunk, traceId }; break;
|
|
@@ -534,16 +573,23 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
534
573
|
|
|
535
574
|
// toolresult — last chance to transform the result before it's recorded/yielded (hard redaction),
|
|
536
575
|
// or to observe it (auditing: args + result + timing). Owns the LLM-facing + persisted surfaces.
|
|
576
|
+
const durationMs = Date.now() - startedAt;
|
|
577
|
+
|
|
578
|
+
// The bracket, recorded for EVERY tool call — not just the ones that spent tokens. Most tools
|
|
579
|
+
// (bash, http, workspace) run no completion at all, so hanging duration off an accounting record
|
|
580
|
+
// would capture it precisely for the tools that happen to call an LLM and lose it for the rest.
|
|
581
|
+
// Previously this number was computed, handed to the `toolresult` hook, and then discarded, so a
|
|
582
|
+
// consumer had to re-derive it — less accurately — from event arrival times.
|
|
583
|
+
recordSpan(site, new Date(startedAt).toISOString(), durationMs);
|
|
584
|
+
|
|
537
585
|
result = await hookReg.runToolResult({
|
|
538
586
|
session, config, signal,
|
|
539
587
|
toolCall: { id: tc.id, name: tc.name, input: tc.input },
|
|
540
|
-
tool, result, isError, durationMs
|
|
588
|
+
tool, result, isError, durationMs,
|
|
541
589
|
});
|
|
542
590
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
...(callUsage.length > 0 ? { usage: callUsage } : {}) });
|
|
546
|
-
yield { type: 'tool:end', callId: tc.id, result, isError, traceId };
|
|
591
|
+
toolResults.push({ type: 'tool-result', id: tc.id, result, isError });
|
|
592
|
+
yield { type: 'tool:end', callId: tc.id, result, isError, durationMs, traceId };
|
|
547
593
|
}
|
|
548
594
|
|
|
549
595
|
// Add tool results message, then loop for the next provider call.
|
package/src/session-runner.ts
CHANGED
|
@@ -2,14 +2,14 @@ import type {
|
|
|
2
2
|
Session, Message, MessageContent, Principal, PromptFn, PipelineEvent,
|
|
3
3
|
Store, ToolRegistry, SystemContextRegistry, Vault, FileStore, Tool,
|
|
4
4
|
ProviderAdapter, ProviderConfig, SessionRunner, SessionView, OpenOpts, SubmitOpenOpts,
|
|
5
|
-
SteeringPolicy, SteeringDecision,
|
|
5
|
+
SteeringPolicy, SteeringDecision, TurnEntry,
|
|
6
6
|
} from './types.js';
|
|
7
7
|
import type { MatbotPlugin } from './plugin.js';
|
|
8
8
|
import type { HookRegistry } from './hooks.js';
|
|
9
9
|
import type { ToolTypeIndex, ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
10
10
|
import { appendMessage, createMessage } from './session.js';
|
|
11
|
-
import { isReadOnlyError, foldOntoUserTurn, lastUserIndex } from '@matatbread/matbot-plugin-api';
|
|
12
|
-
import {
|
|
11
|
+
import { isReadOnlyError, foldOntoUserTurn, lastUserIndex, runAs } from '@matatbread/matbot-plugin-api';
|
|
12
|
+
import { machineBusy, quiesced, withUsageScope } from '@matatbread/matbot-plugin-api/host';
|
|
13
13
|
import { runSession } from './runner.js';
|
|
14
14
|
|
|
15
15
|
export interface SessionRunnerDeps {
|
|
@@ -103,6 +103,11 @@ interface SessionState {
|
|
|
103
103
|
// Events of the *current* turn, replayed to a subscriber that joins mid-turn. Cleared at each
|
|
104
104
|
// turn boundary — committed history comes from the store, not from here.
|
|
105
105
|
replay: PipelineEvent[];
|
|
106
|
+
// Accounting entries awaiting a flush, held BY REFERENCE to each turn's usage scope. A completion
|
|
107
|
+
// can be recorded after its turn commits — a detached trigger classifier, a `followup` hook — and it
|
|
108
|
+
// appends to the very array captured here, so a late arrival needs no coordination: whatever is in
|
|
109
|
+
// these arrays at the flush is what gets written, and each entry says which turn caused it.
|
|
110
|
+
pendingUsage: TurnEntry[][];
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
interface Sink {
|
|
@@ -163,7 +168,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
163
168
|
const stateFor = (id: string): SessionState => {
|
|
164
169
|
let s = states.get(id);
|
|
165
170
|
if (s === undefined) {
|
|
166
|
-
s = { queue: [], running: false, runningTraceId: undefined, ac: undefined, subscribers: new Set(), replay: [] };
|
|
171
|
+
s = { queue: [], running: false, runningTraceId: undefined, ac: undefined, subscribers: new Set(), replay: [], pendingUsage: [] };
|
|
167
172
|
states.set(id, s);
|
|
168
173
|
}
|
|
169
174
|
return s;
|
|
@@ -190,9 +195,81 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
190
195
|
// How many submissions sit ahead of the item at queue index `i` (the running turn counts as one).
|
|
191
196
|
const aheadOf = (s: SessionState, i: number): number => (s.running ? 1 : 0) + i;
|
|
192
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Write whatever accounting has accrued onto the turn heads that caused it, then clear it.
|
|
200
|
+
*
|
|
201
|
+
* Entries are drained by SPLICING each pending array, so an entry appended after this point (a
|
|
202
|
+
* classifier still in flight) accumulates afresh and lands on the next flush rather than being
|
|
203
|
+
* written twice. That is the whole concurrency story: no marks, no coordination, and eventual
|
|
204
|
+
* delivery — which is all a log needs, since each entry names the turn that caused it.
|
|
205
|
+
*
|
|
206
|
+
* Anchored on the turn's head (its user message) because that is the message a retract-and-rerun
|
|
207
|
+
* keeps; assistant and tool messages are popped into a retraction marker where no reduction over
|
|
208
|
+
* `session.messages` would find them again. An entry whose turn head has since gone (a session
|
|
209
|
+
* edit, a compaction) is dropped rather than orphaned onto an unrelated message.
|
|
210
|
+
*/
|
|
211
|
+
const flushUsage = async (id: string, s: SessionState): Promise<void> => {
|
|
212
|
+
// Splice each array in place and KEEP the references. Dropping a drained array is the obvious
|
|
213
|
+
// tidy-up and it silently loses exactly the case this exists for: a classifier that settles after
|
|
214
|
+
// the flush appends to the array its scope captured, so if the pump has stopped holding it, the
|
|
215
|
+
// write goes nowhere observable. They are left registered for the life of the session state, at a
|
|
216
|
+
// cost of one empty array per turn.
|
|
217
|
+
//
|
|
218
|
+
// The limit of that: `maybeCleanup` disposes the state once the session is quiet and unsubscribed,
|
|
219
|
+
// so a completion still in flight at THAT point is lost. This is the carrier's stated best-effort
|
|
220
|
+
// contract rather than a hole to plug — nothing can know what is still pending — and it is bounded
|
|
221
|
+
// in practice, since the two channels that settle late (a `followup` hook, a user-phase classifier
|
|
222
|
+
// the runner polls) are both awaited inside the pump iteration this scope wraps.
|
|
223
|
+
const drained = s.pendingUsage.flatMap(entries => entries.splice(0));
|
|
224
|
+
if (drained.length === 0) return;
|
|
225
|
+
|
|
226
|
+
const session = await deps.store.get(id);
|
|
227
|
+
if (!session) return;
|
|
228
|
+
|
|
229
|
+
// Anchor on the entry's own turn head when it has one, and on its ROOT's otherwise. A
|
|
230
|
+
// retract-and-rerun re-runs an existing user turn under a fresh traceId and introduces no user
|
|
231
|
+
// message, so its entries have no head of their own — and the root is the honest home anyway, since
|
|
232
|
+
// a retry's cost belongs to the turn being retried. Without this the redo's spend was dropped
|
|
233
|
+
// outright, which is the very under-report anchoring on the turn head exists to prevent.
|
|
234
|
+
const heads = new Set(session.messages.filter(m => m.role === 'user').map(m => m.traceId));
|
|
235
|
+
const byTrace = new Map<string, TurnEntry[]>();
|
|
236
|
+
for (const e of drained) {
|
|
237
|
+
const anchor = e.traceId !== undefined && heads.has(e.traceId) ? e.traceId : e.rootTraceId;
|
|
238
|
+
if (anchor === undefined || !heads.has(anchor)) continue;
|
|
239
|
+
const list = byTrace.get(anchor);
|
|
240
|
+
if (list) list.push(e); else byTrace.set(anchor, [e]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let changed = false;
|
|
244
|
+
const messages = session.messages.map(m => {
|
|
245
|
+
const add = m.role === 'user' ? byTrace.get(m.traceId) : undefined;
|
|
246
|
+
if (add === undefined) return m;
|
|
247
|
+
byTrace.delete(m.traceId);
|
|
248
|
+
changed = true;
|
|
249
|
+
return { ...m, activity: [...(m.activity ?? []), ...add] };
|
|
250
|
+
});
|
|
251
|
+
if (!changed) return;
|
|
252
|
+
await deps.store.set(id, { ...session, messages });
|
|
253
|
+
};
|
|
254
|
+
|
|
193
255
|
const pump = async (id: string, s: SessionState): Promise<void> => {
|
|
194
256
|
if (s.running) return;
|
|
195
257
|
s.running = true;
|
|
258
|
+
// Hold the machine for the WHOLE queue, not per turn. A deferred machine mutation (the
|
|
259
|
+
// StorageBackend swap, a plugin's deferred edit of this very session) may only land where no
|
|
260
|
+
// operation spans it — and the pump's own store work does not stop at a turn's end: it reads the
|
|
261
|
+
// committed document back for followup, appends markers to it, rewrites it for a retract, and
|
|
262
|
+
// persists the next turn's user message before that turn opens. All of that sits between turns,
|
|
263
|
+
// and a per-turn hold declared it idle. The queue draining is the real boundary — the same one
|
|
264
|
+
// usage flushes at, for the same reason. `machineBusy` releases on every exit including a throw,
|
|
265
|
+
// so no early return out of the loop below can leave the machine held.
|
|
266
|
+
//
|
|
267
|
+
// Wait out any deferred work still settling BEFORE holding: a flusher that rewrites this very
|
|
268
|
+
// session (a `session_edit` deferred out of the last turn) must finish before this turn reads its
|
|
269
|
+
// copy, or the read would take the pre-edit document and the write-back below would put it back.
|
|
270
|
+
// The edge is only reachable while nothing holds the machine, so the wait comes first and the
|
|
271
|
+
// hold second — the reverse would deadlock on an edge this pump is itself preventing.
|
|
272
|
+
return quiesced().then(() => machineBusy(async () => {
|
|
196
273
|
try {
|
|
197
274
|
while (s.queue.length > 0) {
|
|
198
275
|
// Per-submission concat: the head always runs; if the head is a concat submission it absorbs
|
|
@@ -288,8 +365,8 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
288
365
|
// turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
|
|
289
366
|
// access they trigger) reads it via currentPrincipal(). The consumption MUST happen inside
|
|
290
367
|
// the callback: an async iterator returned out of the scope would lose it before it pulls.
|
|
291
|
-
//
|
|
292
|
-
//
|
|
368
|
+
// runAs, not a context switch of its own: the pump holds the machine across the whole queue
|
|
369
|
+
// (see above), so a turn declares only its owner. Each item carries its own submitter.
|
|
293
370
|
// The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
|
|
294
371
|
// commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
|
|
295
372
|
// refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
|
|
@@ -298,7 +375,14 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
298
375
|
// budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
|
|
299
376
|
// signal-driven aborts and not the policy ones.
|
|
300
377
|
let terminal: PipelineEvent['type'] | undefined;
|
|
301
|
-
|
|
378
|
+
// One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
|
|
379
|
+
// definition and a detached classifier settles whenever it settles, so a scope that ended at
|
|
380
|
+
// the turn's commit would drop precisely the spend that is hardest to account for otherwise.
|
|
381
|
+
// The entries array is registered for flush before any work starts, so a late arrival lands
|
|
382
|
+
// in something already tracked and needs no coordination of its own.
|
|
383
|
+
await withUsageScope(async usage => {
|
|
384
|
+
s.pendingUsage.push(usage.entries);
|
|
385
|
+
await runAs(head.principal, async () => {
|
|
302
386
|
for await (const ev of runSession({
|
|
303
387
|
session,
|
|
304
388
|
config: { provider: head.provider, traceId: head.traceId },
|
|
@@ -323,7 +407,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
323
407
|
if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
|
|
324
408
|
emit(s, ev);
|
|
325
409
|
}
|
|
326
|
-
})
|
|
410
|
+
});
|
|
327
411
|
|
|
328
412
|
// followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
|
|
329
413
|
// head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
|
|
@@ -334,7 +418,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
334
418
|
const committed = await deps.store.get(id);
|
|
335
419
|
if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
|
|
336
420
|
let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
|
|
337
|
-
await
|
|
421
|
+
await runAs(head.principal, async () => {
|
|
338
422
|
followup = await hooks.runFollowup({
|
|
339
423
|
session: committed,
|
|
340
424
|
resubmitDepth: head.resubmitDepth,
|
|
@@ -439,6 +523,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
439
523
|
}
|
|
440
524
|
}
|
|
441
525
|
}
|
|
526
|
+
}, { traceId: head.traceId, rootTraceId: head.rootTraceId });
|
|
442
527
|
} catch (e) {
|
|
443
528
|
emit(s, { type: 'error', error: String(e), traceId: head.traceId });
|
|
444
529
|
} finally {
|
|
@@ -449,12 +534,18 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
449
534
|
s.running = false;
|
|
450
535
|
s.runningTraceId = undefined;
|
|
451
536
|
s.replay = [];
|
|
537
|
+
// Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
|
|
538
|
+
// is not a well-defined moment to total anything at: steers terminate and resume, a retract
|
|
539
|
+
// re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
|
|
540
|
+
// settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
|
|
541
|
+
await flushUsage(id, s);
|
|
452
542
|
// Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
|
|
453
543
|
// (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
|
|
454
544
|
// the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
|
|
455
545
|
notify(s, { type: 'idle', sessionId: id });
|
|
456
546
|
maybeCleanup(id, s);
|
|
457
547
|
}
|
|
548
|
+
}));
|
|
458
549
|
};
|
|
459
550
|
|
|
460
551
|
return {
|
package/src/session.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Session, Message, MessageContent, MessageRole
|
|
1
|
+
import type { Session, Message, MessageContent, MessageRole } from './types.js';
|
|
2
2
|
|
|
3
3
|
export interface CreateSessionOpts {
|
|
4
4
|
title?: string;
|
|
@@ -37,7 +37,6 @@ export function createMessage(opts: {
|
|
|
37
37
|
content: MessageContent[];
|
|
38
38
|
traceId: string;
|
|
39
39
|
providerName?: string;
|
|
40
|
-
usage?: Usage;
|
|
41
40
|
metadata?: Record<string, unknown>;
|
|
42
41
|
}): Message {
|
|
43
42
|
return {
|
|
@@ -47,7 +46,6 @@ export function createMessage(opts: {
|
|
|
47
46
|
createdAt: new Date().toISOString(),
|
|
48
47
|
traceId: opts.traceId,
|
|
49
48
|
...(opts.providerName !== undefined ? { providerName: opts.providerName } : {}),
|
|
50
|
-
...(opts.usage !== undefined ? { usage: opts.usage } : {}),
|
|
51
49
|
...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
|
|
52
50
|
};
|
|
53
51
|
}
|
|
@@ -138,9 +138,22 @@ export class CachingStorageBackend implements StorageBackend {
|
|
|
138
138
|
private readonly ttlMs: number | undefined;
|
|
139
139
|
private readonly stores = new Map<string, CachingStore<{ id: string; version: string }>>();
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Forwarded only when the wrapped backend has it. `namespaces` is optional, and its ABSENCE is the
|
|
143
|
+
* signal a caller reads ("this backend cannot be enumerated") — so a decorator that always declared
|
|
144
|
+
* the method would answer for backends that cannot, turning a degradable capability into a runtime
|
|
145
|
+
* failure. Assigned per instance rather than declared as a method so `'namespaces' in backend` stays
|
|
146
|
+
* truthful through the wrapper.
|
|
147
|
+
*
|
|
148
|
+
* Not cached: the cache is per-namespace and populated by reads, so it knows only what has been
|
|
149
|
+
* asked for — never the set of namespaces that exist.
|
|
150
|
+
*/
|
|
151
|
+
readonly namespaces?: () => Promise<string[]>;
|
|
152
|
+
|
|
141
153
|
constructor(inner: StorageBackend, options: CachingOptions = {}) {
|
|
142
154
|
this.inner = inner;
|
|
143
155
|
this.ttlMs = options.ttlMs;
|
|
156
|
+
if (inner.namespaces !== undefined) this.namespaces = () => inner.namespaces!();
|
|
144
157
|
}
|
|
145
158
|
|
|
146
159
|
createStore<T extends { id: string; version: string }>(namespace: string): Store<T> {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Store, CASResult, StoreQuery, QueryResult } from '@matatbread/matbot-plugin-api';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fail a write whose read came from a different storage medium.
|
|
5
|
+
*
|
|
6
|
+
* Exactly one `StorageBackend` is active, nothing is migrated between them, and a swap repoints every
|
|
7
|
+
* store proxy at once. A caller that read a document before the swap and writes it after is therefore
|
|
8
|
+
* addressing two different media with one read-modify-write, and neither end can tell: `cas` asks
|
|
9
|
+
* "did this document change?", which the new backend answers about a document it never issued — most
|
|
10
|
+
* often "there is nothing here", at which point an unconditional `set` writes the old backend's
|
|
11
|
+
* document into the new one and the session has silently migrated. Compare-and-swap cannot see this,
|
|
12
|
+
* because the medium is not what it compares.
|
|
13
|
+
*
|
|
14
|
+
* The fix belongs here rather than at whatever happened to be running: the exposure is not particular
|
|
15
|
+
* to the quiescent edge's deferred work — an HTTP tool call has always been able to straddle a swap
|
|
16
|
+
* the same way — so pushing the check up to every caller would be asking each of them to know
|
|
17
|
+
* something only storage knows.
|
|
18
|
+
*
|
|
19
|
+
* The version is the only token tying a read to its write, so it carries the medium: stamped on the
|
|
20
|
+
* way out, checked and stripped on the way in. Stripping is what keeps it invisible — a caller that
|
|
21
|
+
* writes a document straight back (`store.set(id, { ...doc, title })`, which does not bump the
|
|
22
|
+
* version) must not persist a stamp, or the next read would stamp it twice.
|
|
23
|
+
*
|
|
24
|
+
* An UNstamped version is always accepted: that is a document the caller minted rather than read, and
|
|
25
|
+
* a create has no earlier medium to disagree with.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const STAMP = /^m(\d+)~([\s\S]*)$/;
|
|
29
|
+
|
|
30
|
+
interface Stamped { id: string; version: string }
|
|
31
|
+
|
|
32
|
+
function stamp<T extends Stamped>(doc: T, generation: number): T {
|
|
33
|
+
return { ...doc, version: `m${generation}~${doc.version}` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Checked =
|
|
37
|
+
| { ok: true; version: string }
|
|
38
|
+
| { ok: false; from: number };
|
|
39
|
+
|
|
40
|
+
function check(version: string, generation: number): Checked {
|
|
41
|
+
const m = STAMP.exec(version);
|
|
42
|
+
if (m === null) return { ok: true, version }; // minted, not read
|
|
43
|
+
if (Number(m[1]) === generation) return { ok: true, version: m[2]! };
|
|
44
|
+
return { ok: false, from: Number(m[1]) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Strip a stamp wherever one appears, so nothing a caller hands back is persisted with one. */
|
|
48
|
+
function strip<T extends Stamped>(doc: T): T {
|
|
49
|
+
const m = STAMP.exec(doc.version);
|
|
50
|
+
return m === null ? doc : { ...doc, version: m[2]! };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wrap `inner` so every document it hands out is stamped with the medium generation it came from, and
|
|
55
|
+
* every write is checked against the generation current at the time of the write. `generation` must
|
|
56
|
+
* increase on every backend swap; the wrapper is placed OUTSIDE the swap proxy, so it is one stable
|
|
57
|
+
* object across every swap and a captured store reference keeps working.
|
|
58
|
+
*/
|
|
59
|
+
export function mediumGuard<T extends { id: string; version: string }>(
|
|
60
|
+
inner: Store<T>,
|
|
61
|
+
generation: () => number,
|
|
62
|
+
namespace: string,
|
|
63
|
+
): Store<T> {
|
|
64
|
+
const stale = (op: string, id: string, from: number): string =>
|
|
65
|
+
`${op} on "${namespace}/${id}" read from storage backend generation ${from}, but generation ${generation()} is now active. ` +
|
|
66
|
+
'Nothing is migrated between backends, so this write would put a document from the previous one into its replacement. Re-read and retry.';
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
async get(id) {
|
|
70
|
+
const doc = await inner.get(id);
|
|
71
|
+
return doc === null ? null : stamp(doc, generation());
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async query(q: StoreQuery): Promise<QueryResult<T>> {
|
|
75
|
+
const page = await inner.query(q);
|
|
76
|
+
const gen = generation();
|
|
77
|
+
return { ...page, items: page.items.map(d => stamp(d, gen)) };
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async set(id, value) {
|
|
81
|
+
const seen = check(value.version, generation());
|
|
82
|
+
// `set` has no failure channel, and silence is what makes the migration invisible — so it
|
|
83
|
+
// throws. It is reachable only after a swap, holding a document read before it.
|
|
84
|
+
if (!seen.ok) throw new Error(stale('set', id, seen.from));
|
|
85
|
+
await inner.set(id, strip(value));
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async cas(id, expected, next): Promise<CASResult<T>> {
|
|
89
|
+
const seen = check(expected, generation());
|
|
90
|
+
// Reported as a lost CAS rather than thrown: the caller already has a path for "someone else got
|
|
91
|
+
// there first", and this is that, with the medium in the role of the other writer. `current` is
|
|
92
|
+
// read from the backend now in force — the document the caller must reconcile against.
|
|
93
|
+
if (!seen.ok) {
|
|
94
|
+
console.warn(`[matbot] ${stale('cas', id, seen.from)}`);
|
|
95
|
+
const current = await inner.get(id);
|
|
96
|
+
return { ok: false, current: current === null ? null : stamp(current, generation()) };
|
|
97
|
+
}
|
|
98
|
+
const res = await inner.cas(id, seen.version, strip(next));
|
|
99
|
+
const gen = generation();
|
|
100
|
+
if (res.ok) return { ok: true, doc: stamp(res.doc, gen) };
|
|
101
|
+
return { ok: false, current: res.current === null ? null : stamp(res.current, gen) };
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async delete(id, expectedVersion) {
|
|
105
|
+
if (expectedVersion === undefined) return inner.delete(id);
|
|
106
|
+
const seen = check(expectedVersion, generation());
|
|
107
|
+
if (!seen.ok) {
|
|
108
|
+
console.warn(`[matbot] ${stale('delete', id, seen.from)}`);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
return inner.delete(id, seen.version);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -47,9 +47,12 @@ export function executeQuery<T extends { id: string; version: string }>(docs: T[
|
|
|
47
47
|
const slice = ordered.slice(page.offset, end);
|
|
48
48
|
const next = page.offset + slice.length;
|
|
49
49
|
|
|
50
|
+
// `limit: 0` is the count form: the filter runs, `total` answers, no document is materialised. It
|
|
51
|
+
// gets no cursor — a zero-length page never advances `offset`, so one would page forever on the
|
|
52
|
+
// same empty slice.
|
|
50
53
|
return {
|
|
51
54
|
items: slice,
|
|
52
55
|
total,
|
|
53
|
-
...(next < total ? { cursor: encodeCursor({ ...page, offset: next }) } : {}),
|
|
56
|
+
...(page.limit !== 0 && next < total ? { cursor: encodeCursor({ ...page, offset: next }) } : {}),
|
|
54
57
|
};
|
|
55
58
|
}
|
package/src/usage.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import type { Message, Usage } from './types.js';
|
|
1
|
+
import type { Message, TurnEntry, Usage, UsageRecord } from './types.js';
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Fold one usage tally into a running total; optional fields are summed only when either side has them.
|
|
5
|
+
*
|
|
6
|
+
* `reported` sums **numeric values key-wise** and drops the rest: a total `service_tier` is meaningless
|
|
7
|
+
* and a summed `latency_checkpoint` is nonsense, so non-numeric retained values stay per-call facts that
|
|
8
|
+
* a consumer reads off the entries. Callers must fold only within ONE provider — `usageByProvider` does,
|
|
9
|
+
* by grouping first — since the same key means different things under different protocols.
|
|
10
|
+
*/
|
|
4
11
|
export function addUsage(acc: Usage | undefined, next: Usage): Usage {
|
|
5
12
|
const a = acc ?? { inputTokens: 0, outputTokens: 0 };
|
|
6
13
|
const add = (x: number | undefined, y: number | undefined): number | undefined =>
|
|
@@ -8,29 +15,71 @@ export function addUsage(acc: Usage | undefined, next: Usage): Usage {
|
|
|
8
15
|
return {
|
|
9
16
|
inputTokens: a.inputTokens + next.inputTokens,
|
|
10
17
|
outputTokens: a.outputTokens + next.outputTokens,
|
|
11
|
-
...(((c) => c !== undefined ? { costUsd: c } : {}))(add(a.costUsd, next.costUsd)),
|
|
12
18
|
...(((c) => c !== undefined ? { cacheReadTokens: c } : {}))(add(a.cacheReadTokens, next.cacheReadTokens)),
|
|
13
19
|
...(((c) => c !== undefined ? { cacheCreationTokens: c } : {}))(add(a.cacheCreationTokens, next.cacheCreationTokens)),
|
|
20
|
+
...(((r) => r !== undefined ? { reported: r } : {}))(addReported(a.reported, next.reported)),
|
|
14
21
|
};
|
|
15
22
|
}
|
|
16
23
|
|
|
24
|
+
function addReported(
|
|
25
|
+
a: Record<string, unknown> | undefined,
|
|
26
|
+
b: Record<string, unknown> | undefined,
|
|
27
|
+
): Record<string, unknown> | undefined {
|
|
28
|
+
if (a === undefined) return b;
|
|
29
|
+
if (b === undefined) return a;
|
|
30
|
+
const out: Record<string, unknown> = { ...a };
|
|
31
|
+
for (const [k, v] of Object.entries(b)) {
|
|
32
|
+
const prev = out[k];
|
|
33
|
+
out[k] = typeof v === 'number' && typeof prev === 'number' ? prev + v : v;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* turn
|
|
39
|
+
* Everything a set of messages records about what happened — provider calls and the brackets around
|
|
40
|
+
* them — in message order.
|
|
41
|
+
*
|
|
42
|
+
* Entries are anchored on turn heads and are self-describing (`site`, `traceId`), so this is a flat
|
|
43
|
+
* read with no correlation to do: filter it for whatever question is being asked — one turn
|
|
44
|
+
* (`traceId`), one tool call (`site`), one session (pass the lot) — and the same one fact set answers
|
|
45
|
+
* "what did this tool cost", "what did this user cost" and "how long did that take".
|
|
22
46
|
*/
|
|
23
|
-
export function
|
|
24
|
-
const out =
|
|
47
|
+
export function turnActivity(messages: Iterable<Message>): TurnEntry[] {
|
|
48
|
+
const out: TurnEntry[] = [];
|
|
25
49
|
for (const m of messages) {
|
|
26
|
-
if (m.
|
|
27
|
-
|
|
50
|
+
if (m.activity !== undefined) out.push(...m.activity);
|
|
51
|
+
|
|
52
|
+
// Sessions written before accounting moved onto the turn head: an assistant message's own `usage`
|
|
53
|
+
// object, and a `tool-result`'s `usage` array. Neither shape is written any more.
|
|
54
|
+
const legacyOwn = (m as { usage?: Usage }).usage;
|
|
55
|
+
if (legacyOwn !== undefined && m.providerName !== undefined) {
|
|
56
|
+
out.push({ kind: 'call', provider: m.providerName, usage: legacyOwn, traceId: m.traceId });
|
|
28
57
|
}
|
|
29
58
|
for (const c of m.content) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
59
|
+
const legacy = c.type === 'tool-result' ? (c as { usage?: UsageRecord[] }).usage : undefined;
|
|
60
|
+
if (legacy !== undefined) out.push(...legacy.map((r): TurnEntry => ({ kind: 'call', ...r })));
|
|
33
61
|
}
|
|
34
62
|
}
|
|
35
63
|
return out;
|
|
36
64
|
}
|
|
65
|
+
|
|
66
|
+
/** The provider calls in a set of messages — `turnActivity` less the spans. */
|
|
67
|
+
export function usageEntries(messages: Iterable<Message>): UsageRecord[] {
|
|
68
|
+
return turnActivity(messages).filter((e): e is { kind: 'call' } & UsageRecord => e.kind === 'call');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Aggregate the token accounting on a set of messages, keyed by the provider billed. Pass a single
|
|
73
|
+
* turn's messages (filtered by `traceId`) for a per-turn breakdown, or a whole session for its total.
|
|
74
|
+
*
|
|
75
|
+
* Note what this deliberately does NOT do: sum a provider's `reported` fields across providers. The
|
|
76
|
+
* same key means different things under different protocols (`prompt_tokens` includes cache hits,
|
|
77
|
+
* `input_tokens` does not), so only the normalised counters are comparable across a mixed turn.
|
|
78
|
+
*/
|
|
79
|
+
export function usageByProvider(messages: Iterable<Message>): Map<string, Usage> {
|
|
80
|
+
const out = new Map<string, Usage>();
|
|
81
|
+
for (const r of usageEntries(messages)) {
|
|
82
|
+
out.set(r.provider, addUsage(out.get(r.provider), r.usage));
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|