pennyrouter 0.2.2 → 0.2.4

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
@@ -26,9 +26,10 @@ With no `--harness` flag, the interactive installer shows Claude Code and Codex.
26
26
  tools start checked. The additional legacy integrations remain available through an explicit
27
27
  `--harness <id>` selection or `--all`.
28
28
 
29
- When Claude Code is selected, the installer also offers an optional Claude Pro/Max
30
- subscription-auth step. Users can skip it during install and enable it later with
31
- `@penny auth` or `npx pennyrouter auth anthropic`.
29
+ When Claude Code is selected, the installer offers Claude subscription authorization. If it is
30
+ declined or unavailable, installation requires an Anthropic API key or OpenRouter key. Codex
31
+ uses an existing ChatGPT login; without one, installation requires an OpenAI API key or
32
+ OpenRouter key. PennyRouter-funded provider keys are never an installed-harness fallback.
32
33
 
33
34
  ## Explicit Penny Commands
34
35
 
@@ -60,7 +61,8 @@ does not currently expose equivalent project-independent custom slash commands.
60
61
  When Codex is already signed in with ChatGPT, the installer preserves that Codex-managed
61
62
  login and configures PennyRouter as an OpenAI-authenticated Responses proxy. Codex continues
62
63
  to own and refresh the OpenAI credentials; PennyRouter stores only its existing `pr-` account
63
- key in the managed provider block. API-key Codex installs continue to use PennyRouter credits.
64
+ key in the managed provider block. API-key Codex installs use the user's encrypted OpenAI or
65
+ OpenRouter key.
64
66
 
65
67
  To point Codex directly at a PennyRouter gateway running on localhost port 8400:
66
68
 
@@ -101,7 +103,7 @@ model you chose. "Penny Custom" is the dynamic option: PennyRouter cost-routes e
101
103
  - Penny Fable: pins `anthropic/claude-fable-5`
102
104
  - Penny Custom: `pennyrouter/auto` — dynamic routing on the bundle/profile saved in the user's account
103
105
 
104
- ## Claude Pro/Max Subscription Auth
106
+ ## Claude Subscription Auth
105
107
 
106
108
  When installing PennyRouter for Claude Code, the interactive installer offers to route
107
109
  Anthropic-native Claude turns through the user's own Claude subscription token. To force this
@@ -109,9 +111,12 @@ in non-interactive installs:
109
111
 
110
112
  ```bash
111
113
  npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
114
+ npx pennyrouter install --harness claude-code --anthropic-api-key <key>
115
+ npx pennyrouter install --harness codex --openai-api-key <key>
116
+ npx pennyrouter install --openrouter-api-key <key>
112
117
  ```
113
118
 
114
- If Claude Code is already logged in to a Pro/Max subscription, use Claude Code's token setup
119
+ If Claude Code is already logged in to a Claude subscription, use Claude Code's token setup
115
120
  flow:
116
121
 
