@monotykamary/pi-tps 1.1.1 → 1.3.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 CHANGED
@@ -20,7 +20,7 @@ _Originally from [badlogic/pi-mono](https://github.com/badlogic/pi-mono/blob/mai
20
20
  ## Quick start
21
21
 
22
22
  ```bash
23
- pi install https://github.com/monotykamary/pi-tps
23
+ pi install npm:@monotykamary/pi-tps
24
24
  ```
25
25
 
26
26
  ## What's included
@@ -43,6 +43,12 @@ pi install https://github.com/monotykamary/pi-tps
43
43
 
44
44
  ## Install
45
45
 
46
+ ```bash
47
+ pi install npm:@monotykamary/pi-tps
48
+ ```
49
+
50
+ Or install from GitHub:
51
+
46
52
  ```bash
47
53
  pi install https://github.com/monotykamary/pi-tps
48
54
  ```
@@ -63,24 +69,35 @@ Then `/reload` in pi.
63
69
  ## Output format
64
70
 
65
71
  ```
66
- TPS 42.5 tok/s · TTFT 1.2s · 29.7s · in 567 · out 1.2K · stall 4.3s×1
72
+ TPS 42.5 tok/s · TTFT 1.2s · 29.7s · in 567 · out 1.2K · stall 4.3s×1 · $3.60/M
67
73
  ```
68
74
 
69
- | Field | Description |
70
- | ------- | ------------------------------------------------------------------- |
71
- | `TPS` | Tokens per second (generation speed, excludes TTFT & stalls) |
72
- | `TTFT` | Time to first token (seconds, 1 decimal) |
73
- | `s` | Total wall-clock time from request to completion |
74
- | `in` | Input tokens (human-readable: K/M/B) |
75
- | `out` | Output tokens (human-readable: K/M/B) |
76
- | `stall` | Accumulated stall time × stall count (shown only when stalls exist) |
75
+ | Field | Description |
76
+ | ------- | ---------------------------------------------------------------------------------------- |
77
+ | `TPS` | Tokens per second (generation speed, excludes TTFT & stalls) |
78
+ | `TTFT` | Time to first token (seconds, 1 decimal) |
79
+ | `s` | Total wall-clock time from request to completion |
80
+ | `in` | Input tokens (human-readable: K/M/B) |
81
+ | `out` | Output tokens (human-readable: K/M/B) |
82
+ | `stall` | Accumulated stall time × stall count (shown only when stalls exist) |
83
+ | `$/M` | Blended $/million-tokens rate for this turn (shown only when a cost source is available) |
77
84
 
78
85
  When TPS can't be determined (burst delivery, too few chunks), the field shows `—`:
79
86
 
80
87
  ```
81
- TPS — · TTFT 0.8s · 1.3s · in 291 · out 46
88
+ TPS — · TTFT 0.8s · 1.3s · in 291 · out 46 · $2.59/M
82
89
  ```
83
90
 
91
+ ### $/M-tokens rate
92
+
93
+ The trailing `$/M` segment is a blended per-turn rate: `effectiveCost / (totalTokens / 1_000_000)`, shown to two decimals. Only one cost source contributes per turn (no double-counting):
94
+
95
+ - When [pi-neuralwatt-provider](https://github.com/monotykamary/pi-neuralwatt-provider) reports the **energy-billed cost** for the turn (what you're actually invoiced), that wins outright.
96
+ - Otherwise the **list-price compute cost** from `message.usage.cost.total` (derived from the model's pricing table via pi-ai) is used.
97
+ - When neither is available (no pricing, zero tokens), the segment is omitted.
98
+
99
+ The cross-extension handoff is event-based: pi-neuralwatt-provider emits `neuralwatt:turn-energy` with `{ costUsd, energyJoules, turnIndex }` in its own `turn_end`; pi-tps subscribes at load and stashes `costUsd` keyed by `turnIndex`. pi dispatches `turn_end` handlers sequentially, so if the neuralwatt provider was registered before pi-tps the value is already cached when pi-tps's `turn_end` runs; if not, pi-tps falls back to the list-price rate for that one turn (the neuralwatt widget still shows billing separately). No latency is added — pi-tps reads the cache synchronously, never awaits.
100
+
84
101
  Human-readable scaling (for token counts):
85
102
 
86
103
  - `< 1K`: raw integer (`567`)
@@ -186,29 +203,30 @@ pi.events.on('tps:telemetry', (data) => {
186
203
 
187
204
  The event payload:
188
205
 
189
- | Field | Type | Description |
190
- | --------------------- | ---------------- | --------------------------------------------------- |
191
- | `tps` | `number \| null` | Tokens per second, or null when unidentifiable |
192
- | `model.provider` | `string` | Provider name (e.g. `openai`) |
193
- | `model.modelId` | `string` | Model identifier (e.g. `gpt-4`) |
194
- | `tokens.input` | `number` | Input tokens (summed across all assistant messages) |
195
- | `tokens.output` | `number` | Output tokens generated by the LLM |
196
- | `tokens.cacheRead` | `number` | Cache-read tokens (provider-dependent) |
197
- | `tokens.cacheWrite` | `number` | Cache-write tokens (provider-dependent) |
198
- | `tokens.total` | `number` | Total tokens (input + output + cache) |
199
- | `timing.ttftMs` | `number \| null` | Time to first token in milliseconds |
200
- | `timing.totalMs` | `number` | Total wall-clock time from request to completion |
201
- | `timing.generationMs` | `number` | Streaming wall clock (message_start → message_end) |
202
- | `timing.streamMs` | `number \| null` | Inter-update span: first → last streaming update |
203
- | `timing.stallMs` | `number` | Accumulated inference stall time in ms |
204
- | `timing.stallCount` | `number` | Number of discrete stall events |
205
- | `timing.messageCount` | `number` | Assistant messages in this turn |
206
- | `cost.input` | `number \| null` | Input token cost |
207
- | `cost.output` | `number \| null` | Output token cost |
208
- | `cost.cacheRead` | `number \| null` | Cache-read token cost |
209
- | `cost.cacheWrite` | `number \| null` | Cache-write token cost |
210
- | `cost.total` | `number \| null` | Total cost for this turn |
211
- | `timestamp` | `number` | Unix timestamp (ms) when telemetry was computed |
206
+ | Field | Type | Description |
207
+ | --------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
208
+ | `tps` | `number \| null` | Tokens per second, or null when unidentifiable |
209
+ | `model.provider` | `string` | Provider name (e.g. `openai`) |
210
+ | `model.modelId` | `string` | Model identifier (e.g. `gpt-4`) |
211
+ | `tokens.input` | `number` | Input tokens (summed across all assistant messages) |
212
+ | `tokens.output` | `number` | Output tokens generated by the LLM |
213
+ | `tokens.cacheRead` | `number` | Cache-read tokens (provider-dependent) |
214
+ | `tokens.cacheWrite` | `number` | Cache-write tokens (provider-dependent) |
215
+ | `tokens.total` | `number` | Total tokens (input + output + cache) |
216
+ | `timing.ttftMs` | `number \| null` | Time to first token in milliseconds |
217
+ | `timing.totalMs` | `number` | Total wall-clock time from request to completion |
218
+ | `timing.generationMs` | `number` | Streaming wall clock (message_start → message_end) |
219
+ | `timing.streamMs` | `number \| null` | Inter-update span: first → last streaming update |
220
+ | `timing.stallMs` | `number` | Accumulated inference stall time in ms |
221
+ | `timing.stallCount` | `number` | Number of discrete stall events |
222
+ | `timing.messageCount` | `number` | Assistant messages in this turn |
223
+ | `cost.input` | `number \| null` | Input token cost |
224
+ | `cost.output` | `number \| null` | Output token cost |
225
+ | `cost.cacheRead` | `number \| null` | Cache-read token cost |
226
+ | `cost.cacheWrite` | `number \| null` | Cache-write token cost |
227
+ | `cost.total` | `number \| null` | Total cost for this turn |
228
+ | `timestamp` | `number` | Unix timestamp (ms) when telemetry was computed |
229
+ | `rateUsdPerMTokens` | `number \| null` | Blended $/M-tokens rate for this turn (Neuralwatt billed cost when available, else list-price compute cost). `null` when no cost source is usable or tokens are zero. |
212
230
 
213
231
  When `cost` is unavailable (provider doesn't report it), the entire `cost` object is `null`.
214
232
 
@@ -0,0 +1,252 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import type { AssistantMessage } from '@earendil-works/pi-ai';
3
+ import { createTestFixture, activateExtension, tick, makeAssistantMessage } from './helpers';
4
+
5
+ /**
6
+ * Coverage for the blended $/M-tokens notification field:
7
+ * rateUsdPerMTokens = effectiveCost / (tokens.total / 1_000_000)
8
+ *
9
+ * effectiveCost is:
10
+ * - the Neuralwatt billed cost when stashed via the `neuralwatt:turn-energy`
11
+ * event (energy-based, what the user actually pays)
12
+ * - otherwise the list-price compute cost from message.usage.cost.total
13
+ *
14
+ * Only one source contributes per turn (no double-counting): when both are
15
+ * present the billed cost wins outright.
16
+ */
17
+
18
+ function makeMessageWithCost(opts: {
19
+ input: number;
20
+ output: number;
21
+ costTotal: number;
22
+ provider?: string;
23
+ model?: string;
24
+ }): AssistantMessage {
25
+ const { input, output, costTotal, provider = 'openai', model = 'gpt-4' } = opts;
26
+ return {
27
+ role: 'assistant',
28
+ content: [{ type: 'text', text: 'Hello' }],
29
+ api: 'openai-completions',
30
+ provider,
31
+ model,
32
+ usage: {
33
+ input,
34
+ output,
35
+ cacheRead: 0,
36
+ cacheWrite: 0,
37
+ totalTokens: input + output,
38
+ cost: {
39
+ input: 0,
40
+ output: 0,
41
+ cacheRead: 0,
42
+ cacheWrite: 0,
43
+ total: costTotal,
44
+ },
45
+ },
46
+ stopReason: 'stop',
47
+ timestamp: Date.now(),
48
+ };
49
+ }
50
+
51
+ /** Drive a minimal turn that yields primary-branch null TPS (burst) but valid telemetry. */
52
+ async function runBurstTurn(
53
+ fixture: ReturnType<typeof createTestFixture>,
54
+ message: AssistantMessage,
55
+ turnIndex = 0
56
+ ) {
57
+ const { handlers, mockCtx } = fixture;
58
+ handlers['turn_start']?.({ type: 'turn_start', turnIndex, timestamp: Date.now() });
59
+ await tick(50);
60
+ handlers['message_start']?.({ type: 'message_start', message });
61
+ await tick(50);
62
+ handlers['message_update']?.({
63
+ type: 'message_update',
64
+ message,
65
+ assistantMessageEvent: { type: 'text_delta', delta: 't' },
66
+ });
67
+ handlers['message_end']?.({ type: 'message_end', message });
68
+ handlers['turn_end']?.({ type: 'turn_end', turnIndex, message, toolResults: [] }, mockCtx);
69
+ }
70
+
71
+ describe('pi-tps extension — blended $/M-tokens rate', () => {
72
+ let fixture: ReturnType<typeof createTestFixture>;
73
+
74
+ beforeEach(async () => {
75
+ fixture = createTestFixture();
76
+ await activateExtension(fixture);
77
+ });
78
+
79
+ afterEach(() => {
80
+ vi.restoreAllMocks();
81
+ });
82
+
83
+ it('shows list-price $/M rate in the banner from message.usage.cost.total', async () => {
84
+ // 1000 in + 1000 out = 2000 tokens; cost.total = $0.008 → $4.00/M
85
+ const message = makeMessageWithCost({ input: 1000, output: 1000, costTotal: 0.008 });
86
+
87
+ await runBurstTurn(fixture, message);
88
+
89
+ const { notifySpy, appendEntrySpy } = fixture;
90
+ expect(notifySpy).toHaveBeenCalledOnce();
91
+ const banner = notifySpy.mock.calls[0][0] as string;
92
+ expect(banner).toContain('$4.00/M');
93
+ expect(banner).not.toMatch(/\$.*\/M.*\$.*\/M/); // exactly one rate segment
94
+
95
+ const [, data] = appendEntrySpy.mock.calls[0];
96
+ expect(data.rateUsdPerMTokens).toBe(4.0);
97
+ });
98
+
99
+ it('uses Neuralwatt billed cost over list-price when the energy event fires first', async () => {
100
+ // Same token volume, but billed cost differs from list price.
101
+ // 2000 tokens; list cost.total = $0.008 ($4.00/M); billed = $0.006 ($3.00/M)
102
+ const message = makeMessageWithCost({
103
+ input: 1000,
104
+ output: 1000,
105
+ costTotal: 0.008,
106
+ provider: 'neuralwatt',
107
+ model: 'moonshotai/Kimi-K2.5',
108
+ });
109
+
110
+ // Simulate the neuralwatt provider's turn_end running BEFORE ours: it emits
111
+ // the per-turn energy event, which our listener stashes keyed by turnIndex.
112
+ fixture.emitEvent('neuralwatt:turn-energy', {
113
+ costUsd: 0.006,
114
+ energyJoules: 21.6,
115
+ turnIndex: 0,
116
+ });
117
+
118
+ await runBurstTurn(fixture, message, 0);
119
+
120
+ const { notifySpy, appendEntrySpy } = fixture;
121
+ expect(notifySpy).toHaveBeenCalledOnce();
122
+ const banner = notifySpy.mock.calls[0][0] as string;
123
+ // Billed ($3.00/M) wins over list-price ($4.00/M)
124
+ expect(banner).toContain('$3.00/M');
125
+ expect(banner).not.toContain('$4.00/M');
126
+
127
+ const [, data] = appendEntrySpy.mock.calls[0];
128
+ expect(data.rateUsdPerMTokens).toBe(3.0);
129
+ // The cost block still carries the list-price compute cost separately.
130
+ expect(data.cost).toEqual(expect.objectContaining({ total: 0.008 }));
131
+ });
132
+
133
+ it('null rate when totalTokens is zero (degenerate)', async () => {
134
+ const message = makeMessageWithCost({ input: 0, output: 1000, costTotal: 0.008 });
135
+ message.usage.totalTokens = 0;
136
+
137
+ await runBurstTurn(fixture, message);
138
+
139
+ const { appendEntrySpy, notifySpy } = fixture;
140
+ const banner = notifySpy.mock.calls[0][0] as string;
141
+ expect(banner).not.toMatch(/\$.*\/M/);
142
+ const [, data] = appendEntrySpy.mock.calls[0];
143
+ expect(data.rateUsdPerMTokens).toBeNull();
144
+ });
145
+
146
+ it('null rate when cost is unavailable', async () => {
147
+ // No usage.cost at all → effectiveCost null → rate null.
148
+ const message = makeAssistantMessage({ output: 500, input: 500 });
149
+ // Strip the cost block that makeAssistantMessage adds.
150
+ (message.usage as any).cost = null;
151
+ // Allow the isAssistantMessage guard to still pass: it only checks input/output.
152
+ await runBurstTurn(fixture, message);
153
+
154
+ const { appendEntrySpy } = fixture;
155
+ const [, data] = appendEntrySpy.mock.calls[0];
156
+ expect(data.rateUsdPerMTokens).toBeNull();
157
+ expect(data.cost).toBeNull();
158
+ });
159
+
160
+ it('falls back to list-price rate when billed-cost event misses (out-of-order load)', async () => {
161
+ // Neuralwatt turn but the energy event never arrives (provider loaded after
162
+ // us). Must not block or crash — falls back to the list-price compute rate.
163
+ const message = makeMessageWithCost({
164
+ input: 500,
165
+ output: 500,
166
+ costTotal: 0.004, // $4.00/M for 1000 tokens
167
+ provider: 'neuralwatt',
168
+ model: 'moonshotai/Kimi-K2.5',
169
+ });
170
+
171
+ await runBurstTurn(fixture, message);
172
+
173
+ const { notifySpy } = fixture;
174
+ const banner = notifySpy.mock.calls[0][0] as string;
175
+ expect(banner).toContain('$4.00/M');
176
+ });
177
+
178
+ it('ignores neuralwatt:turn-energy payloads lacking a numeric turnIndex', async () => {
179
+ // Defensive: malformed event must not pollute the cache.
180
+ fixture.emitEvent('neuralwatt:turn-energy', { costUsd: 0.006, energyJoules: 21.6 }); // no turnIndex
181
+ const message = makeMessageWithCost({ input: 1000, output: 1000, costTotal: 0.008 });
182
+
183
+ await runBurstTurn(fixture, message);
184
+
185
+ const { notifySpy } = fixture;
186
+ const banner = notifySpy.mock.calls[0][0] as string;
187
+ // Falls back to list-price since no valid turnIndex-keyed entry landed.
188
+ expect(banner).toContain('$4.00/M');
189
+ });
190
+
191
+ it('rehydrates a rate segment from structured telemetry on session resume', async () => {
192
+ const { handlers, notifySpy, mockEntries } = fixture;
193
+
194
+ mockEntries.push({
195
+ type: 'custom',
196
+ customType: 'tps',
197
+ data: {
198
+ model: { provider: 'openai', modelId: 'gpt-4' },
199
+ tokens: { input: 1000, output: 1000, cacheRead: 0, cacheWrite: 0, total: 2000 },
200
+ timing: {
201
+ ttftMs: 1000,
202
+ totalMs: 3000,
203
+ generationMs: 2000,
204
+ stallMs: 0,
205
+ stallCount: 0,
206
+ messageCount: 1,
207
+ },
208
+ tps: 10.0,
209
+ rateUsdPerMTokens: 4.0,
210
+ timestamp: Date.now(),
211
+ },
212
+ });
213
+
214
+ handlers['session_start']?.({ reason: 'resume' }, fixture.mockCtx);
215
+ await tick();
216
+
217
+ expect(notifySpy).toHaveBeenCalledOnce();
218
+ const banner = notifySpy.mock.calls[0][0] as string;
219
+ expect(banner).toContain('$4.00/M');
220
+ });
221
+
222
+ it('rehydrates older telemetry without a rate field without crashing (omits the segment)', async () => {
223
+ const { handlers, notifySpy, mockEntries } = fixture;
224
+
225
+ mockEntries.push({
226
+ type: 'custom',
227
+ customType: 'tps',
228
+ data: {
229
+ model: { provider: 'openai', modelId: 'gpt-4' },
230
+ tokens: { input: 50, output: 100, cacheRead: 0, cacheWrite: 0, total: 150 },
231
+ timing: {
232
+ ttftMs: 1000,
233
+ totalMs: 3000,
234
+ generationMs: 2000,
235
+ stallMs: 0,
236
+ stallCount: 0,
237
+ messageCount: 1,
238
+ },
239
+ tps: 10.0,
240
+ // rateUsdPerMTokens intentionally absent (pre-feature entry)
241
+ timestamp: Date.now(),
242
+ },
243
+ });
244
+
245
+ handlers['session_start']?.({ reason: 'resume' }, fixture.mockCtx);
246
+ await tick();
247
+
248
+ expect(notifySpy).toHaveBeenCalledOnce();
249
+ const banner = notifySpy.mock.calls[0][0] as string;
250
+ expect(banner).not.toMatch(/\$.*\/M/);
251
+ });
252
+ });
@@ -77,6 +77,8 @@ export interface TestFixture {
77
77
  notifySpy: ReturnType<typeof vi.fn>;
78
78
  appendEntrySpy: ReturnType<typeof vi.fn>;
79
79
  eventsEmitSpy: ReturnType<typeof vi.fn>;
80
+ /** Emit an event on the mock event bus, dispatching to registered listeners (and the emit spy). */
81
+ emitEvent: (event: string, payload: unknown) => void;
80
82
  registerCommandSpy: ReturnType<typeof vi.fn>;
81
83
  mockEntries: Array<{ type?: string; role?: string; customType?: string; data?: unknown }>;
82
84
  mockCtx: ExtensionContext;
@@ -123,6 +125,19 @@ export function createTestFixture(): TestFixture {
123
125
  } as any as ExtensionContext;
124
126
 
125
127
  const eventsEmitSpy = vi.fn();
128
+ // Real event bus: listeners registered via events.on are dispatched on emit.
129
+ // eventsEmitSpy still captures every emit for assertions (e.g. 'tps:telemetry').
130
+ const eventListeners = new Map<string, ((payload: unknown) => void)[]>();
131
+ const dispatchEvent = (event: string, payload: unknown) => {
132
+ eventsEmitSpy(event, payload);
133
+ for (const listener of eventListeners.get(event) ?? []) {
134
+ try {
135
+ listener(payload);
136
+ } catch {
137
+ // listener errors shouldn't break emit dispatch in tests
138
+ }
139
+ }
140
+ };
126
141
 
127
142
  const mockPi: Partial<ExtensionAPI> = {
128
143
  on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
@@ -131,7 +146,14 @@ export function createTestFixture(): TestFixture {
131
146
  }),
132
147
  appendEntry: appendEntrySpy,
133
148
  registerCommand: registerCommandSpy,
134
- events: { emit: eventsEmitSpy, on: vi.fn() },
149
+ events: {
150
+ emit: dispatchEvent,
151
+ on: vi.fn((event: string, listener: (payload: unknown) => void) => {
152
+ const list = eventListeners.get(event) ?? [];
153
+ list.push(listener);
154
+ eventListeners.set(event, list);
155
+ }),
156
+ } as any,
135
157
  };
136
158
 
137
159
  return {
@@ -141,6 +163,7 @@ export function createTestFixture(): TestFixture {
141
163
  notifySpy,
142
164
  appendEntrySpy,
143
165
  eventsEmitSpy,
166
+ emitEvent: dispatchEvent,
144
167
  registerCommandSpy,
145
168
  mockEntries,
146
169
  mockCtx,
@@ -0,0 +1,372 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import type { AssistantMessage } from '@earendil-works/pi-ai';
3
+ import { createTestFixture, activateExtension } from './helpers';
4
+
5
+ describe('pi-tps extension — volume-based TPS gate', () => {
6
+ let fixture: ReturnType<typeof createTestFixture>;
7
+
8
+ beforeEach(async () => {
9
+ fixture = createTestFixture();
10
+ await activateExtension(fixture);
11
+ });
12
+
13
+ afterEach(() => {
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ /**
18
+ * Drive a full turn with mocked performance.now() timestamps.
19
+ * Customizable output token count for volume-based testing.
20
+ *
21
+ * Timestamp mapping (one performance.now() per handler call):
22
+ * [0] turn_start → turnStartMs, lastUpdateMs
23
+ * [1] turn_start → (second call — same init)
24
+ * [2] message_start → currentMessageStartMs, lastUpdateMs reset
25
+ * [3] message_update (TTFT) → firstTokenMs, lastUpdateMs
26
+ * [4..4+n-1] message_update (stream) → each streamUpdate timestamp
27
+ * [4+n] message_end → generation time end
28
+ * [5+n] turn_end → total time
29
+ *
30
+ * streamMs = last streamUpdate - first streamUpdate (not total window!)
31
+ * updateCount = streamUpdates.length (post-TTFT events)
32
+ */
33
+ function driveTurn(clocks: {
34
+ turnStart: number;
35
+ messageStart: number;
36
+ firstUpdate: number;
37
+ streamUpdates: number[];
38
+ messageEnd: number;
39
+ turnEnd?: number;
40
+ output: number;
41
+ input?: number;
42
+ isToolCall?: boolean;
43
+ }) {
44
+ const { handlers, notifySpy, appendEntrySpy } = fixture;
45
+
46
+ const timestamps = [
47
+ clocks.turnStart,
48
+ clocks.turnStart,
49
+ clocks.messageStart,
50
+ clocks.firstUpdate,
51
+ ...clocks.streamUpdates,
52
+ clocks.messageEnd,
53
+ clocks.turnEnd ?? clocks.messageEnd,
54
+ ];
55
+
56
+ let callIdx = 0;
57
+ const spy = vi.spyOn(performance, 'now').mockImplementation(() => {
58
+ return timestamps[Math.min(callIdx++, timestamps.length - 1)];
59
+ });
60
+
61
+ const input = clocks.input ?? 50;
62
+ const assistantMessage: AssistantMessage = {
63
+ role: 'assistant',
64
+ content: [{ type: 'text', text: 'Response' }],
65
+ api: 'openai-completions',
66
+ provider: 'openai',
67
+ model: 'gpt-4',
68
+ usage: {
69
+ input,
70
+ output: clocks.output,
71
+ cacheRead: 0,
72
+ cacheWrite: 0,
73
+ totalTokens: input + clocks.output,
74
+ cost: {
75
+ input: 0.001,
76
+ output: 0.002,
77
+ cacheRead: 0,
78
+ cacheWrite: 0,
79
+ total: 0.003,
80
+ },
81
+ },
82
+ stopReason: clocks.isToolCall ? 'toolUse' : 'stop',
83
+ timestamp: Date.now(),
84
+ };
85
+
86
+ handlers['turn_start']?.({ type: 'turn_start', turnIndex: 0, timestamp: Date.now() });
87
+ handlers['message_start']?.({ type: 'message_start', message: assistantMessage });
88
+ handlers['message_update']?.({
89
+ type: 'message_update',
90
+ message: assistantMessage,
91
+ assistantMessageEvent: { type: 'text_delta', delta: 't' },
92
+ });
93
+ for (const _ts of clocks.streamUpdates) {
94
+ handlers['message_update']?.({
95
+ type: 'message_update',
96
+ message: assistantMessage,
97
+ assistantMessageEvent: { type: 'text_delta', delta: 't' },
98
+ });
99
+ }
100
+
101
+ if (clocks.isToolCall) {
102
+ handlers['tool_execution_start']?.({
103
+ type: 'tool_execution_start',
104
+ toolCallId: 'call_123',
105
+ toolName: 'bash',
106
+ args: { command: 'ls' },
107
+ });
108
+ }
109
+
110
+ handlers['message_end']?.({ type: 'message_end', message: assistantMessage });
111
+ handlers['turn_end']?.(
112
+ { type: 'turn_end', turnIndex: 0, message: assistantMessage, toolResults: [] },
113
+ fixture.mockCtx
114
+ );
115
+
116
+ spy.mockRestore();
117
+ return { notifySpy, appendEntrySpy };
118
+ }
119
+
120
+ // ── Primary branch: volume gate ────────────────────────────────────────
121
+
122
+ it('should null TPS when primary-branch TPS exceeds plausibility ceiling', () => {
123
+ // streamUpdates: [400, 450, 500, 550, 600] → streamMs = 200ms
124
+ // 3000 tokens / 0.2s = 15,000 TPS — exceeds 10,000 ceiling
125
+ const { appendEntrySpy } = driveTurn({
126
+ turnStart: 0,
127
+ messageStart: 200,
128
+ firstUpdate: 200.123,
129
+ streamUpdates: [400, 450, 500, 550, 600],
130
+ messageEnd: 700,
131
+ output: 3000,
132
+ });
133
+
134
+ const [, data] = appendEntrySpy.mock.calls[0];
135
+ expect(data.tps).toBeNull();
136
+ expect(data.isPrimaryBranch).toBe(false);
137
+ });
138
+
139
+ it('should preserve primary-branch TPS when it is within plausibility', () => {
140
+ // streamUpdates: [400, 500, 600, 700, 800] → streamMs = 400ms
141
+ // 1000 tokens / 0.4s = 2,500 TPS — well within 10,000 ceiling
142
+ const { appendEntrySpy } = driveTurn({
143
+ turnStart: 0,
144
+ messageStart: 200,
145
+ firstUpdate: 200.123,
146
+ streamUpdates: [400, 500, 600, 700, 800],
147
+ messageEnd: 900,
148
+ output: 1000,
149
+ });
150
+
151
+ const [, data] = appendEntrySpy.mock.calls[0];
152
+ expect(data.tps).not.toBeNull();
153
+ expect(data.tps).toBeGreaterThanOrEqual(2000);
154
+ expect(data.tps).toBeLessThanOrEqual(3000);
155
+ expect(data.isPrimaryBranch).toBe(true);
156
+ });
157
+
158
+ it('should preserve primary-branch TPS for high volume with long enough window', () => {
159
+ // streamUpdates: [500, 600, 700, 800, 1000] → streamMs = 500ms
160
+ // 3000 tokens / 0.5s = 6,000 TPS — within ceiling
161
+ const { appendEntrySpy } = driveTurn({
162
+ turnStart: 0,
163
+ messageStart: 200,
164
+ firstUpdate: 200.123,
165
+ streamUpdates: [500, 600, 700, 800, 1000],
166
+ messageEnd: 1100,
167
+ output: 3000,
168
+ });
169
+
170
+ const [, data] = appendEntrySpy.mock.calls[0];
171
+ expect(data.tps).not.toBeNull();
172
+ expect(data.tps).toBeGreaterThanOrEqual(5500);
173
+ expect(data.tps).toBeLessThanOrEqual(6500);
174
+ expect(data.isPrimaryBranch).toBe(true);
175
+ });
176
+
177
+ it('should null TPS for very large token volume over minimum effective span', () => {
178
+ // streamUpdates: [400, 450, 500, 550, 600] → streamMs = 200ms
179
+ // 5000 tokens / 0.2s = 25,000 TPS — well beyond ceiling
180
+ const { appendEntrySpy } = driveTurn({
181
+ turnStart: 0,
182
+ messageStart: 200,
183
+ firstUpdate: 200.123,
184
+ streamUpdates: [400, 450, 500, 550, 600],
185
+ messageEnd: 700,
186
+ output: 5000,
187
+ });
188
+
189
+ const [, data] = appendEntrySpy.mock.calls[0];
190
+ expect(data.tps).toBeNull();
191
+ expect(data.isPrimaryBranch).toBe(false);
192
+ });
193
+
194
+ // ── Fallback branch: volume gate ───────────────────────────────────────
195
+
196
+ it('should null TPS when fallback-branch TPS exceeds plausibility ceiling', () => {
197
+ // 2 updates (fallback branch), generationMs = 200ms
198
+ // 3000 tokens / 0.2s = 15,000 TPS — exceeds ceiling
199
+ const { appendEntrySpy } = driveTurn({
200
+ turnStart: 0,
201
+ messageStart: 50,
202
+ firstUpdate: 50.1,
203
+ streamUpdates: [50.15, 50.3],
204
+ messageEnd: 250,
205
+ output: 3000,
206
+ });
207
+
208
+ const [, data] = appendEntrySpy.mock.calls[0];
209
+ expect(data.tps).toBeNull();
210
+ });
211
+
212
+ it('should preserve fallback-branch TPS when it is within plausibility', () => {
213
+ // 2 updates (fallback branch), generationMs = 200ms
214
+ // 400 tokens / 0.2s = 2,000 TPS — within ceiling
215
+ const { appendEntrySpy } = driveTurn({
216
+ turnStart: 0,
217
+ messageStart: 50,
218
+ firstUpdate: 50.1,
219
+ streamUpdates: [50.15, 50.3],
220
+ messageEnd: 250,
221
+ output: 400,
222
+ });
223
+
224
+ const [, data] = appendEntrySpy.mock.calls[0];
225
+ expect(data.tps).not.toBeNull();
226
+ expect(data.tps).toBeGreaterThanOrEqual(1500);
227
+ expect(data.tps).toBeLessThanOrEqual(2500);
228
+ });
229
+
230
+ // ── Boundary: exactly at ceiling ──────────────────────────────────────
231
+
232
+ it('should preserve TPS when exactly at the plausibility ceiling (not exceeded)', () => {
233
+ // streamUpdates: [400, 450, 500, 550, 600] → streamMs = 200ms
234
+ // 2000 tokens / 0.2s = 10,000 TPS — exactly at ceiling, not > ceiling
235
+ const { appendEntrySpy } = driveTurn({
236
+ turnStart: 0,
237
+ messageStart: 200,
238
+ firstUpdate: 200.123,
239
+ streamUpdates: [400, 450, 500, 550, 600],
240
+ messageEnd: 700,
241
+ output: 2000,
242
+ });
243
+
244
+ const [, data] = appendEntrySpy.mock.calls[0];
245
+ expect(data.tps).not.toBeNull();
246
+ expect(data.tps).toBeGreaterThanOrEqual(9900);
247
+ expect(data.tps).toBeLessThanOrEqual(10100);
248
+ expect(data.isPrimaryBranch).toBe(true);
249
+ });
250
+
251
+ it('should null TPS just above the plausibility ceiling', () => {
252
+ // streamUpdates: [400, 450, 500, 550, 600] → streamMs = 200ms
253
+ // 2100 tokens / 0.2s = 10,500 TPS — just above threshold
254
+ const { appendEntrySpy } = driveTurn({
255
+ turnStart: 0,
256
+ messageStart: 200,
257
+ firstUpdate: 200.123,
258
+ streamUpdates: [400, 450, 500, 550, 600],
259
+ messageEnd: 700,
260
+ output: 2100,
261
+ });
262
+
263
+ const [, data] = appendEntrySpy.mock.calls[0];
264
+ expect(data.tps).toBeNull();
265
+ expect(data.isPrimaryBranch).toBe(false);
266
+ });
267
+
268
+ // ── Notification display ──────────────────────────────────────────────
269
+
270
+ it('should show TPS dash when volume gate nulls TPS', () => {
271
+ const { notifySpy } = driveTurn({
272
+ turnStart: 0,
273
+ messageStart: 200,
274
+ firstUpdate: 200.123,
275
+ streamUpdates: [400, 450, 500, 550, 600],
276
+ messageEnd: 700,
277
+ output: 3000,
278
+ });
279
+
280
+ const notification = notifySpy.mock.calls[0][0] as string;
281
+ expect(notification).toContain('TPS —');
282
+ });
283
+
284
+ // ── Interaction with dynamic TPS cap ──────────────────────────────────
285
+
286
+ it('should not let volume-gated turns set the dynamic TPS cap', () => {
287
+ // Turn 1: 3000 tokens / 0.2s = 15,000 TPS — volume gates to null.
288
+ // The cap condition requires isPrimaryBranch && tps !== null,
289
+ // both of which are false after the volume gate. So the cap
290
+ // should NOT be set from this turn.
291
+ driveTurn({
292
+ turnStart: 0,
293
+ messageStart: 200,
294
+ firstUpdate: 200.123,
295
+ streamUpdates: [400, 450, 500, 550, 600],
296
+ messageEnd: 700,
297
+ output: 3000,
298
+ });
299
+
300
+ const [, data1] = fixture.appendEntrySpy.mock.calls[0];
301
+ expect(data1.tps).toBeNull();
302
+ expect(data1.isPrimaryBranch).toBe(false);
303
+
304
+ // Turn 2: reliable non-tool-call streaming at ~50 TPS (20 tokens / 0.4s)
305
+ // This should set the cap at ~50 TPS, not at 15,000.
306
+ // If the volume-gated turn had set the cap at 15,000, the cap
307
+ // would be 15,000 and a subsequent tool call would be allowed
308
+ // up to 15,000 — which is inflated.
309
+ const { appendEntrySpy } = driveTurn({
310
+ turnStart: 0,
311
+ messageStart: 200,
312
+ firstUpdate: 200.123,
313
+ streamUpdates: [400, 500, 600, 700, 800],
314
+ messageEnd: 900,
315
+ output: 20,
316
+ });
317
+
318
+ const [, data2] = appendEntrySpy.mock.calls[1];
319
+ expect(data2.tps).not.toBeNull();
320
+ expect(data2.tps).toBeGreaterThanOrEqual(40);
321
+ expect(data2.tps).toBeLessThanOrEqual(60);
322
+ expect(data2.isPrimaryBranch).toBe(true);
323
+ });
324
+
325
+ // ── Volume gate doesn't affect normal token counts ────────────────────
326
+
327
+ it('should not affect TPS for normal token counts even at short effective span', () => {
328
+ // streamUpdates: [400, 450, 500, 550, 600] → streamMs = 200ms
329
+ // 20 tokens / 0.2s = 100 TPS — well within ceiling
330
+ const { appendEntrySpy } = driveTurn({
331
+ turnStart: 0,
332
+ messageStart: 200,
333
+ firstUpdate: 200.123,
334
+ streamUpdates: [400, 450, 500, 550, 600],
335
+ messageEnd: 700,
336
+ output: 20,
337
+ });
338
+
339
+ const [, data] = appendEntrySpy.mock.calls[0];
340
+ expect(data.tps).not.toBeNull();
341
+ expect(data.tps).toBeGreaterThanOrEqual(80);
342
+ expect(data.tps).toBeLessThanOrEqual(120);
343
+ expect(data.isPrimaryBranch).toBe(true);
344
+ });
345
+
346
+ // ── High-output `read`-style burst ────────────────────────────────────
347
+
348
+ it('should null TPS when provider dumps 1000+ tokens in a burst that passes timing gates', () => {
349
+ // Real-world scenario: a provider spits out 1500 tokens in a single
350
+ // fast burst. The timing gates pass (5+ updates with ≥1ms gaps,
351
+ // 200ms+ effective span), but the rate is inflated because the
352
+ // generation window is too short relative to the volume.
353
+ // streamUpdates: [400, 450, 500, 550, 600] → streamMs = 200ms
354
+ // 1500 tokens / 0.2s = 7,500 TPS — below 10,000 ceiling, passes gate
355
+ // This is actually plausible for a very fast provider, so it should
356
+ // NOT be nulled. Test that the gate is not overly aggressive.
357
+ const { appendEntrySpy } = driveTurn({
358
+ turnStart: 0,
359
+ messageStart: 200,
360
+ firstUpdate: 200.123,
361
+ streamUpdates: [400, 450, 500, 550, 600],
362
+ messageEnd: 700,
363
+ output: 1500,
364
+ });
365
+
366
+ const [, data] = appendEntrySpy.mock.calls[0];
367
+ expect(data.tps).not.toBeNull();
368
+ // 1500 / 0.2 = 7,500 TPS — plausible, within ceiling
369
+ expect(data.tps).toBeGreaterThanOrEqual(7000);
370
+ expect(data.tps).toBeLessThanOrEqual(8000);
371
+ });
372
+ });
@@ -72,6 +72,11 @@ interface ToolExecutionStartEvent {
72
72
  /** Minimum gap between token updates to count as a stall (ms) */
73
73
  const STALL_THRESHOLD_MS = 500;
74
74
 
75
+ /** Event name emitted by pi-neuralwatt-provider per turn with energy-billed cost data. */
76
+ const NEURALWATT_ENERGY_EVENT = 'neuralwatt:turn-energy';
77
+ /** Cap the per-turn billed-cost cache (turnIndex → costUsd) to avoid unbounded growth in long sessions. */
78
+ const NEURALWATT_ENERGY_CACHE_MAX = 32;
79
+
75
80
  // ─── Data types ─────────────────────────────────────────────────────────────
76
81
 
77
82
  /** Structured telemetry persisted per turn in the session JSONL */
@@ -96,6 +101,15 @@ interface TurnTelemetry {
96
101
  cacheWrite: number;
97
102
  total: number;
98
103
  } | null;
104
+ /**
105
+ * Blended $/M-tokens rate shown in the notification banner: the per-turn
106
+ * cost divided by (totalTokens / 1_000_000). Cost source is the Neuralwatt
107
+ * billed cost when available (energy-based), otherwise the list-price
108
+ * compute cost from `cost.total`. null when neither is usable or tokens
109
+ * are zero. Purely derived — recomputed in composeDisplayString, not a
110
+ * duplicate of the cost block.
111
+ */
112
+ rateUsdPerMTokens: number | null;
99
113
  timestamp: number;
100
114
  }
101
115
 
@@ -122,6 +136,20 @@ interface TurnTiming {
122
136
 
123
137
  // ─── Helpers ────────────────────────────────────────────────────────────────
124
138
 
139
+ /**
140
+ * Compute the blended $/M-tokens rate: costUsd / (totalTokens / 1_000_000).
141
+ * The single calculation backing the banner's $/M-tokens field. Returns null
142
+ * when the inputs can't produce a meaningful rate (zero/negative tokens,
143
+ * non-finite or negative cost).
144
+ */
145
+ function computeRateUsdPerM(costUsd: number | null, totalTokens: number): number | null {
146
+ if (costUsd === null || !Number.isFinite(costUsd) || costUsd < 0) return null;
147
+ if (!Number.isFinite(totalTokens) || totalTokens <= 0) return null;
148
+ const rate = costUsd / (totalTokens / 1_000_000);
149
+ if (!Number.isFinite(rate) || rate < 0) return null;
150
+ return Math.round(rate * 100) / 100;
151
+ }
152
+
125
153
  function isAssistantMessage(message: unknown): message is AssistantMessage {
126
154
  if (!message || typeof message !== 'object') return false;
127
155
  const msg = message as Record<string, unknown>;
@@ -236,6 +264,9 @@ function composeDisplayString(t: TurnTelemetry): string {
236
264
  const stallStr = formatDuration(t.timing.stallMs / 1000);
237
265
  parts.push(`stall ${stallStr}×${t.timing.stallCount}`);
238
266
  }
267
+ if (t.rateUsdPerMTokens != null) {
268
+ parts.push(`$${t.rateUsdPerMTokens.toFixed(2)}/M`);
269
+ }
239
270
  return parts.join(' · ');
240
271
  }
241
272
 
@@ -243,7 +274,11 @@ function composeDisplayString(t: TurnTelemetry): string {
243
274
  * Build structured TurnTelemetry from accumulated turn timing.
244
275
  * Returns null if the turn had no meaningful LLM output.
245
276
  */
246
- function buildTelemetry(timing: TurnTiming, turnEndMs: number): TurnTelemetry | null {
277
+ function buildTelemetry(
278
+ timing: TurnTiming,
279
+ turnEndMs: number,
280
+ billedCost: number | null = null
281
+ ): TurnTelemetry | null {
247
282
  let input = 0;
248
283
  let output = 0;
249
284
  let cacheRead = 0;
@@ -308,6 +343,19 @@ function buildTelemetry(timing: TurnTiming, turnEndMs: number): TurnTelemetry |
308
343
  const STALL_REDUCTION_DENOM = 2;
309
344
  const STALL_DOMINANCE_RATIO = 0.85;
310
345
 
346
+ // Maximum plausible generation speed (tokens/second). Beyond this, the
347
+ // measured TPS is almost certainly a measurement artifact — the effective
348
+ // generation window is too short relative to the token volume to
349
+ // distinguish genuine inference from a buffer-flush dispatch of
350
+ // pre-generated tokens. At 10_000 TPS this is 5× the fastest known
351
+ // commercial inference (Cerebras ~2_000 tok/s). The gate is phrased as
352
+ // "extraordinary claims require extraordinary evidence": for X output
353
+ // tokens, the minimum reliable measurement window is X / MAX_PLAUSIBLE_TPS
354
+ // seconds. Below that, the volume of tokens cannot be reliably timed, and
355
+ // TPS is set to null. This is mathematically equivalent to nulling out
356
+ // TPS when computed TPS > MAX_PLAUSIBLE_TPS.
357
+ const MAX_PLAUSIBLE_TPS = 10_000;
358
+
311
359
  const streamMs =
312
360
  timing.updateCount > 0 && timing.firstStreamUpdateMs !== null
313
361
  ? timing.lastStreamUpdateMs - timing.firstStreamUpdateMs
@@ -385,6 +433,30 @@ function buildTelemetry(timing: TurnTiming, turnEndMs: number): TurnTelemetry |
385
433
  tps = null;
386
434
  }
387
435
 
436
+ // Volume-based sanity gate: extraordinary TPS claims require
437
+ // proportionally longer measurement windows. When a provider emits a
438
+ // large volume of tokens in a short time window, the measured rate is
439
+ // dominated by dispatch/buffer-flush timing rather than actual inference
440
+ // speed. For X output tokens, the minimum reliable generation window is
441
+ // X / MAX_PLAUSIBLE_TPS seconds — below that, the volume of tokens cannot
442
+ // be reliably timed, and TPS is set to null.
443
+ //
444
+ // Mathematically equivalent to: computed TPS > MAX_PLAUSIBLE_TPS → null.
445
+ // But phrased as a measurement-reliability principle: the evidence
446
+ // (effective window duration) must be proportional to the claim (token
447
+ // volume ÷ inferred rate).
448
+ if (tps !== null && tps > MAX_PLAUSIBLE_TPS) {
449
+ tps = null;
450
+ isPrimaryBranch = false;
451
+ }
452
+
453
+ // Single blended $/M-tokens rate for the banner. Cost source: Neuralwatt's
454
+ // billed cost when present (energy-based, what the user actually pays),
455
+ // otherwise the list-price compute cost from message.usage.cost. Never both
456
+ // — billedCost wins outright when present, so there's no double-counting.
457
+ const effectiveCost = billedCost ?? (hasCost ? costTotal : null);
458
+ const rateUsdPerMTokens = computeRateUsdPerM(effectiveCost, totalTokens);
459
+
388
460
  return {
389
461
  model,
390
462
  tokens: { input, output, cacheRead, cacheWrite, total: totalTokens },
@@ -408,6 +480,7 @@ function buildTelemetry(timing: TurnTiming, turnEndMs: number): TurnTelemetry |
408
480
  total: costTotal,
409
481
  }
410
482
  : null,
483
+ rateUsdPerMTokens,
411
484
  timestamp: Date.now(),
412
485
  };
413
486
  }
@@ -425,6 +498,34 @@ export default function tpsExtension(pi: ExtensionAPI) {
425
498
  // Cached session entries for argument completion (captured on session_start / session_tree)
426
499
  let cachedEntries: Array<{ type?: string; customType?: string; data?: unknown }> = [];
427
500
 
501
+ // ── Neuralwatt per-turn billed-cost integration ─────────────────────────
502
+ // pi-neuralwatt-provider emits NEURALWATT_ENERGY_EVENT in its turn_end handler
503
+ // (after its tee reader drains) with { costUsd, energyJoules, turnIndex }. We stash
504
+ // only the one number we need — billed costUsd — keyed by turnIndex, and read it
505
+ // synchronously in our own turn_end. pi awaits turn_end handlers sequentially in
506
+ // registration order, so if the neuralwatt provider was registered before us the
507
+ // event has already fired and the cache hit is immediate; if after us we miss it
508
+ // for that one turn and fall back to the list-price compute rate (the neuralwatt
509
+ // widget still shows billing separately). Zero latency, no awaiting, no duplicate
510
+ // capture — the raw energy/cost records stay solely in the neuralwatt provider's
511
+ // own session entries. Non-Neuralwatt turns never emit, so this stays empty.
512
+ const neuralwattBilledCostByTurn = new Map<number, number>();
513
+
514
+ pi.events?.on(NEURALWATT_ENERGY_EVENT, (payload: unknown) => {
515
+ if (!payload || typeof payload !== 'object') return;
516
+ const p = payload as Record<string, unknown>;
517
+ const turnIndex = typeof p.turnIndex === 'number' ? p.turnIndex : null;
518
+ const costUsd = typeof p.costUsd === 'number' ? p.costUsd : null;
519
+ if (turnIndex === null || costUsd === null) return; // require turnIndex correlation
520
+ neuralwattBilledCostByTurn.set(turnIndex, costUsd);
521
+ // Bound the cache (oldest = lowest turnIndex)
522
+ if (neuralwattBilledCostByTurn.size > NEURALWATT_ENERGY_CACHE_MAX) {
523
+ let oldest = Infinity;
524
+ for (const k of neuralwattBilledCostByTurn.keys()) if (k < oldest) oldest = k;
525
+ if (oldest !== Infinity) neuralwattBilledCostByTurn.delete(oldest);
526
+ }
527
+ });
528
+
428
529
  // ── Rehydration ─────────────────────────────────────────────────────────
429
530
 
430
531
  /**
@@ -584,15 +685,28 @@ export default function tpsExtension(pi: ExtensionAPI) {
584
685
 
585
686
  // ── Persist telemetry ───────────────────────────────────────────────────
586
687
 
587
- // Calculate, display, and persist telemetry at the end of each LLM turn
588
- pi.on('turn_end', (_event: TurnEndEvent, ctx: ExtensionContext) => {
688
+ // Calculate, display, and persist telemetry at the end of each LLM turn.
689
+ // Synchronous: the Neuralwatt billed cost (if any) was stashed by our
690
+ // NEURALWATT_ENERGY_EVENT listener, keyed by turnIndex, and is read here
691
+ // synchronously. No awaiting, no added latency to turn dispatch.
692
+ pi.on('turn_end', (event: TurnEndEvent, ctx: ExtensionContext) => {
589
693
  if (!currentTiming) return;
590
694
 
591
695
  const timing = currentTiming;
592
696
  currentTiming = null;
593
697
 
594
698
  const turnEndMs = performance.now();
595
- const telemetry = buildTelemetry(timing, turnEndMs);
699
+
700
+ // Pick the effective cost for the blended $/M-tokens rate: Neuralwatt's
701
+ // billed cost when present (cache hit), otherwise the list-price compute
702
+ // cost from message.usage.cost. The neuralwatt provider emits its event
703
+ // in its own turn_end, which runs before or after ours depending on load
704
+ // order — a cache miss here just falls back to the compute rate for this
705
+ // one turn (no double-counting: only one source contributes to the rate).
706
+ const billedCost = neuralwattBilledCostByTurn.get(event.turnIndex) ?? null;
707
+ neuralwattBilledCostByTurn.delete(event.turnIndex);
708
+
709
+ const telemetry = buildTelemetry(timing, turnEndMs, billedCost);
596
710
  if (!telemetry) return;
597
711
 
598
712
  // ── Dynamic TPS cap ────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-tps",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Tokens-per-second tracker for pi — see your LLM generation speed after every agent turn",
5
5
  "keywords": [
6
6
  "pi-package"