@genesislcap/ai-assistant 15.10.6 → 15.12.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.
Files changed (70) hide show
  1. package/dist/ai-assistant.api.json +226 -0
  2. package/dist/ai-assistant.d.ts +100 -3
  3. package/dist/chat-driver.cjs +540 -117
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +524 -116
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +439 -66
  8. package/dist/dts/chat-driver-node.d.ts +5 -2
  9. package/dist/dts/chat-driver-node.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +20 -3
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts +2 -0
  13. package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts.map +1 -0
  14. package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts +2 -0
  15. package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts.map +1 -0
  16. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  17. package/dist/dts/components/settings-modal/settings-modal.template.d.ts +1 -1
  18. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  19. package/dist/dts/config/config.d.ts +39 -2
  20. package/dist/dts/config/config.d.ts.map +1 -1
  21. package/dist/dts/config/define-stateful-agent.d.ts +15 -1
  22. package/dist/dts/config/define-stateful-agent.d.ts.map +1 -1
  23. package/dist/dts/main/main.d.ts +25 -0
  24. package/dist/dts/main/main.d.ts.map +1 -1
  25. package/dist/dts/main/main.template.d.ts.map +1 -1
  26. package/dist/dts/react.d.ts +9 -8
  27. package/dist/dts/utils/strip-agent-handlers.d.ts +1 -1
  28. package/dist/dts/utils/sum-usage.d.ts +37 -4
  29. package/dist/dts/utils/sum-usage.d.ts.map +1 -1
  30. package/dist/dts/utils/usage-rows.d.ts +102 -0
  31. package/dist/dts/utils/usage-rows.d.ts.map +1 -0
  32. package/dist/dts/utils/usage-rows.test.d.ts +2 -0
  33. package/dist/dts/utils/usage-rows.test.d.ts.map +1 -0
  34. package/dist/esm/chat-driver-node.js +36 -1
  35. package/dist/esm/components/chat-driver/chat-driver.js +73 -10
  36. package/dist/esm/components/chat-driver/chat-driver.thinking-policy.test.js +137 -0
  37. package/dist/esm/components/chat-driver/chat-driver.trace-capture.test.js +200 -0
  38. package/dist/esm/components/settings-modal/settings-modal.styles.js +22 -0
  39. package/dist/esm/components/settings-modal/settings-modal.template.js +25 -4
  40. package/dist/esm/components/settings-modal/settings-modal.template.test.js +1 -1
  41. package/dist/esm/config/define-stateful-agent.js +11 -0
  42. package/dist/esm/main/blocked-state.test.js +1 -0
  43. package/dist/esm/main/budget-meter.test.js +42 -0
  44. package/dist/esm/main/main.js +49 -0
  45. package/dist/esm/main/main.template.js +20 -1
  46. package/dist/esm/utils/strip-agent-handlers.js +1 -1
  47. package/dist/esm/utils/sum-usage.js +37 -4
  48. package/dist/esm/utils/usage-rows.js +90 -0
  49. package/dist/esm/utils/usage-rows.test.js +189 -0
  50. package/dist/react.cjs +11 -6
  51. package/dist/react.mjs +10 -5
  52. package/dist/tsconfig.tsbuildinfo +1 -1
  53. package/package.json +17 -17
  54. package/src/chat-driver-node.ts +58 -0
  55. package/src/components/chat-driver/chat-driver.thinking-policy.test.ts +185 -0
  56. package/src/components/chat-driver/chat-driver.trace-capture.test.ts +251 -0
  57. package/src/components/chat-driver/chat-driver.ts +90 -10
  58. package/src/components/settings-modal/settings-modal.styles.ts +22 -0
  59. package/src/components/settings-modal/settings-modal.template.test.ts +1 -1
  60. package/src/components/settings-modal/settings-modal.template.ts +27 -2
  61. package/src/config/config.ts +50 -1
  62. package/src/config/define-stateful-agent.ts +37 -0
  63. package/src/main/blocked-state.test.ts +1 -0
  64. package/src/main/budget-meter.test.ts +50 -0
  65. package/src/main/main.template.ts +19 -1
  66. package/src/main/main.ts +47 -0
  67. package/src/utils/strip-agent-handlers.ts +1 -1
  68. package/src/utils/sum-usage.ts +37 -4
  69. package/src/utils/usage-rows.test.ts +237 -0
  70. package/src/utils/usage-rows.ts +187 -0
