pennyrouter 0.3.43 → 0.3.45

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,43 @@ 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
+ // A reply the gateway answered itself, with no model call, reports an estimate of the whole
65
+ // context as fresh input so Claude Code's context gauge stays honest. Nothing was billed for
66
+ // it. The gateway marks these replies with a `penny_local_` request id (which Claude Code keeps
67
+ // in its transcript and telemetry) and a `msg_swallow_` message id.
68
+ const isLocalReply = (requestId, messageId) =>
69
+ String(requestId || '').startsWith('penny_local_') || String(messageId || '').startsWith('msg_swallow_');
70
+
71
+ // Without a request id (a gateway may not relay Anthropic's), a telemetry event and its
72
+ // transcript line still carry the same usage, so that usage identifies the call.
73
+ const usagePrint = (model, input, read, write, output) =>
74
+ [String(model || '').split('/').pop().replace('[1m]', ''), input, read, write, output].join(':');
75
+ const addPrint = (prints, print) => prints.set(print, (prints.get(print) || 0) + 1);
76
+ const takePrint = (prints, print) => {
77
+ const count = prints.get(print) || 0;
78
+ if (count) prints.set(print, count - 1);
79
+ return count > 0;
80
+ };
81
+
44
82
  function emptyTotals() {
45
83
  return { cache_read: 0, cache_write: 0, write_5m: 0, write_1h: 0,
46
84
  fresh_input: 0, output: 0, reasoning_output: 0, total: 0, cost: 0 };
47
85
  }
48
86
 
