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
package/README.md
CHANGED
|
@@ -87,12 +87,12 @@ of `npx pennyrouter install`; to install or refresh it separately, run:
|
|
|
87
87
|
npx pennyrouter compare
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
90
|
+
Run `penny compare` on macOS, Linux, or Windows to open the browser-based setup. It mirrors the
|
|
91
|
+
classic launcher's Claude/Codex pane, directory, label, and model choices. The browser version
|
|
92
|
+
uses Python session metering on macOS/Linux and Node transcript metering on Windows, so it does
|
|
93
|
+
not need tmux. The classic terminal interface remains available as `penny-compare` on macOS/Linux
|
|
94
|
+
and requires tmux and Python 3.10+. Sync is Ctrl+Shift+S; Exit saves the report and shows the
|
|
95
|
+
final summary.
|
|
96
96
|
|
|
97
97
|
On macOS and Linux, a normal install adds a small `penny` launcher under `~/.local/bin`.
|
|
98
98
|
On Windows it installs `penny.cmd` under `%LOCALAPPDATA%\\PennyRouter\\bin` and adds that
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -138,9 +138,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
138
138
|
throw new Error("Penny Compare requires macOS, Linux, or Windows.");
|
|
139
139
|
}
|
|
140
140
|
console.log(`Penny Compare installed at ${result.path}`);
|
|
141
|
-
console.log(
|
|
142
|
-
|
|
143
|
-
: "Run `penny-compare` or `penny-session` to use it.");
|
|
141
|
+
console.log("Run `penny compare` to open the browser-based comparison setup.");
|
|
142
|
+
if (process.platform !== "win32") console.log("Run `penny-compare` for the classic tmux interface.");
|
|
144
143
|
return;
|
|
145
144
|
}
|
|
146
145
|
|
|
@@ -1010,9 +1009,9 @@ export async function install(flags = {}) {
|
|
|
1010
1009
|
}
|
|
1011
1010
|
}
|
|
1012
1011
|
if (runtimeResult && !runtimeResult.available) {
|
|
1013
|
-
console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
|
|
1012
|
+
console.log(color(color("Add the PennyRouter launcher directory to PATH before using the `penny` command.", "bold"), "yellow"));
|
|
1014
1013
|
} else if (runtimeResult?.changed) {
|
|
1015
|
-
console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
|
|
1014
|
+
console.log(color(color("Open a new terminal (or reload your shell) before using the `penny` command.", "bold"), "yellow"));
|
|
1016
1015
|
}
|
|
1017
1016
|
|
|
1018
1017
|
// Registered after routing, which is the primary job: a naming conflict here reports itself
|
|
@@ -1460,9 +1459,9 @@ async function update(flags) {
|
|
|
1460
1459
|
|
|
1461
1460
|
console.log("");
|
|
1462
1461
|
if (runtimeResult && !runtimeResult.available) {
|
|
1463
|
-
console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
|
|
1462
|
+
console.log(color(color("Add the PennyRouter launcher directory to PATH before using the `penny` command.", "bold"), "yellow"));
|
|
1464
1463
|
} else if (runtimeResult?.changed) {
|
|
1465
|
-
console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
|
|
1464
|
+
console.log(color(color("Open a new terminal (or reload your shell) before using the `penny` command.", "bold"), "yellow"));
|
|
1466
1465
|
}
|
|
1467
1466
|
console.log("Restart any running Claude Code or Codex sessions to pick up the update.");
|
|
1468
1467
|
reportFailures(failures);
|
|
@@ -2391,6 +2390,7 @@ function color(text, style) {
|
|
|
2391
2390
|
bold: ["\x1b[1m", "\x1b[22m"],
|
|
2392
2391
|
cyan: ["\x1b[36m", "\x1b[39m"],
|
|
2393
2392
|
green: ["\x1b[32m", "\x1b[39m"],
|
|
2393
|
+
yellow: ["\x1b[33m", "\x1b[39m"],
|
|
2394
2394
|
};
|
|
2395
2395
|
const pair = codes[style];
|
|
2396
2396
|
return pair ? `${pair[0]}${text}${pair[1]}` : text;
|
package/src/launch.js
CHANGED
|
@@ -314,13 +314,13 @@ export async function installPennyCompare() {
|
|
|
314
314
|
}
|
|
315
315
|
await mkdir(join(destination, "reports"), { recursive: true, mode: 0o700 });
|
|
316
316
|
await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
|
|
317
|
+
const modules = join(destination, "web-demo", "node_modules");
|
|
318
|
+
for (const name of ["@xterm/addon-fit", "@xterm/xterm", "node-pty", "ws"]) {
|
|
319
|
+
const target = join(modules, name);
|
|
320
|
+
await mkdir(dirname(target), { recursive: true });
|
|
321
|
+
await cp(await findInstalledPackage(name), target, { recursive: true });
|
|
322
|
+
}
|
|
317
323
|
if (process.platform === "win32") {
|
|
318
|
-
const modules = join(destination, "web-demo", "node_modules");
|
|
319
|
-
for (const name of ["@xterm/addon-fit", "@xterm/xterm", "node-pty", "ws"]) {
|
|
320
|
-
const target = join(modules, name);
|
|
321
|
-
await mkdir(dirname(target), { recursive: true });
|
|
322
|
-
await cp(await findInstalledPackage(name), target, { recursive: true });
|
|
323
|
-
}
|
|
324
324
|
const link = join(pennyBinDir(), "penny-compare.cmd");
|
|
325
325
|
const marker = "@REM PennyRouter-managed compare launcher";
|
|
326
326
|
let previous = "";
|
|
@@ -917,6 +917,9 @@ export async function writeClaudeLaunchSettings({
|
|
|
917
917
|
// An empty value is treated as unset by Claude Code, and prevents a
|
|
918
918
|
// stale API key in the shared settings from creating dual auth.
|
|
919
919
|
ANTHROPIC_API_KEY: "",
|
|
920
|
+
// Claude Code turns tool search off behind a custom base URL unless told
|
|
921
|
+
// otherwise; Penny relays deferred tools and their tool_reference blocks.
|
|
922
|
+
ENABLE_TOOL_SEARCH: "true",
|
|
920
923
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: "",
|
|
921
924
|
ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME: "",
|
|
922
925
|
ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION: "",
|
|
@@ -1413,6 +1416,7 @@ export function buildPennyInvocation(harness, args, config, models = null) {
|
|
|
1413
1416
|
// Ask Claude Code to discover the gateway's model catalog at startup. The gateway
|
|
1414
1417
|
// advertises Penny-facing ids in /v1/models; this is separate from inference routing.
|
|
1415
1418
|
env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
|
|
1419
|
+
env.ENABLE_TOOL_SEARCH = "true";
|
|
1416
1420
|
delete env.ANTHROPIC_API_KEY;
|
|
1417
1421
|
for (const slot of ["haiku", "sonnet", "opus", "fable"]) {
|
|
1418
1422
|
const name = `ANTHROPIC_DEFAULT_${slot.toUpperCase()}_MODEL`;
|
|
@@ -1501,16 +1505,36 @@ export async function runPennyClaudeStats(args, profile) {
|
|
|
1501
1505
|
return result.code ?? 1;
|
|
1502
1506
|
}
|
|
1503
1507
|
|
|
1508
|
+
/** Launch the separately installed Penny Compare app from the `penny` command. */
|
|
1509
|
+
async function runPennyCompare(args) {
|
|
1510
|
+
const compareDir = pennyCompareDir();
|
|
1511
|
+
const executable = join(compareDir, "web-demo", "server.mjs");
|
|
1512
|
+
try {
|
|
1513
|
+
await stat(executable);
|
|
1514
|
+
} catch {
|
|
1515
|
+
throw new Error("Penny Compare is not installed. Run `npx pennyrouter compare` first.");
|
|
1516
|
+
}
|
|
1517
|
+
const child = spawn(process.execPath, [executable, "--compare", ...args], {
|
|
1518
|
+
cwd: process.cwd(), env: process.env, stdio: "inherit",
|
|
1519
|
+
});
|
|
1520
|
+
const result = await waitForChild(child);
|
|
1521
|
+
if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
|
|
1522
|
+
return result.code ?? 1;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1504
1525
|
export async function runPenny(argv = process.argv.slice(2)) {
|
|
1505
1526
|
const harness = argv[0];
|
|
1506
1527
|
if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
|
|
1507
|
-
console.log("Usage: penny <claude|codex|opencode> [
|
|
1528
|
+
console.log("Usage: penny <claude|codex|opencode|compare> [arguments...]");
|
|
1529
|
+
console.log(" penny compare launches Penny Compare; install it with `npx pennyrouter compare`.");
|
|
1530
|
+
console.log(" penny <claude|codex|opencode> [--penny-profile <name>] [native arguments...]");
|
|
1508
1531
|
console.log(" --private / --cloud select the like-named profile.");
|
|
1509
1532
|
console.log(" penny claude --stats adds the live single-session token readout.");
|
|
1510
1533
|
return 0;
|
|
1511
1534
|
}
|
|
1535
|
+
if (harness === "compare") return runPennyCompare(argv.slice(1));
|
|
1512
1536
|
if (!["claude", "codex", "opencode"].includes(harness)) {
|
|
1513
|
-
throw new Error(`Unknown
|
|
1537
|
+
throw new Error(`Unknown Penny command "${harness}". Try penny claude, penny codex, penny opencode, or penny compare.`);
|
|
1514
1538
|
}
|
|
1515
1539
|
const statsSelection = extractStatsArgs(argv.slice(1));
|
|
1516
1540
|
if (statsSelection.stats && harness !== "claude") {
|
package/src/launch.test.js
CHANGED
|
@@ -139,6 +139,7 @@ async function main() {
|
|
|
139
139
|
assert.equal(claudeLaunchSettings.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
|
|
140
140
|
assert.equal(claudeLaunchSettings.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
|
|
141
141
|
assert.equal(claudeLaunchSettings.env.ANTHROPIC_API_KEY, "");
|
|
142
|
+
assert.equal(claudeLaunchSettings.env.ENABLE_TOOL_SEARCH, "true");
|
|
142
143
|
assert.equal(claudeLaunchSettings.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "");
|
|
143
144
|
assert.equal(claudeLaunchSettings.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME, "");
|
|
144
145
|
await writeClaudeLaunchSettings({
|
|
@@ -264,6 +265,7 @@ async function main() {
|
|
|
264
265
|
assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
|
|
265
266
|
assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
|
|
266
267
|
assert.equal(claude.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY, "1");
|
|
268
|
+
assert.equal(claude.env.ENABLE_TOOL_SEARCH, "true");
|
|
267
269
|
// 1M-capable slots carry the `[1m]` suffix; Haiku has no 1M variant and stays bare.
|
|
268
270
|
// Per-slot suffixes, not the global CLAUDE_CODE_MAX_CONTEXT_TOKENS override, which
|
|
269
271
|
// would wrongly hand Haiku a 1M window too.
|
package/standalone/penny-compare
CHANGED
|
@@ -62,9 +62,11 @@ label_right=""
|
|
|
62
62
|
wire=""
|
|
63
63
|
wire_both=""
|
|
64
64
|
wire_https=""
|
|
65
|
+
pricing_profile=""
|
|
65
66
|
while [ $# -gt 0 ]; do
|
|
66
67
|
case "$1" in
|
|
67
68
|
--model) model="$2"; shift 2 ;;
|
|
69
|
+
--pricing-profile) pricing_profile="$2"; shift 2 ;;
|
|
68
70
|
--dir-left) dir_left="$2"; shift 2 ;;
|
|
69
71
|
--dir-right) dir_right="$2"; shift 2 ;;
|
|
70
72
|
--native-proxy) native_proxy="--native-proxy"; shift ;;
|
|
@@ -175,6 +177,17 @@ else
|
|
|
175
177
|
fi
|
|
176
178
|
fi
|
|
177
179
|
|
|
180
|
+
# Pricing is an estimate-only display setting, so choose it explicitly instead of silently
|
|
181
|
+
# coupling it to the native transport proxy. This also keeps direct (two-argument) launches
|
|
182
|
+
# consistent with menu launches.
|
|
183
|
+
if [ -z "$pricing_profile" ]; then
|
|
184
|
+
pricing_profile="$(choose 'pricing profile' standard standard govcloud)"
|
|
185
|
+
fi
|
|
186
|
+
case "$pricing_profile" in
|
|
187
|
+
standard|govcloud) ;;
|
|
188
|
+
*) echo "unknown pricing profile: $pricing_profile (try standard or govcloud)" >&2; exit 1 ;;
|
|
189
|
+
esac
|
|
190
|
+
|
|
178
191
|
# A directory that does not exist would otherwise surface as a confusing tmux failure.
|
|
179
192
|
for candidate in "$dir_left" "$dir_right"; do
|
|
180
193
|
if [ -n "$candidate" ] && [ ! -d "${candidate/#\~/$HOME}" ]; then
|
|
@@ -243,9 +256,7 @@ workdir_right_arg=" --cwd $(printf '%q' "$workdir_right")"
|
|
|
243
256
|
# a penny-* pane already owns its own proxy routing and ignores the flag either way.
|
|
244
257
|
native_proxy_left=""
|
|
245
258
|
native_proxy_right=""
|
|
246
|
-
pricing_profile="standard"
|
|
247
259
|
if [ -n "$native_proxy" ]; then
|
|
248
|
-
pricing_profile="govcloud"
|
|
249
260
|
case "$left" in claude|codex) native_proxy_left="$native_proxy" ;; esac
|
|
250
261
|
case "$right" in claude|codex) native_proxy_right="$native_proxy" ;; esac
|
|
251
262
|
fi
|
package/standalone/pricing.py
CHANGED
|
@@ -7,15 +7,27 @@ that uses 1-hour caching, which the gateway does by default.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load_standard_rates():
|
|
15
|
+
"""Standard per-token rates from rates.json (scripts/generate_compare_rates.py), the same
|
|
16
|
+
file the web-demo meters read. Returns (flat rates, tiered rates) keyed by model id; a tier
|
|
17
|
+
entry is (inclusive threshold, <= threshold rates, > threshold rates)."""
|
|
18
|
+
document = json.loads((Path(__file__).with_name("rates.json")).read_text())
|
|
19
|
+
flat, tiered = {}, {}
|
|
20
|
+
for name, model in document["models"].items():
|
|
21
|
+
flat[name] = tuple(model["rates"])
|
|
22
|
+
if "above" in model:
|
|
23
|
+
above = model["above"]
|
|
24
|
+
tiered[name] = (above["threshold"], tuple(model["rates"]), tuple(above["rates"]))
|
|
25
|
+
return flat, tiered
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# input, cache_read, write_5m, write_1h, output. OpenAI does not offer a 1-hour cache tier;
|
|
29
|
+
# its write rate applies uniformly.
|
|
30
|
+
RATES, STANDARD_TIERED_RATES = _load_standard_rates()
|
|
19
31
|
|
|
20
32
|
# --native-proxy means the comparison is measuring the GovCloud route. Pricing remains the
|
|
21
33
|
# same on both panes, but both use this schedule instead of the standard one.
|
|
@@ -26,9 +38,7 @@ GOVCLOUD_RATES = {
|
|
|
26
38
|
"claude-haiku-4-5": (1.10e-06, 1.10e-07, 1.375e-06, 2.20e-06, 5.50e-06),
|
|
27
39
|
}
|
|
28
40
|
|
|
29
|
-
#
|
|
30
|
-
# Each entry is (inclusive threshold, <= threshold rates, > threshold rates), with rate tuples
|
|
31
|
-
# in the same input/read/write-5m/write-1h/output order as RATES.
|
|
41
|
+
# The GovCloud schedule for the same tiered GPT models.
|
|
32
42
|
GOVCLOUD_TIERED_RATES = {
|
|
33
43
|
"gpt-5.6-luna": (
|
|
34
44
|
272_000,
|
|
@@ -85,11 +95,11 @@ def _matching(name: str, choices) -> str | None:
|
|
|
85
95
|
|
|
86
96
|
def rates_for(model: str, profile: str = "standard", context_tokens: int = 0):
|
|
87
97
|
name = _normalize(model)
|
|
88
|
-
if profile == "govcloud"
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
tiers = GOVCLOUD_TIERED_RATES if profile == "govcloud" else STANDARD_TIERED_RATES
|
|
99
|
+
tiered = _matching(name, tiers)
|
|
100
|
+
if tiered:
|
|
101
|
+
threshold, low, high = tiers[tiered]
|
|
102
|
+
return high if context_tokens > threshold else low
|
|
93
103
|
rates = GOVCLOUD_RATES if profile == "govcloud" else RATES
|
|
94
104
|
match = _matching(name, rates)
|
|
95
105
|
if match:
|