@@ -4,7 +4,7 @@ import type { AggregateUsage, ChatMessage } from '@genesislcap/foundation-ai';
4
4
  * Zeroed {@link AggregateUsage}. A factory, not a shared const, because callers
5
5
  * accumulate into the returned object.
6
6
  *
7
- * @internal
7
+ * @beta
8
8
  */
9
9
  export function emptyUsage(): AggregateUsage {
10
10
  return {
@@ -26,7 +26,7 @@ export function emptyUsage(): AggregateUsage {
26
26
  * NOT the count of distinct tokens in the conversation, so it grows
27
27
  * super-linearly with turn count and is expected to dwarf the context size.
28
28
  *
29
- * @internal
29
+ * @beta
30
30
  */
31
31
  export function totalTokens(usage: AggregateUsage): number {
32
32
  return (
@@ -41,7 +41,7 @@ export function totalTokens(usage: AggregateUsage): number {
41
41
  * proves. Pure rather than in-place because both operands are live state a caller
42
42
  * must not mutate.
43
43
  *
44
- * @internal
44
+ * @beta
45
45
  */
46
46
  export function addUsage(a: AggregateUsage, b: AggregateUsage): AggregateUsage {
47
47
  return {
@@ -77,7 +77,40 @@ export function addUsage(a: AggregateUsage, b: AggregateUsage): AggregateUsage {
77
77
  * Returns a zeroed total when no message carries usage — providers that report
78
78
  * none (e.g. Chrome built-in) are skipped silently rather than guessed at.
79
79
  *
80
- * @internal
80
+ * @remarks
81
+ * This sums the WHOLE array, which is the right answer only when the array is the
82
+ * whole of the work being billed. A driver whose conversation **continues across
83
+ * rounds** opens its history with turns an earlier round already paid for, so
84
+ * summing it again bills them twice — and the session total then grows
85
+ * quadratically in the number of rounds while every individual figure in it stays
86
+ * correct. Nothing about the result looks wrong, which is why it is called out
87
+ * here rather than left to the caller to notice.
88
+ *
89
+ * Do NOT solve this with a "sum from this message onwards" marker. Compaction
90
+ * *replaces* the messages it summarises — and compaction is one of this function's
91
+ * own call sites — so the marker message can be absent from history by the time
92
+ * the sum runs, leaving the caller to either fail or silently sum everything,
93
+ * which is the double-count the marker was meant to prevent.
94
+ *
95
+ * Instead hold a **banked baseline**: spend that predates the current transcript,
96
+ * carried as a value, with `usage = banked + live` composed via {@link addUsage}.
97
+ * `resolveBankedUsage` in `utils/cost-session-history.ts` is the worked form, and
98
+ * the shape of its signature is the load-bearing part. It separates three cases:
99
+ *
100
+ * 1. no prior record — a genuine first round, so nothing is banked;
101
+ * 2. prior record, and the live transcript already accounts for it (a restored
102
+ * session) — carry the prior record's own banked figure forward;
103
+ * 3. prior record, and the transcript starts empty — bank the prior record's
104
+ * whole total, because nothing in the transcript can re-derive it.
105
+ *
106
+ * Case 1 and case 3 take the same arithmetic and mean opposite things, so the
107
+ * prior record must be a **required** input with no zero-defaulting path: a
108
+ * caller who carries a transcript and forgets its baseline reports the entire
109
+ * prior conversation as this round's spend. Distinguishing 2 from 3 is a separate
110
+ * decision, and getting it wrong is destructive in the other direction — it
111
+ * rewrites a total down to the current page-load and drops earlier spend.
112
+ *
113
+ * @beta
81
114
  */
82
115
  export function sumUsage(messages: readonly ChatMessage[]): AggregateUsage {
83
116
  const total = emptyUsage();
@@ -0,0 +1,237 @@
1
+ import type { AggregateUsage, ChatMessage } from '@genesislcap/foundation-ai';
2
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
+ import { sumUsage } from './sum-usage';
4
+ import { usageRows } from './usage-rows';
5
+
6
+ const msg = (over: Partial<ChatMessage>): ChatMessage =>
7
+ ({ role: 'assistant', content: '', ...over }) as ChatMessage;
8
+
9
+ const rolled = (over: Partial<AggregateUsage>): AggregateUsage => ({
10
+ costUsd: 0,
11
+ uncachedInputTokens: 0,
12
+ cacheReadTokens: 0,
13
+ cacheWriteTokens: 0,
14
+ outputTokens: 0,
15
+ ...over,
16
+ });
17
+
18
+ /** Every place spend can hide, in one transcript: a delegation and a compaction. */
19
+ const richHistory = (): ChatMessage[] => [
20
+ msg({
21
+ role: 'compacted-summary',
22
+ content: 'summary',
23
+ compaction: {
24
+ compactedCount: 4,
25
+ rolledUpUsage: rolled({ costUsd: 0.05, uncachedInputTokens: 400, outputTokens: 60 }),
26
+ // The summariser's model — NOT what the banked spend ran on.
27
+ model: 'claude-haiku-4-5-20251001',
28
+ },
29
+ } as Partial<ChatMessage>),
30
+ msg({ role: 'user', content: 'go' }),
31
+ msg({
32
+ model: 'claude-sonnet-5',
33
+ agentName: 'boss',
34
+ cost: 0.01,
35
+ inputTokens: 1_000,
36
+ cacheReadTokens: 900,
37
+ outputTokens: 50,
38
+ toolCalls: [
39
+ {
40
+ id: 'tc1',
41
+ name: 'delegate',
42
+ args: {},
43
+ subAgentTrace: [
44
+ msg({
45
+ model: 'gemini-2.5-flash',
46
+ agentName: 'worker',
47
+ cost: 0.12,
48
+ inputTokens: 500,
49
+ outputTokens: 200,
50
+ }),
51
+ ],
52
+ },
53
+ ],
54
+ }),
55
+ ];
56
+
57
+ const suite = createLogicSuite('usageRows');
58
+
59
+ suite('reconciles exactly with sumUsage over the same input', () => {
60
+ // THE guarantee. A per-call view that disagrees with the total is worse than none,
61
+ // because both look right in isolation — which is how the consumer's own copy
62
+ // under-reported for a month.
63
+ const history = richHistory();
64
+ const rows = usageRows(history);
65
+ const total = sumUsage(history);
66
+
67
+ const rowCost = rows.reduce((n, r) => n + (r.costUsd ?? 0) + (r.externalCostUsd ?? 0), 0);
68
+ assert.ok(Math.abs(rowCost - total.costUsd) < 1e-12, `${rowCost} vs ${total.costUsd}`);
69
+
70
+ const bucket = (
71
+ k: 'uncachedInputTokens' | 'cacheReadTokens' | 'cacheWriteTokens' | 'outputTokens',
72
+ ) => rows.reduce((n, r) => n + r[k], 0);
73
+ assert.is(bucket('uncachedInputTokens'), total.uncachedInputTokens, 'uncached input');
74
+ assert.is(bucket('cacheReadTokens'), total.cacheReadTokens, 'cache read');
75
+ assert.is(bucket('cacheWriteTokens'), total.cacheWriteTokens, 'cache write');
76
+ assert.is(bucket('outputTokens'), total.outputTokens, 'output');
77
+ });
78
+
79
+ suite('emits a compaction row so banked spend is not lost from a per-call view', () => {
80
+ // The shortfall this function exists to close: the summary message carries no
81
+ // `cost` and no token fields of its own, so a naive per-message walk drops the
82
+ // spend of every turn the compaction deleted.
83
+ const rows = usageRows(richHistory());
84
+ const compactionRows = rows.filter((r) => r.source === 'compaction');
85
+ assert.is(compactionRows.length, 1);
86
+ assert.is(compactionRows[0].costUsd, 0.05);
87
+ assert.is(compactionRows[0].uncachedInputTokens, 400);
88
+ assert.is(
89
+ compactionRows[0].model,
90
+ undefined,
91
+ 'no model — the banked spend may span several, and compaction.model is the summariser',
92
+ );
93
+ });
94
+
95
+ suite('recurses into sub-agent traces, tagging depth and the spawning call', () => {
96
+ const rows = usageRows(richHistory());
97
+ const child = rows.find((r) => r.model === 'gemini-2.5-flash');
98
+ assert.ok(child, 'the sub-agent turn produced a row');
99
+ assert.is(child!.subAgentDepth, 1);
100
+ assert.is(child!.subAgentOf, 'tc1');
101
+ assert.is(child!.agentName, 'worker');
102
+ const parent = rows.find((r) => r.model === 'claude-sonnet-5');
103
+ assert.is(parent!.subAgentDepth, 0);
104
+ assert.is(parent!.subAgentOf, undefined);
105
+ });
106
+
107
+ suite('prefers the provider the driver stamped over re-deriving it from the model', () => {
108
+ // `ChatMessage.provider` comes from the resolved provider's own status, so it is
109
+ // authoritative and knows vendors the model allowlists do not. Re-deriving from the
110
+ // model id alone drops attribution for a server-proxied turn — exactly the rows a
111
+ // usage ledger cares most about getting right. PR review.
112
+ const rows = usageRows([
113
+ msg({ model: 'gpt-5', provider: 'openai', cost: 1, inputTokens: 1 } as Partial<ChatMessage>),
114
+ // Stamped provider disagrees with what the model id would derive: the stamp still wins.
115
+ msg({
116
+ model: 'claude-sonnet-5',
117
+ provider: 'chrome',
118
+ cost: 1,
119
+ inputTokens: 1,
120
+ } as Partial<ChatMessage>),
121
+ ]);
122
+ assert.equal(
123
+ rows.map((r) => r.provider),
124
+ ['openai', 'chrome'],
125
+ );
126
+ });
127
+
128
+ suite('resolves the provider from the model, and leaves it unset when unknown', () => {
129
+ const rows = usageRows([
130
+ msg({ model: 'claude-sonnet-5', cost: 1, inputTokens: 1 }),
131
+ msg({ model: 'gemini-2.5-flash', cost: 1, inputTokens: 1 }),
132
+ msg({ model: 'gpt-5', cost: 1, inputTokens: 1 }),
133
+ msg({ cost: 1, inputTokens: 1 }),
134
+ ]);
135
+ assert.equal(
136
+ rows.map((r) => r.provider),
137
+ ['anthropic', 'gemini', undefined, undefined],
138
+ 'an unrecognised or absent model leaves provider unset rather than guessing',
139
+ );
140
+ });
141
+
142
+ suite('splits the prompt into buckets the same way sumUsage does', () => {
143
+ const [row] = usageRows([
144
+ msg({
145
+ model: 'claude-sonnet-5',
146
+ inputTokens: 1_000,
147
+ cacheReadTokens: 600,
148
+ cacheWriteTokens: 300,
149
+ }),
150
+ ]);
151
+ assert.is(row.uncachedInputTokens, 100, 'the remainder, not an addition');
152
+ assert.is(row.cacheReadTokens, 600);
153
+ assert.is(row.cacheWriteTokens, 300);
154
+ });
155
+
156
+ suite('clamps a prompt whose cache buckets exceed the total', () => {
157
+ const [row] = usageRows([msg({ inputTokens: 100, cacheReadTokens: 900 })]);
158
+ assert.is(row.uncachedInputTokens, 0, 'never negative');
159
+ });
160
+
161
+ suite('leaves costUsd undefined when nothing was reported, rather than zero', () => {
162
+ // What the "carried usage but no cost" audit keys off. A zero here would read as a
163
+ // free call and the alarm would never fire.
164
+ const [row] = usageRows([msg({ model: 'claude-sonnet-5', inputTokens: 500, outputTokens: 10 })]);
165
+ assert.is(row.costUsd, undefined);
166
+ assert.is(row.source, 'request');
167
+ });
168
+
169
+ suite('records external (non-LLM) cost and counts it in the reconciliation', () => {
170
+ const history = [msg({ externalCostUsd: 0.4 }), msg({ model: 'claude-sonnet-5', cost: 0.1 })];
171
+ const rows = usageRows(history);
172
+ assert.is(rows.find((r) => r.externalCostUsd != null)!.externalCostUsd, 0.4);
173
+ const rowCost = rows.reduce((n, r) => n + (r.costUsd ?? 0) + (r.externalCostUsd ?? 0), 0);
174
+ assert.ok(Math.abs(rowCost - sumUsage(history).costUsd) < 1e-12);
175
+ });
176
+
177
+ suite('produces no row for a message carrying no usage', () => {
178
+ assert.equal(
179
+ usageRows([
180
+ msg({ role: 'user', content: 'hello' }),
181
+ msg({ content: 'thinking out loud', category: 'reasoning' }),
182
+ ]),
183
+ [],
184
+ );
185
+ });
186
+
187
+ suite('is empty for an empty transcript', () => {
188
+ assert.equal(usageRows([]), []);
189
+ });
190
+
191
+ suite('reconciles on a nested delegation two levels deep', () => {
192
+ const history = [
193
+ msg({
194
+ model: 'claude-sonnet-5',
195
+ cost: 0.01,
196
+ inputTokens: 100,
197
+ toolCalls: [
198
+ {
199
+ id: 'a',
200
+ name: 'delegate',
201
+ args: {},
202
+ subAgentTrace: [
203
+ msg({
204
+ model: 'claude-sonnet-5',
205
+ cost: 0.02,
206
+ inputTokens: 200,
207
+ toolCalls: [
208
+ {
209
+ id: 'b',
210
+ name: 'delegate',
211
+ args: {},
212
+ subAgentTrace: [msg({ model: 'gemini-2.5-flash', cost: 0.04, inputTokens: 400 })],
213
+ },
214
+ ],
215
+ }),
216
+ ],
217
+ },
218
+ ],
219
+ }),
220
+ ];
221
+ const rows = usageRows(history);
222
+ assert.equal(
223
+ rows.map((r) => r.subAgentDepth),
224
+ [0, 1, 2],
225
+ );
226
+ const rowCost = rows.reduce((n, r) => n + (r.costUsd ?? 0), 0);
227
+ assert.ok(Math.abs(rowCost - sumUsage(history).costUsd) < 1e-12, 'still reconciles');
228
+ });
229
+
230
+ suite('does not mutate the transcript', () => {
231
+ const history = richHistory();
232
+ const before = JSON.stringify(history);
233
+ usageRows(history);
234
+ assert.is(JSON.stringify(history), before, 'a projection, not a transform');
235
+ });
236
+
237
+ suite.run();
@@ -0,0 +1,187 @@
1
+ import type { AggregateUsage, AIProviderType, ChatMessage } from '@genesislcap/foundation-ai';
2
+ import { vendorOfModel } from '@genesislcap/foundation-ai';
3
+
4
+ /**
5
+ * One billable unit of spend, projected out of a transcript.
6
+ *
7
+ * @remarks
8
+ * Deliberately **not** a `ChatMessage`, and not an extension of one. A row is a
9
+ * derived record for a ledger, and keeping the types distinct makes the one
10
+ * dangerous mistake — feeding rows back into `sumUsage`, which does its own
11
+ * recursion and would count everything twice — a compile error rather than a
12
+ * plausible wrong number.
13
+ *
14
+ * @beta
15
+ */
16
+ export interface UsageRow {
17
+ /**
18
+ * Where the spend came from.
19
+ *
20
+ * - `'request'` — one model call, with a message behind it.
21
+ * - `'compaction'` — spend **banked** by a compaction. The turns that incurred it
22
+ * were summarised and deleted, so there is no single call to attribute it to;
23
+ * the amount survives on the summary's `compaction.rolledUpUsage`. Emitted as
24
+ * its own row because otherwise this money is simply missing from a per-call
25
+ * view while remaining present in the total, and the two disagree silently.
26
+ */
27
+ source: 'request' | 'compaction';
28
+ /**
29
+ * Model that produced the call. `undefined` on a `'compaction'` row: the banked
30
+ * spend covers however many turns were summarised, which may have run on
31
+ * different models, so attributing it to one would invent a fact. (The summary's
32
+ * `compaction.model` is the model that did the *summarising* — a different thing,
33
+ * and not what this spend was.)
34
+ */
35
+ model?: string;
36
+ /**
37
+ * Vendor that served the call. Taken from the provider the driver stamped on the message,
38
+ * which is authoritative and covers vendors the model allowlists do not — a server-proxied
39
+ * `gpt-5` turn is `'openai'` here and unknown to `vendorOfModel`. Falls back to resolving
40
+ * {@link UsageRow.model} through `vendorOfModel` only for messages persisted before that
41
+ * field existed, and is left unset when neither knows rather than guessed at.
42
+ */
43
+ provider?: AIProviderType;
44
+ /**
45
+ * USD for this row, as reported by the provider and stamped by the transport.
46
+ *
47
+ * `undefined` means no cost was reported — **not** zero. Nothing here estimates
48
+ * one from the token counts: an estimate is plausible, unremarkable and quietly
49
+ * wrong, whereas a total that is visibly too low gets investigated. A row with
50
+ * tokens but no `costUsd` is worth alarming on; that is the shape that check needs.
51
+ */
52
+ costUsd?: number;
53
+ /** Non-LLM spend a host reported for this turn (an external service it called). */
54
+ externalCostUsd?: number;
55
+ /** Prompt tokens billed at the full uncached rate — the remainder after the cache buckets. */
56
+ uncachedInputTokens: number;
57
+ /** Prompt tokens served from the provider's cache. */
58
+ cacheReadTokens: number;
59
+ /** Prompt tokens written to the provider's cache. */
60
+ cacheWriteTokens: number;
61
+ /** Generated tokens, including reasoning tokens the provider bills as output. */
62
+ outputTokens: number;
63
+ /** Agent that produced the call, when known. */
64
+ agentName?: string;
65
+ /** Delegation depth: 0 for the top-level conversation, 1 for a sub-agent, 2 for its sub-agent, … */
66
+ subAgentDepth: number;
67
+ /** Id of the tool call that spawned this row's conversation — correlates a row back to its delegation. */
68
+ subAgentOf?: string;
69
+ }
70
+
71
+ /** Whether a message carries anything a ledger would record. */
72
+ function hasUsage(m: ChatMessage): boolean {
73
+ return (
74
+ m.cost != null ||
75
+ m.externalCostUsd != null ||
76
+ m.inputTokens != null ||
77
+ m.outputTokens != null ||
78
+ m.cacheReadTokens != null ||
79
+ m.cacheWriteTokens != null
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Project a transcript into one row per billable unit of spend.
85
+ *
86
+ * @remarks
87
+ * Reads the messages and returns a table; it stores nothing, mutates nothing, and
88
+ * adds nothing to history. Use it where an **aggregate is not enough** — writing
89
+ * usage rows for per-project attribution, a cost dashboard, or auditing which turns
90
+ * came back unpriced. For a single total, `sumUsage` is the answer and this is the
91
+ * wrong tool.
92
+ *
93
+ * Walks the same shape `sumUsage` does, and is guaranteed to agree with it: summing
94
+ * `costUsd` and `externalCostUsd` across these rows equals `sumUsage(...).costUsd`
95
+ * over the same input, and the four token buckets reconcile likewise. That property
96
+ * is the point of the function — a per-call view that quietly disagrees with the
97
+ * total is worse than no per-call view, because both look right in isolation.
98
+ *
99
+ * Covers the two places spend hides:
100
+ *
101
+ * - **Sub-agent conversations.** A delegating tool call carries the child's entire
102
+ * conversation on `toolCall.subAgentTrace`, so a walk of top-level messages alone
103
+ * misses everything a delegating agent spent. A consumer that reimplemented this
104
+ * and omitted the branch reported $0.106 on a run that cost $0.234.
105
+ * - **Compactions.** A compaction deletes the messages it summarises, banking their
106
+ * spend on the summary. Those turns produce a single `source: 'compaction'` row.
107
+ *
108
+ * Messages carrying no usage at all (a user turn, a narration) produce no row.
109
+ *
110
+ * **Do not pass the result to `sumUsage`** — the types prevent it, and the reason is
111
+ * that both recurse, so a pre-flattened list would be counted twice. Both functions
112
+ * take raw history.
113
+ *
114
+ * @beta
115
+ */
116
+ export function usageRows(messages: readonly ChatMessage[]): UsageRow[] {
117
+ return collectRows(messages, 0, undefined);
118
+ }
119
+
120
+ /** Map an already-bucketed {@link AggregateUsage} onto a row's token fields. */
121
+ function bucketsOf(
122
+ usage: AggregateUsage,
123
+ ): Pick<UsageRow, 'uncachedInputTokens' | 'cacheReadTokens' | 'cacheWriteTokens' | 'outputTokens'> {
124
+ return {
125
+ uncachedInputTokens: usage.uncachedInputTokens,
126
+ cacheReadTokens: usage.cacheReadTokens,
127
+ cacheWriteTokens: usage.cacheWriteTokens,
128
+ outputTokens: usage.outputTokens,
129
+ };
130
+ }
131
+
132
+ function collectRows(
133
+ messages: readonly ChatMessage[],
134
+ depth: number,
135
+ subAgentOf: string | undefined,
136
+ ): UsageRow[] {
137
+ const rows: UsageRow[] = [];
138
+ for (const m of messages) {
139
+ if (hasUsage(m)) {
140
+ const cacheReadTokens = m.cacheReadTokens ?? 0;
141
+ const cacheWriteTokens = m.cacheWriteTokens ?? 0;
142
+ rows.push({
143
+ source: 'request',
144
+ model: m.model,
145
+ // The driver stamps `ChatMessage.provider` from the resolved provider's own
146
+ // status, so it is AUTHORITATIVE and covers vendors the model allowlists do
147
+ // not — a server-proxied `gpt-5` turn is `provider: 'openai'` on the message
148
+ // and unknown to `vendorOfModel`. Prefer it; fall back to resolving the model
149
+ // id only for messages persisted before the field existed. Still left
150
+ // undefined when neither knows, rather than guessed at.
151
+ provider: m.provider ?? (m.model ? vendorOfModel(m.model) : undefined),
152
+ ...(m.cost != null && { costUsd: m.cost }),
153
+ ...(m.externalCostUsd != null && { externalCostUsd: m.externalCostUsd }),
154
+ // Uncached input is the REMAINDER, matching `sumUsage`: `inputTokens` is the
155
+ // whole prompt and the cache fields break it down. Clamped for the same
156
+ // reason — hand-edited history must not drive a total negative.
157
+ uncachedInputTokens: Math.max(0, (m.inputTokens ?? 0) - cacheReadTokens - cacheWriteTokens),
158
+ cacheReadTokens,
159
+ cacheWriteTokens,
160
+ outputTokens: m.outputTokens ?? 0,
161
+ ...(m.agentName != null && { agentName: m.agentName }),
162
+ subAgentDepth: depth,
163
+ ...(subAgentOf != null && { subAgentOf }),
164
+ });
165
+ }
166
+
167
+ // Spend the compaction banked on this summary's behalf. Its buckets are already
168
+ // disjoint (it is an `AggregateUsage`), so they map across without the
169
+ // subtraction a per-message record needs.
170
+ const rolled = m.compaction?.rolledUpUsage;
171
+ if (rolled) {
172
+ rows.push({
173
+ source: 'compaction',
174
+ ...(rolled.costUsd != null && { costUsd: rolled.costUsd }),
175
+ ...bucketsOf(rolled),
176
+ ...(m.agentName != null && { agentName: m.agentName }),
177
+ subAgentDepth: depth,
178
+ ...(subAgentOf != null && { subAgentOf }),
179
+ });
180
+ }
181
+
182
+ for (const tc of m.toolCalls ?? []) {
183
+ if (tc.subAgentTrace) rows.push(...collectRows(tc.subAgentTrace, depth + 1, tc.id));
184
+ }
185
+ }
186
+ return rows;
187
+ }