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.
- package/package.json +1 -1
- package/src/launch.js +20 -0
- package/src/launch.test.js +16 -1
- package/standalone/govcloud-rates.json +106 -0
- package/standalone/pricing.py +9 -31
- package/standalone/usage.py +7 -0
- package/standalone/web-demo/README.md +4 -4
- package/standalone/web-demo/claude-meter.mjs +222 -54
- package/standalone/web-demo/compare.html +1402 -0
- package/standalone/web-demo/gallery-submit.mjs +70 -0
- package/standalone/web-demo/pane-options.mjs +58 -0
- package/standalone/web-demo/server.mjs +164 -81
- package/standalone/web-demo/telemetry.mjs +71 -0
- package/standalone/web-demo/terminal-preview.html +0 -384
|
@@ -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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// output]; a model may carry an `above` list that applies once
|
|
9
|
-
// exceeds its threshold.
|
|
10
|
-
const
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
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.
|
|
307
|
+
this.threads = new Map();
|
|
308
|
+
this.metas = new Map();
|
|
187
309
|
this.started = Date.now() / 1000;
|
|
188
310
|
}
|
|
189
311
|
|
|
190
|
-
|
|
191
|
-
|
|
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())
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
220
|
-
|
|
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
|
|
368
|
+
try { ({ size } = await stat(path)); } catch { return; }
|
|
223
369
|
let offset = this.files.get(path) || 0;
|
|
224
|
-
if (size < offset) { offset = 0;
|
|
225
|
-
if (size === offset) return
|
|
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
|
|
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)
|
|
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 (
|
|
244
|
-
const delta = fingerprint.map((value, index) => Math.max(0, value - (
|
|
245
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|