@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
|
|
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: {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 ────────────────────────────────────────────────
|