pennyrouter 0.3.19 → 0.3.21
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 +13 -2
- package/package.json +2 -1
- package/src/cli.js +13 -0
- package/src/launch.js +60 -4
- package/src/launch.test.js +8 -0
- package/standalone/.env.example +8 -0
- package/standalone/README-original.md +79 -0
- package/standalone/README.md +60 -0
- package/standalone/claude-proxy.sh +220 -0
- package/standalone/codex-proxy.sh +208 -0
- package/standalone/delta.py +41 -0
- package/standalone/demo_example.md +7 -0
- package/standalone/hud.py +122 -0
- package/standalone/install.sh +58 -0
- package/standalone/pane.py +430 -0
- package/standalone/penny-compare +318 -0
- package/standalone/penny-compare-viewer +10 -0
- package/standalone/penny-session +103 -0
- package/standalone/pricing.py +139 -0
- package/standalone/report.py +137 -0
- package/standalone/usage.py +575 -0
- package/standalone/viewer.py +232 -0
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
|
|
@@ -46,9 +47,9 @@ ones:
|
|
|
46
47
|
"harnesses": ["claude-code", "codex"],
|
|
47
48
|
"gatewayBaseUrl": "https://penny-router.apps-dev.gtri.org",
|
|
48
49
|
"claudeFunding": ["anthropic-proxy"],
|
|
49
|
-
"anthropicProxyBaseUrl": "https://litellm.core.gtri.org
|
|
50
|
+
"anthropicProxyBaseUrl": "https://litellm.core.gtri.org",
|
|
50
51
|
"codexFunding": ["openai-proxy"],
|
|
51
|
-
"openaiProxyBaseUrl": "https://litellm.core.gtri.org
|
|
52
|
+
"openaiProxyBaseUrl": "https://litellm.core.gtri.org"
|
|
52
53
|
}
|
|
53
54
|
```
|
|
54
55
|
|
|
@@ -79,6 +80,16 @@ OpenRouter key. PennyRouter-funded provider keys are never an installed-harness
|
|
|
79
80
|
|
|
80
81
|
## Explicit Penny Commands
|
|
81
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
|
+
|
|
82
93
|
On macOS and Linux, a normal install adds a small `penny` launcher under `~/.local/bin`.
|
|
83
94
|
On Windows it installs `penny.cmd` under `%LOCALAPPDATA%\\PennyRouter\\bin` and adds that
|
|
84
95
|
directory to the user PATH:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pennyrouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
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;
|
|
@@ -2436,6 +2448,7 @@ Usage:
|
|
|
2436
2448
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
2437
2449
|
pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
|
|
2438
2450
|
pennyrouter status
|
|
2451
|
+
pennyrouter compare install or refresh Penny Compare
|
|
2439
2452
|
pennyrouter profiles [name]
|
|
2440
2453
|
pennyrouter mcp install|uninstall|status [--harness claude-code,codex]
|
|
2441
2454
|
|
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
|
-
|
|
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 }) {
|
|
@@ -775,9 +806,26 @@ export function startPennyTerminalTitle({ gatewayBaseUrl, apiKey, titleSessionId
|
|
|
775
806
|
};
|
|
776
807
|
}
|
|
777
808
|
|
|
778
|
-
async function writeClaudeLaunchSettings() {
|
|
809
|
+
async function writeClaudeLaunchSettings({ gatewayBaseUrl, apiKey } = {}) {
|
|
779
810
|
const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
|
|
780
|
-
|
|
811
|
+
// Claude Code gives values from --settings precedence over the parent process
|
|
812
|
+
// environment. Keep this overlay authoritative so a separately-managed
|
|
813
|
+
// ~/.claude/settings.json (for example scripts/proxy/claude-proxy.sh) cannot
|
|
814
|
+
// redirect a `penny claude` session away from the selected Penny profile.
|
|
815
|
+
const overlay = {
|
|
816
|
+
disableClaudeAiConnectors: true,
|
|
817
|
+
...(gatewayBaseUrl && apiKey
|
|
818
|
+
? {
|
|
819
|
+
env: {
|
|
820
|
+
ANTHROPIC_BASE_URL: gatewayBaseUrl,
|
|
821
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
822
|
+
// An empty value is treated as unset by Claude Code, and prevents a
|
|
823
|
+
// stale API key in the shared settings from creating dual auth.
|
|
824
|
+
ANTHROPIC_API_KEY: "",
|
|
825
|
+
},
|
|
826
|
+
}
|
|
827
|
+
: {}),
|
|
828
|
+
};
|
|
781
829
|
if (native.statusLine) overlay.statusLine = native.statusLine;
|
|
782
830
|
await configureClaudeCodeStatusline(overlay);
|
|
783
831
|
await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
|
|
@@ -843,7 +891,7 @@ export async function configurePennyHarness(harness, {
|
|
|
843
891
|
config.harnesses ||= {};
|
|
844
892
|
if (harness === "claude-code") {
|
|
845
893
|
await writeClaudeUserSkill();
|
|
846
|
-
await writeClaudeLaunchSettings();
|
|
894
|
+
await writeClaudeLaunchSettings({ gatewayBaseUrl, apiKey });
|
|
847
895
|
config.harnesses.claude = {
|
|
848
896
|
enabled: true,
|
|
849
897
|
subscription_auth: Boolean(subscriptionAuth),
|
|
@@ -1237,6 +1285,14 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
1237
1285
|
profile: config.profile,
|
|
1238
1286
|
});
|
|
1239
1287
|
}
|
|
1288
|
+
if (harness === "claude") {
|
|
1289
|
+
// Refresh on every launch so profile selection is honored immediately,
|
|
1290
|
+
// including --private, --cloud, and named profiles.
|
|
1291
|
+
await writeClaudeLaunchSettings({
|
|
1292
|
+
gatewayBaseUrl: config.gateway_base_url,
|
|
1293
|
+
apiKey: config.api_key,
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1240
1296
|
const models = harness === "opencode" ? await resolveOpenCodeLaunchModels(config) : null;
|
|
1241
1297
|
const invocation = buildPennyInvocation(harness, argv.slice(1), config, models);
|
|
1242
1298
|
const child = spawn(invocation.command, invocation.args, {
|
package/src/launch.test.js
CHANGED
|
@@ -123,6 +123,14 @@ async function main() {
|
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
assert.equal(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"), nativeClaude);
|
|
126
|
+
const claudeLaunchSettings = JSON.parse(
|
|
127
|
+
fs.readFileSync(claudeLaunchSettingsPath(), "utf8"),
|
|
128
|
+
);
|
|
129
|
+
assert.deepEqual(claudeLaunchSettings.env, {
|
|
130
|
+
ANTHROPIC_BASE_URL: "https://gateway.example/",
|
|
131
|
+
ANTHROPIC_AUTH_TOKEN: "pr-super-secret",
|
|
132
|
+
ANTHROPIC_API_KEY: "",
|
|
133
|
+
});
|
|
126
134
|
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
127
135
|
assert.equal(await fileMode(pennyConfigPath()), 0o600);
|
|
128
136
|
assert.equal(await fileMode(claudeLaunchSettingsPath()), 0o600);
|
|
@@ -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
|