pennyrouter 0.1.6 → 0.1.8
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 +54 -5
- package/package.json +1 -1
- package/src/cli.js +224 -7
- package/src/harnesses/claude-code.js +68 -12
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ status checks, and uninstall metadata.
|
|
|
11
11
|
```bash
|
|
12
12
|
npx pennyrouter install
|
|
13
13
|
npx pennyrouter install --existing-account
|
|
14
|
+
npx pennyrouter auth anthropic
|
|
14
15
|
npx pennyrouter install --harness claude-code,cline,opencode,codex,windsurf,zed,codegpt,aider
|
|
15
16
|
npx pennyrouter disable
|
|
16
17
|
npx pennyrouter enable
|
|
@@ -21,6 +22,10 @@ npx pennyrouter status
|
|
|
21
22
|
With no `--harness` flag, the interactive installer shows a checkbox list. Tools with
|
|
22
23
|
detected configs start checked; press Enter to accept or type tool IDs to toggle them.
|
|
23
24
|
|
|
25
|
+
When Claude Code is selected, the installer also offers an optional Claude Pro/Max
|
|
26
|
+
subscription-auth step. Users can skip it during install and enable it later with
|
|
27
|
+
`@penny auth` or `npx pennyrouter auth anthropic`.
|
|
28
|
+
|
|
24
29
|
## Supported Tools
|
|
25
30
|
|
|
26
31
|
- Claude Code
|
|
@@ -34,12 +39,56 @@ detected configs start checked; press Enter to accept or type tool IDs to toggle
|
|
|
34
39
|
|
|
35
40
|
## Penny Models
|
|
36
41
|
|
|
37
|
-
Tools that expose a model picker get four curated choices
|
|
42
|
+
Tools that expose a model picker (e.g. Claude Code `/model`) get four curated choices. The three
|
|
43
|
+
named-model choices PIN the trunk to that exact model — you pick the model, PennyRouter still cuts
|
|
44
|
+
the bill via its side machinery (cache/compaction/swallowed background calls) without switching the
|
|
45
|
+
model you chose. "Penny Custom" is the dynamic option: PennyRouter cost-routes each turn.
|
|
46
|
+
|
|
47
|
+
- Penny Opus: pins `anthropic/claude-opus-4-8`
|
|
48
|
+
- Penny Sonnet: pins `anthropic/claude-sonnet-4-6`
|
|
49
|
+
- Penny Haiku: pins `anthropic/claude-haiku-4-5-20251001`
|
|
50
|
+
- Penny Custom: `pennyrouter/auto` — dynamic routing on the bundle/profile saved in the user's account
|
|
51
|
+
|
|
52
|
+
## Claude Pro/Max Subscription Auth
|
|
53
|
+
|
|
54
|
+
When installing PennyRouter for Claude Code, the interactive installer offers to route
|
|
55
|
+
Anthropic-native Claude turns through the user's own Claude subscription token. To force this
|
|
56
|
+
in non-interactive installs:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
If Claude Code is already logged in to a Pro/Max subscription, use Claude Code's token setup
|
|
63
|
+
flow:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
claude setup-token
|
|
67
|
+
ANTHROPIC_AUTH_TOKEN=<token> npx pennyrouter auth anthropic
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Other token sources are supported:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npx pennyrouter auth anthropic --token <token>
|
|
74
|
+
npx pennyrouter auth anthropic --token-command '<command that prints the token>'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
If the separate Anthropic `ant` CLI is installed and logged in, PennyRouter can use it as a
|
|
78
|
+
convenience fallback:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
ant auth login
|
|
82
|
+
npx pennyrouter auth anthropic
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The command updates Claude Code so `x-api-key` carries the PennyRouter key and
|
|
86
|
+
`Authorization: Bearer ...` carries the Anthropic OAuth token. The gateway forwards that
|
|
87
|
+
bearer token to Anthropic with the required OAuth beta header and does not deduct
|
|
88
|
+
PennyRouter credit for those Anthropic-native upstream calls.
|
|
38
89
|
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
- Penny Power: `pennyrouter/penny-power:premium`
|
|
42
|
-
- Penny Custom: `pennyrouter/auto`, using the bundle/profile saved in the user's PennyRouter account
|
|
90
|
+
If the stored token expires during a long-running session, refresh the token source and rerun
|
|
91
|
+
`npx pennyrouter auth anthropic`.
|
|
43
92
|
|
|
44
93
|
## Agent Flow
|
|
45
94
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import { emitKeypressEvents } from "node:readline";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { stdin as input, stdout as output } from "node:process";
|
|
5
5
|
import { aiderHarness } from "./harnesses/aider.js";
|
|
6
|
-
import { claudeCodeHarness } from "./harnesses/claude-code.js";
|
|
6
|
+
import { claudeCodeHarness, readClaudeCodePennyRouterKey } from "./harnesses/claude-code.js";
|
|
7
7
|
import { clineHarness } from "./harnesses/cline.js";
|
|
8
8
|
import { codeGptHarness } from "./harnesses/codegpt.js";
|
|
9
9
|
import { codexHarness } from "./harnesses/codex.js";
|
|
@@ -52,6 +52,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
52
52
|
return;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
if (command === "auth") {
|
|
56
|
+
await authProvider(flags);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
55
60
|
if (command === "uninstall") {
|
|
56
61
|
await uninstall(flags);
|
|
57
62
|
return;
|
|
@@ -79,6 +84,7 @@ function parseArgs(argv) {
|
|
|
79
84
|
const command = argv[0] && !argv[0].startsWith("-") ? argv[0] : "install";
|
|
80
85
|
const rest = command === argv[0] ? argv.slice(1) : argv;
|
|
81
86
|
const flags = {};
|
|
87
|
+
flags.args = [];
|
|
82
88
|
|
|
83
89
|
for (let i = 0; i < rest.length; i += 1) {
|
|
84
90
|
const arg = rest[i];
|
|
@@ -88,16 +94,105 @@ function parseArgs(argv) {
|
|
|
88
94
|
else if (arg === "--dry-run") flags.dryRun = true;
|
|
89
95
|
else if (arg === "--no-browser") flags.noBrowser = true;
|
|
90
96
|
else if (arg === "--existing-account") flags.existingAccount = true;
|
|
97
|
+
else if (arg === "--anthropic-auth") flags.anthropicAuth = true;
|
|
98
|
+
else if (arg === "--no-anthropic-auth") flags.noAnthropicAuth = true;
|
|
99
|
+
else if (arg === "--penny-key") flags.pennyKey = rest[++i] || "";
|
|
100
|
+
else if (arg.startsWith("--penny-key=")) flags.pennyKey = arg.slice("--penny-key=".length);
|
|
101
|
+
else if (arg === "--token") flags.token = rest[++i] || "";
|
|
102
|
+
else if (arg.startsWith("--token=")) flags.token = arg.slice("--token=".length);
|
|
103
|
+
else if (arg === "--token-command") flags.tokenCommand = rest[++i] || "";
|
|
104
|
+
else if (arg.startsWith("--token-command=")) flags.tokenCommand = arg.slice("--token-command=".length);
|
|
91
105
|
else if (arg === "--harness") flags.harness = rest[++i] || "";
|
|
92
106
|
else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
|
|
93
107
|
else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
|
|
94
108
|
else if (arg.startsWith("--app-url=")) flags.appUrl = arg.slice("--app-url=".length);
|
|
109
|
+
else if (arg === "--gateway-base-url") flags.gatewayBaseUrl = rest[++i] || "";
|
|
110
|
+
else if (arg.startsWith("--gateway-base-url=")) flags.gatewayBaseUrl = arg.slice("--gateway-base-url=".length);
|
|
111
|
+
// --local: point the installed harness at a locally-running gateway (default
|
|
112
|
+
// http://localhost:8400), for developing/testing against your own gateway. Auth still goes
|
|
113
|
+
// to prod (pennyrouter.com) so you get a real key; only the harness's serving URL is local.
|
|
114
|
+
// `--local=<url>` overrides the port/host.
|
|
115
|
+
else if (arg === "--local") flags.gatewayBaseUrl = "http://localhost:8400";
|
|
116
|
+
else if (arg.startsWith("--local=")) flags.gatewayBaseUrl = arg.slice("--local=".length);
|
|
117
|
+
else if (!arg.startsWith("-")) flags.args.push(arg);
|
|
95
118
|
else throw new Error(`Unknown option "${arg}". Run "pennyrouter help".`);
|
|
96
119
|
}
|
|
97
120
|
|
|
98
121
|
return { command, flags };
|
|
99
122
|
}
|
|
100
123
|
|
|
124
|
+
async function authProvider(flags) {
|
|
125
|
+
const provider = (flags.args[0] || "anthropic").toLowerCase();
|
|
126
|
+
if (provider !== "anthropic" && provider !== "claude") {
|
|
127
|
+
throw new Error(`Unknown auth provider "${provider}". Try "pennyrouter auth anthropic".`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (flags.dryRun) {
|
|
131
|
+
console.log("Will configure Claude Code for PennyRouter + Anthropic OAuth dual auth.");
|
|
132
|
+
console.log("Dry run only. No files changed.");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const pennyKey = flags.pennyKey || await readClaudeCodePennyRouterKey();
|
|
137
|
+
if (!pennyKey) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"No PennyRouter key found in Claude Code settings. Run `pennyrouter install --harness claude-code` first, or pass `--penny-key pr-...`.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const tokenResult = readAnthropicOAuthToken(flags);
|
|
144
|
+
const token = tokenResult.token;
|
|
145
|
+
if (!token) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
"No Anthropic OAuth token found. Run `claude setup-token` and pass it with `--token`, set ANTHROPIC_AUTH_TOKEN, or pass `--token-command <cmd>`.",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
|
|
152
|
+
const record = await claudeCodeHarness.installAnthropicOAuth({
|
|
153
|
+
pennyKey,
|
|
154
|
+
anthropicAuthToken: token,
|
|
155
|
+
gatewayBaseUrl,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const manifest = await loadManifest();
|
|
159
|
+
upsertManifestRecord(manifest, {
|
|
160
|
+
...record,
|
|
161
|
+
anthropic_oauth: true,
|
|
162
|
+
token_source: tokenResult.source,
|
|
163
|
+
});
|
|
164
|
+
await saveManifest(manifest);
|
|
165
|
+
|
|
166
|
+
console.log("Configured Claude Code for PennyRouter + Anthropic OAuth.");
|
|
167
|
+
console.log("Anthropic-native Claude turns will use your Claude subscription token upstream.");
|
|
168
|
+
console.log("If Claude auth expires, refresh the token source and run `pennyrouter auth anthropic` again.");
|
|
169
|
+
console.log(`Config: ${record.config_path}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readAnthropicOAuthToken(flags = {}) {
|
|
173
|
+
if (flags.token) return { token: flags.token.trim(), source: "flag" };
|
|
174
|
+
if (flags.tokenCommand) {
|
|
175
|
+
const result = spawnSync(flags.tokenCommand, {
|
|
176
|
+
encoding: "utf8",
|
|
177
|
+
shell: true,
|
|
178
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
179
|
+
});
|
|
180
|
+
if (result.status === 0) {
|
|
181
|
+
return { token: String(result.stdout || "").trim(), source: "token-command" };
|
|
182
|
+
}
|
|
183
|
+
return { token: "", source: "token-command" };
|
|
184
|
+
}
|
|
185
|
+
if (process.env.ANTHROPIC_AUTH_TOKEN) {
|
|
186
|
+
return { token: process.env.ANTHROPIC_AUTH_TOKEN.trim(), source: "env" };
|
|
187
|
+
}
|
|
188
|
+
const result = spawnSync("ant", ["auth", "print-credentials", "--access-token"], {
|
|
189
|
+
encoding: "utf8",
|
|
190
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
191
|
+
});
|
|
192
|
+
if (result.status !== 0) return { token: "", source: "none" };
|
|
193
|
+
return { token: String(result.stdout || "").trim(), source: "ant" };
|
|
194
|
+
}
|
|
195
|
+
|
|
101
196
|
async function install(flags) {
|
|
102
197
|
const selected = await selectHarnesses(flags);
|
|
103
198
|
if (selected.length === 0) {
|
|
@@ -112,6 +207,10 @@ async function install(flags) {
|
|
|
112
207
|
for (const harness of selected) {
|
|
113
208
|
console.log(`Will install on ${harness.name}${installNoteForHarness(harness.id)}.`);
|
|
114
209
|
}
|
|
210
|
+
if (flags.gatewayBaseUrl) console.log(`Serving gateway: ${flags.gatewayBaseUrl}`);
|
|
211
|
+
if (selected.some((harness) => harness.id === "claude-code")) {
|
|
212
|
+
console.log("Claude Code subscription auth prompt would be shown unless skipped.");
|
|
213
|
+
}
|
|
115
214
|
console.log("Dry run only. No files changed.");
|
|
116
215
|
return;
|
|
117
216
|
}
|
|
@@ -136,17 +235,32 @@ async function install(flags) {
|
|
|
136
235
|
console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
|
|
137
236
|
console.log("");
|
|
138
237
|
|
|
238
|
+
const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(flags, selected);
|
|
239
|
+
|
|
139
240
|
const manifest = await loadManifest();
|
|
140
241
|
const records = [];
|
|
141
242
|
const failures = [];
|
|
142
243
|
for (const harness of selected) {
|
|
143
244
|
await runHarnessJob(failures, harness.id, "install", async () => {
|
|
144
245
|
const plan = await harness.planInstall();
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
246
|
+
const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
|
|
247
|
+
const record = harness.id === "claude-code" && claudeAnthropicAuth
|
|
248
|
+
? await harness.installAnthropicOAuth({
|
|
249
|
+
pennyKey: claim.api_key,
|
|
250
|
+
anthropicAuthToken: claudeAnthropicAuth.token,
|
|
251
|
+
gatewayBaseUrl,
|
|
252
|
+
})
|
|
253
|
+
: await harness.install({
|
|
254
|
+
apiKey: claim.api_key,
|
|
255
|
+
// An explicit --local / --gateway-base-url overrides the URL the server hands back
|
|
256
|
+
// (which points at prod) — so a --local install serves through the local gateway.
|
|
257
|
+
gatewayBaseUrl,
|
|
258
|
+
plan,
|
|
259
|
+
});
|
|
260
|
+
if (harness.id === "claude-code" && claudeAnthropicAuth) {
|
|
261
|
+
record.anthropic_oauth = true;
|
|
262
|
+
record.token_source = claudeAnthropicAuth.source;
|
|
263
|
+
}
|
|
150
264
|
records.push(record);
|
|
151
265
|
upsertManifestRecord(manifest, record);
|
|
152
266
|
await saveManifest(manifest);
|
|
@@ -164,6 +278,56 @@ async function install(flags) {
|
|
|
164
278
|
reportFailures(failures);
|
|
165
279
|
}
|
|
166
280
|
|
|
281
|
+
async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
|
|
282
|
+
if (!selected.some((harness) => harness.id === "claude-code")) return null;
|
|
283
|
+
if (flags.noAnthropicAuth) return null;
|
|
284
|
+
|
|
285
|
+
if (flags.token || flags.tokenCommand || process.env.ANTHROPIC_AUTH_TOKEN) {
|
|
286
|
+
const tokenResult = readAnthropicOAuthToken(flags);
|
|
287
|
+
if (tokenResult.token) return tokenResult;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (flags.yes || !input.isTTY || !output.isTTY) {
|
|
291
|
+
if (flags.anthropicAuth) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
"Claude subscription auth was requested, but no Anthropic bearer token was found. Pass --token, --token-command, or ANTHROPIC_AUTH_TOKEN.",
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!flags.anthropicAuth) {
|
|
300
|
+
console.log("Claude Code subscription auth");
|
|
301
|
+
console.log("PennyRouter can use your Claude Pro/Max subscription for Anthropic Claude calls.");
|
|
302
|
+
console.log("PennyRouter still handles routing, optimization, and @penny commands.");
|
|
303
|
+
console.log("");
|
|
304
|
+
const ok = await confirm("Enable Claude Pro/Max subscription auth for Claude Code?");
|
|
305
|
+
if (!ok) {
|
|
306
|
+
console.log("Skipped Claude subscription auth. Enable later with `@penny auth` or `pennyrouter auth anthropic`.");
|
|
307
|
+
console.log("");
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const tokenResult = readAnthropicOAuthToken({ tokenCommand: flags.tokenCommand });
|
|
313
|
+
if (tokenResult.token) return tokenResult;
|
|
314
|
+
|
|
315
|
+
console.log("");
|
|
316
|
+
if (commandExists("claude")) {
|
|
317
|
+
console.log("Run `claude setup-token` in another terminal, then paste the token here.");
|
|
318
|
+
} else {
|
|
319
|
+
console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
|
|
320
|
+
}
|
|
321
|
+
const token = await promptSecretish("Anthropic token (blank to skip): ");
|
|
322
|
+
if (!token) {
|
|
323
|
+
console.log("Skipped Claude subscription auth. Enable later with `@penny auth` or `pennyrouter auth anthropic`.");
|
|
324
|
+
console.log("");
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
console.log("");
|
|
328
|
+
return { token, source: "prompt" };
|
|
329
|
+
}
|
|
330
|
+
|
|
167
331
|
async function disable(flags) {
|
|
168
332
|
const manifest = await loadManifest();
|
|
169
333
|
if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
|
|
@@ -410,6 +574,7 @@ async function status() {
|
|
|
410
574
|
if (record.disabled_snapshot_path) console.log(` disabled snapshot: ${record.disabled_snapshot_path}`);
|
|
411
575
|
if (record.installed_at) console.log(` installed: ${record.installed_at}`);
|
|
412
576
|
if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
|
|
577
|
+
if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
|
|
413
578
|
if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
|
|
414
579
|
}
|
|
415
580
|
}
|
|
@@ -650,8 +815,57 @@ async function confirm(question) {
|
|
|
650
815
|
return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
|
|
651
816
|
}
|
|
652
817
|
|
|
818
|
+
async function promptSecretish(question) {
|
|
819
|
+
if (!input.isTTY || !output.isTTY) {
|
|
820
|
+
const rl = createInterface({ input, output });
|
|
821
|
+
const answer = await rl.question(question);
|
|
822
|
+
rl.close();
|
|
823
|
+
return answer.trim();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const previousRawMode = input.isRaw;
|
|
827
|
+
emitKeypressEvents(input);
|
|
828
|
+
input.setRawMode(true);
|
|
829
|
+
input.resume();
|
|
830
|
+
output.write(question);
|
|
831
|
+
|
|
832
|
+
return new Promise((resolve, reject) => {
|
|
833
|
+
let value = "";
|
|
834
|
+
const cleanup = () => {
|
|
835
|
+
input.off("keypress", onKeypress);
|
|
836
|
+
input.setRawMode(Boolean(previousRawMode));
|
|
837
|
+
input.pause();
|
|
838
|
+
output.write("\n");
|
|
839
|
+
};
|
|
840
|
+
const onKeypress = (str, key = {}) => {
|
|
841
|
+
if (key.ctrl && key.name === "c") {
|
|
842
|
+
cleanup();
|
|
843
|
+
reject(new Error("Cancelled."));
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (key.name === "return" || key.name === "enter") {
|
|
847
|
+
cleanup();
|
|
848
|
+
resolve(value.trim());
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (key.name === "backspace") {
|
|
852
|
+
value = value.slice(0, -1);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (str) value += str;
|
|
856
|
+
};
|
|
857
|
+
input.on("keypress", onKeypress);
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function commandExists(command) {
|
|
862
|
+
const result = spawnSync(command, ["--version"], { stdio: "ignore" });
|
|
863
|
+
return result.status === 0;
|
|
864
|
+
}
|
|
865
|
+
|
|
653
866
|
function installNoteForRecord(record) {
|
|
654
867
|
if (record.manual_required) return " (manual setup shown)";
|
|
868
|
+
if (record.anthropic_oauth) return " (Claude subscription auth)";
|
|
655
869
|
return installNoteForHarness(record.harness);
|
|
656
870
|
}
|
|
657
871
|
|
|
@@ -673,7 +887,8 @@ function printHelp() {
|
|
|
673
887
|
console.log(`PennyRouter CLI
|
|
674
888
|
|
|
675
889
|
Usage:
|
|
676
|
-
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account]
|
|
890
|
+
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]]
|
|
891
|
+
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL]
|
|
677
892
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
678
893
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
679
894
|
pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
@@ -682,6 +897,8 @@ Usage:
|
|
|
682
897
|
Examples:
|
|
683
898
|
npx pennyrouter install
|
|
684
899
|
npx pennyrouter install --existing-account
|
|
900
|
+
npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
|
|
901
|
+
npx pennyrouter auth anthropic
|
|
685
902
|
npx pennyrouter install --all
|
|
686
903
|
npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
|
|
687
904
|
npx pennyrouter disable
|
|
@@ -2,12 +2,23 @@ import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile
|
|
|
2
2
|
|
|
3
3
|
const CONFIG_PATH = "~/.claude/settings.json";
|
|
4
4
|
const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
|
|
5
|
+
// Values written into Claude Code's four model slots. CC displays a slot's VALUE verbatim in
|
|
6
|
+
// its /model menu AND sends it as the request `model`, so each value is both the menu label
|
|
7
|
+
// and the gateway's routing key. The three concrete Claude slots carry friendly PINNED labels
|
|
8
|
+
// ("Penny Opus/Sonnet/Haiku") that the gateway maps to that exact model — the user's pick
|
|
9
|
+
// runs on the model they chose, no dynamic tier switching on the trunk (background/utility
|
|
10
|
+
// calls still route cheap, which is where the savings come from). The Fable slot is the
|
|
11
|
+
// DYNAMIC "Penny Custom" option: let PennyRouter cost-route. Legacy "Penny Speed/Core/Power"
|
|
12
|
+
// installs (dynamic bundles) are still recognized for uninstall (see isPennyRouterModelValue).
|
|
5
13
|
const CLAUDE_CODE_MODELS = {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
custom: "Penny Custom",
|
|
14
|
+
haiku: "Penny Haiku", // -> pinned anthropic/claude-haiku-4-5-20251001
|
|
15
|
+
sonnet: "Penny Sonnet", // -> pinned anthropic/claude-sonnet-4-6
|
|
16
|
+
opus: "Penny Opus", // -> pinned anthropic/claude-opus-4-8
|
|
17
|
+
custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
|
|
10
18
|
};
|
|
19
|
+
// Retired dynamic-bundle labels (pre-pinned-trunk). Not written by a fresh install; matched
|
|
20
|
+
// only so uninstall/detect still recognize and clean up an older install.
|
|
21
|
+
const LEGACY_PENNY_LABELS = ["Penny Speed", "Penny Core", "Penny Power"];
|
|
11
22
|
|
|
12
23
|
export const claudeCodeHarness = {
|
|
13
24
|
id: "claude-code",
|
|
@@ -40,9 +51,9 @@ export const claudeCodeHarness = {
|
|
|
40
51
|
delete current.env.ANTHROPIC_API_KEY;
|
|
41
52
|
// Claude Code displays these env values directly in its model picker; the
|
|
42
53
|
// gateway decodes the friendly labels into bundle/profile overrides.
|
|
43
|
-
current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.
|
|
44
|
-
current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.
|
|
45
|
-
current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.
|
|
54
|
+
current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
|
|
55
|
+
current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
|
|
56
|
+
current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
|
|
46
57
|
current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.custom;
|
|
47
58
|
|
|
48
59
|
await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
|
|
@@ -66,12 +77,47 @@ export const claudeCodeHarness = {
|
|
|
66
77
|
};
|
|
67
78
|
},
|
|
68
79
|
|
|
80
|
+
async installAnthropicOAuth({ pennyKey, anthropicAuthToken, gatewayBaseUrl }) {
|
|
81
|
+
const backupPath = await backupFile(CONFIG_PATH, "claude-code-anthropic-auth");
|
|
82
|
+
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
83
|
+
current.env ||= {};
|
|
84
|
+
current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
|
|
85
|
+
// Dual-auth mode: x-api-key authenticates the local user to PennyRouter; the
|
|
86
|
+
// Authorization bearer token is forwarded by the gateway to Anthropic.
|
|
87
|
+
current.env.ANTHROPIC_API_KEY = pennyKey;
|
|
88
|
+
current.env.ANTHROPIC_AUTH_TOKEN = anthropicAuthToken;
|
|
89
|
+
current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
|
|
90
|
+
current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
|
|
91
|
+
current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
|
|
92
|
+
current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.custom;
|
|
93
|
+
|
|
94
|
+
await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
|
|
95
|
+
return {
|
|
96
|
+
harness: "claude-code",
|
|
97
|
+
config_path: compactHome(expandHome(CONFIG_PATH)),
|
|
98
|
+
backup_path: backupPath ? compactHome(backupPath) : null,
|
|
99
|
+
installed_at: new Date().toISOString(),
|
|
100
|
+
restart: "restart Claude Code",
|
|
101
|
+
changes: {
|
|
102
|
+
env_keys: [
|
|
103
|
+
"ANTHROPIC_BASE_URL",
|
|
104
|
+
"ANTHROPIC_API_KEY",
|
|
105
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
106
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
107
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
108
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
109
|
+
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
|
|
69
115
|
async uninstall(record) {
|
|
70
116
|
const current = (await readJsonFile(record.config_path, null)) || {};
|
|
71
117
|
if (hasPennyRouterEnv(current.env)) {
|
|
72
118
|
delete current.env.ANTHROPIC_BASE_URL;
|
|
73
119
|
const tok = current.env.ANTHROPIC_AUTH_TOKEN;
|
|
74
|
-
if (typeof tok === "string" && tok.startsWith("pr-")) {
|
|
120
|
+
if (typeof tok === "string" && (tok.startsWith("pr-") || record.anthropic_oauth)) {
|
|
75
121
|
delete current.env.ANTHROPIC_AUTH_TOKEN;
|
|
76
122
|
}
|
|
77
123
|
// Legacy installs stored the key under ANTHROPIC_API_KEY.
|
|
@@ -126,9 +172,19 @@ function isPennyRouterBaseUrl(value) {
|
|
|
126
172
|
function isPennyRouterModelValue(value) {
|
|
127
173
|
return (
|
|
128
174
|
isPennyRouterValue(value) ||
|
|
129
|
-
value === CLAUDE_CODE_MODELS.
|
|
130
|
-
value === CLAUDE_CODE_MODELS.
|
|
131
|
-
value === CLAUDE_CODE_MODELS.
|
|
132
|
-
value === CLAUDE_CODE_MODELS.custom
|
|
175
|
+
value === CLAUDE_CODE_MODELS.haiku ||
|
|
176
|
+
value === CLAUDE_CODE_MODELS.sonnet ||
|
|
177
|
+
value === CLAUDE_CODE_MODELS.opus ||
|
|
178
|
+
value === CLAUDE_CODE_MODELS.custom ||
|
|
179
|
+
LEGACY_PENNY_LABELS.includes(value)
|
|
133
180
|
);
|
|
134
181
|
}
|
|
182
|
+
|
|
183
|
+
export async function readClaudeCodePennyRouterKey() {
|
|
184
|
+
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
185
|
+
const env = current.env || {};
|
|
186
|
+
for (const value of [env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN, current["anthropic.apiKey"]]) {
|
|
187
|
+
if (typeof value === "string" && value.startsWith("pr-")) return value;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|