@monotykamary/pi-tps 1.2.0 → 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,
@@ -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;
@@ -415,6 +450,13 @@ function buildTelemetry(timing: TurnTiming, turnEndMs: number): TurnTelemetry |
415
450
  isPrimaryBranch = false;
416
451
  }
417
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
+
418
460
  return {
419
461
  model,
420
462
  tokens: { input, output, cacheRead, cacheWrite, total: totalTokens },
@@ -438,6 +480,7 @@ function buildTelemetry(timing: TurnTiming, turnEndMs: number): TurnTelemetry |
438
480
  total: costTotal,
439
481
  }
440
482
  : null,
483
+ rateUsdPerMTokens,
441
484
  timestamp: Date.now(),
442
485
  };
443
486
  }
@@ -455,6 +498,34 @@ export default function tpsExtension(pi: ExtensionAPI) {
455
498
  // Cached session entries for argument completion (captured on session_start / session_tree)
456
499
  let cachedEntries: Array<{ type?: string; customType?: string; data?: unknown }> = [];
457
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
+
458
529
  // ── Rehydration ─────────────────────────────────────────────────────────
459
530
 
460
531
  /**
@@ -614,15 +685,28 @@ export default function tpsExtension(pi: ExtensionAPI) {
614
685
 
615
686
  // ── Persist telemetry ───────────────────────────────────────────────────
616
687
 
617
- // Calculate, display, and persist telemetry at the end of each LLM turn
618
- 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) => {
619
693
  if (!currentTiming) return;
620
694
 
621
695
  const timing = currentTiming;
622
696
  currentTiming = null;
623
697
 
624
698
  const turnEndMs = performance.now();
625
- 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);
626
710
  if (!telemetry) return;
627
711
 
628
712
  // ── Dynamic TPS cap ────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-tps",
3
- "version": "1.2.0",
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"