49
87
  export class ClaudeMeter {
50
- constructor({ sessionId, cwd, side, role, label, variant }) {
88
+ constructor({ sessionId, cwd, side, role, label, variant, pricingProfile = 'standard', options = {} }) {
51
89
  this.sessionId = sessionId;
52
90
  this.cwd = cwd;
53
91
  this.side = side;
54
92
  this.role = role;
55
93
  this.label = label || role;
56
94
  this.variant = variant;
95
+ this.options = options;
96
+ this.pricingProfile = pricingProfiles.includes(pricingProfile) ? pricingProfile : 'standard';
57
97
  this.root = join(homedir(), '.claude', 'projects');
58
98
  this.files = new Map();
59
99
  this.ids = new Set();
@@ -62,6 +102,67 @@ export class ClaudeMeter {
62
102
  this.model = '';
63
103
  this.transcriptPath = null;
64
104
  this.started = Date.now() / 1000;
105
+ this.requestIds = new Set();
106
+ this.transcriptPrints = new Map();
107
+ this.chargedPrints = new Map();
108
+ this.telemetry = new Map();
109
+ this.sideCalls = { count: 0, cost: 0, models: {} };
110
+ this.localReplyIds = new Set();
111
+ }
112
+
113
+ // api_request events from this pane's telemetry (see telemetry.mjs).
114
+ ingestTelemetry(requests) {
115
+ for (const request of requests) {
116
+ if (!this.telemetry.has(request.key)) {
117
+ this.telemetry.set(request.key, { ...request, receivedAt: Date.now(), resolved: false, charged: false });
118
+ }
119
+ }
120
+ }
121
+
122
+ // Charges requests Claude Code made outside the transcript (the auto-mode reviewer, titles,
123
+ // and similar background calls). Pass final once the session has ended to skip the grace.
124
+ chargeSideCalls({ final = false } = {}) {
125
+ const now = Date.now();
126
+ for (const request of this.telemetry.values()) {
127
+ if (request.resolved) continue;
128
+ if (isLocalReply(request.requestId)) {
129
+ request.resolved = true;
130
+ this.localReplyIds.add(request.requestId);
131
+ continue;
132
+ }
133
+ const { model, cache_read: read, cache_write: write, fresh_input: input, output } = request;
134
+ const print = usagePrint(model, input, read, write, output);
135
+ if (request.requestId ? this.requestIds.has(request.requestId) : takePrint(this.transcriptPrints, print)) {
136
+ request.resolved = true;
137
+ continue;
138
+ }
139
+ if (!final && now - request.receivedAt < sideCallGraceMs) continue;
140
+ request.resolved = true;
141
+ request.charged = true;
142
+ if (!request.requestId) addPrint(this.chargedPrints, print);
143
+ const prices = rateFor(model, read + write + input, this.pricingProfile);
144
+ // Telemetry does not split cache writes by TTL; they are priced at the 5-minute rate.
145
+ const cost = input * prices[0] + read * prices[1] + write * prices[2] + output * prices[4];
146
+ const total = read + write + input + output;
147
+ this.calls.push({ timestamp: request.timestamp, recorded_at: now / 1000, model,
148
+ cache_read: read, cache_write: write, write_5m: write, write_1h: 0, fresh_input: input, output,
149
+ reasoning_output: 0, total, cost, context_tokens: read + write + input,
150
+ pricing_profile: this.pricingProfile, side_call: true, query_source: request.querySource,
151
+ rates_per_million: Object.fromEntries(
152
+ ['input', 'cache_read', 'cache_write_5m', 'cache_write_1h', 'output']
153
+ .map((name, index) => [name, prices[index] * 1_000_000])) });
154
+ this.totals.cache_read += read;
155
+ this.totals.cache_write += write;
156
+ this.totals.write_5m += write;
157
+ this.totals.fresh_input += input;
158
+ this.totals.output += output;
159
+ this.totals.total += total;
160
+ this.totals.cost += cost;
161
+ const byModel = this.sideCalls.models[model] ||= { calls: 0, tokens: 0, cost: 0 };
162
+ byModel.calls += 1; byModel.tokens += total; byModel.cost += cost;
163
+ this.sideCalls.count += 1;
164
+ this.sideCalls.cost += cost;
165
+ }
65
166
  }
66
167
 
67
168
  async findTranscript() {
@@ -93,7 +194,7 @@ export class ClaudeMeter {
93
194
  return null;
94
195
  }
95
196
 
96
- async poll() {
197
+ async poll({ final = false } = {}) {
97
198
  const transcript = await this.findTranscript();
98
199
  if (!transcript) return this.snapshot();
99
200
  const paths = [transcript];
@@ -108,6 +209,7 @@ export class ClaudeMeter {
108
209
  };
109
210
  await collect(join(transcript.slice(0, -6), 'subagents'));
110
211
  for (const path of paths) await this.readAppended(path);
212
+ this.chargeSideCalls({ final });
111
213
  return this.snapshot();
112
214
  }
113
215
 
@@ -131,6 +233,11 @@ export class ClaudeMeter {
131
233
  const message = obj.message;
132
234
  const usage = message?.usage;
133
235
  if (!usage || typeof usage !== 'object') continue;
236
+ if (obj.requestId) {
237
+ this.requestIds.add(obj.requestId);
238
+ // Already charged from telemetry when its transcript line arrived late.
239
+ if (this.telemetry.get(obj.requestId)?.charged) continue;
240
+ }
134
241
  if (message.id && this.ids.has(message.id)) continue;
135
242
  if (message.id) this.ids.add(message.id);
136
243
  const read = Number(usage.cache_read_input_tokens) || 0;
@@ -141,7 +248,19 @@ export class ClaudeMeter {
141
248
  const input = Number(usage.input_tokens) || 0;
142
249
  const output = Number(usage.output_tokens) || 0;
143
250
  const model = message.model || this.model;
144
- const prices = rateFor(model, read + write + input);
251
+ if (isLocalReply(obj.requestId, message.id)) {
252
+ this.localReplyIds.add(obj.requestId || message.id);
253
+ // Without a request id the matching telemetry event is recognized by its usage.
254
+ if (!obj.requestId) addPrint(this.transcriptPrints, usagePrint(model, input, read, write, output));
255
+ continue;
256
+ }
257
+ if (!obj.requestId) {
258
+ const print = usagePrint(model, input, read, write, output);
259
+ // Already charged from telemetry when its transcript line arrived late.
260
+ if (takePrint(this.chargedPrints, print)) continue;
261
+ addPrint(this.transcriptPrints, print);
262
+ }
263
+ const prices = rateFor(model, read + write + input, this.pricingProfile);
145
264
  const cost = input * prices[0] + read * prices[1] + write5 * prices[2] +
146
265
  write1 * prices[3] + output * prices[4];
147
266
  const call = { timestamp: obj.timestamp || null, recorded_at: Date.now() / 1000,
@@ -149,7 +268,7 @@ export class ClaudeMeter {
149
268
  fresh_input: input, output, reasoning_output: 0,
150
269
  total: read + write + input + output, cost,
151
270
  context_tokens: read + write + input,
152
- pricing_profile: 'standard',
271
+ pricing_profile: this.pricingProfile,
153
272
  rates_per_million: Object.fromEntries(
154
273
  ['input', 'cache_read', 'cache_write_5m', 'cache_write_1h', 'output']
155
274
  .map((name, index) => [name, prices[index] * 1_000_000])),
@@ -169,89 +288,121 @@ export class ClaudeMeter {
169
288
  }
170
289
 
171
290
  snapshot() {
172
- 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,
291
+ return { side: this.side, role: this.role, label: this.label, variant: this.variant, options: this.options,
292
+ harness: 'claude-code', cwd: this.cwd, pricing_profile: this.pricingProfile, session_id: this.sessionId,
174
293
  transcript_path: this.transcriptPath, model: this.model, call_count: this.calls.length,
175
- totals: { ...this.totals } };
294
+ totals: { ...this.totals }, local_replies: this.localReplyIds.size,
295
+ side_calls: { ...this.sideCalls, models: structuredClone(this.sideCalls.models) } };
176
296
  }
177
297
  }
178
298
 
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.
299
+ // Codex writes cumulative token_count snapshots to dated rollout JSONL files, one per thread.
300
+ // Subagent threads sit beside the root and name their parent in session_meta, so the meter
301
+ // follows that lineage (as usage.related_transcripts does) and charges each thread's deltas once.
181
302
  export class CodexMeter extends ClaudeMeter {
182
303
  constructor(options) {
183
304
  super(options);
184
305
  this.root = join(homedir(), '.codex', 'sessions');
185
306
  this.transcriptPath = null;
186
- this.previous = null;
307
+ this.threads = new Map();
308
+ this.metas = new Map();
187
309
  this.started = Date.now() / 1000;
188
310
  }
189
311
 
190
- async findTranscript() {
191
- if (this.transcriptPath) return this.transcriptPath;
312
+ // Rollouts touched since this comparison started, with their session_meta payloads.
313
+ async recentRollouts() {
314
+ const found = [];
192
315
  const visit = async (directory) => {
193
316
  let entries;
194
- try { entries = await readdir(directory, { withFileTypes: true }); } catch { return []; }
195
- const found = [];
317
+ try { entries = await readdir(directory, { withFileTypes: true }); } catch { return; }
196
318
  for (const entry of entries) {
197
319
  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
- }
320
+ if (entry.isDirectory()) { await visit(path); continue; }
321
+ if (!entry.name.endsWith('.jsonl')) continue;
322
+ try {
323
+ const info = await stat(path);
324
+ if (info.mtimeMs / 1000 < this.started - 3) continue;
325
+ if (!this.metas.has(path)) this.metas.set(path, JSON.parse(await readFirstLine(path)).payload || {});
326
+ found.push({ path, meta: this.metas.get(path), mtimeMs: info.mtimeMs });
327
+ } catch { /* incomplete or unrelated rollout */ }
209
328
  }
210
- return found;
211
329
  };
212
- const candidates = await visit(this.root);
213
- candidates.sort((a, b) => b[1] - a[1]);
214
- this.transcriptPath = candidates[0]?.[0] || null;
330
+ await visit(this.root);
331
+ return found;
332
+ }
333
+
334
+ // Each pane has its own folder, so the folder and start time identify its session. The
335
+ // provider cannot: subscription-backed Penny sessions record model_provider = openai.
336
+ async findTranscript(rollouts) {
337
+ if (this.transcriptPath) return this.transcriptPath;
338
+ const roots = (rollouts || await this.recentRollouts()).filter(({ meta }) =>
339
+ meta?.cwd && meta.cwd.toLowerCase() === this.cwd.toLowerCase() && !parentOf(meta));
340
+ roots.sort((a, b) => b.mtimeMs - a.mtimeMs);
341
+ this.transcriptPath = roots[0]?.path || null;
215
342
  return this.transcriptPath;
216
343
  }
217
344
 
218
345
  async poll() {
219
- const path = await this.findTranscript();
220
- if (!path) return this.snapshot();
346
+ const rollouts = await this.recentRollouts();
347
+ const root = await this.findTranscript(rollouts);
348
+ if (!root) return this.snapshot();
349
+ const lineage = new Set([threadId(this.metas.get(root))].filter(Boolean));
350
+ const paths = [root];
351
+ for (let added = true; added;) {
352
+ added = false;
353
+ for (const { path, meta } of rollouts) {
354
+ if (paths.includes(path) || !lineage.has(parentOf(meta))) continue;
355
+ paths.push(path);
356
+ if (threadId(meta)) lineage.add(threadId(meta));
357
+ added = true;
358
+ }
359
+ }
360
+ for (const path of paths) await this.readRollout(path, path === root);
361
+ return this.snapshot();
362
+ }
363
+
364
+ async readRollout(path, isRoot) {
365
+ const thread = this.threads.get(path) || { previous: null, model: '' };
366
+ this.threads.set(path, thread);
221
367
  let size;
222
- try { ({ size } = await stat(path)); } catch { return this.snapshot(); }
368
+ try { ({ size } = await stat(path)); } catch { return; }
223
369
  let offset = this.files.get(path) || 0;
224
- if (size < offset) { offset = 0; this.previous = null; }
225
- if (size === offset) return this.snapshot();
370
+ if (size < offset) { offset = 0; thread.previous = null; }
371
+ if (size === offset) return;
226
372
  const handle = await open(path, 'r');
227
373
  try {
228
374
  const buffer = Buffer.alloc(size - offset);
229
375
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
230
376
  const text = buffer.subarray(0, bytesRead).toString('utf8');
231
377
  const end = text.lastIndexOf('\n');
232
- if (end < 0) return this.snapshot();
378
+ if (end < 0) return;
233
379
  this.files.set(path, offset + Buffer.byteLength(text.slice(0, end + 1)));
234
380
  for (const line of text.slice(0, end).split('\n')) {
235
381
  let obj;
236
382
  try { obj = JSON.parse(line); } catch { continue; }
237
383
  const payload = obj.payload;
238
- if (obj.type === 'turn_context' && payload?.model) this.model = payload.model;
384
+ if (obj.type === 'turn_context' && payload?.model) {
385
+ thread.model = payload.model;
386
+ if (isRoot) this.model = payload.model;
387
+ }
239
388
  if (payload?.type !== 'token_count' || !payload.info?.total_token_usage) continue;
240
389
  const total = payload.info.total_token_usage;
241
390
  const fingerprint = ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens',
242
391
  '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;
392
+ if (thread.previous && fingerprint.every((value, index) => value === thread.previous[index])) continue;
393
+ const delta = fingerprint.map((value, index) => Math.max(0, value - (thread.previous?.[index] || 0)));
394
+ thread.previous = fingerprint;
246
395
  const [input, cached, write, output, reasoning] = delta;
247
396
  const fresh = Math.max(0, input - cached - write);
248
397
  const context = Number(payload.info.last_token_usage?.input_tokens) || input;
249
- const prices = rateFor(this.model, context);
398
+ const model = thread.model || this.model;
399
+ const prices = rateFor(model, context, this.pricingProfile);
250
400
  const cost = fresh * prices[0] + cached * prices[1] + write * prices[2] + output * prices[4];
251
401
  const call = { timestamp: obj.timestamp || null, recorded_at: Date.now() / 1000,
252
- model: this.model, cache_read: cached, cache_write: write, write_5m: write,
402
+ model, cache_read: cached, cache_write: write, write_5m: write,
253
403
  write_1h: 0, fresh_input: fresh, output, reasoning_output: reasoning,
254
- total: input + output, cost, context_tokens: context, pricing_profile: 'standard',
404
+ total: input + output, cost, context_tokens: context, pricing_profile: this.pricingProfile,
405
+ ...(isRoot ? {} : { thread: basename(path) }),
255
406
  rates_per_million: Object.fromEntries(
256
407
  ['input', 'cache_read', 'cache_write_5m', 'cache_write_1h', 'output']
257
408
  .map((name, index) => [name, prices[index] * 1_000_000])) };
@@ -262,7 +413,6 @@ export class CodexMeter extends ClaudeMeter {
262
413
  this.totals.total += call.total; this.totals.cost += cost;
263
414
  }
264
415
  } finally { await handle.close(); }
265
- return this.snapshot();
266
416
  }
267
417
 
268
418
  snapshot() {
@@ -271,7 +421,10 @@ export class CodexMeter extends ClaudeMeter {
271
421
  }
272
422
  }
273
423
 
274
- export async function saveReport(left, right, reportsDir) {
424
+ const threadId = (meta) => String(meta?.id || meta?.session_id || '');
425
+ const parentOf = (meta) => String(meta?.parent_thread_id || meta?.forked_from_id || '');
426
+
427
+ export async function saveReport(left, right, reportsDir, environment) {
275
428
  const sides = [left.snapshot(), right.snapshot()];
276
429
  const native = sides.find((side) => side.role === 'NATIVE') || sides[0];
277
430
  const penny = sides.find((side) => side.role === 'PENNY') || sides[1];
@@ -282,7 +435,8 @@ export async function saveReport(left, right, reportsDir) {
282
435
  token_percent: native.totals.total ? (native.totals.total - penny.totals.total) / native.totals.total * 100 : 0,
283
436
  };
284
437
  const report = { created_at: new Date().toISOString(),
285
- measured_from_epoch: Math.min(left.started, right.started), pricing_profile: 'standard',
438
+ measured_from_epoch: Math.min(left.started, right.started), pricing_profile: left.pricingProfile,
439
+ ...(environment ? { environment } : {}),
286
440
  left: { ...native, calls: left.calls }, right: { ...penny, calls: right.calls },
287
441
  savings_native_minus_penny: savings };
288
442
  await mkdir(reportsDir, { recursive: true });
@@ -291,3 +445,17 @@ export async function saveReport(left, right, reportsDir) {
291
445
  return { path, savings, native: { calls: native.call_count, totals: native.totals },
292
446
  penny: { calls: penny.call_count, totals: penny.totals } };
293
447
  }
448
+
449
+ // The report a gallery submission carries (apps/web/lib/gallery.ts validates it and recomputes
450
+ // the savings). Only a native pane and a Penny pane on the same harness can be published.
451
+ export function galleryReport(left, right, { environment, harnessVersions }) {
452
+ const sides = [left.snapshot(), right.snapshot()];
453
+ const native = sides.find((side) => side.role === 'NATIVE');
454
+ const penny = sides.find((side) => side.role === 'PENNY');
455
+ if (!native || !penny || native.harness !== penny.harness) return null;
456
+ const side = (snapshot) => ({ model: snapshot.model, call_count: snapshot.call_count, totals: snapshot.totals,
457
+ side_calls: snapshot.side_calls || { count: 0, cost: 0, models: {} } });
458
+ return JSON.stringify({ version: 1, harness: native.harness,
459
+ harness_version: harnessVersions?.[native.harness === 'codex' ? 'codex' : 'claude'] || '',
460
+ environment, native: side(native), penny: side(penny), finished_at: new Date().toISOString() });
461
+ }