@ilya-lesikov/pi-pi 0.6.0 → 0.7.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.
@@ -96,6 +96,7 @@ describe("usage-tracker", () => {
96
96
  cacheWriteTokens: 0,
97
97
  cacheSupported: false,
98
98
  turns: 1,
99
+ subscription: false,
99
100
  },
100
101
  "anthropic/claude-opus-4-6": {
101
102
  inputTokens: 7,
@@ -104,6 +105,7 @@ describe("usage-tracker", () => {
104
105
  cacheWriteTokens: 0,
105
106
  cacheSupported: false,
106
107
  turns: 1,
108
+ subscription: false,
107
109
  },
108
110
  });
109
111
  });
@@ -186,6 +188,7 @@ describe("usage-tracker", () => {
186
188
  cost: 0.2,
187
189
  durationMs: 900,
188
190
  toolUses: 3,
191
+ subscription: false,
189
192
  },
190
193
  ]);
191
194
  });
@@ -226,12 +229,21 @@ describe("usage-tracker", () => {
226
229
  expect(tracker.getTotalCost()).toBe(2);
227
230
  });
228
231
 
229
- it("getCacheHitRate calculates ratio", () => {
232
+ it("getCacheHitRate is cacheRead over processed input (uncached + read + write)", () => {
230
233
  const tracker = createUsageTracker();
231
234
 
232
- tracker.recordTurn("openai/gpt-5", "openai", 30, 0, 10, 0, 0, true);
235
+ // uncached 30, cacheRead 10, cacheWrite 10 → 10 / (30+10+10) = 0.2
236
+ tracker.recordTurn("openai/gpt-5", "openai", 30, 0, 10, 10, 0, true);
233
237
 
234
- expect(tracker.getCacheHitRate()).toBeCloseTo(10 / 40);
238
+ expect(tracker.getCacheHitRate()).toBeCloseTo(10 / 50);
239
+ });
240
+
241
+ it("getCacheHitRate includes subagent cache tokens", () => {
242
+ const tracker = createUsageTracker();
243
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 0, 0, 0, 0, true);
244
+ tracker.recordSubagentCompletion({ input: 0, output: 0, cacheRead: 30, cacheWrite: 10 } as any);
245
+ // cacheRead 30 / processed (10 + 30 + 10) = 0.6
246
+ expect(tracker.getCacheHitRate()).toBeCloseTo(30 / 50);
235
247
  });
236
248
 
237
249
  it("getCacheHitRate returns zero when denominator is zero", () => {
@@ -239,6 +251,22 @@ describe("usage-tracker", () => {
239
251
  expect(tracker.getCacheHitRate()).toBe(0);
240
252
  });
241
253
 
254
+ it("getTotalProcessedInputTokens sums uncached + cache read + cache write across main and subagents", () => {
255
+ const tracker = createUsageTracker();
256
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 0, 20, 5, 0, true);
257
+ tracker.recordSubagentCompletion({ input: 3, output: 0, cacheRead: 7, cacheWrite: 2 } as any);
258
+ // main: 10+20+5=35, subagent: 3+7+2=12 → 47
259
+ expect(tracker.getTotalProcessedInputTokens()).toBe(47);
260
+ });
261
+
262
+ it("getTotalCacheReadTokens and getTotalCacheWriteTokens include subagents", () => {
263
+ const tracker = createUsageTracker();
264
+ tracker.recordTurn("openai/gpt-5", "openai", 0, 0, 20, 5, 0, true);
265
+ tracker.recordSubagentCompletion({ input: 0, output: 0, cacheRead: 7, cacheWrite: 2 } as any);
266
+ expect(tracker.getTotalCacheReadTokens()).toBe(27);
267
+ expect(tracker.getTotalCacheWriteTokens()).toBe(7);
268
+ });
269
+
242
270
  it("isCacheSupported true when any model or subagent has cache", () => {
243
271
  const tracker = createUsageTracker();
244
272
  expect(tracker.isCacheSupported()).toBe(false);
@@ -382,6 +410,106 @@ describe("usage-tracker", () => {
382
410
  expect(roundTrip.subagents).toEqual((mid as any).subagents);
383
411
  });
384
412
 
413
+ it("subscription main turns count tokens but contribute zero cost", () => {
414
+ const tracker = createUsageTracker();
415
+
416
+ // Detected via the sub/ model id prefix...
417
+ tracker.recordTurn("sub/claude-opus-4-6", "anthropic", 100, 50, 10, 5, 1.23, true);
418
+ // ...or via the subscription provider with a bare id (keyed under sub/).
419
+ tracker.recordTurn("claude-opus-4-6", "pp-flant-anthropic-sub", 20, 10, 0, 0, 0.5, true);
420
+
421
+ expect(tracker.getMainInputTokens()).toBe(120);
422
+ expect(tracker.getMainOutputTokens()).toBe(60);
423
+ expect(tracker.getMainCacheReadTokens()).toBe(10);
424
+ expect(tracker.getMainCost()).toBe(0);
425
+ expect(tracker.getTotalCost()).toBe(0);
426
+ expect(tracker.getPerModelUsage()["sub/claude-opus-4-6"]?.subscription).toBe(true);
427
+ });
428
+
429
+ it("provider-detected subscription turns key under sub/ so paid rows stay separate", () => {
430
+ const tracker = createUsageTracker();
431
+
432
+ // Same underlying model id: one paid, one subscription (provider-only).
433
+ tracker.recordTurn("claude-opus-4-6", "pp-flant-anthropic", 10, 5, 0, 0, 0.4, false);
434
+ tracker.recordTurn("claude-opus-4-6", "pp-flant-anthropic-sub", 20, 10, 0, 0, 2.0, false);
435
+
436
+ const usage = tracker.getPerModelUsage();
437
+ expect(usage["claude-opus-4-6"]).toMatchObject({ inputTokens: 10, outputTokens: 5, subscription: false });
438
+ expect(usage["sub/claude-opus-4-6"]).toMatchObject({ inputTokens: 20, outputTokens: 10, subscription: true });
439
+ expect(tracker.getMainCost()).toBeCloseTo(0.4);
440
+ });
441
+
442
+ it("subscription subagents count tokens but contribute zero cost", () => {
443
+ const tracker = createUsageTracker();
444
+
445
+ tracker.recordSubagentCompletion({ input: 30, output: 15 } as any, 0.9, {
446
+ description: "Explore", agentType: "explore", modelId: "sub/claude-haiku-4-5",
447
+ });
448
+
449
+ expect(tracker.getSubagentTotals()).toEqual({ inputTokens: 30, outputTokens: 15, cost: 0 });
450
+ expect(tracker.getSubagentList()[0]?.subscription).toBe(true);
451
+ expect(tracker.getSubagentList()[0]?.cost).toBe(0);
452
+ });
453
+
454
+ it("mixed paid and subscription session totals only the paid portion", () => {
455
+ const tracker = createUsageTracker();
456
+
457
+ tracker.recordTurn("openai/gpt-5", "openai", 10, 5, 0, 0, 0.4, false);
458
+ tracker.recordTurn("sub/claude-opus-4-6", "pp-flant-anthropic-sub", 10, 5, 0, 0, 2.0, false);
459
+ tracker.recordSubagentCompletion({ input: 5, output: 2 } as any, 0.1, { modelId: "openai/gpt-5" });
460
+ tracker.recordSubagentCompletion({ input: 5, output: 2 } as any, 3.0, { modelId: "sub/claude-haiku-4-5" });
461
+
462
+ expect(tracker.getMainCost()).toBeCloseTo(0.4);
463
+ expect(tracker.getSubagentTotals().cost).toBeCloseTo(0.1);
464
+ expect(tracker.getTotalCost()).toBeCloseTo(0.5);
465
+ });
466
+
467
+ it("subscription marker survives JSON round-trip and stays paid-only", () => {
468
+ const source = createUsageTracker();
469
+ source.recordTurn("sub/claude-opus-4-6", "pp-flant-anthropic-sub", 10, 5, 0, 0, 2.0, true);
470
+ source.recordSubagentCompletion({ input: 5, output: 2 } as any, 3.0, { modelId: "sub/claude-haiku-4-5" });
471
+
472
+ const mid = source.toSummary() as Record<string, unknown>;
473
+ const restored = createUsageTracker();
474
+ restored.loadFromSummary(mid);
475
+
476
+ const roundTrip = restored.toSummary() as any;
477
+ expect(restored.getTotalCost()).toBe(0);
478
+ expect(restored.getPerModelUsage()["sub/claude-opus-4-6"]?.subscription).toBe(true);
479
+ expect(restored.getSubagentList()[0]?.subscription).toBe(true);
480
+ expect(roundTrip.totals).toEqual((mid as any).totals);
481
+ expect(roundTrip.models).toEqual((mid as any).models);
482
+ expect(roundTrip.subagents).toEqual((mid as any).subagents);
483
+ });
484
+
485
+ it("legacy summaries without subscription field default to non-subscription", () => {
486
+ const tracker = createUsageTracker();
487
+ tracker.loadFromSummary({
488
+ totals: { inputTokens: 10, outputTokens: 5, cost: 1.5, turns: 1 },
489
+ models: { "openai/gpt-5": { inputTokens: 10, outputTokens: 5, turns: 1 } },
490
+ subagents: [{ description: "a", agentType: "x", modelId: "m", inputTokens: 3, outputTokens: 1, cost: 0.2 }],
491
+ });
492
+
493
+ expect(tracker.getPerModelUsage()["openai/gpt-5"]?.subscription).toBe(false);
494
+ expect(tracker.getSubagentList()[0]?.subscription).toBe(false);
495
+ expect(tracker.getMainCost()).toBe(1.5);
496
+ expect(tracker.getSubagentTotals().cost).toBeCloseTo(0.2);
497
+ });
498
+
499
+ it("loadFromSummary recovers subscription from sub/ prefix when flag is absent", () => {
500
+ const tracker = createUsageTracker();
501
+ tracker.loadFromSummary({
502
+ totals: { inputTokens: 10, outputTokens: 5, cost: 0, turns: 1 },
503
+ models: { "sub/claude-opus-4-6": { inputTokens: 10, outputTokens: 5, turns: 1 } },
504
+ subagents: [{ description: "a", agentType: "x", modelId: "sub/claude-haiku-4-5", inputTokens: 3, outputTokens: 1, cost: 2.5 }],
505
+ });
506
+
507
+ expect(tracker.getPerModelUsage()["sub/claude-opus-4-6"]?.subscription).toBe(true);
508
+ expect(tracker.getSubagentList()[0]?.subscription).toBe(true);
509
+ expect(tracker.getSubagentList()[0]?.cost).toBe(0);
510
+ expect(tracker.getSubagentTotals().cost).toBe(0);
511
+ });
512
+
385
513
  it("reset clears all state", () => {
386
514
  const tracker = createUsageTracker();
387
515
  tracker.recordTurn("openai/gpt-5", "openai", 1, 2, 3, 4, 0.2, true);
@@ -1,6 +1,19 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { SUB_MODEL_PREFIX, SUB_PROVIDER } from "./flant-infra.js";
5
+
6
+ /**
7
+ * A usage unit is subscription-routed (flat-rate personal Claude subscription,
8
+ * not per-token billed) when its main-turn provider is the subscription
9
+ * provider, or when the registered model id carries the `sub/` prefix. The
10
+ * prefix is the only signal available for subagents (subagents:completed has no
11
+ * provider field), so we always check it in addition to the provider.
12
+ */
13
+ export function isSubscriptionRouted(modelId?: string, provider?: string): boolean {
14
+ if (provider === SUB_PROVIDER) return true;
15
+ return typeof modelId === "string" && modelId.startsWith(SUB_MODEL_PREFIX);
16
+ }
4
17
 
5
18
  export interface ModelUsage {
6
19
  inputTokens: number;
@@ -9,6 +22,8 @@ export interface ModelUsage {
9
22
  cacheWriteTokens: number;
10
23
  cacheSupported: boolean;
11
24
  turns: number;
25
+ /** Flat-rate personal subscription: dollars are excluded from cost totals, tokens are not. */
26
+ subscription: boolean;
12
27
  }
13
28
 
14
29
  export interface UsageTracker {
@@ -19,6 +34,8 @@ export interface UsageTracker {
19
34
  getTotalOutputTokens(): number;
20
35
  getTotalCacheReadTokens(): number;
21
36
  getTotalCacheWriteTokens(): number;
37
+ /** Total input the model actually processed: uncached input + cache read + cache write (main + subagents). */
38
+ getTotalProcessedInputTokens(): number;
22
39
  getTotalCost(): number;
23
40
  getMainInputTokens(): number;
24
41
  getMainOutputTokens(): number;
@@ -46,6 +63,8 @@ export interface SubagentUsage {
46
63
  cost: number;
47
64
  durationMs: number;
48
65
  toolUses: number;
66
+ /** Flat-rate personal subscription: dollars are excluded from cost totals, tokens are not. */
67
+ subscription: boolean;
49
68
  }
50
69
 
51
70
  interface TrackerState {
@@ -58,6 +77,8 @@ interface TrackerState {
58
77
  totalTurns: number;
59
78
  subagentInputTokens: number;
60
79
  subagentOutputTokens: number;
80
+ subagentCacheReadTokens: number;
81
+ subagentCacheWriteTokens: number;
61
82
  subagentCost: number;
62
83
  models: Map<string, ModelUsage>;
63
84
  subagents: SubagentUsage[];
@@ -89,6 +110,8 @@ function createInitialState(): TrackerState {
89
110
  totalTurns: 0,
90
111
  subagentInputTokens: 0,
91
112
  subagentOutputTokens: 0,
113
+ subagentCacheReadTokens: 0,
114
+ subagentCacheWriteTokens: 0,
92
115
  subagentCost: 0,
93
116
  models: new Map<string, ModelUsage>(),
94
117
  subagents: [],
@@ -99,12 +122,15 @@ export function createUsageTracker(): UsageTracker {
99
122
  const state = createInitialState();
100
123
 
101
124
  return {
102
- recordTurn(modelId: string, _provider: string, input: number, output: number, cacheRead: number, cacheWrite: number, cost: number, cacheSupported?: boolean): void {
125
+ recordTurn(modelId: string, provider: string, input: number, output: number, cacheRead: number, cacheWrite: number, cost: number, cacheSupported?: boolean): void {
103
126
  const safeInput = toFiniteNumber(input);
104
127
  const safeOutput = toFiniteNumber(output);
105
128
  const safeCacheRead = toFiniteNumber(cacheRead);
106
129
  const safeCacheWrite = toFiniteNumber(cacheWrite);
107
- const safeCost = toFiniteNumber(cost);
130
+ const subscription = isSubscriptionRouted(modelId, provider);
131
+ // Subscription-routed turns are flat-rate: keep the tokens but exclude the
132
+ // fictitious per-token dollars so totals stay paid-only by construction.
133
+ const safeCost = subscription ? 0 : toFiniteNumber(cost);
108
134
 
109
135
  state.totalInputTokens += safeInput;
110
136
  state.totalOutputTokens += safeOutput;
@@ -113,7 +139,11 @@ export function createUsageTracker(): UsageTracker {
113
139
  state.totalCost += safeCost;
114
140
  state.totalTurns += 1;
115
141
 
116
- const key = modelId || "unknown-model";
142
+ // Key subscription turns under the sub/ prefix even when detected only by
143
+ // provider (bare model id), so a paid and a subscription turn for the same
144
+ // underlying model never share a row and the paid dollars stay visible.
145
+ const baseKey = modelId || "unknown-model";
146
+ const key = subscription && !baseKey.startsWith(SUB_MODEL_PREFIX) ? `${SUB_MODEL_PREFIX}${baseKey}` : baseKey;
117
147
  const usage = state.models.get(key) ?? {
118
148
  inputTokens: 0,
119
149
  outputTokens: 0,
@@ -121,6 +151,7 @@ export function createUsageTracker(): UsageTracker {
121
151
  cacheWriteTokens: 0,
122
152
  cacheSupported: false,
123
153
  turns: 0,
154
+ subscription: false,
124
155
  };
125
156
 
126
157
  usage.inputTokens += safeInput;
@@ -128,6 +159,7 @@ export function createUsageTracker(): UsageTracker {
128
159
  usage.cacheReadTokens += safeCacheRead;
129
160
  usage.cacheWriteTokens += safeCacheWrite;
130
161
  if (cacheSupported) usage.cacheSupported = true;
162
+ if (subscription) usage.subscription = true;
131
163
  usage.turns += 1;
132
164
  state.models.set(key, usage);
133
165
  },
@@ -142,12 +174,17 @@ export function createUsageTracker(): UsageTracker {
142
174
  const safeTotal = toFiniteNumber(tokens.total);
143
175
  const safeCacheRead = toFiniteNumber(tokens.cacheRead);
144
176
  const safeCacheWrite = toFiniteNumber(tokens.cacheWrite);
145
- const safeCost = toFiniteNumber(tokens.cost ?? cost);
177
+ // Subagents carry no provider field, so detect subscription routing from
178
+ // the registered model id prefix. Zero the dollars when subscription.
179
+ const subscription = isSubscriptionRouted(meta?.modelId);
180
+ const safeCost = subscription ? 0 : toFiniteNumber(tokens.cost ?? cost);
146
181
 
147
182
  const effectiveInput = safeInput === 0 && safeOutput === 0 ? safeTotal : safeInput;
148
183
 
149
184
  state.subagentInputTokens += effectiveInput;
150
185
  state.subagentOutputTokens += safeOutput;
186
+ state.subagentCacheReadTokens += safeCacheRead;
187
+ state.subagentCacheWriteTokens += safeCacheWrite;
151
188
  state.subagentCost += safeCost;
152
189
 
153
190
  const cacheSupported = typeof tokens.cacheRead === "number" || typeof tokens.cacheWrite === "number";
@@ -164,6 +201,7 @@ export function createUsageTracker(): UsageTracker {
164
201
  cost: safeCost,
165
202
  durationMs: toFiniteNumber(meta?.durationMs),
166
203
  toolUses: toFiniteNumber(meta?.toolUses),
204
+ subscription,
167
205
  });
168
206
  },
169
207
 
@@ -183,24 +221,33 @@ export function createUsageTracker(): UsageTracker {
183
221
  state.subagents = [];
184
222
  state.subagentInputTokens = 0;
185
223
  state.subagentOutputTokens = 0;
224
+ state.subagentCacheReadTokens = 0;
225
+ state.subagentCacheWriteTokens = 0;
186
226
  state.subagentCost = 0;
187
227
  for (const sa of summary.subagents as Record<string, unknown>[]) {
228
+ const modelId = typeof sa.modelId === "string" ? sa.modelId : "unknown";
229
+ // Pre-change summaries lack the flag; recover it from the sub/ prefix
230
+ // so legacy subscription rows are not restored as paid.
231
+ const subscription = sa.subscription === true || isSubscriptionRouted(modelId);
188
232
  const entry: SubagentUsage = {
189
233
  description: typeof sa.description === "string" ? sa.description : "unknown",
190
234
  agentType: typeof sa.agentType === "string" ? sa.agentType : "unknown",
191
- modelId: typeof sa.modelId === "string" ? sa.modelId : "unknown",
235
+ modelId,
192
236
  inputTokens: toFiniteNumber(sa.inputTokens),
193
237
  outputTokens: toFiniteNumber(sa.outputTokens),
194
238
  cacheReadTokens: toFiniteNumber(sa.cacheReadTokens),
195
239
  cacheWriteTokens: toFiniteNumber(sa.cacheWriteTokens),
196
240
  cacheSupported: sa.cacheSupported === true,
197
- cost: toFiniteNumber(sa.cost),
241
+ cost: subscription ? 0 : toFiniteNumber(sa.cost),
198
242
  durationMs: toFiniteNumber(sa.durationMs),
199
243
  toolUses: toFiniteNumber(sa.toolUses),
244
+ subscription,
200
245
  };
201
246
  state.subagents.push(entry);
202
247
  state.subagentInputTokens += entry.inputTokens;
203
248
  state.subagentOutputTokens += entry.outputTokens;
249
+ state.subagentCacheReadTokens += entry.cacheReadTokens;
250
+ state.subagentCacheWriteTokens += entry.cacheWriteTokens;
204
251
  state.subagentCost += entry.cost;
205
252
  }
206
253
  }
@@ -213,6 +260,7 @@ export function createUsageTracker(): UsageTracker {
213
260
  cacheWriteTokens: toFiniteNumber(usage.cacheWriteTokens),
214
261
  cacheSupported: (usage as any).cacheSupported === true,
215
262
  turns: toFiniteNumber(usage.turns),
263
+ subscription: (usage as any).subscription === true || isSubscriptionRouted(modelId),
216
264
  });
217
265
  }
218
266
  }
@@ -230,11 +278,22 @@ export function createUsageTracker(): UsageTracker {
230
278
  },
231
279
 
232
280
  getTotalCacheReadTokens(): number {
233
- return state.totalCacheReadTokens;
281
+ return state.totalCacheReadTokens + state.subagentCacheReadTokens;
234
282
  },
235
283
 
236
284
  getTotalCacheWriteTokens(): number {
237
- return state.totalCacheWriteTokens;
285
+ return state.totalCacheWriteTokens + state.subagentCacheWriteTokens;
286
+ },
287
+
288
+ getTotalProcessedInputTokens(): number {
289
+ return (
290
+ state.totalInputTokens +
291
+ state.subagentInputTokens +
292
+ state.totalCacheReadTokens +
293
+ state.subagentCacheReadTokens +
294
+ state.totalCacheWriteTokens +
295
+ state.subagentCacheWriteTokens
296
+ );
238
297
  },
239
298
 
240
299
  getTotalCost(): number {
@@ -262,9 +321,11 @@ export function createUsageTracker(): UsageTracker {
262
321
  },
263
322
 
264
323
  getCacheHitRate(): number {
265
- const totalInput = state.totalInputTokens + state.subagentInputTokens;
266
- const totalCacheRead = state.totalCacheReadTokens;
267
- const denominator = totalCacheRead + totalInput;
324
+ const totalCacheRead = state.totalCacheReadTokens + state.subagentCacheReadTokens;
325
+ // Session-wide, token-weighted average: cache reads over all processed
326
+ // input (uncached input + cache read + cache write). Including cache
327
+ // writes keeps first-touch (uncached) content from inflating the rate.
328
+ const denominator = this.getTotalProcessedInputTokens();
268
329
  if (denominator <= 0) return 0;
269
330
  return totalCacheRead / denominator;
270
331
  },
@@ -324,6 +385,8 @@ export function createUsageTracker(): UsageTracker {
324
385
  state.totalTurns = 0;
325
386
  state.subagentInputTokens = 0;
326
387
  state.subagentOutputTokens = 0;
388
+ state.subagentCacheReadTokens = 0;
389
+ state.subagentCacheWriteTokens = 0;
327
390
  state.subagentCost = 0;
328
391
  state.models.clear();
329
392
  state.subagents = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilya-lesikov/pi-pi",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Pi-Pi Coding Agent: based on Pi Coding Agent, but twice as good",
5
5
  "keywords": [
6
6
  "pi-package",