pennyrouter 0.3.18 → 0.3.20

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
@@ -18,6 +18,7 @@ npx pennyrouter disable
18
18
  npx pennyrouter enable
19
19
  npx pennyrouter uninstall
20
20
  npx pennyrouter status
21
+ npx pennyrouter compare
21
22
  ```
22
23
 
23
24
  Installation opens a browser and requires login to the PennyRouter account that should own
@@ -29,7 +30,7 @@ Installing against a self-hosted gateway is its own flow, with no browser login:
29
30
  keeps its own accounts, so the key is minted there.
30
31
 
31
32
  ```bash
32
- npx pennyrouter install --private-gateway https://gateway.example.internal
33
+ npx pennyrouter install --private-gateway https://penny-router.apps-dev.gtri.org
33
34
  ```
34
35
 
35
36
  It asks for the gateway URL (unless given) and your email, and mints a key against that
@@ -44,11 +45,11 @@ ones:
44
45
  ```json
45
46
  {
46
47
  "harnesses": ["claude-code", "codex"],
47
- "gatewayBaseUrl": "https://gateway.example.internal",
48
+ "gatewayBaseUrl": "https://penny-router.apps-dev.gtri.org",
48
49
  "claudeFunding": ["anthropic-proxy"],
49
- "proxyBaseUrl": "https://litellm.example.internal",
50
+ "anthropicProxyBaseUrl": "https://litellm.core.gtri.org",
50
51
  "codexFunding": ["openai-proxy"],
51
- "openaiProxyBaseUrl": "https://litellm.example.internal"
52
+ "openaiProxyBaseUrl": "https://litellm.core.gtri.org"
52
53
  }
53
54
  ```
54
55
 
@@ -57,6 +58,13 @@ npx pennyrouter install --config team-install.json
57
58
  ```
58
59
 
59
60
  Each person is prompted for their email and their proxy key; everything else is prefilled.
61
+
62
+ For clarity, `proxyBaseUrl`/`proxyApiKey` are the legacy names for the Claude/Anthropic
63
+ wire lane. New files should use `anthropicProxyBaseUrl`/`anthropicProxyApiKey`; Codex uses
64
+ `openaiProxyBaseUrl`/`openaiProxyApiKey`. The `--private-server` spelling is a compatibility
65
+ alias for `--private-gateway`.
66
+
67
+
60
68
  Any field left out of the file is prompted for, and an unattended run that cannot prompt
61
69
  refuses by name rather than installing a half-configured harness. Add `email`, `proxyApiKey`
62
70
  and `openaiProxyApiKey` to the file to skip the prompts entirely.
@@ -72,6 +80,16 @@ OpenRouter key. PennyRouter-funded provider keys are never an installed-harness
72
80
 
73
81
  ## Explicit Penny Commands
74
82
 
83
+ Penny Compare is bundled with the CLI on macOS and Linux. It is installed automatically as part
84
+ of `npx pennyrouter install`; to install or refresh it separately, run:
85
+
86
+ ```sh
87
+ npx pennyrouter compare
88
+ ```
89
+
90
+ Then use `penny-compare` for a side-by-side run or `penny-session` for one Penny-routed session.
91
+ The bundle requires `tmux`, Python 3.10+, and the harnesses being compared.
92
+
75
93
  On macOS and Linux, a normal install adds a small `penny` launcher under `~/.local/bin`.
76
94
  On Windows it installs `penny.cmd` under `%LOCALAPPDATA%\\PennyRouter\\bin` and adds that
77
95
  directory to the user PATH:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -20,6 +20,7 @@
20
20
  "files": [
21
21
  "bin",
22
22
  "src",
23
+ "standalone",
23
24
  "README.md"
24
25
  ],