117
122
  ```bash
@@ -139,6 +144,11 @@ PennyRouter `pr-` bearer in its settings; the gateway retrieves and forwards the
139
144
  with the required OAuth beta header and does not deduct PennyRouter credit for successfully
140
145
  served Anthropic-subscription calls.
141
146
 
147
+ On a transient subscription 429/529, the gateway retries the same subscription once. If it
148
+ still fails, it can fall back only to credentials owned by that user: Anthropic API then
149
+ OpenRouter for Claude, or OpenAI API then OpenRouter for Codex. If no such credential is
150
+ configured, the provider error is returned; PennyRouter never changes the payer to itself.
151
+
142
152
  PennyRouter also sets `disableClaudeAiConnectors: true` while its custom gateway is installed.
143
153
  Claude Code cannot load claude.ai organization connectors when gateway authentication takes
144
154
  precedence, and otherwise displays a persistent warning about that state. Making the disablement
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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
@@ -24,6 +24,7 @@ import {
24
24
  probeClaudeCodeGateway,
25
25
  sleep,
26
26
  storeAnthropicToken,
27
+ storeUpstreamCredential,
27
28
  } from "./session.js";
28
29
  import {
29
30
  backupFile,
@@ -60,6 +61,16 @@ const HARNESS_BY_ID = {
60
61
 
61
62
  const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
62
63
  const DEFAULT_INSTALL_HARNESS_IDS = ["claude-code", "codex"];
64
+ const CLAUDE_PROVIDER_OPTIONS = [
65
+ { id: "subscription", name: "Claude subscription" },
66
+ { id: "anthropic-api", name: "Anthropic API key" },
67
+ { id: "openrouter", name: "OpenRouter API key" },
68
+ ];
69
+ const CODEX_PROVIDER_OPTIONS = [
70
+ { id: "subscription", name: "ChatGPT subscription" },
71
+ { id: "openai-api", name: "OpenAI API key" },
72
+ { id: "openrouter", name: "OpenRouter API key" },
73
+ ];
63
74
 
64
75
  export async function main(argv = process.argv.slice(2)) {
65
76
  const { command, flags } = parseArgs(argv);
@@ -129,6 +140,12 @@ function parseArgs(argv) {
129
140
  else if (arg.startsWith("--token=")) flags.token = arg.slice("--token=".length);
130
141
  else if (arg === "--token-command") flags.tokenCommand = rest[++i] || "";
131
142
  else if (arg.startsWith("--token-command=")) flags.tokenCommand = arg.slice("--token-command=".length);
143
+ else if (arg === "--anthropic-api-key") flags.anthropicApiKey = rest[++i] || "";
144
+ else if (arg.startsWith("--anthropic-api-key=")) flags.anthropicApiKey = arg.slice("--anthropic-api-key=".length);
145
+ else if (arg === "--openai-api-key") flags.openaiApiKey = rest[++i] || "";
146
+ else if (arg.startsWith("--openai-api-key=")) flags.openaiApiKey = arg.slice("--openai-api-key=".length);
147
+ else if (arg === "--openrouter-api-key") flags.openrouterApiKey = rest[++i] || "";
148
+ else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
132
149
  else if (arg === "--harness") flags.harness = rest[++i] || "";
133
150
  else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
134
151
  else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
@@ -389,7 +406,7 @@ async function install(flags) {
389
406
  }
390
407
  if (flags.gatewayBaseUrl) console.log(`Serving gateway: ${flags.gatewayBaseUrl}`);
391
408
  if (selected.some((harness) => harness.id === "claude-code")) {
392
- console.log("Claude Code subscription auth prompt would be shown unless skipped.");
409
+ console.log("Claude Code provider picker would be shown unless providers were supplied by flags or environment.");
393
410
  }
394
411
  console.log("Dry run only. No files changed.");
395
412
  return;
@@ -411,17 +428,24 @@ async function install(flags) {
411
428
  console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
412
429
  console.log("");
413
430
 
414
- const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(flags, selected);
431
+ const claudeProviders = await selectClaudeFundingProviders(flags, selected);
432
+ const codexProviders = await selectCodexFundingProviders(flags, selected);
433
+ const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
434
+ flags, selected, claudeProviders.has("subscription"));
435
+ const funding = await configureUserFunding(
436
+ flags, selected, claudeAnthropicAuth, claudeProviders, codexProviders);
415
437
  const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
416
438
 
439
+ for (const credential of funding.credentials) {
440
+ await storeUpstreamCredential({
441
+ gatewayBaseUrl,
442
+ apiKey: claim.api_key,
443
+ provider: credential.provider,
444
+ token: credential.token,
445
+ });
446
+ }
447
+
417
448
  if (selected.some((harness) => harness.id === "claude-code")) {
418
- if (claudeAnthropicAuth) {
419
- await storeAnthropicToken({
420
- gatewayBaseUrl,
421
- apiKey: claim.api_key,
422
- token: claudeAnthropicAuth.token,
423
- });
424
- }
425
449
  console.log("Verifying modern Claude Code compatibility before changing local settings...");
426
450
  await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
427
451
  console.log("");
@@ -455,7 +479,7 @@ async function install(flags) {
455
479
  }
456
480
 
457
481
  const codexSubscriptionAuth = harness.id === "codex"
458
- ? await detectCodexChatgptAuth()
482
+ ? funding.codexSubscriptionAuth
459
483
  : false;
460
484
  const claudeSubscriptionAuth = harness.id === "claude-code"
461
485
  && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
@@ -503,6 +527,12 @@ async function install(flags) {
503
527
  record.anthropic_token_stored = true;
504
528
  record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
505
529
  }
530
+ if (harness.id === "claude-code" && funding.claudeCredentialProvider) {
531
+ record.user_funded_provider = funding.claudeCredentialProvider;
532
+ }
533
+ if (harness.id === "codex" && funding.codexCredentialProvider) {
534
+ record.user_funded_provider = funding.codexCredentialProvider;
535
+ }
506
536
  if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
507
537
  records.push(record);
508
538
  upsertManifestRecord(manifest, record);
@@ -589,8 +619,13 @@ async function chooseDefaultRouting(flags, selected, manifest) {
589
619
  }
590
620
  console.log("");
591
621
  const bare = launchable.map((item) => item.id === "claude-code" ? "claude" : "codex");
592
- const label = bare.length === 1 ? bare[0] : `${bare.slice(0, -1).join(", ")} and ${bare.at(-1)}`;
593
- newInstallChoice = await confirm(`Also route ${color(label, "bold")} through PennyRouter by default?`);
622
+ const quoted = bare.map((command) => `'${command}'`);
623
+ const label = quoted.length === 1
624
+ ? quoted[0]
625
+ : `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
626
+ newInstallChoice = await confirm(
627
+ `Set ${label} to automatically load PennyRouter with every session?`,
628
+ );
594
629
  }
