@monotykamary/pi-tps 1.3.0 → 1.3.2

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.
@@ -175,6 +175,76 @@ describe('pi-tps extension — blended $/M-tokens rate', () => {
175
175
  expect(banner).toContain('$4.00/M');
176
176
  });
177
177
 
178
+ it('corrects the banner + persisted entry when the energy event arrives late', async () => {
179
+ // Provider loads AFTER pi-tps: our turn_end runs first on the list-price
180
+ // fallback, then the provider's turn_end emits the energy event, which
181
+ // must retroactively correct the rate without a second turn_end.
182
+ const message = makeMessageWithCost({
183
+ input: 1000,
184
+ output: 1000,
185
+ costTotal: 0.008, // list price: $4.00/M; billed: $3.00/M
186
+ provider: 'neuralwatt',
187
+ model: 'moonshotai/Kimi-K2.5',
188
+ });
189
+
190
+ await runBurstTurn(fixture, message, 0);
191
+
192
+ const { notifySpy, appendEntrySpy } = fixture;
193
+ // Initially committed on the list-price fallback.
194
+ expect(appendEntrySpy.mock.calls[0][0]).toBe('tps');
195
+ expect(appendEntrySpy.mock.calls[0][1].rateUsdPerMTokens).toBe(4.0);
196
+ expect(notifySpy.mock.calls[0][0]).toContain('$4.00/M');
197
+
198
+ // Provider's turn_end fires the energy event after ours.
199
+ fixture.emitEvent('neuralwatt:turn-energy', {
200
+ costUsd: 0.006, // $3.00/M for 2000 tokens
201
+ energyJoules: 21.6,
202
+ turnIndex: 0,
203
+ });
204
+
205
+ // A corrected `tps` entry is appended in place of the list-price one.
206
+ expect(appendEntrySpy.mock.calls).toHaveLength(2);
207
+ expect(appendEntrySpy.mock.calls[1][0]).toBe('tps');
208
+ expect(appendEntrySpy.mock.calls[1][1].rateUsdPerMTokens).toBe(3.0);
209
+
210
+ // Banner is refreshed to the billed rate.
211
+ const lastBanner = notifySpy.mock.calls.at(-1)![0] as string;
212
+ expect(lastBanner).toContain('$3.00/M');
213
+ expect(lastBanner).not.toContain('$4.00/M');
214
+ });
215
+
216
+ it('does not double-correct a turn already billed at commit time', async () => {
217
+ // Provider loads BEFORE pi-tps: the event fires first, our turn_end reads
218
+ // it from the live cache (billedApplied=true), so no late correction.
219
+ const message = makeMessageWithCost({
220
+ input: 1000,
221
+ output: 1000,
222
+ costTotal: 0.008,
223
+ provider: 'neuralwatt',
224
+ model: 'moonshotai/Kimi-K2.5',
225
+ });
226
+
227
+ fixture.emitEvent('neuralwatt:turn-energy', {
228
+ costUsd: 0.006,
229
+ energyJoules: 21.6,
230
+ turnIndex: 0,
231
+ });
232
+ await runBurstTurn(fixture, message, 0);
233
+
234
+ // A stray duplicate event must not append a second corrected entry.
235
+ fixture.emitEvent('neuralwatt:turn-energy', {
236
+ costUsd: 0.006,
237
+ energyJoules: 21.6,
238
+ turnIndex: 0,
239
+ });
240
+
241
+ const { appendEntrySpy, notifySpy } = fixture;
242
+ expect(appendEntrySpy.mock.calls.filter((c) => c[0] === 'tps')).toHaveLength(1);
243
+ expect(notifySpy).toHaveBeenCalledOnce();
244
+ const banner = notifySpy.mock.calls[0][0] as string;
245
+ expect(banner).toContain('$3.00/M');
246
+ });
247
+
178
248
  it('ignores neuralwatt:turn-energy payloads lacking a numeric turnIndex', async () => {
179
249
  // Defensive: malformed event must not pollute the cache.
180
250
  fixture.emitEvent('neuralwatt:turn-energy', { costUsd: 0.006, energyJoules: 21.6 }); // no turnIndex
@@ -249,4 +319,86 @@ describe('pi-tps extension — blended $/M-tokens rate', () => {
249
319
  const banner = notifySpy.mock.calls[0][0] as string;
250
320
  expect(banner).not.toMatch(/\$.*\/M/);
251
321
  });
322
+
323
+ it('falls back to a persisted neuralwatt-energy entry when the live event is missed', async () => {
324
+ const now = Date.now();
325
+ const message = makeMessageWithCost({
326
+ input: 1000,
327
+ output: 1000,
328
+ costTotal: 0.008, // list price: $4.00/M
329
+ provider: 'neuralwatt',
330
+ model: 'moonshotai/Kimi-K2.5',
331
+ });
332
+
333
+ // Provider appended its energy entry before pi-tps handled turn_end.
334
+ fixture.mockEntries.push({
335
+ type: 'custom',
336
+ customType: 'neuralwatt-energy',
337
+ data: { energy_joules: 21.6, cost_usd: 0.006 }, // $3.00/M for 2000 tokens
338
+ timestamp: now,
339
+ });
340
+
341
+ const { handlers, notifySpy, appendEntrySpy } = fixture;
342
+ handlers['turn_start']?.({ type: 'turn_start', turnIndex: 0, timestamp: now - 100 });
343
+ await tick(50);
344
+ handlers['message_start']?.({ type: 'message_start', message });
345
+ await tick(50);
346
+ handlers['message_update']?.({
347
+ type: 'message_update',
348
+ message,
349
+ assistantMessageEvent: { type: 'text_delta', delta: 'H' },
350
+ });
351
+ handlers['message_end']?.({ type: 'message_end', message });
352
+ handlers['turn_end']?.(
353
+ { type: 'turn_end', turnIndex: 0, message, toolResults: [] },
354
+ fixture.mockCtx
355
+ );
356
+
357
+ expect(notifySpy).toHaveBeenCalledOnce();
358
+ const banner = notifySpy.mock.calls[0][0] as string;
359
+ expect(banner).toContain('$3.00/M');
360
+ expect(banner).not.toContain('$4.00/M');
361
+
362
+ const [, data] = appendEntrySpy.mock.calls[0];
363
+ expect(data.rateUsdPerMTokens).toBe(3.0);
364
+ });
365
+
366
+ it('ignores stale neuralwatt-energy entries from before this turn', async () => {
367
+ const now = Date.now();
368
+ const message = makeMessageWithCost({
369
+ input: 1000,
370
+ output: 1000,
371
+ costTotal: 0.008, // list price: $4.00/M
372
+ provider: 'neuralwatt',
373
+ model: 'moonshotai/Kimi-K2.5',
374
+ });
375
+
376
+ fixture.mockEntries.push({
377
+ type: 'custom',
378
+ customType: 'neuralwatt-energy',
379
+ data: { energy_joules: 1, cost_usd: 9999 }, // absurd cost, should be ignored
380
+ timestamp: now - 1000,
381
+ });
382
+
383
+ const { handlers, notifySpy } = fixture;
384
+ handlers['turn_start']?.({ type: 'turn_start', turnIndex: 0, timestamp: now });
385
+ await tick(50);
386
+ handlers['message_start']?.({ type: 'message_start', message });
387
+ await tick(50);
388
+ handlers['message_update']?.({
389
+ type: 'message_update',
390
+ message,
391
+ assistantMessageEvent: { type: 'text_delta', delta: 'H' },
392
+ });
393
+ handlers['message_end']?.({ type: 'message_end', message });
394
+ handlers['turn_end']?.(
395
+ { type: 'turn_end', turnIndex: 0, message, toolResults: [] },
396
+ fixture.mockCtx
397
+ );
398
+
399
+ expect(notifySpy).toHaveBeenCalledOnce();
400
+ const banner = notifySpy.mock.calls[0][0] as string;
401
+ expect(banner).toContain('$4.00/M');
402
+ expect(banner).not.toContain('$9999');
403
+ });
252
404
  });
