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
package/package.json
CHANGED
package/src/launch.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
readFile,
|
|
9
9
|
rm,
|
|
10
10
|
readdir,
|
|
11
|
+
rename,
|
|
11
12
|
stat,
|
|
12
13
|
symlink,
|
|
13
14
|
unlink,
|
|
@@ -290,6 +291,8 @@ async function findInstalledPackage(name) {
|
|
|
290
291
|
}
|
|
291
292
|
}
|
|
292
293
|
|
|
294
|
+
const REPLACED_PREFIX = ".replaced-node-modules-";
|
|
295
|
+
|
|
293
296
|
/** Install the bundled, repository-independent Penny Compare tools. */
|
|
294
297
|
export async function installPennyCompare() {
|
|
295
298
|
if (!(process.platform === "darwin" || process.platform === "linux" || process.platform === "win32")) {
|
|
@@ -303,9 +306,26 @@ export async function installPennyCompare() {
|
|
|
303
306
|
return { supported: true, installed: false, path: destination };
|
|
304
307
|
}
|
|
305
308
|
await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
309
|
+
// A running Penny Compare keeps node-pty's native module loaded, and Windows will not delete
|
|
310
|
+
// a loaded module. Move the old dependencies aside first: a running copy then stops the
|
|
311
|
+
// install before anything is removed, instead of leaving a half-deleted install behind.
|
|
312
|
+
try {
|
|
313
|
+
await rename(join(destination, "web-demo", "node_modules"), join(destination, `${REPLACED_PREFIX}${Date.now()}`));
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (["EPERM", "EBUSY", "EACCES"].includes(error.code)) {
|
|
316
|
+
throw new Error("Penny Compare is still running, so its files cannot be replaced. " +
|
|
317
|
+
"Stop it (Ctrl+C in its terminal, or close that window), then run `npx pennyrouter compare` again.");
|
|
318
|
+
}
|
|
319
|
+
if (error.code !== "ENOENT") throw error;
|
|
320
|
+
}
|
|
306
321
|
// Preserve user state while replacing the shipped code on every CLI upgrade.
|
|
307
322
|
for (const entry of await readdir(destination)) {
|
|
308
323
|
if (entry === ".env" || entry === "reports") continue;
|
|
324
|
+
if (entry.startsWith(REPLACED_PREFIX)) {
|
|
325
|
+
// Dependencies moved aside above, or by an earlier install whose copy was still running.
|
|
326
|
+
await rm(join(destination, entry), { recursive: true, force: true }).catch(() => {});
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
309
329
|
await rm(join(destination, entry), { recursive: true, force: true });
|
|
310
330
|
}
|
|
311
331
|
for (const entry of await readdir(source)) {
|
package/src/launch.test.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
createCodexDesktopHome,
|
|
17
17
|
configurePennyHarness,
|
|
18
18
|
fileMode,
|
|
19
|
+
installPennyCompare,
|
|
19
20
|
installPennyRuntime,
|
|
20
21
|
isClaudeDesktopRunning,
|
|
21
22
|
isCodexDesktopRunning,
|
|
@@ -106,7 +107,7 @@ async function main() {
|
|
|
106
107
|
assert.equal(second.changed, false);
|
|
107
108
|
assert.equal(await fileMode(pennyShimPath()), 0o755);
|
|
108
109
|
assert.ok(fs.existsSync(path.join(pennyRuntimeDir(), "bin", "penny.js")));
|
|
109
|
-
for (const asset of ["terminal-unicode.js", "artifacts.mjs", "artifact-preview.html",
|
|
110
|
+
for (const asset of ["terminal-unicode.js", "artifacts.mjs", "artifact-preview.html", "telemetry.mjs", "gallery-submit.mjs", "pane-options.mjs", "compare.html",
|
|
110
111
|
"node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js"]) {
|
|
111
112
|
assert.ok(fs.existsSync(path.join(pennyCompareDir(), "web-demo", asset)), `compare installs ${asset}`);
|
|
112
113
|
}
|
|
@@ -115,6 +116,20 @@ async function main() {
|
|
|
115
116
|
fs.realpathSync(path.join(pennyCompareDir(), "penny-compare")),
|
|
116
117
|
);
|
|
117
118
|
assert.ok(!fs.existsSync(staleSkill), "runtime upgrade removes stale plugin files");
|
|
119
|
+
// A running copy that holds its dependencies open (as Windows does for node-pty's native
|
|
120
|
+
// module) stops a reinstall before anything is removed. A read-only folder blocks the same move.
|
|
121
|
+
const compareDemo = path.join(pennyCompareDir(), "web-demo");
|
|
122
|
+
fs.chmodSync(compareDemo, 0o555);
|
|
123
|
+
try {
|
|
124
|
+
await assert.rejects(installPennyCompare(), /still running/);
|
|
125
|
+
assert.ok(fs.existsSync(path.join(compareDemo, "server.mjs")), "a blocked reinstall leaves the install intact");
|
|
126
|
+
} finally {
|
|
127
|
+
fs.chmodSync(compareDemo, 0o755);
|
|
128
|
+
}
|
|
129
|
+
await installPennyCompare();
|
|
130
|
+
assert.ok(fs.existsSync(path.join(compareDemo, "node_modules", "node-pty")), "reinstall restores dependencies");
|
|
131
|
+
assert.deepEqual(fs.readdirSync(pennyCompareDir()).filter((entry) => entry.startsWith(".replaced-")), [],
|
|
132
|
+
"moved-aside dependencies are removed once nothing holds them");
|
|
118
133
|
const rc = fs.readFileSync(path.join(home, ".zshrc"), "utf8");
|
|
119
134
|
assert.equal((rc.match(/>>> pennyrouter PATH >>>/g) || []).length, 1);
|
|
120
135
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"note": "GovCloud per-token rates, maintained by hand. Models listed here override rates.json under the govcloud pricing profile; other models keep their standard rate.",
|
|
3
|
+
"order": [
|
|
4
|
+
"input",
|
|
5
|
+
"cache_read",
|
|
6
|
+
"cache_write_5m",
|
|
7
|
+
"cache_write_1h",
|
|
8
|
+
"output"
|
|
9
|
+
],
|
|
10
|
+
"unit": "usd_per_token",
|
|
11
|
+
"models": {
|
|
12
|
+
"claude-opus-5": {
|
|
13
|
+
"rates": [
|
|
14
|
+
5.5e-06,
|
|
15
|
+
5.5e-07,
|
|
16
|
+
6.875e-06,
|
|
17
|
+
1.1e-05,
|
|
18
|
+
2.75e-05
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"claude-opus-4-8": {
|
|
22
|
+
"rates": [
|
|
23
|
+
5.5e-06,
|
|
24
|
+
5.5e-07,
|
|
25
|
+
6.875e-06,
|
|
26
|
+
1.1e-05,
|
|
27
|
+
2.75e-05
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"claude-sonnet-5": {
|
|
31
|
+
"rates": [
|
|
32
|
+
2.2e-06,
|
|
33
|
+
2.2e-07,
|
|
34
|
+
2.75e-06,
|
|
35
|
+
4.4e-06,
|
|
36
|
+
1.1e-05
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"claude-haiku-4-5": {
|
|
40
|
+
"rates": [
|
|
41
|
+
1.1e-06,
|
|
42
|
+
1.1e-07,
|
|
43
|
+
1.375e-06,
|
|
44
|
+
2.2e-06,
|
|
45
|
+
5.5e-06
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"gpt-5.6-luna": {
|
|
49
|
+
"rates": [
|
|
50
|
+
2.2e-07,
|
|
51
|
+
2.2e-08,
|
|
52
|
+
2.75e-07,
|
|
53
|
+
2.75e-07,
|
|
54
|
+
1.32e-06
|
|
55
|
+
],
|
|
56
|
+
"above": {
|
|
57
|
+
"threshold": 272000,
|
|
58
|
+
"rates": [
|
|
59
|
+
4.4e-07,
|
|
60
|
+
4.4e-08,
|
|
61
|
+
5.5e-07,
|
|
62
|
+
5.5e-07,
|
|
63
|
+
1.98e-06
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"gpt-5.6-terra": {
|
|
68
|
+
"rates": [
|
|
69
|
+
2.2e-06,
|
|
70
|
+
2.2e-07,
|
|
71
|
+
2.75e-06,
|
|
72
|
+
2.75e-06,
|
|
73
|
+
1.32e-05
|
|
74
|
+
],
|
|
75
|
+
"above": {
|
|
76
|
+
"threshold": 272000,
|
|
77
|
+
"rates": [
|
|
78
|
+
4.4e-06,
|
|
79
|
+
4.4e-07,
|
|
80
|
+
5.5e-06,
|
|
81
|
+
5.5e-06,
|
|
82
|
+
1.98e-05
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"gpt-5.6-sol": {
|
|
87
|
+
"rates": [
|
|
88
|
+
4.4e-06,
|
|
89
|
+
4.4e-07,
|
|
90
|
+
5.5e-06,
|
|
91
|
+
5.5e-06,
|
|
92
|
+
2.2e-05
|
|
93
|
+
],
|
|
94
|
+
"above": {
|
|
95
|
+
"threshold": 272000,
|
|
96
|
+
"rates": [
|
|
97
|
+
8.8e-06,
|
|
98
|
+
8.8e-07,
|
|
99
|
+
1.1e-05,
|
|
100
|
+
1.1e-05,
|
|
101
|
+
3.3e-05
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
package/standalone/pricing.py
CHANGED
|
@@ -11,11 +11,13 @@ import json
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
def _load_rates(filename: str):
|
|
15
|
+
"""Per-token rates from a JSON schedule beside this file. rates.json holds the standard
|
|
16
|
+
schedule (scripts/generate_compare_rates.py); govcloud-rates.json holds the hand-maintained
|
|
17
|
+
GovCloud overrides. The web-demo meters read the same files. Returns (flat rates, tiered
|
|
18
|
+
rates) keyed by model id; a tier entry is (inclusive threshold, <= threshold rates,
|
|
19
|
+
> threshold rates)."""
|
|
20
|
+
document = json.loads((Path(__file__).with_name(filename)).read_text())
|
|
19
21
|
flat, tiered = {}, {}
|
|
20
22
|
for name, model in document["models"].items():
|
|
21
23
|
flat[name] = tuple(model["rates"])
|
|
@@ -27,35 +29,11 @@ def _load_standard_rates():
|
|
|
27
29
|
|
|
28
30
|
# input, cache_read, write_5m, write_1h, output. OpenAI does not offer a 1-hour cache tier;
|
|
29
31
|
# its write rate applies uniformly.
|
|
30
|
-
RATES, STANDARD_TIERED_RATES =
|
|
32
|
+
RATES, STANDARD_TIERED_RATES = _load_rates("rates.json")
|
|
31
33
|
|
|
32
34
|
# --native-proxy means the comparison is measuring the GovCloud route. Pricing remains the
|
|
33
35
|
# same on both panes, but both use this schedule instead of the standard one.
|
|
34
|
-
GOVCLOUD_RATES =
|
|
35
|
-
"claude-opus-5": (5.50e-06, 5.50e-07, 6.875e-06, 1.10e-05, 2.75e-05),
|
|
36
|
-
"claude-opus-4-8": (5.50e-06, 5.50e-07, 6.875e-06, 1.10e-05, 2.75e-05),
|
|
37
|
-
"claude-sonnet-5": (2.20e-06, 2.20e-07, 2.75e-06, 4.40e-06, 1.10e-05),
|
|
38
|
-
"claude-haiku-4-5": (1.10e-06, 1.10e-07, 1.375e-06, 2.20e-06, 5.50e-06),
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
# The GovCloud schedule for the same tiered GPT models.
|
|
42
|
-
GOVCLOUD_TIERED_RATES = {
|
|
43
|
-
"gpt-5.6-luna": (
|
|
44
|
-
272_000,
|
|
45
|
-
(0.22e-06, 0.022e-06, 0.275e-06, 0.275e-06, 1.32e-06),
|
|
46
|
-
(0.44e-06, 0.044e-06, 0.55e-06, 0.55e-06, 1.98e-06),
|
|
47
|
-
),
|
|
48
|
-
"gpt-5.6-terra": (
|
|
49
|
-
272_000,
|
|
50
|
-
(2.20e-06, 0.22e-06, 2.75e-06, 2.75e-06, 13.20e-06),
|
|
51
|
-
(4.40e-06, 0.44e-06, 5.50e-06, 5.50e-06, 19.80e-06),
|
|
52
|
-
),
|
|
53
|
-
"gpt-5.6-sol": (
|
|
54
|
-
272_000,
|
|
55
|
-
(4.40e-06, 0.44e-06, 5.50e-06, 5.50e-06, 22.00e-06),
|
|
56
|
-
(8.80e-06, 0.88e-06, 11.00e-06, 11.00e-06, 33.00e-06),
|
|
57
|
-
),
|
|
58
|
-
}
|
|
36
|
+
GOVCLOUD_RATES, GOVCLOUD_TIERED_RATES = _load_rates("govcloud-rates.json")
|
|
59
37
|
|
|
60
38
|
CONTEXT_WINDOWS = {
|
|
61
39
|
"claude-opus-5": 200_000,
|
package/standalone/usage.py
CHANGED
|
@@ -214,6 +214,13 @@ class UsageReader:
|
|
|
214
214
|
return
|
|
215
215
|
state.counted.add(mid)
|
|
216
216
|
|
|
217
|
+
# A reply the gateway answered itself carries an estimate of the whole context as fresh
|
|
218
|
+
# input, and nothing was billed for it. The gateway marks these replies with a
|
|
219
|
+
# `penny_local_` request id and a `msg_swallow_` message id.
|
|
220
|
+
if (str(obj.get("requestId") or "").startswith("penny_local_")
|
|
221
|
+
or str(mid or "").startswith("msg_swallow_")):
|
|
222
|
+
return
|
|
223
|
+
|
|
217
224
|
read = int(usage.get("cache_read_input_tokens") or 0)
|
|
218
225
|
write = int(usage.get("cache_creation_input_tokens") or 0)
|
|
219
226
|
creation = usage.get("cache_creation") or {}
|
|
@@ -15,16 +15,16 @@ To launch Claude Code in both panes immediately:
|
|
|
15
15
|
npm start -- --claude
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
To run
|
|
18
|
+
To run Penny Compare in the panes:
|
|
19
19
|
|
|
20
20
|
```sh
|
|
21
21
|
npm start -- --compare
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
This opens a setup screen with the
|
|
24
|
+
This opens a setup screen with the Claude/Codex pane choices, folders, labels, shared model, and environment (provider, proxy, pricing). The default is native Claude on the left and Penny Claude on the right, using `native` and `penny` folders under the directory `npm start` was run from; missing folders are created. Each pane's meter reads Claude or Codex transcripts and prices calls in Node on every platform, with no Python or tmux requirement. Claude panes also report background model calls (such as the auto-mode reviewer) to this server over local OpenTelemetry, which the meter adds as a "side" line. Exit saves a JSON report under `apps/compare/reports` and shows the final summary. The selected harness commands must be on `PATH`.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
A Penny Claude pane has an **Options** section under its picker, set separately for each side. **Cache TTL** chooses 5 minutes (the default) or 1 hour for the conversation cache by setting Claude Code's `FORCE_PROMPT_CACHING_5M` or `ENABLE_PROMPT_CACHING_1H` for that pane; the shared prefix is always 1 hour. The choice is shown in the pane header and saved in the report. Options are defined in `pane-options.mjs`.
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Use `--harness=codex` to preselect Codex in the setup screen. The pane selectors can also mix Claude and Codex; the shared model control is available when both panes use the same harness family. `--compare-node` is accepted as an older name for `--compare`.
|
|
29
29
|
|
|
30
30
|
The demo requires Claude Code on `PATH` for that option. Closing or reloading the page ends both sessions. The local server listens only on `127.0.0.1`; it does not start a PennyRouter gateway.
|