595
630
  }
596
631
  out.set(harness.id, newInstallChoice);
@@ -598,9 +633,54 @@ async function chooseDefaultRouting(flags, selected, manifest) {
598
633
  return out;
599
634
  }
600
635
 
601
- async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
636
+ async function selectClaudeFundingProviders(flags, selected) {
637
+ if (!selected.some((harness) => harness.id === "claude-code")) return new Set();
638
+
639
+ const supplied = new Set();
640
+ if (!flags.noAnthropicAuth && (flags.anthropicAuth || flags.token || flags.tokenCommand
641
+ || process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
642
+ if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
643
+ if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
644
+
645
+ // Explicit flags are the automation selector. Interactive installs always default to the
646
+ // subscription alone; environment keys are used if the user checks their provider.
647
+ const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
648
+ || flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey;
649
+ if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
650
+
651
+ console.log("PennyRouter can use your Claude subscription, an Anthropic API key, or an OpenRouter API key to run Claude Code.");
652
+ console.log("Choose one or more providers. PennyRouter will try them in order when available.");
653
+ return promptFundingProviderCheckboxes(
654
+ CLAUDE_PROVIDER_OPTIONS,
655
+ "Which providers should PennyRouter use for Claude Code?",
656
+ new Set(["subscription"]),
657
+ );
658
+ }
659
+
660
+ async function selectCodexFundingProviders(flags, selected) {
661
+ if (!selected.some((harness) => harness.id === "codex")) return new Set();
662
+
663
+ const supplied = new Set();
664
+ if (flags.openaiApiKey || process.env.OPENAI_API_KEY) supplied.add("openai-api");
665
+ if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
666
+ const hasChatgptLogin = await detectCodexChatgptAuth();
667
+ if (hasChatgptLogin) supplied.add("subscription");
668
+
669
+ const explicit = flags.openaiApiKey || flags.openrouterApiKey;
670
+ if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
671
+
672
+ console.log("PennyRouter can use your ChatGPT subscription, an OpenAI API key, or an OpenRouter API key to run Codex.");
673
+ console.log("Choose one or more providers. PennyRouter will try them in order when available.");
674
+ return promptFundingProviderCheckboxes(
675
+ CODEX_PROVIDER_OPTIONS,
676
+ "Which providers should PennyRouter use for Codex?",
677
+ new Set(["subscription"]),
678
+ );
679
+ }
680
+
681
+ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true) {
602
682
  if (!selected.some((harness) => harness.id === "claude-code")) return null;
603
- if (flags.noAnthropicAuth) return null;
683
+ if (!enabled || flags.noAnthropicAuth) return null;
604
684
 
605
685
  if (flags.token || flags.tokenCommand || process.env.ANTHROPIC_AUTH_TOKEN) {
606
686
  const tokenResult = readAnthropicOAuthToken(flags);
@@ -616,19 +696,6 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
616
696
  return null;
617
697
  }
618
698
 
619
- if (!flags.anthropicAuth) {
620
- console.log("Claude Code subscription auth");
621
- console.log("PennyRouter can use your Claude Pro/Max subscription for Anthropic Claude calls.");
622
- console.log("PennyRouter still handles routing, optimization, and @penny commands.");
623
- console.log("");
624
- const ok = await confirm("Enable Claude Pro/Max subscription auth for Claude Code?");
625
- if (!ok) {
626
- console.log("Skipped Claude subscription auth. Enable later with `@penny auth` or `pennyrouter auth anthropic`.");
627
- console.log("");
628
- return null;
629
- }
630
- }
631
-
632
699
  const tokenResult = readAnthropicOAuthToken({ tokenCommand: flags.tokenCommand });
633
700
  if (tokenResult.token) return tokenResult;
634
701
 
@@ -659,6 +726,114 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
659
726
  return { token, source: "prompt" };
660
727
  }
661
728
 
729
+ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
730
+ claudeProviders = new Set(), codexProviders = new Set()) {
731
+ const wantsClaude = selected.some((harness) => harness.id === "claude-code");
732
+ const wantsCodex = selected.some((harness) => harness.id === "codex");
733
+ const interactive = !flags.yes && input.isTTY && output.isTTY;
734
+ const credentials = [];
735
+
736
+ let anthropicApiKey = String(
737
+ flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY || "",
738
+ ).trim();
739
+ let openaiApiKey = String(
740
+ flags.openaiApiKey || process.env.OPENAI_API_KEY || "",
741
+ ).trim();
742
+ let openrouterApiKey = String(
743
+ flags.openrouterApiKey || process.env.OPENROUTER_API_KEY || "",
744
+ ).trim();
745
+ const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
746
+ ? await detectCodexChatgptAuth()
747
+ : false;
748
+
749
+ if (wantsClaude && claudeProviders.has("anthropic-api") && !anthropicApiKey) {
750
+ if (!interactive) throw new Error("Anthropic API was selected, but no key was provided.");
751
+ anthropicApiKey = (await promptSecretish("Anthropic API key: ")).trim();
752
+ }
753
+ if (wantsClaude && claudeProviders.has("openrouter") && !openrouterApiKey) {
754
+ if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
755
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
756
+ }
757
+ if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
758
+ throw new Error(
759
+ "ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
760
+ );
761
+ }
762
+ if (wantsCodex && codexProviders.has("openai-api") && !openaiApiKey) {
763
+ if (!interactive) throw new Error("OpenAI API was selected, but no key was provided.");
764
+ openaiApiKey = (await promptSecretish("OpenAI API key: ")).trim();
765
+ }
766
+ if (wantsCodex && codexProviders.has("openrouter") && !openrouterApiKey) {
767
+ if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
768
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
769
+ }
770
+
771
+ if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey) {
772
+ if (!interactive) {
773
+ throw new Error(
774
+ "Claude Code requires a Claude subscription, --anthropic-api-key (or ANTHROPIC_API_KEY), "
775
+ + "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
776
+ );
777
+ }
778
+ console.log("Claude Code needs a user-funded provider. PennyRouter will never fall back to its own API keys.");
779
+ anthropicApiKey = (await promptSecretish(
780
+ "Anthropic API key (leave blank to use OpenRouter): ",
781
+ )).trim();
782
+ if (!anthropicApiKey) {
783
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
784
+ }
785
+ if (!anthropicApiKey && !openrouterApiKey) {
786
+ throw new Error("Claude Code installation cancelled: no subscription or API key was provided.");
787
+ }
788
+ console.log("");
789
+ }
790
+
791
+ if (wantsCodex && !codexSubscriptionAuth && !openaiApiKey && !openrouterApiKey) {
792
+ if (!interactive) {
793
+ throw new Error(
794
+ "Codex requires ChatGPT login, --openai-api-key (or OPENAI_API_KEY), "
795
+ + "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
796
+ );
797
+ }
798
+ console.log("Codex is not logged into ChatGPT and needs a user-funded provider.");
799
+ console.log("PennyRouter will never fall back to its own API keys.");
800
+ openaiApiKey = (await promptSecretish(
801
+ "OpenAI API key (leave blank to use OpenRouter): ",
802
+ )).trim();
803
+ if (!openaiApiKey) {
804
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
805
+ }
806
+ if (!openaiApiKey && !openrouterApiKey) {
807
+ throw new Error("Codex installation cancelled: no subscription or API key was provided.");
808
+ }
809
+ console.log("");
810
+ }
811
+
812
+ if (claudeAnthropicAuth) {
813
+ credentials.push({ provider: "anthropic_oauth", token: claudeAnthropicAuth.token });
814
+ }
815
+ if (wantsClaude && anthropicApiKey) {
816
+ credentials.push({ provider: "anthropic_api", token: anthropicApiKey });
817
+ }
818
+ if (wantsCodex && openaiApiKey) {
819
+ credentials.push({ provider: "openai_api", token: openaiApiKey });
820
+ }
821
+ if ((wantsClaude || wantsCodex) && openrouterApiKey) {
822
+ credentials.push({ provider: "openrouter", token: openrouterApiKey });
823
+ }
824
+
825
+ return {
826
+ credentials,
827
+ codexSubscriptionAuth,
828
+ claudeCredentialProvider: claudeAnthropicAuth
829
+ ? "claude-subscription"
830
+ : anthropicApiKey ? "anthropic-api" : openrouterApiKey ? "openrouter" : null,
831
+ codexCredentialProvider: codexSubscriptionAuth
832
+ ? "chatgpt-subscription"
833
+ : openaiApiKey ? "openai-api" : openrouterApiKey ? "openrouter" : null,
834
+ };
835
+ }
836
+
662
837
  async function disable(flags) {
663
838
  const manifest = await loadManifest();
664
839
  if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
@@ -1080,6 +1255,72 @@ async function promptHarnessCheckboxesByText(harnesses, selectedIds) {
1080
1255
  }
1081
1256
  }