@@ -140,7 +140,7 @@ export function createTestFixture(): TestFixture {
140
140
  };
141
141
 
142
142
  const mockPi: Partial<ExtensionAPI> = {
143
- on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
143
+ on: vi.fn((event: string, handler: any) => {
144
144
  handlers[event] = handler;
145
145
  return mockPi as ExtensionAPI;
146
146
  }),
@@ -132,6 +132,7 @@ interface TurnTiming {
132
132
  messageCount: number;
133
133
  isToolCall: boolean; // tool_execution_start fired during this turn
134
134
  isPrimaryBranch: boolean; // TPS came from primary-branch (reliable) measurement
135
+ turnStartTimestamp: number; // wall-clock ms at turn_start, for correlating session energy entries
135
136
  }
136
137
 
137
138
  // ─── Helpers ────────────────────────────────────────────────────────────────
@@ -150,6 +151,40 @@ function computeRateUsdPerM(costUsd: number | null, totalTokens: number): number
150
151
  return Math.round(rate * 100) / 100;
151
152
  }
152
153
 
154
+ /**
155
+ * Fallback: find a Neuralwatt energy entry that was persisted in this session
156
+ * during the current turn. This covers the case where pi-neuralwatt-provider
157
+ * loads before pi-tps, so the energy entry exists before our turn_end runs,
158
+ * but the live event cache missed (e.g. malformed payload, event ordering, or
159
+ * a race).
160
+ *
161
+ * Scans backward from the most recent entry and requires the entry timestamp
162
+ * to be >= turnStartTimestamp so a previous turn's energy data is not reused.
163
+ */
164
+ function findEnergyCostFromSession(
165
+ ctx: ExtensionContext,
166
+ turnStartTimestamp: number
167
+ ): number | null {
168
+ if (!ctx.sessionManager?.getEntries) return null;
169
+
170
+ const entries = ctx.sessionManager.getEntries();
171
+ for (let i = entries.length - 1; i >= 0; i--) {
172
+ const e = entries[i];
173
+ if (e.type !== 'custom' || e.customType !== 'neuralwatt-energy') continue;
174
+
175
+ const entryTimestamp = typeof e.timestamp === 'number' ? e.timestamp : 0;
176
+ if (entryTimestamp < turnStartTimestamp) return null;
177
+
178
+ const data = e.data as Record<string, unknown> | null | undefined;
179
+ if (!data) continue;
180
+
181
+ const costUsd = typeof data.cost_usd === 'number' ? data.cost_usd : null;
182
+ if (costUsd !== null && Number.isFinite(costUsd) && costUsd >= 0) return costUsd;
183
+ }
184
+
185
+ return null;
186
+ }
187
+
153
188
  function isAssistantMessage(message: unknown): message is AssistantMessage {
154
189
  if (!message || typeof message !== 'object') return false;
155
190
  const msg = message as Record<string, unknown>;
@@ -511,6 +546,18 @@ export default function tpsExtension(pi: ExtensionAPI) {
511
546
  // own session entries. Non-Neuralwatt turns never emit, so this stays empty.
512
547
  const neuralwattBilledCostByTurn = new Map<number, number>();
513
548
 
549
+ // Tracks the most recently committed turn so a late-arriving
550
+ // `neuralwatt:turn-energy` event (provider loaded AFTER us) can retroactively
551
+ // correct a turn that we already persisted with the list-price fallback.
552
+ // Load-order-independent: when the provider loads before us the live cache
553
+ // hits and `billedApplied` is already true, so this never fires.
554
+ let lastCommittedTurn: {
555
+ turnIndex: number;
556
+ telemetry: TurnTelemetry;
557
+ billedApplied: boolean;
558
+ ctx: ExtensionContext;
559
+ } | null = null;
560
+
514
561
  pi.events?.on(NEURALWATT_ENERGY_EVENT, (payload: unknown) => {
515
562
  if (!payload || typeof payload !== 'object') return;
516
563
  const p = payload as Record<string, unknown>;
@@ -518,6 +565,36 @@ export default function tpsExtension(pi: ExtensionAPI) {
518
565
  const costUsd = typeof p.costUsd === 'number' ? p.costUsd : null;
519
566
  if (turnIndex === null || costUsd === null) return; // require turnIndex correlation
520
567
  neuralwattBilledCostByTurn.set(turnIndex, costUsd);
568
+
569
+ // Late-arrival correction: the event reached us after our turn_end already
570
+ // committed this turn on the list-price fallback. Recompute the blended
571
+ // rate from the billed cost, persist a corrected `tps` entry (which later
572
+ // resume/export reads in place of the stale list-price one), and refresh
573
+ // the banner — but only if no newer turn is mid-flight (currentTiming set
574
+ // means a new turn is streaming; updating the banner then would be
575
+ // misleading). Idempotent: once `billedApplied` flips true we never
576
+ // re-correct, so repeated or duplicate events are safe.
577
+ const committed = lastCommittedTurn;
578
+ if (
579
+ committed &&
580
+ !committed.billedApplied &&
581
+ committed.turnIndex === turnIndex &&
582
+ !Number.isNaN(costUsd)
583
+ ) {
584
+ const totalTokens = committed.telemetry.tokens.total;
585
+ const correctedRate = computeRateUsdPerM(costUsd, totalTokens);
586
+ if (correctedRate !== null && correctedRate !== committed.telemetry.rateUsdPerMTokens) {
587
+ const corrected = { ...committed.telemetry, rateUsdPerMTokens: correctedRate };
588
+ committed.telemetry = corrected;
589
+ committed.billedApplied = true;
590
+ pi.appendEntry('tps', corrected);
591
+ pi.events?.emit('tps:telemetry', corrected);
592
+ cachedEntries.push({ type: 'custom', customType: 'tps' });
593
+ if (committed.ctx.hasUI && !currentTiming) {
594
+ committed.ctx.ui.notify(composeDisplayString(corrected), 'info');
595
+ }
596
+ }
597
+ }
521
598
  // Bound the cache (oldest = lowest turnIndex)
522
599
  if (neuralwattBilledCostByTurn.size > NEURALWATT_ENERGY_CACHE_MAX) {
523
600
  let oldest = Infinity;
@@ -579,6 +656,7 @@ export default function tpsExtension(pi: ExtensionAPI) {
579
656
  pi.on('turn_start', (_event: TurnStartEvent) => {
580
657
  currentTiming = {
581
658
  turnStartMs: performance.now(),
659
+ turnStartTimestamp: typeof _event.timestamp === 'number' ? _event.timestamp : Date.now(),
582
660
  lastUpdateMs: performance.now(),
583
661
  firstTokenMs: null,
584
662
  currentMessageStartMs: null,
@@ -698,14 +776,19 @@ export default function tpsExtension(pi: ExtensionAPI) {
698
776
  const turnEndMs = performance.now();
699
777
 
700
778
  // 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;
779
+ // billed cost when present, otherwise the list-price compute cost from
780
+ // message.usage.cost. The live event is the primary source; if it missed,
781
+ // we read the persisted neuralwatt-energy session entry. Only if neither
782
+ // energy source is available do we fall back to the compute rate.
783
+ // Prefer the live energy event, fall back to the persisted energy entry,
784
+ // and finally to the list-price compute cost.
785
+ let billedCost = neuralwattBilledCostByTurn.get(event.turnIndex) ?? null;
707
786
  neuralwattBilledCostByTurn.delete(event.turnIndex);
708
787
 
788
+ if (billedCost === null && timing.turnStartTimestamp) {
789
+ billedCost = findEnergyCostFromSession(ctx, timing.turnStartTimestamp);
790
+ }
791
+
709
792
  const telemetry = buildTelemetry(timing, turnEndMs, billedCost);
710
793
  if (!telemetry) return;
711
794
 
@@ -731,6 +814,17 @@ export default function tpsExtension(pi: ExtensionAPI) {
731
814
  }
732
815
  }
733
816
 
817
+ // Record this turn so a late-arriving neuralwatt:turn-energy event
818
+ // (provider loaded after us) can retroactively correct the rate. Billed
819
+ // cost wins when present, so a turn already billed at commit time is
820
+ // never re-corrected.
821
+ lastCommittedTurn = {
822
+ turnIndex: event.turnIndex,
823
+ telemetry,
824
+ billedApplied: billedCost !== null,
825
+ ctx,
826
+ };
827
+
734
828
  // Persist structured telemetry to session for export and rehydration
735
829
  pi.appendEntry('tps', telemetry);
736
830