@monotykamary/pi-tps 1.2.0 → 1.3.1
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 +52 -34
- package/extensions/pi-tps/__tests__/cost-rate.test.ts +334 -0
- package/extensions/pi-tps/__tests__/helpers.ts +25 -2
- package/extensions/pi-tps/index.ts +129 -4
- package/npm-shrinkwrap.json +2791 -2814
- package/package.json +3 -1
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,334 @@
|
|
|
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
|
+
|
|
253
|
+
it('falls back to a persisted neuralwatt-energy entry when the live event is missed', async () => {
|
|
254
|
+
const now = Date.now();
|
|
255
|
+
const message = makeMessageWithCost({
|
|
256
|
+
input: 1000,
|
|
257
|
+
output: 1000,
|
|
258
|
+
costTotal: 0.008, // list price: $4.00/M
|
|
259
|
+
provider: 'neuralwatt',
|
|
260
|
+
model: 'moonshotai/Kimi-K2.5',
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Provider appended its energy entry before pi-tps handled turn_end.
|
|
264
|
+
fixture.mockEntries.push({
|
|
265
|
+
type: 'custom',
|
|
266
|
+
customType: 'neuralwatt-energy',
|
|
267
|
+
data: { energy_joules: 21.6, cost_usd: 0.006 }, // $3.00/M for 2000 tokens
|
|
268
|
+
timestamp: now,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const { handlers, notifySpy, appendEntrySpy } = fixture;
|
|
272
|
+
handlers['turn_start']?.({ type: 'turn_start', turnIndex: 0, timestamp: now - 100 });
|
|
273
|
+
await tick(50);
|
|
274
|
+
handlers['message_start']?.({ type: 'message_start', message });
|
|
275
|
+
await tick(50);
|
|
276
|
+
handlers['message_update']?.({
|
|
277
|
+
type: 'message_update',
|
|
278
|
+
message,
|
|
279
|
+
assistantMessageEvent: { type: 'text_delta', delta: 'H' },
|
|
280
|
+
});
|
|
281
|
+
handlers['message_end']?.({ type: 'message_end', message });
|
|
282
|
+
handlers['turn_end']?.(
|
|
283
|
+
{ type: 'turn_end', turnIndex: 0, message, toolResults: [] },
|
|
284
|
+
fixture.mockCtx
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
expect(notifySpy).toHaveBeenCalledOnce();
|
|
288
|
+
const banner = notifySpy.mock.calls[0][0] as string;
|
|
289
|
+
expect(banner).toContain('$3.00/M');
|
|
290
|
+
expect(banner).not.toContain('$4.00/M');
|
|
291
|
+
|
|
292
|
+
const [, data] = appendEntrySpy.mock.calls[0];
|
|
293
|
+
expect(data.rateUsdPerMTokens).toBe(3.0);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('ignores stale neuralwatt-energy entries from before this turn', async () => {
|
|
297
|
+
const now = Date.now();
|
|
298
|
+
const message = makeMessageWithCost({
|
|
299
|
+
input: 1000,
|
|
300
|
+
output: 1000,
|
|
301
|
+
costTotal: 0.008, // list price: $4.00/M
|
|
302
|
+
provider: 'neuralwatt',
|
|
303
|
+
model: 'moonshotai/Kimi-K2.5',
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
fixture.mockEntries.push({
|
|
307
|
+
type: 'custom',
|
|
308
|
+
customType: 'neuralwatt-energy',
|
|
309
|
+
data: { energy_joules: 1, cost_usd: 9999 }, // absurd cost, should be ignored
|
|
310
|
+
timestamp: now - 1000,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const { handlers, notifySpy } = fixture;
|
|
314
|
+
handlers['turn_start']?.({ type: 'turn_start', turnIndex: 0, timestamp: now });
|
|
315
|
+
await tick(50);
|
|
316
|
+
handlers['message_start']?.({ type: 'message_start', message });
|
|
317
|
+
await tick(50);
|
|
318
|
+
handlers['message_update']?.({
|
|
319
|
+
type: 'message_update',
|
|
320
|
+
message,
|
|
321
|
+
assistantMessageEvent: { type: 'text_delta', delta: 'H' },
|
|
322
|
+
});
|
|
323
|
+
handlers['message_end']?.({ type: 'message_end', message });
|
|
324
|
+
handlers['turn_end']?.(
|
|
325
|
+
{ type: 'turn_end', turnIndex: 0, message, toolResults: [] },
|
|
326
|
+
fixture.mockCtx
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
expect(notifySpy).toHaveBeenCalledOnce();
|
|
330
|
+
const banner = notifySpy.mock.calls[0][0] as string;
|
|
331
|
+
expect(banner).toContain('$4.00/M');
|
|
332
|
+
expect(banner).not.toContain('$9999');
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -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,15 +125,35 @@ 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
|
-
on: vi.fn((event: string, handler:
|
|
143
|
+
on: vi.fn((event: string, handler: any) => {
|
|
129
144
|
handlers[event] = handler;
|
|
130
145
|
return mockPi as ExtensionAPI;
|
|
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,
|