1082
1257
 
1258
+ async function promptFundingProviderCheckboxes(providerOptions, title,
1259
+ initialIds = new Set(["subscription"])) {
1260
+ const selectedIds = new Set(initialIds);
1261
+ if (!input.isTTY || !output.isTTY) return selectedIds;
1262
+
1263
+ let cursor = 0;
1264
+ let validation = "";
1265
+ const previousRawMode = input.isRaw;
1266
+ emitKeypressEvents(input);
1267
+ input.setRawMode(true);
1268
+ input.resume();
1269
+ output.write("\x1b[?25l");
1270
+
1271
+ const render = () => {
1272
+ output.write("\x1b[2J\x1b[H");
1273
+ output.write("\n\n");
1274
+ output.write(`${color(title, "bold")}\n\n`);
1275
+ providerOptions.forEach((provider, index) => {
1276
+ const pointer = index === cursor ? color(">", "cyan") : " ";
1277
+ const checked = selectedIds.has(provider.id) ? color("x", "green") : " ";
1278
+ const name = index === cursor ? color(provider.name, "cyan") : provider.name;
1279
+ output.write(` ${pointer} [${checked}] ${name}\n`);
1280
+ });
1281
+ output.write("\nSpace to toggle · Enter to continue.\n");
1282
+ if (validation) output.write(`${validation}\n`);
1283
+ };
1284
+ render();
1285
+
1286
+ return new Promise((resolve, reject) => {
1287
+ const cleanup = () => {
1288
+ input.off("keypress", onKeypress);
1289
+ input.setRawMode(Boolean(previousRawMode));
1290
+ input.pause();
1291
+ output.write("\x1b[?25h\n");
1292
+ };
1293
+ const onKeypress = (_str, key = {}) => {
1294
+ if (key.ctrl && key.name === "c") {
1295
+ cleanup();
1296
+ reject(new Error("Cancelled."));
1297
+ } else if (key.name === "up" || key.name === "k") {
1298
+ cursor = (cursor - 1 + providerOptions.length) % providerOptions.length;
1299
+ validation = "";
1300
+ render();
1301
+ } else if (key.name === "down" || key.name === "j") {
1302
+ cursor = (cursor + 1) % providerOptions.length;
1303
+ validation = "";
1304
+ render();
1305
+ } else if (key.name === "space") {
1306
+ const id = providerOptions[cursor].id;
1307
+ if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id);
1308
+ validation = "";
1309
+ render();
1310
+ } else if (key.name === "return" || key.name === "enter") {
1311
+ if (!selectedIds.size) {
1312
+ validation = "Select at least one provider.";
1313
+ render();
1314
+ return;
1315
+ }
1316
+ cleanup();
1317
+ resolve(selectedIds);
1318
+ }
1319
+ };
1320
+ input.on("keypress", onKeypress);
1321
+ });
1322
+ }
1323
+
1083
1324
  function orderedHarnessesForPrompt(detectedIds) {
1084
1325
  return DEFAULT_INSTALL_HARNESS_IDS.map((id) => HARNESS_BY_ID[id]).sort((left, right) => {
1085
1326
  const leftDetected = detectedIds.has(left.id);
@@ -1318,7 +1559,7 @@ function printHelp() {
1318
1559
  console.log(`PennyRouter CLI
1319
1560
 
1320
1561
  Usage:
1321
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]] [--allow-running-harnesses]
1562
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--local[=URL]] [--allow-running-harnesses]
1322
1563
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1323
1564
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1324
1565
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
@@ -1330,6 +1571,9 @@ Examples:
1330
1571
  npx pennyrouter install --make-default
1331
1572
  npx pennyrouter install --penny-only
1332
1573
  npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
1574
+ npx pennyrouter install --harness claude-code --anthropic-api-key <key>
1575
+ npx pennyrouter install --harness codex --openai-api-key <key>
1576
+ npx pennyrouter install --openrouter-api-key <key>
1333
1577
  npx pennyrouter auth anthropic
1334
1578
  npx pennyrouter install --all
1335
1579
  npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
package/src/session.js CHANGED
@@ -46,10 +46,10 @@ export function sleep(ms) {
46
46
  return new Promise((resolve) => setTimeout(resolve, ms));
47
47
  }
48
48
 
49
- // Encrypt-and-store an Anthropic setup-token against the account server-side, so the
50
- // pr- key alone unlocks Claude and no token is written to the harness config. The
51
- // gateway (not the web app) owns this: it holds the pr- key auth and the encryption.
52
- export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
49
+ // Encrypt and store a user-owned upstream credential against the account server-side,
50
+ // so the pr- key alone unlocks the selected harness and the provider secret is never
51
+ // written to harness configuration.
52
+ export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider, token }) {
53
53
  const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials`;
54
54
  const response = await fetch(url, {
55
55
  method: "POST",
@@ -57,7 +57,7 @@ export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
57
57
  "content-type": "application/json",
58
58
  authorization: `Bearer ${apiKey}`,
59
59
  },
60
- body: JSON.stringify({ provider: "anthropic", token }),
60
+ body: JSON.stringify({ provider, token }),
61
61
  });
62
62
  if (!response.ok) {
63
63
  let detail = "";
@@ -66,15 +66,22 @@ export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
66
66
  } catch {
67
67
  // non-JSON body; fall through to status-only message
68
68
  }
69
- throw new Error(`Storing Claude token failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`);
69
+ throw new Error(`Storing ${provider} credential failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`);
70
70
  }
71
71
  return response.json();
72
72
  }
73
73
 
74
+ export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
75
+ return storeUpstreamCredential({
76
+ gatewayBaseUrl, apiKey, provider: "anthropic_oauth", token,
77
+ });
78
+ }
79
+
74
80
  // Exercise the exact modern Claude Code request feature that has historically broken at
75
81
  // proxy boundaries BEFORE changing ~/.claude/settings.json (a user-global redirect). This
76
- // is intentionally a real, tiny Messages call: a generic /health check cannot prove that
77
- // Anthropic beta headers survive the gateway's subscription/native relay.
82
+ // is intentionally sent through /v1/messages rather than a generic /health endpoint, so it
83
+ // proves the gateway can parse the modern beta/tool/context-management envelope. The exact
84
+ // one-token install probe is answered locally by compatible gateways and costs nothing.
78
85
  export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
79
86
  const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/messages?beta=true`;
80
87
  const controller = new AbortController();
@@ -91,7 +98,7 @@ export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
91
98
  },
92
99
  body: JSON.stringify({
93
100
  model: "Penny Sonnet",
94
- max_tokens: 16,
101
+ max_tokens: 1,
95
102
  stream: false,
96
103
  messages: [{ role: "user", content: "Reply OK." }],
97
104
  tools: [{
@@ -28,6 +28,7 @@ try {
28
28
  assert.equal(seen.options.headers.authorization, "Bearer pr-test");
29
29
  assert.equal(seen.options.headers["anthropic-beta"], "compact-2026-01-12");
30
30
  const body = JSON.parse(seen.options.body);
31
+ assert.equal(body.max_tokens, 1);
31
32
  assert.equal(body.context_management.edits[0].type, "compact_20260112");
32
33
  assert.equal(body.tools[0].name, "pennyrouter_install_probe");
33
34