25
26
  "scripts": {
package/src/cli.js CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  configurePennyHarness,
60
60
  hasConfiguredPennyHarnesses,
61
61
  installPennyRuntime,
62
+ installPennyCompare,
62
63
  loadPennyConfig,
63
64
  pennyConfigPath,
64
65
  removePennyHarness,
@@ -120,6 +121,17 @@ export async function main(argv = process.argv.slice(2)) {
120
121
  return;
121
122
  }
122
123
 
124
+ if (command === "compare") {
125
+ const result = await installPennyCompare();
126
+ if (!result.installed) {
127
+ if (result.supported) throw new Error("Penny Compare is not included in this CLI package.");
128
+ throw new Error("Penny Compare requires macOS or Linux with tmux and Python 3.10+.");
129
+ }
130
+ console.log(`Penny Compare installed at ${result.path}`);
131
+ console.log("Run `penny-compare` or `penny-session` to use it.");
132
+ return;
133
+ }
134
+
123
135
  if (command === "update") {
124
136
  await update(flags);
125
137
  return;
@@ -234,6 +246,10 @@ function parseArgs(argv) {
234
246
  else if (arg.startsWith("--openai-proxy-base-url=")) flags.openaiProxyBaseUrl = arg.slice("--openai-proxy-base-url=".length);
235
247
  else if (arg === "--openai-proxy-api-key") flags.openaiProxyApiKey = rest[++i] || "";
236
248
  else if (arg.startsWith("--openai-proxy-api-key=")) flags.openaiProxyApiKey = arg.slice("--openai-proxy-api-key=".length);
249
+ else if (arg === "--anthropic-proxy-base-url") flags.proxyBaseUrl = rest[++i] || "";
250
+ else if (arg.startsWith("--anthropic-proxy-base-url=")) flags.proxyBaseUrl = arg.slice("--anthropic-proxy-base-url=".length);
251
+ else if (arg === "--anthropic-proxy-api-key") flags.proxyApiKey = rest[++i] || "";
252
+ else if (arg.startsWith("--anthropic-proxy-api-key=")) flags.proxyApiKey = arg.slice("--anthropic-proxy-api-key=".length);
237
253
  else if (arg === "--proxy-base-url") flags.proxyBaseUrl = rest[++i] || "";
238
254
  else if (arg.startsWith("--proxy-base-url=")) flags.proxyBaseUrl = arg.slice("--proxy-base-url=".length);
239
255
  else if (arg === "--proxy-api-key") flags.proxyApiKey = rest[++i] || "";
@@ -250,17 +266,17 @@ function parseArgs(argv) {
250
266
  // --gateway-base-url, which expects the URL and an admin-issued key up front, this asks
251
267
  // for what it needs (gateway URL, then email) and mints the key from the gateway itself,
252
268
  // so a user on a VPN can install without an administrator handing them a key first.
253
- else if (arg === "--private-gateway") {
269
+ else if (arg === "--private-gateway" || arg === "--private-server") {
254
270
  flags.privateGatewayFlow = true;
255
271
  flags.privateGateway = true;
256
272
  // Optional inline URL: `--private-gateway https://...` rather than a prompt. Only
257
273
  // consume the next argument when it is not itself a flag.
258
274
  if (rest[i + 1] && !rest[i + 1].startsWith("-")) flags.gatewayBaseUrl = rest[++i];
259
275
  }
260
- else if (arg.startsWith("--private-gateway=")) {
276
+ else if (arg.startsWith("--private-gateway=") || arg.startsWith("--private-server=")) {
261
277
  flags.privateGatewayFlow = true;
262
278
  flags.privateGateway = true;
263
- flags.gatewayBaseUrl = arg.slice("--private-gateway=".length);
279
+ flags.gatewayBaseUrl = arg.slice(arg.indexOf("=") + 1);
264
280
  }
265
281
  // Email the private gateway mints the key against; prompted for when absent.
266
282
  else if (arg === "--email") flags.email = rest[++i] || "";
@@ -328,6 +344,15 @@ export function applyConfigFile(flags) {
328
344
  if (config.harnesses && flags.harness === undefined) {
329
345
  flags.harness = config.harnesses.join(",");
330
346
  }
347
+ // Migrate the ambiguous Claude proxy keys in-memory. Existing shared install files remain
348
+ // valid while new files can say explicitly which wire lane they configure.
349
+ if (config.anthropicProxyBaseUrl === undefined && config.proxyBaseUrl !== undefined) {
350
+ config.anthropicProxyBaseUrl = config.proxyBaseUrl;
351
+ }
352
+ if (config.anthropicProxyApiKey === undefined && config.proxyApiKey !== undefined) {
353
+ config.anthropicProxyApiKey = config.proxyApiKey;
354
+ }
355
+
331
356
  const claudeFunding = new Set(config.claudeFunding || []);
332
357
  const codexFunding = new Set(config.codexFunding || []);
333
358
  if (claudeFunding.has("subscription") && flags.anthropicAuth === undefined) {
@@ -358,6 +383,15 @@ export function applyConfigFile(flags) {
358
383
  const value = String(config[field.config_key] || "").trim();
359
384
  if (value && flags[field.config_key] === undefined) flags[field.config_key] = value;
360
385
  }
386
+ // ``proxy*`` was the original Claude/Anthropic naming. Keep it as an input alias, but
387
+ // normalize the explicit JSON names back to the internal flags used by the installer.
388
+ if (flags.proxyBaseUrl === undefined && flags.anthropicProxyBaseUrl !== undefined) {
389
+ flags.proxyBaseUrl = flags.anthropicProxyBaseUrl;
390
+ }
391
+ if (flags.proxyApiKey === undefined && flags.anthropicProxyApiKey !== undefined) {
392
+ flags.proxyApiKey = flags.anthropicProxyApiKey;
393
+ }
394
+
361
395
  if (flags.gatewayBaseUrl) {
362
396
  flags.privateGateway = true;
363
397
  // Take the self-hosted install path rather than the hosted browser claim, whose key is
@@ -1423,8 +1457,10 @@ async function selectClaudeFundingProviders(flags, selected) {
1423
1457
  || process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
1424
1458
  if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
1425
1459
  if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
1426
- if ((flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL)
1427
- && (flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY)) {
1460
+ if ((flags.proxyBaseUrl || process.env.PENNYROUTER_ANTHROPIC_PROXY_BASE_URL
1461
+ || process.env.PENNYROUTER_PROXY_BASE_URL)
1462
+ && (flags.proxyApiKey || process.env.PENNYROUTER_ANTHROPIC_PROXY_API_KEY
1463
+ || process.env.PENNYROUTER_PROXY_API_KEY)) {
1428
1464
  supplied.add("anthropic-proxy");
1429
1465
  }
1430
1466
  // A --config file states its providers outright, and that choice stands whether or not the
@@ -1559,10 +1595,14 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1559
1595
  flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY || "",
1560
1596
  ).trim();
1561
1597
  let proxyBaseUrl = String(
1562
- flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL || "",
1598
+ flags.proxyBaseUrl || flags.anthropicProxyBaseUrl
1599
+ || process.env.PENNYROUTER_ANTHROPIC_PROXY_BASE_URL
1600
+ || process.env.PENNYROUTER_PROXY_BASE_URL || "",
1563
1601
  ).trim();
1564
1602
  let proxyApiKey = String(
1565
- flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY || "",
1603
+ flags.proxyApiKey || flags.anthropicProxyApiKey
1604
+ || process.env.PENNYROUTER_ANTHROPIC_PROXY_API_KEY
1605
+ || process.env.PENNYROUTER_PROXY_API_KEY || "",
1566
1606
  ).trim();
1567
1607
  const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
1568
1608
  ? await detectCodexChatgptAuth()
@@ -2401,13 +2441,14 @@ function printHelp() {
2401
2441
  console.log(`PennyRouter CLI
2402
2442
 
2403
2443
  Usage:
2404
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openai-proxy-base-url URL --openai-proxy-api-key KEY] [--openrouter-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--private-gateway[=URL] [--email you@example.com]] [--gateway-base-url URL] [--private-key pr-...] [--local[=URL]|--prod] [--config PATH]
2444
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openai-proxy-base-url URL --openai-proxy-api-key KEY] [--openrouter-api-key KEY] [--anthropic-proxy-base-url URL --anthropic-proxy-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--private-gateway|--private-server[=URL] [--email you@example.com]] [--gateway-base-url URL] [--private-key pr-...] [--local[=URL]|--prod] [--config PATH]
2405
2445
  pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp]
2406
2446
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
2407
2447
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
2408
2448
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
2409
2449
  pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
2410
2450
  pennyrouter status
2451
+ pennyrouter compare install or refresh Penny Compare
2411
2452
  pennyrouter profiles [name]
2412
2453
  pennyrouter mcp install|uninstall|status [--harness claude-code,codex]
2413
2454
 
@@ -56,12 +56,12 @@
56
56
  "name": "Proxy (e.g., Bedrock, Azure, private server)",
57
57
  "fields": [
58
58
  {
59
- "config_key": "proxyBaseUrl",
60
- "label": "Proxy base URL",
59
+ "config_key": "anthropicProxyBaseUrl",
60
+ "label": "Anthropic proxy base URL",
61
61
  "secret": false,
62
62
  "placeholder": "https://proxy.example.com"
63
63
  },
64
- { "config_key": "proxyApiKey", "label": "Proxy API key", "secret": true }
64
+ { "config_key": "anthropicProxyApiKey", "label": "Anthropic proxy API key", "secret": true }
65
65
  ]
66
66
  }
67
67
  ]
package/src/launch.js CHANGED
@@ -65,6 +65,10 @@ export function pennyRuntimeDir() {
65
65
  return join(pennyDataDir(), "runtime");
66
66
  }
67
67
 
68
+ export function pennyCompareDir() {
69
+ return join(pennyDataDir(), "compare");
70
+ }
71
+
68
72
  export function pennyBinDir() {
69
73
  if (process.platform === "win32") return join(pennyDataDir(), "bin");
70
74
  return join(homeDir(), ".local", "bin");
@@ -257,6 +261,32 @@ async function copyRuntime() {
257
261
  await cp(join(source, "package.json"), join(destination, "package.json"), { force: true });
258
262
  }
259
263
 
264
+ /** Install the bundled, repository-independent Penny Compare tools. */
265
+ export async function installPennyCompare() {
266
+ if (!(process.platform === "darwin" || process.platform === "linux")) {
267
+ return { supported: false, installed: false, path: pennyCompareDir() };
268
+ }
269
+ const source = join(packageRoot(), "standalone");
270
+ const destination = pennyCompareDir();
271
+ try {
272
+ await stat(source);
273
+ } catch {
274
+ return { supported: true, installed: false, path: destination };
275
+ }
276
+ await mkdir(destination, { recursive: true, mode: 0o700 });
277
+ // Preserve user state while replacing the shipped code on every CLI upgrade.
278
+ for (const entry of await readdir(destination)) {
279
+ if (entry === ".env" || entry === "reports") continue;
280
+ await rm(join(destination, entry), { recursive: true, force: true });
281
+ }
282
+ for (const entry of await readdir(source)) {
283
+ if (entry === ".env" || entry === "reports") continue;
284
+ await cp(join(source, entry), join(destination, entry), { recursive: true, force: true });
285
+ }
286
+ await mkdir(join(destination, "reports"), { recursive: true, mode: 0o700 });
287
+ return { supported: true, installed: true, path: destination };
288
+ }
289
+
260
290
  async function writeShim() {
261
291
  await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
262
292
  const entry = join(pennyRuntimeDir(), "bin", "penny.js");
@@ -386,7 +416,8 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
386
416
  config.windows_path_managed = true;
387
417
  await savePennyConfig(config);
388
418
  }
389
- return { supported: true, ...path, shim_path: pennyShimPath() };
419
+ const compare = await installPennyCompare();
420
+ return { supported: true, ...path, shim_path: pennyShimPath(), compare };
390
421
  }
391
422
 
392
423
  export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
@@ -0,0 +1,8 @@
1
+ # Copy to .env (gitignored) and fill in.
2
+ #
3
+ # Used only by the NATIVE pane, and only when penny-compare is run with --native-proxy
4
+ # or you answer yes to the proxy question in the menu. Without that, the NATIVE pane
5
+ # always talks to Anthropic directly.
6
+
7
+ PENNY_COMPARE_NATIVE_BASE_URL=
8
+ PENNY_COMPARE_NATIVE_AUTH_TOKEN=
@@ -0,0 +1,79 @@
1
+ Comparison tool
2
+
3
+ ## Single-session HUD
4
+
5
+ For one normal interactive session with the same live token/cost panel used by the comparison
6
+ tool, run:
7
+
8
+ apps/compare/penny-session
9
+
10
+ It starts a Penny-routed Claude Code session by default. Choose another client explicitly when
11
+ needed:
12
+
13
+ apps/compare/penny-session penny-codex
14
+ apps/compare/penny-session penny-opencode
15
+ apps/compare/penny-session claude --model sonnet
16
+
17
+ OpenCode can be compared directly or through PennyRouter:
18
+
19
+ penny-compare opencode penny-opencode
20
+ penny-compare --model pennyrouter/auto opencode penny-opencode
21
+
22
+ OpenCode usage is read from its local SQLite session store, including cache and output tokens.
23
+ The compare tool separates native and PennyRouter sessions using OpenCode's recorded provider id.
24
+
25
+ The launcher accepts `--dir PATH`, `--label NAME`, `--model MODEL`, `--pricing-profile PROFILE`,
26
+ and `--native-proxy`. When the client exits, the HUD exits with it; no comparison window or tmux
27
+ session is created.
28
+
29
+ The live HUD and saved reports use the same model prices on both sides. A direct comparison uses
30
+ standard rates. Selecting `--native-proxy` switches both panes to the GovCloud schedule. Its
31
+ Opus 5 rates are $5.50/M input, $27.50/M output, $0.55/M cache read, $6.875/M 5-minute cache
32
+ write, and $11.00/M 1-hour cache write. The GovCloud profile also carries dedicated rates for
33
+ Opus 4.8, Sonnet 5, Haiku 4.5, and the context-tiered GPT 5.6 Luna, Terra, and Sol models. GPT's
34
+ high rate is selected per call only when that call's input context exceeds 272K.
35
+
36
+ Ctrl-Shift-X (or Ctrl-B then X) snapshots the interval since launch or the last reset, writes a
37
+ per-call JSON report to `apps/compare/reports`, shows total token and cost savings, and waits for
38
+ Enter before closing the tmux session.
39
+
40
+ ## Report viewer
41
+
42
+ Start the local web viewer with:
43
+
44
+ apps/compare/penny-compare-viewer
45
+
46
+ Then open http://127.0.0.1:8788. It lists saved comparisons, shows side-by-side token and cost
47
+ metrics, and lets you select any linked transcript message to read its complete output. New
48
+ reports record each pane's stable `session_id` and `transcript_path` so the viewer can resolve the
49
+ original Claude Code or Codex JSONL session. Reports created before this metadata was added are
50
+ still viewable for their summary metrics, but cannot identify a transcript automatically.
51
+
52
+ To use another port for a one-off run:
53
+
54
+ apps/compare/penny-compare-viewer --port 8790
55
+
56
+ penny-compare \
57
+ --model opus \
58
+ --dir-left /Users/jduke34/Documents/GitHub/test-session/native \
59
+ --dir-right /Users/jduke34/Documents/GitHub/test-session/penny \
60
+ claude penny-claude
61
+
62
+ #with proxy
63
+ penny-compare \
64
+ --dir-left ~/Documents/GitHub/test-session/native \
65
+ --dir-right ~/Documents/GitHub/test-session/penny \
66
+ --label-left NATIVE \
67
+ --label-right PENNY \
68
+ --model sonnet \
69
+ --native-proxy \
70
+ claude penny-claude
71
+
72
+ penny-compare \
73
+ --dir-left ~/Documents/GitHub/test-session/native \
74
+ --dir-right ~/Documents/GitHub/test-session/penny \
75
+ --label-left "NATIVE BEDROCK" \
76
+ --label-right "PENNY BEDROCK" \
77
+ --model opus \
78
+ --native-proxy \
79
+ claude penny-claude
@@ -0,0 +1,60 @@
1
+ # Penny Compare — portable bundle
2
+
3
+ Live token/cost HUDs for coding-agent sessions. Run two harnesses side by side
4
+ (`penny-compare`) or one on its own (`penny-session`).
5
+
6
+ Self-contained: stdlib Python only, no pip, no npm.
7
+
8
+ ## Install
9
+
10
+ ./install.sh # symlinks into ~/.local/bin
11
+ ./install.sh ~/bin # or a directory you choose
12
+
13
+ Keep this folder where it is — the commands are symlinks back to it. Move it and
14
+ re-run `install.sh` to repoint.
15
+
16
+ ### Requirements
17
+
18
+ - `tmux` and `python3` 3.10+ (`install.sh` checks both and stops if either is missing)
19
+ - Whichever harnesses you're comparing: `claude`, `codex`, `opencode`
20
+ - For the penny-routed panes: `npx pennyrouter install`
21
+
22
+ ## Use
23
+
24
+ penny-compare menu
25
+ penny-compare claude penny-claude native vs penny, side by side
26
+ penny-compare --model opus claude penny-claude both panes on one model
27
+ penny-session one penny-routed Claude session
28
+ penny-session claude --model sonnet one native session
29
+ penny-compare-viewer browse saved reports
30
+
31
+ Keys, in-session: `ctrl-shift-s` sync · `ctrl-shift-r` reset counters ·
32
+ `ctrl-shift-x` write report and quit. Each also has a `ctrl-b` prefix spelling
33
+ for terminals that can't send ctrl-shift (Terminal.app among them).
34
+
35
+ Reset before the run that counts — a comparison is only fair once both sides
36
+ have a warm cache.
37
+
38
+ ## `.env` (optional)
39
+
40
+ Only needed for `--native-proxy`, which routes the NATIVE pane through a proxy
41
+ instead of straight to Anthropic. **Not included in this bundle** — it holds auth
42
+ tokens. Copy `.env.example` to `.env` and fill it in, or copy your existing
43
+ `.env` across separately.
44
+
45
+ Without it, everything else works; the NATIVE pane just talks to Anthropic directly.
46
+
47
+ ## Reports
48
+
49
+ New runs land in `reports/` beside the bundle. `penny-compare-viewer` serves them at
50
+ http://localhost:8765. A release bundle starts with an empty reports directory; reports are
51
+ local runtime data and are never copied into a release.
52
+
53
+ ## Caveat: pricing goes stale
54
+
55
+ `pricing.py` hardcodes per-token rates, and the Codex model list in
56
+ `penny-compare` mirrors the gateway's catalog. Neither updates itself away from
57
+ the repo, and a stale table misprices silently. Re-copy those two files when
58
+ rates change.
59
+
60
+ Original repo docs: `README-original.md`.
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env bash
2
+ # Point Claude Code (and penny-compare's NATIVE pane) at an OpenAI/Anthropic-compatible
3
+ # proxy, and put it back.
4
+ #
5
+ # This does two things together, since both are needed to fully switch over:
6
+ #
7
+ # 1. Writes ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN into ~/.claude/settings.json's
8
+ # `env` block, which is what a bare `claude` invocation reads on startup. Every
9
+ # other setting in that file is preserved -- the edit is a key-level merge, not a
10
+ # rewrite.
11
+ # 2. Writes PENNY_COMPARE_NATIVE_BASE_URL / PENNY_COMPARE_NATIVE_AUTH_TOKEN into this
12
+ # repo's .env file. penny-compare's pane.py reads those two vars from its own
13
+ # process environment when run with --native-proxy, and injects them into the
14
+ # NATIVE pane's subprocess as ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN. Without
15
+ # this, --native-proxy has nothing to read and the NATIVE pane falls back to
16
+ # Anthropic direct even with settings.json pointed at the proxy, because pane.py
17
+ # strips ambient ANTHROPIC_* vars before optionally reinjecting its own.
18
+ # 3. Sets settings.json's disableClaudeAiConnectors to true while the proxy is on.
19
+ # Once ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN override auth, Claude Code is no
20
+ # longer authenticated against claude.ai, so claude.ai-hosted MCP connectors
21
+ # (Gmail, Calendar, Drive, etc.) are unreachable regardless -- this just says so
22
+ # up front instead of showing the "disabled because an auth source is set"
23
+ # warning on every startup. Cleared on `off`.
24
+ #
25
+ # claude-proxy.sh on [url] point Claude Code (and --native-proxy) at the proxy
26
+ # claude-proxy.sh off remove it, back to Anthropic direct everywhere
27
+ # claude-proxy.sh status show what is set right now, for both
28
+ #
29
+ # The token is read from CLAUDE_PROXY_TOKEN, or prompted for without echo.
30
+ # It is never passed on the command line, where it would land in shell history.
31
+ #
32
+ # export CLAUDE_PROXY_URL=https://litellm.core.gtri.org
33
+ # export CLAUDE_PROXY_TOKEN=sk-example
34
+ # ./claude-proxy.sh on
35
+ #
36
+ # The first `on` backs up settings.json and .env once per invocation (whichever
37
+ # already exist), before any write. Recoverable from settings.json.bak.<timestamp>
38
+ # and .env.bak.<timestamp> in ~/.claude and this repo respectively.
39
+
40
+ set -euo pipefail
41
+
42
+ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
43
+ ENV_FILE="${PROXY_ENV_FILE:-$SCRIPT_DIR/.env}"
44
+ if [ -f "$ENV_FILE" ]; then
45
+ set -a
46
+ . "$ENV_FILE"
47
+ set +a
48
+ fi
49
+
50
+ SETTINGS="${CLAUDE_SETTINGS:-$HOME/.claude/settings.json}"
51
+ # Falls back to whatever URL is already saved in .env from the last `on` run,
52
+ # so a bare `./claude-proxy.sh on` reuses it without retyping.
53
+ DEFAULT_URL="${CLAUDE_PROXY_URL:-${PENNY_COMPARE_NATIVE_BASE_URL:-}}"
54
+
55
+ NATIVE_URL_VAR="PENNY_COMPARE_NATIVE_BASE_URL"
56
+ NATIVE_TOKEN_VAR="PENNY_COMPARE_NATIVE_AUTH_TOKEN"
57
+
58
+ die() { echo "error: $*" >&2; exit 1; }
59
+
60
+ command -v python3 >/dev/null || die "python3 is required"
61
+
62
+ # Claude Code creates this on first run; an install that has never been launched
63
+ # has no file, and writing one here would guess at the rest of its contents.
64
+ [ -f "$SETTINGS" ] || die "no settings file at $SETTINGS (run claude once first)"
65
+
66
+ # One backup per run, before any write.
67
+ backup() {
68
+ local dest="$SETTINGS.bak.$(date +%Y%m%dT%H%M%SZ)"
69
+ cp "$SETTINGS" "$dest"
70
+ echo "backed up settings to $dest"
71
+
72
+ if [ -f "$ENV_FILE" ]; then
73
+ local env_dest="$ENV_FILE.bak.$(date +%Y%m%dT%H%M%SZ)"
74
+ cp "$ENV_FILE" "$env_dest"
75
+ echo "backed up .env to $env_dest"
76
+ fi
77
+ }
78
+
79
+ # Merge or delete the two env keys in settings.json. Reads the file, edits the
80
+ # parsed object, and writes to a temp file in the same directory before renaming
81
+ # over the original, so an interrupted run cannot leave a half-written file behind.
82
+ edit_settings() {
83
+ ACTION="$1" URL="${2:-}" TOKEN="${3:-}" SETTINGS="$SETTINGS" python3 <<'PY'
84
+ import json, os, tempfile
85
+
86
+ path = os.environ["SETTINGS"]
87
+ action = os.environ["ACTION"]
88
+
89
+ with open(path) as fh:
90
+ settings = json.load(fh)
91
+
92
+ env = settings.setdefault("env", {})
93
+ if action == "on":
94
+ env["ANTHROPIC_BASE_URL"] = os.environ["URL"]
95
+ env["ANTHROPIC_AUTH_TOKEN"] = os.environ["TOKEN"]
96
+ # claude.ai connectors rely on claude.ai OAuth; a proxy auth source means
97
+ # Claude Code is no longer authenticated against that account, so the
98
+ # connectors would be unreachable anyway -- this just says so up front.
99
+ settings["disableClaudeAiConnectors"] = True
100
+ else:
101
+ env.pop("ANTHROPIC_BASE_URL", None)
102
+ env.pop("ANTHROPIC_AUTH_TOKEN", None)
103
+ # Leave no empty env block behind if this script put everything in it.
104
+ if not env:
105
+ settings.pop("env")
106
+ settings.pop("disableClaudeAiConnectors", None)
107
+
108
+ directory = os.path.dirname(path) or "."
109
+ with tempfile.NamedTemporaryFile("w", dir=directory, delete=False) as tmp:
110
+ json.dump(settings, tmp, indent=2)
111
+ tmp.write("\n")
112
+ temp_path = tmp.name
113
+ os.chmod(temp_path, 0o600)
114
+ os.replace(temp_path, path)
115
+ PY
116
+ }
117
+
118
+ # Remove any existing line for the given var name from .env.
119
+ strip_env_var() {
120
+ local var="$1"
121
+ [ -f "$ENV_FILE" ] || return 0
122
+ grep -v -e "^${var}=" "$ENV_FILE" > "$ENV_FILE.tmp" || true
123
+ mv "$ENV_FILE.tmp" "$ENV_FILE"
124
+ }
125
+
126
+ # Merge or delete the two penny-compare native-proxy keys in .env, preserving any
127
+ # other lines already in the file.
128
+ edit_native_env() {
129
+ local action="$1" url="${2:-}" token="${3:-}"
130
+ touch "$ENV_FILE"
131
+ strip_env_var "$NATIVE_URL_VAR"
132
+ strip_env_var "$NATIVE_TOKEN_VAR"
133
+ if [ "$action" = "on" ]; then
134
+ printf '%s=%s\n' "$NATIVE_URL_VAR" "$url" >> "$ENV_FILE"
135
+ printf '%s=%s\n' "$NATIVE_TOKEN_VAR" "$token" >> "$ENV_FILE"
136
+ fi
137
+ chmod 600 "$ENV_FILE"
138
+ }
139
+
140
+ show_status() {
141
+ SETTINGS="$SETTINGS" python3 <<'PY'
142
+ import json, os
143
+
144
+ with open(os.environ["SETTINGS"]) as fh:
145
+ settings = json.load(fh)
146
+
147
+ env = settings.get("env", {})
148
+ url = env.get("ANTHROPIC_BASE_URL")
149
+ token = env.get("ANTHROPIC_AUTH_TOKEN")
150
+ if url:
151
+ shown = f"{token[:6]}...{token[-4:]}" if token and len(token) > 12 else ("set" if token else "MISSING")
152
+ connectors = "disabled" if settings.get("disableClaudeAiConnectors") else "enabled"
153
+ print(f"claude (settings.json): ON\n ANTHROPIC_BASE_URL: {url}\n ANTHROPIC_AUTH_TOKEN: {shown}\n claude.ai connectors: {connectors}")
154
+ else:
155
+ print("claude (settings.json): OFF (Claude Code talks to Anthropic directly)")
156
+ PY
157
+
158
+ if [ ! -f "$ENV_FILE" ]; then
159
+ echo "penny-compare --native-proxy (.env): OFF (no .env file)"
160
+ return
161
+ fi
162
+
163
+ local url token
164
+ url="$(grep -e "^${NATIVE_URL_VAR}=" "$ENV_FILE" | tail -n1 | cut -d= -f2-)"
165
+ token="$(grep -e "^${NATIVE_TOKEN_VAR}=" "$ENV_FILE" | tail -n1 | cut -d= -f2-)"
166
+
167
+ if [ -n "$url" ]; then
168
+ local shown="MISSING"
169
+ if [ -n "$token" ]; then
170
+ if [ "${#token}" -gt 12 ]; then
171
+ shown="${token:0:6}...${token: -4}"
172
+ else
173
+ shown="set"
174
+ fi
175
+ fi
176
+ echo "penny-compare --native-proxy (.env): ON"
177
+ echo " $NATIVE_URL_VAR: $url"
178
+ echo " $NATIVE_TOKEN_VAR: $shown"
179
+ else
180
+ echo "penny-compare --native-proxy (.env): OFF"
181
+ fi
182
+ }
183
+
184
+ case "${1:-status}" in
185
+ on)
186
+ url="${2:-$DEFAULT_URL}"
187
+ [ -n "$url" ] || die "usage: $(basename "$0") on <proxy-url> (or set CLAUDE_PROXY_URL)"
188
+
189
+ # Falls back to whatever token is already saved in .env from the last
190
+ # `on` run, so a bare `./claude-proxy.sh on` reuses it without retyping.
191
+ token="${CLAUDE_PROXY_TOKEN:-${PENNY_COMPARE_NATIVE_AUTH_TOKEN:-}}"
192
+ if [ -z "$token" ]; then
193
+ # -s so the key does not linger on screen or in scrollback.
194
+ read -rsp "Proxy API key: " token < /dev/tty
195
+ echo
196
+ fi
197
+ [ -n "$token" ] || die "no token given"
198
+
199
+ backup
200
+ edit_settings on "$url" "$token"
201
+ edit_native_env on "$url" "$token"
202
+ echo "Claude Code -> $url"
203
+ echo "penny-compare --native-proxy -> $url"
204
+ echo "Restart any running claude/penny-compare session to pick this up."
205
+ ;;
206
+ off)
207
+ backup
208
+ edit_settings off
209
+ edit_native_env off
210
+ echo "Claude Code -> Anthropic direct"
211
+ echo "penny-compare --native-proxy -> Anthropic direct"
212
+ echo "Restart any running claude/penny-compare session to pick this up."
213
+ ;;
214
+ status)
215
+ show_status
216
+ ;;
217
+ *)
218
+ die "usage: $(basename "$0") {on [url]|off|status}"
219
+ ;;
220
+ esac