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 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
- - Penny Core: `pennyrouter/penny-core:premium`
40
- - Penny Speed: `pennyrouter/penny-speed:standard`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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
@@ -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 record = await harness.install({
146
- apiKey: claim.api_key,
147
- gatewayBaseUrl: claim.gateway_base_url,
148
- plan,
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
- speed: "Penny Speed",
7
- core: "Penny Core",
8
- power: "Penny Power",
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.power;
44
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.core;
45
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.speed;
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.speed ||
130
- value === CLAUDE_CODE_MODELS.core ||
131
- value === CLAUDE_CODE_MODELS.power ||
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
+ }