@genesislcap/ai-assistant 15.12.0 → 15.13.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/dist/ai-assistant.api.json +245 -3
- package/dist/ai-assistant.d.ts +127 -7
- package/dist/chat-driver.cjs +79 -13
- package/dist/chat-driver.cjs.map +2 -2
- package/dist/chat-driver.mjs +76 -12
- package/dist/chat-driver.mjs.map +2 -2
- package/dist/custom-elements.json +452 -359
- package/dist/dts/chat-driver-node.d.ts +2 -2
- package/dist/dts/chat-driver-node.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +45 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts +2 -0
- package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +20 -1
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/dts/state/persistence/diagnostics.d.ts +65 -8
- package/dist/dts/state/persistence/diagnostics.d.ts.map +1 -1
- package/dist/dts/state/persistence/index.d.ts +1 -1
- package/dist/dts/state/persistence/index.d.ts.map +1 -1
- package/dist/dts/state/persistence/session-persister.d.ts +4 -3
- package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
- package/dist/dts/utils/sum-usage.d.ts +20 -0
- package/dist/dts/utils/sum-usage.d.ts.map +1 -1
- package/dist/esm/chat-driver-node.js +12 -2
- package/dist/esm/components/chat-driver/chat-driver.js +60 -5
- package/dist/esm/components/chat-driver/chat-driver.turn-usage.test.js +268 -0
- package/dist/esm/main/main.js +53 -28
- package/dist/esm/state/debug-event-log.js +7 -2
- package/dist/esm/state/persistence/diagnostics.js +79 -16
- package/dist/esm/state/persistence/diagnostics.test.js +174 -1
- package/dist/esm/state/persistence/index.js +1 -1
- package/dist/esm/state/persistence/session-persister.js +13 -5
- package/dist/esm/state/persistence/session-persister.test.js +31 -0
- package/dist/esm/utils/sum-usage.js +43 -0
- package/dist/esm/utils/sum-usage.test.js +45 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +17 -17
- package/src/chat-driver-node.ts +12 -2
- package/src/components/chat-driver/chat-driver.ts +107 -6
- package/src/components/chat-driver/chat-driver.turn-usage.test.ts +362 -0
- package/src/main/main.ts +52 -23
- package/src/state/debug-event-log.ts +7 -2
- package/src/state/persistence/diagnostics.test.ts +208 -1
- package/src/state/persistence/diagnostics.ts +117 -15
- package/src/state/persistence/index.ts +1 -1
- package/src/state/persistence/session-persister.test.ts +37 -0
- package/src/state/persistence/session-persister.ts +13 -5
- package/src/utils/sum-usage.test.ts +52 -1
- package/src/utils/sum-usage.ts +45 -0
|
@@ -465,6 +465,43 @@ Suite(
|
|
|
465
465
|
},
|
|
466
466
|
);
|
|
467
467
|
|
|
468
|
+
Suite('flushDiagnostics() re-emits a turn once its per-call usage lands', async () => {
|
|
469
|
+
// A turn entry is created BEFORE its model call and priced when the response returns,
|
|
470
|
+
// so a flush landing in between persists it unpriced. Emitting it exactly once more —
|
|
471
|
+
// when `usage` is there — is what keeps per-call cost in a stitched lifetime log;
|
|
472
|
+
// `assembleDebugLog` collapses the pair back onto the priced copy.
|
|
473
|
+
const inFlight: DiagnosticEntry = {
|
|
474
|
+
kind: 'turn',
|
|
475
|
+
turnIndex: '0',
|
|
476
|
+
timestamp: '2026-01-01T00:00:01.000Z',
|
|
477
|
+
toolNames: [],
|
|
478
|
+
};
|
|
479
|
+
const { p, f } = makePersister({ diagnosticEntries: [inFlight] });
|
|
480
|
+
await p.flushDiagnostics();
|
|
481
|
+
assert.is(f.appended.length, 1, 'the unpriced turn is captured');
|
|
482
|
+
assert.is(f.appended[0][0].usage, undefined);
|
|
483
|
+
|
|
484
|
+
const priced: DiagnosticEntry = {
|
|
485
|
+
...inFlight,
|
|
486
|
+
usage: {
|
|
487
|
+
costUsd: 0.25,
|
|
488
|
+
uncachedInputTokens: 100,
|
|
489
|
+
cacheReadTokens: 900,
|
|
490
|
+
cacheWriteTokens: 0,
|
|
491
|
+
outputTokens: 40,
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
f.diagnosticEntries = [priced];
|
|
495
|
+
await p.flushDiagnostics();
|
|
496
|
+
assert.is(f.appended.length, 2, 'the priced copy is appended');
|
|
497
|
+
assert.equal(f.appended[1][0].usage, priced.usage);
|
|
498
|
+
|
|
499
|
+
// ...and only once. A throttled flush runs on every transcript change, so a turn that
|
|
500
|
+
// re-emitted per flush would multiply the biggest entries in the stream.
|
|
501
|
+
await p.flushDiagnostics();
|
|
502
|
+
assert.is(f.appended.length, 2, 'no further re-append of the priced turn');
|
|
503
|
+
});
|
|
504
|
+
|
|
468
505
|
Suite(
|
|
469
506
|
'flushDiagnostics() still dedups a genuine re-append of the same message across flushes',
|
|
470
507
|
async () => {
|
|
@@ -635,9 +635,10 @@ export class SessionPersister {
|
|
|
635
635
|
|
|
636
636
|
/**
|
|
637
637
|
* The diagnostic entries not yet appended this load — the forward-capture delta
|
|
638
|
-
* (§5.8). Skips messages present at restore or already appended; emits each
|
|
639
|
-
* (by
|
|
640
|
-
*
|
|
638
|
+
* (§5.8). Skips messages present at restore or already appended; emits each meta-event
|
|
639
|
+
* (by index) once and each turn (by turn-index) once unpriced plus once more when its
|
|
640
|
+
* per-call `usage` lands; appends a `meta-snapshot` only when the block changed.
|
|
641
|
+
* Advances the shared per-session cursors as it goes.
|
|
641
642
|
*
|
|
642
643
|
* TODO(GENC-1461, longer-term): this whole delta step exists only because the element *pulls* the
|
|
643
644
|
* ENTIRE debug log on every throttled flush (`getDiagnosticEntries()` returns the full timeline),
|
|
@@ -663,9 +664,16 @@ export class SessionPersister {
|
|
|
663
664
|
}
|
|
664
665
|
delta.push(entry);
|
|
665
666
|
} else if (entry.kind === 'turn') {
|
|
667
|
+
// A turn entry is created BEFORE its model call and priced when the response
|
|
668
|
+
// lands, so a flush that falls in between persists it unpriced. Emit it a second
|
|
669
|
+
// time once `usage` is there — keyed separately so it happens at most once per
|
|
670
|
+
// turn — and let `assembleDebugLog` collapse the pair onto the priced copy.
|
|
671
|
+
// Without this the stitched lifetime log silently loses per-call cost for any
|
|
672
|
+
// turn that outlived a flush, which is most of the slow ones.
|
|
666
673
|
const key = String(entry.turnIndex ?? '');
|
|
667
|
-
|
|
668
|
-
cursors.emittedTurnKeys.
|
|
674
|
+
const emitKey = entry.usage ? `${key}::usage` : key;
|
|
675
|
+
if (cursors.emittedTurnKeys.has(emitKey)) continue;
|
|
676
|
+
cursors.emittedTurnKeys.add(emitKey);
|
|
669
677
|
delta.push(entry);
|
|
670
678
|
} else if (entry.kind === 'event') {
|
|
671
679
|
const index = typeof entry.index === 'number' ? entry.index : -1;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChatMessage } from '@genesislcap/foundation-ai';
|
|
2
2
|
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
3
|
-
import { addUsage, emptyUsage, sumUsage, totalTokens } from './sum-usage';
|
|
3
|
+
import { addUsage, emptyUsage, messageUsage, sumUsage, totalTokens } from './sum-usage';
|
|
4
4
|
|
|
5
5
|
const suite = createLogicSuite('sumUsage');
|
|
6
6
|
|
|
@@ -137,4 +137,55 @@ suite('addUsage with an empty operand is the identity', () => {
|
|
|
137
137
|
assert.equal(addUsage(emptyUsage(), a), a);
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
suite('messageUsage is undefined for a message reporting nothing', () => {
|
|
141
|
+
// A user turn, or a display-only reasoning/narration split. Distinct from a zeroed
|
|
142
|
+
// usage: "this call reported nothing" is not "this call cost nothing".
|
|
143
|
+
assert.is(messageUsage(msg({ role: 'user', content: 'hi' })), undefined);
|
|
144
|
+
assert.is(messageUsage(msg({ content: 'thinking…', category: 'reasoning' })), undefined);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
suite('messageUsage agrees with sumUsage over the same single message', () => {
|
|
148
|
+
// The two derivations are deliberately separate — `accumulate` mutates one
|
|
149
|
+
// accumulator rather than allocating per message — so pin them together. A drift
|
|
150
|
+
// here would put one figure on a turn entry and a different one in the session
|
|
151
|
+
// total, both looking plausible.
|
|
152
|
+
const cases: Partial<ChatMessage>[] = [
|
|
153
|
+
{ inputTokens: 1000, cacheReadTokens: 700, cacheWriteTokens: 200, outputTokens: 50, cost: 0.4 },
|
|
154
|
+
{ inputTokens: 400, outputTokens: 60 },
|
|
155
|
+
{ inputTokens: 100, cacheReadTokens: 90, cacheWriteTokens: 90 },
|
|
156
|
+
{ cost: 0.01, externalCostUsd: 0.02 },
|
|
157
|
+
];
|
|
158
|
+
for (const over of cases) {
|
|
159
|
+
const m = msg(over);
|
|
160
|
+
assert.equal(
|
|
161
|
+
messageUsage(m),
|
|
162
|
+
sumUsage([m]),
|
|
163
|
+
`messageUsage must match sumUsage for ${JSON.stringify(over)}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
suite('messageUsage is shallow — it prices the one request, not the tree', () => {
|
|
169
|
+
// Deliberately blind to sub-agent traces and banked compaction usage: it answers
|
|
170
|
+
// "what did this call cost", which is what a turn snapshot needs. `sumUsage` is the
|
|
171
|
+
// function that walks everything underneath.
|
|
172
|
+
const m = msg({
|
|
173
|
+
inputTokens: 100,
|
|
174
|
+
outputTokens: 10,
|
|
175
|
+
cost: 0.05,
|
|
176
|
+
toolCalls: [
|
|
177
|
+
{
|
|
178
|
+
id: 't1',
|
|
179
|
+
name: 'delegate',
|
|
180
|
+
args: {},
|
|
181
|
+
subAgentTrace: [msg({ inputTokens: 900, outputTokens: 90, cost: 0.5 })],
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
const usage = messageUsage(m)!;
|
|
186
|
+
assert.is(usage.costUsd, 0.05, 'the child conversation is not folded in');
|
|
187
|
+
assert.is(usage.uncachedInputTokens, 100);
|
|
188
|
+
assert.ok(sumUsage([m]).costUsd > usage.costUsd, 'sumUsage does fold it in');
|
|
189
|
+
});
|
|
190
|
+
|
|
140
191
|
suite.run();
|
package/src/utils/sum-usage.ts
CHANGED
|
@@ -53,6 +53,51 @@ export function addUsage(a: AggregateUsage, b: AggregateUsage): AggregateUsage {
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The four buckets plus USD for ONE message — the per-message fields projected into
|
|
58
|
+
* the disjoint, addable shape, or `undefined` when the message reports no usage at
|
|
59
|
+
* all (a user turn, a display-only reasoning/narration split, a provider that
|
|
60
|
+
* reports none).
|
|
61
|
+
*
|
|
62
|
+
* Shallow by design: it does NOT recurse into `toolCalls[].subAgentTrace` and does
|
|
63
|
+
* NOT count `compaction.rolledUpUsage`, so it answers "what did this one request
|
|
64
|
+
* cost" rather than "what did this message and everything under it cost". Use
|
|
65
|
+
* {@link sumUsage} for the latter — passing a whole transcript through this one
|
|
66
|
+
* message at a time would silently drop both.
|
|
67
|
+
*
|
|
68
|
+
* `costUsd` folds in `externalCostUsd` (non-LLM spend a widget reported), matching
|
|
69
|
+
* `sumUsage`. It is `0` when the provider priced nothing, which is NOT a claim that
|
|
70
|
+
* the call was free — see {@link AggregateUsage}; `UsageRow.costUsd` is the
|
|
71
|
+
* shape that keeps unpriced distinguishable, and is the right tool for a ledger.
|
|
72
|
+
*
|
|
73
|
+
* @beta
|
|
74
|
+
*/
|
|
75
|
+
export function messageUsage(m: ChatMessage): AggregateUsage | undefined {
|
|
76
|
+
if (
|
|
77
|
+
m.cost == null &&
|
|
78
|
+
m.externalCostUsd == null &&
|
|
79
|
+
m.inputTokens == null &&
|
|
80
|
+
m.outputTokens == null &&
|
|
81
|
+
m.cacheReadTokens == null &&
|
|
82
|
+
m.cacheWriteTokens == null
|
|
83
|
+
) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const cacheReadTokens = m.cacheReadTokens ?? 0;
|
|
87
|
+
const cacheWriteTokens = m.cacheWriteTokens ?? 0;
|
|
88
|
+
return {
|
|
89
|
+
costUsd: (m.cost ?? 0) + (m.externalCostUsd ?? 0),
|
|
90
|
+
// The uncached REMAINDER, clamped — the same arithmetic `accumulate` below applies,
|
|
91
|
+
// for the same reasons. The two are deliberately separate implementations (that walk
|
|
92
|
+
// mutates one accumulator rather than allocating per message) and must agree;
|
|
93
|
+
// `sum-usage.test.ts` pins them together.
|
|
94
|
+
uncachedInputTokens: Math.max(0, (m.inputTokens ?? 0) - cacheReadTokens - cacheWriteTokens),
|
|
95
|
+
cacheReadTokens,
|
|
96
|
+
cacheWriteTokens,
|
|
97
|
+
outputTokens: m.outputTokens ?? 0,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
56
101
|
/**
|
|
57
102
|
* Sum cost and per-bucket token usage across a message list.
|
|
58
103
|
*
|