pennyrouter 0.3.43 → 0.3.44

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.
@@ -1,22 +1,38 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { mkdir, open, readdir, stat, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { basename, join } from 'node:path';
5
5
 
6
- // Standard per-token rates from ../rates.json (scripts/generate_compare_rates.py), the same
7
- // file pricing.py reads. Each rate list is [input, cache read, cache write 5m, cache write 1h,
8
- // output]; a model may carry an `above` list that applies once one request's input context
9
- // exceeds its threshold.
10
- const { models } = JSON.parse(readFileSync(new URL('../rates.json', import.meta.url), 'utf8'));
6
+ // Per-token rates from ../rates.json (scripts/generate_compare_rates.py) and the hand-maintained
7
+ // ../govcloud-rates.json, the same files pricing.py reads. Each rate list is [input, cache read,
8
+ // cache write 5m, cache write 1h, output]; a model may carry an `above` list that applies once
9
+ // one request's input context exceeds its threshold.
10
+ const schedule = (file) => JSON.parse(readFileSync(new URL(file, import.meta.url), 'utf8')).models;
11
+ const schedules = { standard: schedule('../rates.json'), govcloud: schedule('../govcloud-rates.json') };
12
+ export const pricingProfiles = Object.keys(schedules);
11
13
 
12
14
  function longestPrefix(name, table) {
15
+ if (table[name]) return name;
13
16
  const keys = Object.keys(table).filter((key) => name.startsWith(key));
14
17
  return keys.sort((a, b) => b.length - a.length)[0];
15
18
  }
16
19
 
17
- function rateFor(model, contextTokens = 0) {
18
- const normalized = String(model || '').replace(/^.*\//, '').replace('[1m]', '').trim();
19
- const entry = models[longestPrefix(normalized, models)] || models['claude-sonnet-5'];
20
+ // Mirrors pricing._normalize: vendor prefixes (including Bedrock's `anthropic.`), the
21
+ // long-context suffix, and the dotted Anthropic ids some routes record.
22
+ function normalize(model) {
23
+ let name = String(model || '').replace(/^.*\//, '').trim();
24
+ if (name.startsWith('anthropic.')) name = name.slice('anthropic.'.length);
25
+ name = name.replace('[1m]', '').trim();
26
+ return { 'claude-opus-4.8': 'claude-opus-4-8', 'claude-haiku-4.5': 'claude-haiku-4-5' }[name] || name;
27
+ }
28
+
29
+ // A GovCloud schedule overrides only the models it lists; others keep their standard rate.
30
+ export function rateFor(model, contextTokens = 0, profile = 'standard') {
31
+ const name = normalize(model);
32
+ const table = schedules[profile] || schedules.standard;
33
+ const key = longestPrefix(name, table);
34
+ if (!key && table !== schedules.standard) return rateFor(model, contextTokens, 'standard');
35
+ const entry = table[key] || schedules.standard['claude-sonnet-5'];
20
36
  return entry.above && contextTokens > entry.above.threshold ? entry.above.rates : entry.rates;
21
37
  }
22
38
 
@@ -41,19 +57,35 @@ async function readFirstLine(path, limit = 1024 * 1024) {
41
57
  } finally { await handle.close(); }
42
58
  }
43
59
 
60
+ // Telemetry usually arrives before the transcript line for the same request; an event counts
61
+ // as a background call only once its request id has stayed out of the transcript this long.
62
+ const sideCallGraceMs = 5000;
63
+
64
+ // Without a request id (a gateway may not relay Anthropic's), a telemetry event and its
65
+ // transcript line still carry the same usage, so that usage identifies the call.
66
+ const usagePrint = (model, input, read, write, output) =>
67
+ [String(model || '').split('/').pop().replace('[1m]', ''), input, read, write, output].join(':');
68
+ const addPrint = (prints, print) => prints.set(print, (prints.get(print) || 0) + 1);
69
+ const takePrint = (prints, print) => {
70
+ const count = prints.get(print) || 0;
71
+ if (count) prints.set(print, count - 1);
72
+ return count > 0;
73
+ };
74
+
44
75
  function emptyTotals() {
45
76
  return { cache_read: 0, cache_write: 0, write_5m: 0, write_1h: 0,
46
77
  fresh_input: 0, output: 0, reasoning_output: 0, total: 0, cost: 0 };
47
78
  }
48
79
 
49
80
  export class ClaudeMeter {
50
- constructor({ sessionId, cwd, side, role, label, variant }) {
81
+ constructor({ sessionId, cwd, side, role, label, variant, pricingProfile = 'standard' }) {
51
82
  this.sessionId = sessionId;
52
83
  this.cwd = cwd;
53
84
  this.side = side;
54
85
  this.role = role;
55
86
  this.label = label || role;
56
87
  this.variant = variant;
88
+ this.pricingProfile = pricingProfiles.includes(pricingProfile) ? pricingProfile : 'standard';
57
89
  this.root = join(homedir(), '.claude', 'projects');
58
90
  this.files = new Map();
59
91
  this.ids = new Set();
@@ -62,6 +94,61 @@ export class ClaudeMeter {
62
94
  this.model = '';
63
95
  this.transcriptPath = null;
64
96
  this.started = Date.now() / 1000;
97
+ this.requestIds = new Set();
98
+ this.transcriptPrints = new Map();
99
+ this.chargedPrints = new Map();
100
+ this.telemetry = new Map();
101
+ this.sideCalls = { count: 0, cost: 0, models: {} };
102
+ }
103
+
104
+ // api_request events from this pane's telemetry (see telemetry.mjs).
105
+ ingestTelemetry(requests) {
106
+ for (const request of requests) {
107
+ if (!this.telemetry.has(request.key)) {
108
+ this.telemetry.set(request.key, { ...request, receivedAt: Date.now(), resolved: false, charged: false });
109
+ }
110
+ }
111
+ }
112
+
113
+ // Charges requests Claude Code made outside the transcript (the auto-mode reviewer, titles,
114
+ // and similar background calls). Pass final once the session has ended to skip the grace.
115
+ chargeSideCalls({ final = false } = {}) {
116
+ const now = Date.now();
117
+ for (const request of this.telemetry.values()) {
118
+ if (request.resolved) continue;
119
+ const { model, cache_read: read, cache_write: write, fresh_input: input, output } = request;
120
+ const print = usagePrint(model, input, read, write, output);
121
+ if (request.requestId ? this.requestIds.has(request.requestId) : takePrint(this.transcriptPrints, print)) {
122
+ request.resolved = true;
123
+ continue;
124
+ }
125
+ if (!final && now - request.receivedAt < sideCallGraceMs) continue;
126
+ request.resolved = true;
127
+ request.charged = true;
128
+ if (!request.requestId) addPrint(this.chargedPrints, print);
129
+ const prices = rateFor(model, read + write + input, this.pricingProfile);
130
+ // Telemetry does not split cache writes by TTL; they are priced at the 5-minute rate.
131
+ const cost = input * prices[0] + read * prices[1] + write * prices[2] + output * prices[4];
132
+ const total = read + write + input + output;
133
+ this.calls.push({ timestamp: request.timestamp, recorded_at: now / 1000, model,
134
+ cache_read: read, cache_write: write, write_5m: write, write_1h: 0, fresh_input: input, output,
135
+ reasoning_output: 0, total, cost, context_tokens: read + write + input,
136
+ pricing_profile: this.pricingProfile, side_call: true, query_source: request.querySource,
137
+ rates_per_million: Object.fromEntries(
138
+ ['input', 'cache_read', 'cache_write_5m', 'cache_write_1h', 'output']
139
+ .map((name, index) => [name, prices[index] * 1_000_000])) });
140
+ this.totals.cache_read += read;
141
+ this.totals.cache_write += write;
142
+ this.totals.write_5m += write;
143
+ this.totals.fresh_input += input;
144
+ this.totals.output += output;
145
+ this.totals.total += total;
146
+ this.totals.cost += cost;
147
+ const byModel = this.sideCalls.models[model] ||= { calls: 0, tokens: 0, cost: 0 };
148
+ byModel.calls += 1; byModel.tokens += total; byModel.cost += cost;
149
+ this.sideCalls.count += 1;
150
+ this.sideCalls.cost += cost;
151
+ }
65
152
  }
66
153
 
67
154
  async findTranscript() {
@@ -93,7 +180,7 @@ export class ClaudeMeter {
93
180
  return null;
94
181
  }
95
182
 
96
- async poll() {
183
+ async poll({ final = false } = {}) {
97
184
  const transcript = await this.findTranscript();
98
185
  if (!transcript) return this.snapshot();
99
186
  const paths = [transcript];
@@ -108,6 +195,7 @@ export class ClaudeMeter {
108
195
  };
109
196
  await collect(join(transcript.slice(0, -6), 'subagents'));
110
197
  for (const path of paths) await this.readAppended(path);
198
+ this.chargeSideCalls({ final });
111
199
  return this.snapshot();
112
200
  }
113
201
 
@@ -131,6 +219,11 @@ export class ClaudeMeter {
131
219
  const message = obj.message;
132
220
  const usage = message?.usage;
133
221
  if (!usage || typeof usage !== 'object') continue;
222
+ if (obj.requestId) {
223
+ this.requestIds.add(obj.requestId);
224
+ // Already charged from telemetry when its transcript line arrived late.
225
+ if (this.telemetry.get(obj.requestId)?.charged) continue;
226
+ }
134
227
  if (message.id && this.ids.has(message.id)) continue;
135
228
  if (message.id) this.ids.add(message.id);
136
229
  const read = Number(usage.cache_read_input_tokens) || 0;
@@ -141,7 +234,13 @@ export class ClaudeMeter {
141
234
  const input = Number(usage.input_tokens) || 0;
142
235
  const output = Number(usage.output_tokens) || 0;
143
236
  const model = message.model || this.model;
144
- const prices = rateFor(model, read + write + input);
237
+ if (!obj.requestId) {
238
+ const print = usagePrint(model, input, read, write, output);
239
+ // Already charged from telemetry when its transcript line arrived late.
240
+ if (takePrint(this.chargedPrints, print)) continue;
241
+ addPrint(this.transcriptPrints, print);
242
+ }
243
+ const prices = rateFor(model, read + write + input, this.pricingProfile);
145
244
  const cost = input * prices[0] + read * prices[1] + write5 * prices[2] +
146
245
  write1 * prices[3] + output * prices[4];
147
246
  const call = { timestamp: obj.timestamp || null, recorded_at: Date.now() / 1000,
@@ -149,7 +248,7 @@ export class ClaudeMeter {
149
248
  fresh_input: input, output, reasoning_output: 0,
150
249
  total: read + write + input + output, cost,
151
250
  context_tokens: read + write + input,
152
- pricing_profile: 'standard',
251
+ pricing_profile: this.pricingProfile,
153
252
  rates_per_million: Object.fromEntries(
154
253
  ['input', 'cache_read', 'cache_write_5m', 'cache_write_1h', 'output']
155
254
  .map((name, index) => [name, prices[index] * 1_000_000])),
@@ -170,88 +269,120 @@ export class ClaudeMeter {
170
269
 
171
270
  snapshot() {
172
271
  return { side: this.side, role: this.role, label: this.label, variant: this.variant,
173
- harness: 'claude-code', cwd: this.cwd, pricing_profile: 'standard', session_id: this.sessionId,
272
+ harness: 'claude-code', cwd: this.cwd, pricing_profile: this.pricingProfile, session_id: this.sessionId,
174
273
  transcript_path: this.transcriptPath, model: this.model, call_count: this.calls.length,
175
- totals: { ...this.totals } };
274
+ totals: { ...this.totals },
275
+ side_calls: { ...this.sideCalls, models: structuredClone(this.sideCalls.models) } };
176
276
  }
177
277
  }
178
278
 
179
- // Codex writes cumulative token_count snapshots to dated rollout JSONL files.
180
- // Keep a cursor per transcript and charge each snapshot's delta once.
279
+ // Codex writes cumulative token_count snapshots to dated rollout JSONL files, one per thread.
280
+ // Subagent threads sit beside the root and name their parent in session_meta, so the meter
281
+ // follows that lineage (as usage.related_transcripts does) and charges each thread's deltas once.
181
282
  export class CodexMeter extends ClaudeMeter {
182
283
  constructor(options) {
183
284
  super(options);
184
285
  this.root = join(homedir(), '.codex', 'sessions');
185
286
  this.transcriptPath = null;
186
- this.previous = null;
287
+ this.threads = new Map();
288
+ this.metas = new Map();
187
289
  this.started = Date.now() / 1000;
188
290
  }
189
291
 
190
- async findTranscript() {
191
- if (this.transcriptPath) return this.transcriptPath;
292
+ // Rollouts touched since this comparison started, with their session_meta payloads.
293
+ async recentRollouts() {
294
+ const found = [];
192
295
  const visit = async (directory) => {
193
296
  let entries;
194
- try { entries = await readdir(directory, { withFileTypes: true }); } catch { return []; }
195
- const found = [];
297
+ try { entries = await readdir(directory, { withFileTypes: true }); } catch { return; }
196
298
  for (const entry of entries) {
197
299
  const path = join(directory, entry.name);
198
- if (entry.isDirectory()) found.push(...await visit(path));
199
- else if (entry.name.endsWith('.jsonl')) {
200
- try {
201
- const meta = JSON.parse(await readFirstLine(path)).payload;
202
- const info = await stat(path);
203
- const expectedProvider = this.role === 'NATIVE' ? 'openai' : 'pennyrouter';
204
- if (meta?.cwd && meta.cwd.toLowerCase() === this.cwd.toLowerCase() &&
205
- meta.model_provider === expectedProvider &&
206
- info.mtimeMs / 1000 >= this.started - 3) found.push([path, info.mtimeMs]);
207
- } catch { /* incomplete or unrelated rollout */ }
208
- }
300
+ if (entry.isDirectory()) { await visit(path); continue; }
301
+ if (!entry.name.endsWith('.jsonl')) continue;
302
+ try {
303
+ const info = await stat(path);
304
+ if (info.mtimeMs / 1000 < this.started - 3) continue;
305
+ if (!this.metas.has(path)) this.metas.set(path, JSON.parse(await readFirstLine(path)).payload || {});
306
+ found.push({ path, meta: this.metas.get(path), mtimeMs: info.mtimeMs });
307
+ } catch { /* incomplete or unrelated rollout */ }
209
308
  }
210
- return found;
211
309
  };
212
- const candidates = await visit(this.root);
213
- candidates.sort((a, b) => b[1] - a[1]);
214
- this.transcriptPath = candidates[0]?.[0] || null;
310
+ await visit(this.root);
311
+ return found;
312
+ }
313
+
314
+ // Each pane has its own folder, so the folder and start time identify its session. The
315
+ // provider cannot: subscription-backed Penny sessions record model_provider = openai.
316
+ async findTranscript(rollouts) {
317
+ if (this.transcriptPath) return this.transcriptPath;
318
+ const roots = (rollouts || await this.recentRollouts()).filter(({ meta }) =>
319
+ meta?.cwd && meta.cwd.toLowerCase() === this.cwd.toLowerCase() && !parentOf(meta));
320
+ roots.sort((a, b) => b.mtimeMs - a.mtimeMs);
321
+ this.transcriptPath = roots[0]?.path || null;
215
322
  return this.transcriptPath;
216
323
  }
217
324
 
218
325
  async poll() {
219
- const path = await this.findTranscript();
220
- if (!path) return this.snapshot();
326
+ const rollouts = await this.recentRollouts();
327
+ const root = await this.findTranscript(rollouts);
328
+ if (!root) return this.snapshot();
329
+ const lineage = new Set([threadId(this.metas.get(root))].filter(Boolean));
330
+ const paths = [root];
331
+ for (let added = true; added;) {
332
+ added = false;
333
+ for (const { path, meta } of rollouts) {
334
+ if (paths.includes(path) || !lineage.has(parentOf(meta))) continue;
335
+ paths.push(path);
336
+ if (threadId(meta)) lineage.add(threadId(meta));
337
+ added = true;
338
+ }
339
+ }
340
+ for (const path of paths) await this.readRollout(path, path === root);
341
+ return this.snapshot();
342
+ }
343
+
344
+ async readRollout(path, isRoot) {
345
+ const thread = this.threads.get(path) || { previous: null, model: '' };
346
+ this.threads.set(path, thread);
221
347
  let size;
222
- try { ({ size } = await stat(path)); } catch { return this.snapshot(); }
348
+ try { ({ size } = await stat(path)); } catch { return; }
223
349
  let offset = this.files.get(path) || 0;
224
- if (size < offset) { offset = 0; this.previous = null; }
225
- if (size === offset) return this.snapshot();
350
+ if (size < offset) { offset = 0; thread.previous = null; }
351
+ if (size === offset) return;
226
352
  const handle = await open(path, 'r');
227
353
  try {
228
354
  const buffer = Buffer.alloc(size - offset);
229
355
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
230
356
  const text = buffer.subarray(0, bytesRead).toString('utf8');
231
357
  const end = text.lastIndexOf('\n');
232
- if (end < 0) return this.snapshot();
358
+ if (end < 0) return;
233
359
  this.files.set(path, offset + Buffer.byteLength(text.slice(0, end + 1)));
234
360
  for (const line of text.slice(0, end).split('\n')) {
235
361
  let obj;
236
362
  try { obj = JSON.parse(line); } catch { continue; }
237
363
  const payload = obj.payload;
238
- if (obj.type === 'turn_context' && payload?.model) this.model = payload.model;
364
+ if (obj.type === 'turn_context' && payload?.model) {
365
+ thread.model = payload.model;
366
+ if (isRoot) this.model = payload.model;
367
+ }
239
368
  if (payload?.type !== 'token_count' || !payload.info?.total_token_usage) continue;
240
369
  const total = payload.info.total_token_usage;
241
370
  const fingerprint = ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens',
242
371
  'output_tokens', 'reasoning_output_tokens'].map((key) => Number(total[key]) || 0);
243
- if (this.previous && fingerprint.every((value, index) => value === this.previous[index])) continue;
244
- const delta = fingerprint.map((value, index) => Math.max(0, value - (this.previous?.[index] || 0)));
245
- this.previous = fingerprint;
372
+ if (thread.previous && fingerprint.every((value, index) => value === thread.previous[index])) continue;
373
+ const delta = fingerprint.map((value, index) => Math.max(0, value - (thread.previous?.[index] || 0)));
374
+ thread.previous = fingerprint;
246
375
  const [input, cached, write, output, reasoning] = delta;
247
376
  const fresh = Math.max(0, input - cached - write);
248
377
  const context = Number(payload.info.last_token_usage?.input_tokens) || input;
249
- const prices = rateFor(this.model, context);
378
+ const model = thread.model || this.model;
379
+ const prices = rateFor(model, context, this.pricingProfile);
250
380
  const cost = fresh * prices[0] + cached * prices[1] + write * prices[2] + output * prices[4];
251
381
  const call = { timestamp: obj.timestamp || null, recorded_at: Date.now() / 1000,
252
- model: this.model, cache_read: cached, cache_write: write, write_5m: write,
382
+ model, cache_read: cached, cache_write: write, write_5m: write,
253
383
  write_1h: 0, fresh_input: fresh, output, reasoning_output: reasoning,
254
- total: input + output, cost, context_tokens: context, pricing_profile: 'standard',
384
+ total: input + output, cost, context_tokens: context, pricing_profile: this.pricingProfile,
385
+ ...(isRoot ? {} : { thread: basename(path) }),
255
386
  rates_per_million: Object.fromEntries(
256
387
  ['input', 'cache_read', 'cache_write_5m', 'cache_write_1h', 'output']
257
388
  .map((name, index) => [name, prices[index] * 1_000_000])) };
@@ -262,7 +393,6 @@ export class CodexMeter extends ClaudeMeter {
262
393
  this.totals.total += call.total; this.totals.cost += cost;
263
394
  }
264
395
  } finally { await handle.close(); }
265
- return this.snapshot();
266
396
  }
267
397
 
268
398
  snapshot() {
@@ -271,7 +401,10 @@ export class CodexMeter extends ClaudeMeter {
271
401
  }
272
402
  }
273
403
 
274
- export async function saveReport(left, right, reportsDir) {
404
+ const threadId = (meta) => String(meta?.id || meta?.session_id || '');
405
+ const parentOf = (meta) => String(meta?.parent_thread_id || meta?.forked_from_id || '');
406
+
407
+ export async function saveReport(left, right, reportsDir, environment) {
275
408
  const sides = [left.snapshot(), right.snapshot()];
276
409
  const native = sides.find((side) => side.role === 'NATIVE') || sides[0];
277
410
  const penny = sides.find((side) => side.role === 'PENNY') || sides[1];
@@ -282,7 +415,8 @@ export async function saveReport(left, right, reportsDir) {
282
415
  token_percent: native.totals.total ? (native.totals.total - penny.totals.total) / native.totals.total * 100 : 0,
283
416
  };
284
417
  const report = { created_at: new Date().toISOString(),
285
- measured_from_epoch: Math.min(left.started, right.started), pricing_profile: 'standard',
418
+ measured_from_epoch: Math.min(left.started, right.started), pricing_profile: left.pricingProfile,
419
+ ...(environment ? { environment } : {}),
286
420
  left: { ...native, calls: left.calls }, right: { ...penny, calls: right.calls },
287
421
  savings_native_minus_penny: savings };
288
422
  await mkdir(reportsDir, { recursive: true });
@@ -291,3 +425,17 @@ export async function saveReport(left, right, reportsDir) {
291
425
  return { path, savings, native: { calls: native.call_count, totals: native.totals },
292
426
  penny: { calls: penny.call_count, totals: penny.totals } };
293
427
  }
428
+
429
+ // The report a gallery submission carries (apps/web/lib/gallery.ts validates it and recomputes
430
+ // the savings). Only a native pane and a Penny pane on the same harness can be published.
431
+ export function galleryReport(left, right, { environment, harnessVersions }) {
432
+ const sides = [left.snapshot(), right.snapshot()];
433
+ const native = sides.find((side) => side.role === 'NATIVE');
434
+ const penny = sides.find((side) => side.role === 'PENNY');
435
+ if (!native || !penny || native.harness !== penny.harness) return null;
436
+ const side = (snapshot) => ({ model: snapshot.model, call_count: snapshot.call_count, totals: snapshot.totals,
437
+ side_calls: snapshot.side_calls || { count: 0, cost: 0, models: {} } });
438
+ return JSON.stringify({ version: 1, harness: native.harness,
439
+ harness_version: harnessVersions?.[native.harness === 'codex' ? 'codex' : 'claude'] || '',
440
+ environment, native: side(native), penny: side(penny), finished_at: new Date().toISOString() });
441
+ }