pennyrouter 0.3.37 → 0.3.39
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/README.md +6 -6
- package/package.json +1 -1
- package/src/cli.js +7 -7
- package/src/launch.js +32 -8
- package/src/launch.test.js +2 -0
- package/standalone/penny-compare +13 -2
- package/standalone/pricing.py +27 -17
- package/standalone/rates.json +1078 -0
- package/standalone/web-demo/README.md +3 -3
- package/standalone/web-demo/claude-meter.mjs +141 -22
- package/standalone/web-demo/server.mjs +84 -22
- package/standalone/web-demo/terminal-preview.html +166 -15
|
@@ -21,26 +21,92 @@
|
|
|
21
21
|
.pane { position: relative; }
|
|
22
22
|
.meter { display: none; position: absolute; top: 4px; right: 13px; z-index: 2;
|
|
23
23
|
width: 190px; white-space: pre; color: #9ded83; line-height: 1.15; font-size: 13px;
|
|
24
|
-
pointer-events: none;
|
|
24
|
+
pointer-events: none; padding: 5px 8px; border-radius: 3px;
|
|
25
|
+
background: rgba(12, 12, 16, .82); }
|
|
25
26
|
.pane.metered .meter { display: block; }
|
|
26
27
|
.delta { display: none; position: absolute; top: 4px; right: 220px; z-index: 2;
|
|
27
28
|
color: #9ded83; white-space: nowrap; pointer-events: none; }
|
|
28
29
|
.pane.metered .delta { display: block; }
|
|
29
30
|
.xterm { height: 100%; }
|
|
30
31
|
.xterm-viewport { scrollbar-width: thin; }
|
|
32
|
+
.finish { position: fixed; inset: 25px 0 0; z-index: 5; display: grid; place-items: center;
|
|
33
|
+
padding: 24px; background: rgba(10, 10, 14, .94); }
|
|
34
|
+
.finish[hidden] { display: none; }
|
|
35
|
+
.card { width: min(680px, 100%); border: 1px solid #4d4d52; background: #17171d;
|
|
36
|
+
padding: 28px; box-shadow: 0 16px 60px #0009; }
|
|
37
|
+
.card h1 { margin: 0 0 24px; color: #9ded83; font-size: 18px; letter-spacing: .08em; }
|
|
38
|
+
.metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px; }
|
|
39
|
+
.metric { padding: 14px; background: #202027; }
|
|
40
|
+
.metric span { display: block; color: #92929c; margin-bottom: 8px; }
|
|
41
|
+
.metric strong { font-size: 20px; }
|
|
42
|
+
.sides { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; color: #c4c4cc; white-space: pre-line; }
|
|
43
|
+
.report-path { margin-top: 24px; color: #9ded83; overflow-wrap: anywhere; }
|
|
44
|
+
.finish-note { margin-top: 20px; color: #92929c; }
|
|
45
|
+
.setup { position: fixed; inset: 25px 0 0; z-index: 4; display: grid; place-items: center;
|
|
46
|
+
overflow: auto; padding: 24px; background: rgba(10, 10, 14, .97); }
|
|
47
|
+
.setup[hidden] { display: none; }
|
|
48
|
+
.setup-card { width: min(760px, 100%); border: 1px solid #4d4d52; background: #17171d;
|
|
49
|
+
padding: 26px; }
|
|
50
|
+
.setup-card h1 { margin: 0 0 22px; color: #9ded83; font-size: 18px; }
|
|
51
|
+
.setup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
52
|
+
.setup-field { display: grid; gap: 7px; color: #a5a5ae; }
|
|
53
|
+
.setup-field input, .setup-field select { min-width: 0; padding: 9px; border: 1px solid #4d4d52;
|
|
54
|
+
background: #101015; color: #e5e5ea; font: inherit; }
|
|
55
|
+
.setup-model { margin-top: 16px; }
|
|
56
|
+
.setup-actions { display: flex; align-items: center; gap: 14px; margin-top: 22px; }
|
|
57
|
+
.setup-actions button { padding: 10px 16px; border: 1px solid #9ded83; background: #26362a;
|
|
58
|
+
color: #c8ffd0; font: inherit; cursor: pointer; }
|
|
59
|
+
.setup-actions button:disabled { opacity: .5; cursor: wait; }
|
|
60
|
+
#setup-error { color: #ff8b98; }
|
|
61
|
+
@media (max-width: 600px) { .setup-grid { grid-template-columns: 1fr; } }
|
|
31
62
|
@media (max-width: 720px) { .status { justify-content: flex-start; overflow-x: auto; padding: 0 8px; } }
|
|
32
63
|
</style>
|
|
33
64
|
</head>
|
|
34
65
|
<body>
|
|
35
66
|
<div class="status">
|
|
36
67
|
<button id="sync">SYNC ON ^⇧S</button>
|
|
37
|
-
<button id="exit">EXIT ^⇧X</button>
|
|
68
|
+
<button id="exit" disabled>EXIT ^⇧X</button>
|
|
38
69
|
<span id="connection" style="color:#77777e">connecting…</span>
|
|
39
70
|
</div>
|
|
40
71
|
<main class="panes">
|
|
41
72
|
<section class="pane focused"><div class="meter" id="left-meter"></div><div class="terminal" id="native"></div></section>
|
|
42
73
|
<section class="pane"><div class="delta" id="delta"></div><div class="meter" id="right-meter"></div><div class="terminal" id="penny"></div></section>
|
|
43
74
|
</main>
|
|
75
|
+
<section class="setup" id="setup">
|
|
76
|
+
<form class="setup-card" id="setup-form">
|
|
77
|
+
<h1>PENNY COMPARE</h1>
|
|
78
|
+
<div class="setup-grid">
|
|
79
|
+
<label class="setup-field">Left pane
|
|
80
|
+
<select id="left-variant"></select>
|
|
81
|
+
</label>
|
|
82
|
+
<label class="setup-field">Right pane
|
|
83
|
+
<select id="right-variant"></select>
|
|
84
|
+
</label>
|
|
85
|
+
<label class="setup-field">Left directory
|
|
86
|
+
<input id="left-cwd" autocomplete="off" required>
|
|
87
|
+
</label>
|
|
88
|
+
<label class="setup-field">Right directory
|
|
89
|
+
<input id="right-cwd" autocomplete="off" required>
|
|
90
|
+
</label>
|
|
91
|
+
<label class="setup-field">Left label <input id="left-label" placeholder="NATIVE or PENNY"></label>
|
|
92
|
+
<label class="setup-field">Right label <input id="right-label" placeholder="NATIVE or PENNY"></label>
|
|
93
|
+
</div>
|
|
94
|
+
<label class="setup-field setup-model">Model (both panes)
|
|
95
|
+
<input id="model" list="model-options" placeholder="default">
|
|
96
|
+
<datalist id="model-options"></datalist>
|
|
97
|
+
</label>
|
|
98
|
+
<div class="setup-actions"><button id="launch" type="submit" disabled>Start comparison</button><span id="setup-error"></span></div>
|
|
99
|
+
</form>
|
|
100
|
+
</section>
|
|
101
|
+
<section class="finish" id="finish" hidden aria-live="polite">
|
|
102
|
+
<div class="card">
|
|
103
|
+
<h1 id="finish-title">PENNY COMPARISON</h1>
|
|
104
|
+
<div class="metrics" id="finish-metrics"></div>
|
|
105
|
+
<div class="sides" id="finish-sides"></div>
|
|
106
|
+
<div class="report-path" id="finish-path"></div>
|
|
107
|
+
<div class="finish-note" id="finish-note">Comparison complete. You can close this tab.</div>
|
|
108
|
+
</div>
|
|
109
|
+
</section>
|
|
44
110
|
<script src="/xterm.js"></script>
|
|
45
111
|
<script src="/addon-fit.js"></script>
|
|
46
112
|
<script>
|
|
@@ -61,36 +127,59 @@
|
|
|
61
127
|
native.open(document.getElementById('native'));
|
|
62
128
|
penny.open(document.getElementById('penny'));
|
|
63
129
|
let sync = true;
|
|
130
|
+
let pendingConfig = null;
|
|
64
131
|
const panes = document.querySelectorAll('.pane');
|
|
65
132
|
const send = message => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); };
|
|
66
|
-
|
|
133
|
+
let socket;
|
|
67
134
|
const setFocus = index => { panes.forEach((p, i) => p.classList.toggle('focused', i === index)); (index ? penny : native).focus(); };
|
|
68
135
|
const setSync = value => {
|
|
69
136
|
sync = value;
|
|
70
137
|
document.getElementById('sync').textContent = sync ? 'SYNC ON ^⇧S' : 'sync off ^⇧S';
|
|
71
138
|
};
|
|
72
139
|
const exit = () => {
|
|
140
|
+
if (document.getElementById('exit').disabled) return;
|
|
73
141
|
send({ type: 'exit' });
|
|
74
142
|
document.getElementById('connection').textContent = 'saving report…';
|
|
75
143
|
document.getElementById('exit').disabled = true;
|
|
76
144
|
};
|
|
77
145
|
function resize() {
|
|
146
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
78
147
|
for (const [side, terminal] of [['left', native], ['right', penny]]) {
|
|
79
148
|
fit[side].fit();
|
|
80
149
|
send({ type: 'resize', side, cols: terminal.cols, rows: terminal.rows });
|
|
81
150
|
}
|
|
82
151
|
}
|
|
83
|
-
|
|
152
|
+
function connect() {
|
|
153
|
+
socket = new WebSocket(`ws://${location.host}/terminal`);
|
|
154
|
+
socket.addEventListener('open', () => {
|
|
84
155
|
document.getElementById('connection').textContent = 'live PTYs';
|
|
85
156
|
resize();
|
|
86
|
-
for (const [side, terminal] of [['left', native], ['right', penny]]) {
|
|
87
|
-
send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
|
|
88
|
-
}
|
|
89
157
|
setFocus(0);
|
|
90
|
-
|
|
158
|
+
document.getElementById('launch').disabled = false;
|
|
159
|
+
if (!isCompare) {
|
|
160
|
+
document.getElementById('setup').hidden = true;
|
|
161
|
+
document.getElementById('exit').disabled = false;
|
|
162
|
+
for (const [side, terminal] of [['left', native], ['right', penny]])
|
|
163
|
+
send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
91
166
|
socket.addEventListener('message', event => {
|
|
92
167
|
const message = JSON.parse(event.data);
|
|
93
168
|
const terminal = message.side === 'left' ? native : penny;
|
|
169
|
+
if (message.type === 'configured' && pendingConfig) {
|
|
170
|
+
for (const [side, terminal] of [['left', native], ['right', penny]])
|
|
171
|
+
send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
|
|
172
|
+
pendingConfig = null;
|
|
173
|
+
document.getElementById('launch').disabled = false;
|
|
174
|
+
document.getElementById('exit').disabled = false;
|
|
175
|
+
document.getElementById('setup').hidden = true;
|
|
176
|
+
resize();
|
|
177
|
+
}
|
|
178
|
+
if (message.type === 'configuration-error') {
|
|
179
|
+
pendingConfig = null;
|
|
180
|
+
document.getElementById('launch').disabled = false;
|
|
181
|
+
document.getElementById('setup-error').textContent = message.error;
|
|
182
|
+
}
|
|
94
183
|
if (message.type === 'output') terminal.write(message.data);
|
|
95
184
|
if (message.type === 'exit') terminal.write(`\r\n[process exited: ${message.code}]\r\n`);
|
|
96
185
|
if (message.type === 'error') terminal.write(`\r\n[launch failed: ${message.error}]\r\n`);
|
|
@@ -102,7 +191,7 @@
|
|
|
102
191
|
const data = message.data;
|
|
103
192
|
const total = data.totals;
|
|
104
193
|
document.getElementById(`${data.side}-meter`).textContent =
|
|
105
|
-
`${data.role} ${(data.model || 'waiting').split('/').pop()}\n` +
|
|
194
|
+
`${data.label || data.role} ${(data.model || 'waiting').split('/').pop()}\n` +
|
|
106
195
|
`read ${total.cache_read.toLocaleString().padStart(11)}\n` +
|
|
107
196
|
`write ${total.cache_write.toLocaleString().padStart(11)}\n` +
|
|
108
197
|
`in ${total.fresh_input.toLocaleString().padStart(11)}\n` +
|
|
@@ -119,26 +208,88 @@
|
|
|
119
208
|
const metric = (amount, percent, unit) =>
|
|
120
209
|
`${unit}${unit ? Math.abs(amount).toFixed(2) : Math.abs(amount).toLocaleString()}` +
|
|
121
210
|
` ${amount >= 0 ? 'saved' : 'extra'} (${Math.abs(percent).toFixed(1)}%)`;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
211
|
+
const addMetric = (label, value) => {
|
|
212
|
+
const item = document.createElement('div'); item.className = 'metric';
|
|
213
|
+
const caption = document.createElement('span'); caption.textContent = label;
|
|
214
|
+
const result = document.createElement('strong'); result.textContent = value;
|
|
215
|
+
item.append(caption, result); document.getElementById('finish-metrics').append(item);
|
|
216
|
+
};
|
|
217
|
+
addMetric('COST', metric(savings.cost, savings.cost_percent, '$'));
|
|
218
|
+
addMetric('TOKENS', `${metric(savings.tokens, savings.token_percent, '')} tokens`);
|
|
219
|
+
const sideSummary = side => `${side.calls} calls · ${side.totals.total.toLocaleString()} tokens · $${side.totals.cost.toFixed(2)}`;
|
|
220
|
+
document.getElementById('finish-sides').textContent =
|
|
221
|
+
`NATIVE ${sideSummary(message.native)}\nPENNY ${sideSummary(message.penny)}`;
|
|
222
|
+
document.getElementById('finish-path').textContent = `Report saved: ${message.path}`;
|
|
223
|
+
document.getElementById('finish').hidden = false;
|
|
126
224
|
document.getElementById('connection').textContent = 'report saved';
|
|
127
225
|
}
|
|
128
226
|
if (message.type === 'report-error') {
|
|
129
|
-
|
|
227
|
+
document.getElementById('finish-title').textContent = 'REPORT COULD NOT BE SAVED';
|
|
228
|
+
document.getElementById('finish-metrics').textContent = message.error;
|
|
229
|
+
document.getElementById('finish-note').textContent = 'Comparison sessions have ended. Reload the page to start again.';
|
|
230
|
+
document.getElementById('finish').hidden = false;
|
|
130
231
|
document.getElementById('connection').textContent = 'report failed';
|
|
131
232
|
}
|
|
132
|
-
|
|
233
|
+
});
|
|
133
234
|
socket.addEventListener('close', () => {
|
|
134
235
|
if (!document.getElementById('exit').disabled) {
|
|
135
236
|
document.getElementById('connection').textContent = 'disconnected — reload to reconnect';
|
|
136
237
|
}
|
|
137
238
|
});
|
|
239
|
+
}
|
|
138
240
|
const resizeObserver = new ResizeObserver(resize);
|
|
139
241
|
panes.forEach(pane => resizeObserver.observe(pane));
|
|
140
242
|
document.getElementById('sync').onclick = () => setSync(!sync);
|
|
141
243
|
document.getElementById('exit').onclick = exit;
|
|
244
|
+
const cwd = __PENNY_CWD__;
|
|
245
|
+
const isCompare = __PENNY_COMPARE__;
|
|
246
|
+
const defaultHarness = __PENNY_DEFAULT_HARNESS__;
|
|
247
|
+
const variants = ['claude', 'penny-claude', 'codex', 'penny-codex'];
|
|
248
|
+
for (const side of ['left', 'right']) {
|
|
249
|
+
const select = document.getElementById(`${side}-variant`);
|
|
250
|
+
for (const variant of variants) {
|
|
251
|
+
const option = document.createElement('option'); option.value = variant; option.textContent = variant;
|
|
252
|
+
select.append(option);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
document.getElementById('left-variant').value = defaultHarness;
|
|
256
|
+
document.getElementById('right-variant').value = `penny-${defaultHarness}`;
|
|
257
|
+
document.getElementById('left-cwd').value = `${cwd}/native`;
|
|
258
|
+
document.getElementById('right-cwd').value = `${cwd}/penny`;
|
|
259
|
+
const refreshModels = () => {
|
|
260
|
+
const left = document.getElementById('left-variant').value;
|
|
261
|
+
const right = document.getElementById('right-variant').value;
|
|
262
|
+
const leftCodex = left.includes('codex');
|
|
263
|
+
const rightCodex = right.includes('codex');
|
|
264
|
+
const model = document.getElementById('model');
|
|
265
|
+
model.disabled = leftCodex !== rightCodex;
|
|
266
|
+
if (model.disabled) model.value = '';
|
|
267
|
+
const values = leftCodex && rightCodex
|
|
268
|
+
? ['default', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini']
|
|
269
|
+
: leftCodex !== rightCodex ? []
|
|
270
|
+
: ['default', 'opus', 'sonnet', 'haiku', 'fable'];
|
|
271
|
+
const list = document.getElementById('model-options'); list.replaceChildren();
|
|
272
|
+
for (const value of values) { const option = document.createElement('option'); option.value = value; list.append(option); }
|
|
273
|
+
};
|
|
274
|
+
document.getElementById('left-variant').onchange = refreshModels;
|
|
275
|
+
document.getElementById('right-variant').onchange = refreshModels;
|
|
276
|
+
refreshModels();
|
|
277
|
+
connect();
|
|
278
|
+
document.getElementById('setup-form').addEventListener('submit', event => {
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
const config = {};
|
|
281
|
+
for (const side of ['left', 'right']) config[side] = {
|
|
282
|
+
variant: document.getElementById(`${side}-variant`).value,
|
|
283
|
+
cwd: document.getElementById(`${side}-cwd`).value.trim(),
|
|
284
|
+
label: document.getElementById(`${side}-label`).value.trim(),
|
|
285
|
+
model: document.getElementById('model').value.trim(),
|
|
286
|
+
};
|
|
287
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
288
|
+
pendingConfig = config;
|
|
289
|
+
document.getElementById('launch').disabled = true;
|
|
290
|
+
document.getElementById('setup-error').textContent = '';
|
|
291
|
+
send({ type: 'configure', config });
|
|
292
|
+
});
|
|
142
293
|
document.addEventListener('keydown', event => {
|
|
143
294
|
if (!event.ctrlKey || !event.shiftKey || event.altKey || event.metaKey) return;
|
|
144
295
|
if (event.code === 'KeyS') { event.preventDefault(); event.stopPropagation(); setSync(!sync); }
|