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 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
- On macOS/Linux, the installer puts `penny-compare` and `penny-session` on PATH alongside `penny`.
91
- These commands require `tmux`, Python 3.10+, and the harnesses being compared. On Windows,
92
- `penny-compare` starts a local browser terminal with native Claude and `penny claude` side by
93
- side. Its live token and cost readouts use Claude transcripts, with no tmux or Python install.
94
- Open the local URL printed by the command. Sync is Ctrl+Shift+S; Exit and save report is
95
- Ctrl+Shift+X. The Windows path currently supports Claude Code comparisons.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.37",
3
+ "version": "0.3.39",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
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(process.platform === "win32"
142
- ? "Run `penny-compare`, then open the local URL it prints."
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> [--penny-profile <name>] [native arguments...]");
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 harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
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") {
@@ -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.
@@ -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
@@ -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
- # input, cache_read, write_5m, write_1h, output
11
- RATES = {
12
- "claude-opus-5": (5.00e-06, 5.00e-07, 6.25e-06, 1.00e-05, 2.50e-05),
13
- "claude-sonnet-5": (3.00e-06, 3.00e-07, 3.75e-06, 6.00e-06, 1.50e-05),
14
- "claude-haiku-4-5": (1.00e-06, 1.00e-07, 1.25e-06, 2.00e-06, 5.00e-06),
15
- # OpenAI does not offer a 1-hour cache tier; its write rate applies uniformly.
16
- "gpt-5-codex": (1.25e-06, 1.25e-07, 1.25e-06, 1.25e-06, 1.00e-05),
17
- "gpt-5.1-codex": (1.25e-06, 1.25e-07, 1.25e-06, 1.25e-06, 1.00e-05),
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
- # GPT GovCloud pricing changes when an individual request's input context exceeds 272K.
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
- tiered = _matching(name, GOVCLOUD_TIERED_RATES)
90
- if tiered:
91
- threshold, low, high = GOVCLOUD_TIERED_RATES[tiered]
92
- return high if context_tokens > threshold else low
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: