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.
- 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/web-demo/README.md +3 -5
- package/standalone/web-demo/claude-meter.mjs +201 -53
- package/standalone/web-demo/compare.html +1428 -0
- package/standalone/web-demo/gallery-submit.mjs +70 -0
- package/standalone/web-demo/server.mjs +161 -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,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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
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.
|
|
287
|
+
this.threads = new Map();
|
|
288
|
+
this.metas = new Map();
|
|
187
289
|
this.started = Date.now() / 1000;
|
|
188
290
|
}
|
|
189
291
|
|
|
190
|
-
|
|
191
|
-
|
|
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())
|
|
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
|
-
}
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
220
|
-
|
|
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
|
|
348
|
+
try { ({ size } = await stat(path)); } catch { return; }
|
|
223
349
|
let offset = this.files.get(path) || 0;
|
|
224
|
-
if (size < offset) { offset = 0;
|
|
225
|
-
if (size === offset) return
|
|
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
|
|
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)
|
|
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 (
|
|
244
|
-
const delta = fingerprint.map((value, index) => Math.max(0, value - (
|
|
245
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|