pennyrouter 0.2.2 → 0.2.7

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 --make-default
14
+ npx pennyrouter update
14
15
  npx pennyrouter auth anthropic
15
16
  npx pennyrouter install --harness claude-code,cline,opencode,codex,windsurf,zed,codegpt,aider
16
17
  npx pennyrouter disable
@@ -26,9 +27,10 @@ With no `--harness` flag, the interactive installer shows Claude Code and Codex.
26
27
  tools start checked. The additional legacy integrations remain available through an explicit
27
28
  `--harness <id>` selection or `--all`.
28
29
 
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`.
30
+ When Claude Code is selected, the installer offers Claude subscription authorization. If it is
31
+ declined or unavailable, installation requires an Anthropic API key or OpenRouter key. Codex
32
+ uses an existing ChatGPT login; without one, installation requires an OpenAI API key or
33
+ OpenRouter key. PennyRouter-funded provider keys are never an installed-harness fallback.
32
34
 
33
35
  ## Explicit Penny Commands
34
36
 
@@ -60,7 +62,8 @@ does not currently expose equivalent project-independent custom slash commands.
60
62
  When Codex is already signed in with ChatGPT, the installer preserves that Codex-managed
61
63
  login and configures PennyRouter as an OpenAI-authenticated Responses proxy. Codex continues
62
64
  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.
65
+ key in the managed provider block. API-key Codex installs use the user's encrypted OpenAI or
66
+ OpenRouter key.
64
67
 
65
68
  To point Codex directly at a PennyRouter gateway running on localhost port 8400:
66
69
 
@@ -77,6 +80,39 @@ the gateway must be redeployed or fixed before retrying. This check is about Pen
77
80
  compatibility, not a demand that the user upgrade a Claude Code version that already emits a
78
81
  supported request shape.
79
82
 
83
+ ## Updating
84
+
85
+ To pick up a new CLI version without repeating the install interview:
86
+
87
+ ```bash
88
+ npx pennyrouter update
89
+ npx pennyrouter update --dry-run
90
+ npx pennyrouter update --harness claude-code
91
+ ```
92
+
93
+ `update` refreshes only the files generated from the CLI's own constants: the managed `penny`
94
+ runtime and launcher, the `/penny` Claude skill, the Claude settings overlay and statusline, the
95
+ Codex provider profile, and the model labels and context-window settings in managed tool configs.
96
+ Older installs are migrated forward, so retired model labels are replaced with the current ones.
97
+
98
+ It never touches credentials and never re-runs browser authorization. Upstream provider tokens
99
+ (Claude subscription, Anthropic, OpenAI, OpenRouter) are held encrypted at the gateway, and the
100
+ existing `pr-` account key and gateway URL are read back from the local config, so the command is
101
+ non-interactive and prompts for nothing.
102
+
103
+ Per-tool choices are preserved rather than re-asked. Bare-command routing is read from the
104
+ uninstall manifest, so a Penny-only install keeps bare `claude` and `codex` fully native and an
105
+ install that opted into global routing keeps it. Subscription auth mode is likewise carried
106
+ forward. Use `install` instead when the intent is to change those choices.
107
+
108
+ Because the managed tool config is rewritten only for tools whose bare command already routes
109
+ through PennyRouter, a Penny-only install leaves files such as `~/.claude/settings.json`
110
+ untouched and needs no session shutdown. Updating a tool that owns global routing requires
111
+ closing its running sessions, or passing `--allow-running-harnesses`. Unrelated keys and user
112
+ settings inside managed config files are preserved, and re-running `update` is idempotent.
113
+
114
+ Restart Claude Code or Codex afterwards to pick up the refreshed configuration.
115
+
80
116
  ## Supported Tools
81
117
 
82
118
  - Claude Code
@@ -95,13 +131,13 @@ named-model choices PIN the trunk to that exact model — you pick the model, Pe
95
131
  the bill via its side machinery (cache/compaction/swallowed background calls) without switching the
96
132
  model you chose. "Penny Custom" is the dynamic option: PennyRouter cost-routes each turn.
97
133
 
98
- - Penny Opus: pins `anthropic/claude-opus-4-8`
99
- - Penny Sonnet: pins `anthropic/claude-sonnet-4-6`
100
- - Penny Haiku: pins `anthropic/claude-haiku-4-5-20251001`
101
- - Penny Fable: pins `anthropic/claude-fable-5`
134
+ - Opus 5 (PennyRouter): pins `anthropic/claude-opus-5`
135
+ - Sonnet 5 (PennyRouter): pins `anthropic/claude-sonnet-5`
136
+ - Haiku 4.5 (PennyRouter): pins `anthropic/claude-haiku-4-5-20251001`
137
+ - Fable 5 (PennyRouter): pins `anthropic/claude-fable-5`
102
138
  - Penny Custom: `pennyrouter/auto` — dynamic routing on the bundle/profile saved in the user's account
103
139
 
104
- ## Claude Pro/Max Subscription Auth
140
+ ## Claude Subscription Auth
105
141
 
106
142
  When installing PennyRouter for Claude Code, the interactive installer offers to route
107
143
  Anthropic-native Claude turns through the user's own Claude subscription token. To force this
@@ -109,9 +145,12 @@ in non-interactive installs:
109
145
 
110
146
  ```bash
111
147
  npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
148
+ npx pennyrouter install --harness claude-code --anthropic-api-key <key>
149
+ npx pennyrouter install --harness codex --openai-api-key <key>
150
+ npx pennyrouter install --openrouter-api-key <key>
112
151
  ```
113
152
 
114
- If Claude Code is already logged in to a Pro/Max subscription, use Claude Code's token setup
153
+ If Claude Code is already logged in to a Claude subscription, use Claude Code's token setup
115
154
  flow:
116
155
 
117
156
  ```bash
@@ -139,6 +178,11 @@ PennyRouter `pr-` bearer in its settings; the gateway retrieves and forwards the
139
178
  with the required OAuth beta header and does not deduct PennyRouter credit for successfully
140
179
  served Anthropic-subscription calls.
141
180
 
181
+ On a transient subscription 429/529, the gateway retries the same subscription once. If it
182
+ still fails, it can fall back only to credentials owned by that user: Anthropic API then
183
+ OpenRouter for Claude, or OpenAI API then OpenRouter for Codex. If no such credential is
184
+ configured, the provider error is returned; PennyRouter never changes the payer to itself.
185
+
142
186
  PennyRouter also sets `disableClaudeAiConnectors: true` while its custom gateway is installed.
143
187
  Claude Code cannot load claude.ai organization connectors when gateway authentication takes
144
188
  precedence, and otherwise displays a persistent warning about that state. Making the disablement
@@ -168,6 +212,9 @@ directory, and records an uninstall manifest.
168
212
  `disable` temporarily removes PennyRouter from selected tools while retaining a local
169
213
  PennyRouter-managed snapshot for `enable`.
170
214
 
215
+ `update` rewrites those same managed files for the installed version without re-authorizing,
216
+ and without changing bare-command routing or stored credentials.
217
+
171
218
  It does not delete PennyRouter accounts, credits, or remote API keys.
172
219
 
173
220
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.2.2",
3
+ "version": "0.2.7",
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);
@@ -74,6 +85,11 @@ export async function main(argv = process.argv.slice(2)) {
74
85
  return;
75
86
  }
76
87
 
88
+ if (command === "update") {
89
+ await update(flags);
90
+ return;
91
+ }
92
+
77
93
  if (command === "auth") {
78
94
  await authProvider(flags);
79
95
  return;
@@ -129,6 +145,12 @@ function parseArgs(argv) {
129
145
  else if (arg.startsWith("--token=")) flags.token = arg.slice("--token=".length);
130
146
  else if (arg === "--token-command") flags.tokenCommand = rest[++i] || "";
131
147
  else if (arg.startsWith("--token-command=")) flags.tokenCommand = arg.slice("--token-command=".length);
148
+ else if (arg === "--anthropic-api-key") flags.anthropicApiKey = rest[++i] || "";
149
+ else if (arg.startsWith("--anthropic-api-key=")) flags.anthropicApiKey = arg.slice("--anthropic-api-key=".length);
150
+ else if (arg === "--openai-api-key") flags.openaiApiKey = rest[++i] || "";
151
+ else if (arg.startsWith("--openai-api-key=")) flags.openaiApiKey = arg.slice("--openai-api-key=".length);
152
+ else if (arg === "--openrouter-api-key") flags.openrouterApiKey = rest[++i] || "";
153
+ else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
132
154
  else if (arg === "--harness") flags.harness = rest[++i] || "";
133
155
  else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
134
156
  else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
@@ -389,7 +411,7 @@ async function install(flags) {
389
411
  }
390
412
  if (flags.gatewayBaseUrl) console.log(`Serving gateway: ${flags.gatewayBaseUrl}`);
391
413
  if (selected.some((harness) => harness.id === "claude-code")) {
392
- console.log("Claude Code subscription auth prompt would be shown unless skipped.");
414
+ console.log("Claude Code provider picker would be shown unless providers were supplied by flags or environment.");
393
415
  }
394
416
  console.log("Dry run only. No files changed.");
395
417
  return;
@@ -411,17 +433,24 @@ async function install(flags) {
411
433
  console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
412
434
  console.log("");
413
435
 
414
- const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(flags, selected);
436
+ const claudeProviders = await selectClaudeFundingProviders(flags, selected);
437
+ const codexProviders = await selectCodexFundingProviders(flags, selected);
438
+ const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
439
+ flags, selected, claudeProviders.has("subscription"));
440
+ const funding = await configureUserFunding(
441
+ flags, selected, claudeAnthropicAuth, claudeProviders, codexProviders);
415
442
  const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
416
443
 
444
+ for (const credential of funding.credentials) {
445
+ await storeUpstreamCredential({
446
+ gatewayBaseUrl,
447
+ apiKey: claim.api_key,
448
+ provider: credential.provider,
449
+ token: credential.token,
450
+ });
451
+ }
452
+
417
453
  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
454
  console.log("Verifying modern Claude Code compatibility before changing local settings...");
426
455
  await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
427
456
  console.log("");
@@ -455,7 +484,7 @@ async function install(flags) {
455
484
  }
456
485
 
457
486
  const codexSubscriptionAuth = harness.id === "codex"
458
- ? await detectCodexChatgptAuth()
487
+ ? funding.codexSubscriptionAuth
459
488
  : false;
460
489
  const claudeSubscriptionAuth = harness.id === "claude-code"
461
490
  && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
@@ -503,6 +532,12 @@ async function install(flags) {
503
532
  record.anthropic_token_stored = true;
504
533
  record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
505
534
  }
535
+ if (harness.id === "claude-code" && funding.claudeCredentialProvider) {
536
+ record.user_funded_provider = funding.claudeCredentialProvider;
537
+ }
538
+ if (harness.id === "codex" && funding.codexCredentialProvider) {
539
+ record.user_funded_provider = funding.codexCredentialProvider;
540
+ }
506
541
  if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
507
542
  records.push(record);
508
543
  upsertManifestRecord(manifest, record);
@@ -541,6 +576,143 @@ async function install(flags) {
541
576
  reportFailures(failures);
542
577
  }
543
578
 
579
+ // Refresh the managed local files to this package version WITHOUT re-running any of the
580
+ // account/credential flow. Everything an update needs is already on disk: the pr- key and
581
+ // gateway URL live in the penny config (and, for older installs, in the harness config the
582
+ // installer wrote), and the upstream provider tokens live server-side, encrypted against the
583
+ // account. So no browser claim, no provider checkboxes, no token prompts — this command is
584
+ // non-interactive by construction and never touches a credential.
585
+ //
586
+ // What it DOES rewrite is everything regenerated from code constants, which is exactly what
587
+ // goes stale on a version bump: the copied runtime + `penny` shim, the /penny skill, the
588
+ // Claude launch settings overlay, the Codex provider profile, and the model labels +
589
+ // context-window env in the harness configs.
590
+ //
591
+ // Per-user choices are read back from the manifest, never re-asked: `default_routing` decides
592
+ // whether the bare `claude`/`codex` command keeps pointing at the gateway, and
593
+ // `anthropic_token_stored`/`openai_subscription_auth` decide which auth shape gets rewritten.
594
+ // Re-deriving either from prompt defaults would silently flip a --penny-only user into
595
+ // default routing, or drop subscription auth from a working install.
596
+ async function update(flags) {
597
+ const manifest = await loadManifest();
598
+ if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
599
+ const records = manifest.installations || [];
600
+
601
+ const requested = parseHarnessList(flags.harness);
602
+ const targets = records.filter((record) => {
603
+ if (record.disabled) return false;
604
+ if (flags.all || requested.length === 0) return true;
605
+ return requested.includes(record.harness);
606
+ });
607
+
608
+ if (records.length === 0) {
609
+ throw new Error("No PennyRouter installation found. Run `npx pennyrouter install` first.");
610
+ }
611
+ if (targets.length === 0) {
612
+ console.log("No enabled PennyRouter installations matched. Nothing to update.");
613
+ return;
614
+ }
615
+
616
+ const launchConfig = await loadPennyConfig();
617
+ const globalAuth = await readClaudeCodeGatewayAuth();
618
+ const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
619
+ const gatewayBaseUrl = flags.gatewayBaseUrl
620
+ || launchConfig.gateway_base_url
621
+ || globalAuth.gatewayBaseUrl
622
+ || process.env.PENNYROUTER_GATEWAY_BASE_URL
623
+ || "https://api.pennyrouter.com";
624
+
625
+ if (!apiKey) {
626
+ throw new Error(
627
+ "Could not find your PennyRouter key in the local config. Pass `--penny-key pr-...`, "
628
+ + "or run `npx pennyrouter install` to re-authorize.",
629
+ );
630
+ }
631
+
632
+ console.log("PennyRouter update");
633
+ console.log("");
634
+ console.log(`Key ${maskKey(apiKey)} · gateway ${gatewayBaseUrl}`);
635
+ console.log("Subscription and API keys are left untouched.");
636
+ console.log("");
637
+
638
+ if (flags.dryRun) {
639
+ for (const record of targets) {
640
+ const routing = record.default_routing === false ? "penny command only" : "default routing";
641
+ console.log(`Will refresh ${record.harness} (${routing}).`);
642
+ }
643
+ console.log("Will refresh the penny runtime and shim.");
644
+ console.log("Dry run only. No files changed.");
645
+ return;
646
+ }
647
+
648
+ const failures = [];
649
+ let runtimeResult = null;
650
+ if (targets.some((record) => ["claude-code", "codex"].includes(record.harness))
651
+ && process.platform !== "win32") {
652
+ runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
653
+ }
654
+
655
+ for (const record of targets) {
656
+ const harness = HARNESS_BY_ID[record.harness];
657
+ if (!harness) continue;
658
+
659
+ await runHarnessJob(failures, record.harness, "update", async () => {
660
+ const supportsPennyCommand = ["claude-code", "codex"].includes(record.harness)
661
+ && process.platform !== "win32";
662
+ // An update rewrites the harness's own config only when that config is ours to
663
+ // rewrite — i.e. the bare command still routes through the gateway. In penny-only
664
+ // mode the harness config belongs to the user and we touch just the launch profile.
665
+ const ownsGlobalRouting = !record.launch_available || record.default_routing !== false;
666
+ if (ownsGlobalRouting) assertNoRunningHarness(record.harness, flags);
667
+
668
+ const claudeSubscriptionAuth = record.harness === "claude-code"
669
+ && Boolean(record.anthropic_token_stored);
670
+ const codexSubscriptionAuth = record.harness === "codex"
671
+ ? await detectCodexChatgptAuth()
672
+ : false;
673
+
674
+ if (supportsPennyCommand) {
675
+ await configurePennyHarness(record.harness, {
676
+ apiKey,
677
+ gatewayBaseUrl,
678
+ subscriptionAuth: record.harness === "claude-code"
679
+ ? claudeSubscriptionAuth
680
+ : codexSubscriptionAuth,
681
+ models: record.harness === "claude-code" ? CLAUDE_CODE_MODELS : null,
682
+ });
683
+ }
684
+
685
+ let next = record;
686
+ if (ownsGlobalRouting) {
687
+ const plan = await harness.planInstall();
688
+ // Pass the existing record through so install-time state the harness stashed
689
+ // (previous_model_provider, claude_ai_connectors_before) survives the rewrite
690
+ // and a later uninstall still restores the user's original values.
691
+ const installed = record.harness === "claude-code" && claudeSubscriptionAuth
692
+ ? await harness.installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord: record })
693
+ : await harness.install({ apiKey, gatewayBaseUrl, plan, existingRecord: record });
694
+ next = { ...record, ...installed, installed_at: record.installed_at };
695
+ }
696
+
697
+ next.updated_at = new Date().toISOString();
698
+ if (record.harness === "codex") next.openai_subscription_auth = codexSubscriptionAuth;
699
+ upsertManifestRecord(manifest, next);
700
+ await saveManifest(manifest);
701
+ console.log(`Updated ${record.harness}.`);
702
+ });
703
+ }
704
+
705
+ await saveManifest(manifest);
706
+ console.log("");
707
+ if (runtimeResult && !runtimeResult.available) {
708
+ console.log("Add ~/.local/bin to PATH before using the `penny` command.");
709
+ } else if (runtimeResult?.changed) {
710
+ console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
711
+ }
712
+ console.log("Restart any running Claude Code or Codex sessions to pick up the update.");
713
+ reportFailures(failures);
714
+ }
715
+
544
716
  async function chooseDefaultRouting(flags, selected, manifest) {
545
717
  const out = new Map();
546
718
  const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
@@ -589,8 +761,13 @@ async function chooseDefaultRouting(flags, selected, manifest) {
589
761
  }
590
762
  console.log("");
591
763
  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?`);
764
+ const quoted = bare.map((command) => `'${command}'`);
765
+ const label = quoted.length === 1
766
+ ? quoted[0]
767
+ : `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
768
+ newInstallChoice = await confirm(
769
+ `Set ${label} to automatically load PennyRouter with every session?`,
770
+ );
594
771
  }
595
772
  }
596
773
  out.set(harness.id, newInstallChoice);
@@ -598,9 +775,54 @@ async function chooseDefaultRouting(flags, selected, manifest) {
598
775
  return out;
599
776
  }
600
777
 
601
- async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
778
+ async function selectClaudeFundingProviders(flags, selected) {
779
+ if (!selected.some((harness) => harness.id === "claude-code")) return new Set();
780
+
781
+ const supplied = new Set();
782
+ if (!flags.noAnthropicAuth && (flags.anthropicAuth || flags.token || flags.tokenCommand
783
+ || process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
784
+ if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
785
+ if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
786
+
787
+ // Explicit flags are the automation selector. Interactive installs always default to the
788
+ // subscription alone; environment keys are used if the user checks their provider.
789
+ const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
790
+ || flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey;
791
+ if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
792
+
793
+ console.log("PennyRouter can use your Claude subscription, an Anthropic API key, or an OpenRouter API key to run Claude Code.");
794
+ console.log("Choose one or more providers. PennyRouter will try them in order when available.");
795
+ return promptFundingProviderCheckboxes(
796
+ CLAUDE_PROVIDER_OPTIONS,
797
+ "Which providers should PennyRouter use for Claude Code?",
798
+ new Set(["subscription"]),
799
+ );
800
+ }
801
+
802
+ async function selectCodexFundingProviders(flags, selected) {
803
+ if (!selected.some((harness) => harness.id === "codex")) return new Set();
804
+
805
+ const supplied = new Set();
806
+ if (flags.openaiApiKey || process.env.OPENAI_API_KEY) supplied.add("openai-api");
807
+ if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
808
+ const hasChatgptLogin = await detectCodexChatgptAuth();
809
+ if (hasChatgptLogin) supplied.add("subscription");
810
+
811
+ const explicit = flags.openaiApiKey || flags.openrouterApiKey;
812
+ if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
813
+
814
+ console.log("PennyRouter can use your ChatGPT subscription, an OpenAI API key, or an OpenRouter API key to run Codex.");
815
+ console.log("Choose one or more providers. PennyRouter will try them in order when available.");
816
+ return promptFundingProviderCheckboxes(
817
+ CODEX_PROVIDER_OPTIONS,
818
+ "Which providers should PennyRouter use for Codex?",
819
+ new Set(["subscription"]),
820
+ );
821
+ }
822
+
823
+ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true) {
602
824
  if (!selected.some((harness) => harness.id === "claude-code")) return null;
603
- if (flags.noAnthropicAuth) return null;
825
+ if (!enabled || flags.noAnthropicAuth) return null;
604
826
 
605
827
  if (flags.token || flags.tokenCommand || process.env.ANTHROPIC_AUTH_TOKEN) {
606
828
  const tokenResult = readAnthropicOAuthToken(flags);
@@ -616,19 +838,6 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
616
838
  return null;
617
839
  }
618
840
 
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
841
  const tokenResult = readAnthropicOAuthToken({ tokenCommand: flags.tokenCommand });
633
842
  if (tokenResult.token) return tokenResult;
634
843
 
@@ -659,6 +868,114 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
659
868
  return { token, source: "prompt" };
660
869
  }
661
870
 
871
+ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
872
+ claudeProviders = new Set(), codexProviders = new Set()) {
873
+ const wantsClaude = selected.some((harness) => harness.id === "claude-code");
874
+ const wantsCodex = selected.some((harness) => harness.id === "codex");
875
+ const interactive = !flags.yes && input.isTTY && output.isTTY;
876
+ const credentials = [];
877
+
878
+ let anthropicApiKey = String(
879
+ flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY || "",
880
+ ).trim();
881
+ let openaiApiKey = String(
882
+ flags.openaiApiKey || process.env.OPENAI_API_KEY || "",
883
+ ).trim();
884
+ let openrouterApiKey = String(
885
+ flags.openrouterApiKey || process.env.OPENROUTER_API_KEY || "",
886
+ ).trim();
887
+ const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
888
+ ? await detectCodexChatgptAuth()
889
+ : false;
890
+
891
+ if (wantsClaude && claudeProviders.has("anthropic-api") && !anthropicApiKey) {
892
+ if (!interactive) throw new Error("Anthropic API was selected, but no key was provided.");
893
+ anthropicApiKey = (await promptSecretish("Anthropic API key: ")).trim();
894
+ }
895
+ if (wantsClaude && claudeProviders.has("openrouter") && !openrouterApiKey) {
896
+ if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
897
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
898
+ }
899
+ if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
900
+ throw new Error(
901
+ "ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
902
+ );
903
+ }
904
+ if (wantsCodex && codexProviders.has("openai-api") && !openaiApiKey) {
905
+ if (!interactive) throw new Error("OpenAI API was selected, but no key was provided.");
906
+ openaiApiKey = (await promptSecretish("OpenAI API key: ")).trim();
907
+ }
908
+ if (wantsCodex && codexProviders.has("openrouter") && !openrouterApiKey) {
909
+ if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
910
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
911
+ }
912
+
913
+ if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey) {
914
+ if (!interactive) {
915
+ throw new Error(
916
+ "Claude Code requires a Claude subscription, --anthropic-api-key (or ANTHROPIC_API_KEY), "
917
+ + "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
918
+ );
919
+ }
920
+ console.log("Claude Code needs a user-funded provider. PennyRouter will never fall back to its own API keys.");
921
+ anthropicApiKey = (await promptSecretish(
922
+ "Anthropic API key (leave blank to use OpenRouter): ",
923
+ )).trim();
924
+ if (!anthropicApiKey) {
925
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
926
+ }
927
+ if (!anthropicApiKey && !openrouterApiKey) {
928
+ throw new Error("Claude Code installation cancelled: no subscription or API key was provided.");
929
+ }
930
+ console.log("");
931
+ }
932
+
933
+ if (wantsCodex && !codexSubscriptionAuth && !openaiApiKey && !openrouterApiKey) {
934
+ if (!interactive) {
935
+ throw new Error(
936
+ "Codex requires ChatGPT login, --openai-api-key (or OPENAI_API_KEY), "
937
+ + "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
938
+ );
939
+ }
940
+ console.log("Codex is not logged into ChatGPT and needs a user-funded provider.");
941
+ console.log("PennyRouter will never fall back to its own API keys.");
942
+ openaiApiKey = (await promptSecretish(
943
+ "OpenAI API key (leave blank to use OpenRouter): ",
944
+ )).trim();
945
+ if (!openaiApiKey) {
946
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
947
+ }
948
+ if (!openaiApiKey && !openrouterApiKey) {
949
+ throw new Error("Codex installation cancelled: no subscription or API key was provided.");
950
+ }
951
+ console.log("");
952
+ }
953
+
954
+ if (claudeAnthropicAuth) {
955
+ credentials.push({ provider: "anthropic_oauth", token: claudeAnthropicAuth.token });
956
+ }
957
+ if (wantsClaude && anthropicApiKey) {
958
+ credentials.push({ provider: "anthropic_api", token: anthropicApiKey });
959
+ }
960
+ if (wantsCodex && openaiApiKey) {
961
+ credentials.push({ provider: "openai_api", token: openaiApiKey });
962
+ }
963
+ if ((wantsClaude || wantsCodex) && openrouterApiKey) {
964
+ credentials.push({ provider: "openrouter", token: openrouterApiKey });
965
+ }
966
+
967
+ return {
968
+ credentials,
969
+ codexSubscriptionAuth,
970
+ claudeCredentialProvider: claudeAnthropicAuth
971
+ ? "claude-subscription"
972
+ : anthropicApiKey ? "anthropic-api" : openrouterApiKey ? "openrouter" : null,
973
+ codexCredentialProvider: codexSubscriptionAuth
974
+ ? "chatgpt-subscription"
975
+ : openaiApiKey ? "openai-api" : openrouterApiKey ? "openrouter" : null,
976
+ };
977
+ }
978
+
662
979
  async function disable(flags) {
663
980
  const manifest = await loadManifest();
664
981
  if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
@@ -1080,6 +1397,72 @@ async function promptHarnessCheckboxesByText(harnesses, selectedIds) {
1080
1397
  }
1081
1398
  }
1082
1399
 
1400
+ async function promptFundingProviderCheckboxes(providerOptions, title,
1401
+ initialIds = new Set(["subscription"])) {
1402
+ const selectedIds = new Set(initialIds);
1403
+ if (!input.isTTY || !output.isTTY) return selectedIds;
1404
+
1405
+ let cursor = 0;
1406
+ let validation = "";
1407
+ const previousRawMode = input.isRaw;
1408
+ emitKeypressEvents(input);
1409
+ input.setRawMode(true);
1410
+ input.resume();
1411
+ output.write("\x1b[?25l");
1412
+
1413
+ const render = () => {
1414
+ output.write("\x1b[2J\x1b[H");
1415
+ output.write("\n\n");
1416
+ output.write(`${color(title, "bold")}\n\n`);
1417
+ providerOptions.forEach((provider, index) => {
1418
+ const pointer = index === cursor ? color(">", "cyan") : " ";
1419
+ const checked = selectedIds.has(provider.id) ? color("x", "green") : " ";
1420
+ const name = index === cursor ? color(provider.name, "cyan") : provider.name;
1421
+ output.write(` ${pointer} [${checked}] ${name}\n`);
1422
+ });
1423
+ output.write("\nSpace to toggle · Enter to continue.\n");
1424
+ if (validation) output.write(`${validation}\n`);
1425
+ };
1426
+ render();
1427
+
1428
+ return new Promise((resolve, reject) => {
1429
+ const cleanup = () => {
1430
+ input.off("keypress", onKeypress);
1431
+ input.setRawMode(Boolean(previousRawMode));
1432
+ input.pause();
1433
+ output.write("\x1b[?25h\n");
1434
+ };
1435
+ const onKeypress = (_str, key = {}) => {
1436
+ if (key.ctrl && key.name === "c") {
1437
+ cleanup();
1438
+ reject(new Error("Cancelled."));
1439
+ } else if (key.name === "up" || key.name === "k") {
1440
+ cursor = (cursor - 1 + providerOptions.length) % providerOptions.length;
1441
+ validation = "";
1442
+ render();
1443
+ } else if (key.name === "down" || key.name === "j") {
1444
+ cursor = (cursor + 1) % providerOptions.length;
1445
+ validation = "";
1446
+ render();
1447
+ } else if (key.name === "space") {
1448
+ const id = providerOptions[cursor].id;
1449
+ if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id);
1450
+ validation = "";
1451
+ render();
1452
+ } else if (key.name === "return" || key.name === "enter") {
1453
+ if (!selectedIds.size) {
1454
+ validation = "Select at least one provider.";
1455
+ render();
1456
+ return;
1457
+ }
1458
+ cleanup();
1459
+ resolve(selectedIds);
1460
+ }
1461
+ };
1462
+ input.on("keypress", onKeypress);
1463
+ });
1464
+ }
1465
+
1083
1466
  function orderedHarnessesForPrompt(detectedIds) {
1084
1467
  return DEFAULT_INSTALL_HARNESS_IDS.map((id) => HARNESS_BY_ID[id]).sort((left, right) => {
1085
1468
  const leftDetected = detectedIds.has(left.id);
@@ -1318,18 +1701,28 @@ function printHelp() {
1318
1701
  console.log(`PennyRouter CLI
1319
1702
 
1320
1703
  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]
1704
+ 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]
1705
+ pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL] [--allow-running-harnesses]
1322
1706
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1323
1707
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1324
1708
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1325
1709
  pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
1326
1710
  pennyrouter status
1327
1711
 
1712
+ Update refreshes the managed local config and the penny runtime to this package version.
1713
+ It reuses your existing key and never re-asks for subscription or API credentials.
1714
+
1328
1715
  Examples:
1716
+ npx pennyrouter update
1717
+ npx pennyrouter update --dry-run
1718
+ npx pennyrouter update --harness claude-code
1329
1719
  npx pennyrouter install
1330
1720
  npx pennyrouter install --make-default
1331
1721
  npx pennyrouter install --penny-only
1332
1722
  npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
1723
+ npx pennyrouter install --harness claude-code --anthropic-api-key <key>
1724
+ npx pennyrouter install --harness codex --openai-api-key <key>
1725
+ npx pennyrouter install --openrouter-api-key <key>
1333
1726
  npx pennyrouter auth anthropic
1334
1727
  npx pennyrouter install --all
1335
1728
  npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
@@ -7,22 +7,29 @@ const CONNECTOR_SETTING = "disableClaudeAiConnectors";
7
7
  // Values written into Claude Code's four model slots. CC displays a slot's VALUE verbatim in
8
8
  // its /model menu AND sends it as the request `model`, so each value is both the menu label
9
9
  // and the gateway's routing key. The three concrete Claude slots carry friendly PINNED labels
10
- // ("Penny Opus/Sonnet/Haiku/Fable") that the gateway maps to that exact model — the user's pick
11
- // runs on the model they chose, no dynamic tier switching on the trunk (background/utility
12
- // calls still route cheap, which is where the savings come from). The separate Fable slot is
13
- // pinned to Fable; the DYNAMIC "Penny Custom" option lets PennyRouter cost-route. Legacy
14
- // "Penny Speed/Core/Power"
15
- // installs (dynamic bundles) are still recognized for uninstall (see isPennyRouterModelValue).
10
+ // ("Opus 5/Sonnet 5/Haiku 4.5/Fable 5 (PennyRouter)") that the gateway maps to that exact
11
+ // model — the user's pick runs on the model they chose, no dynamic tier switching on the
12
+ // trunk (background/utility calls still route cheap, which is where the savings come from).
13
+ // Named "<Model> (PennyRouter)" rather than "Penny <Model>" so CC's own generated "Custom ...
14
+ // model" subtext still reads as a real Claude model routed through Penny, not a distinct
15
+ // custom model. The separate Fable slot is pinned to Fable; the DYNAMIC "Penny Custom" option
16
+ // lets PennyRouter cost-route. Legacy "Penny Speed/Core/Power/Opus/Sonnet/Haiku/Fable"
17
+ // installs (dynamic bundles, or the pre-rename pinned labels) are still recognized for
18
+ // uninstall (see isPennyRouterModelValue).
16
19
  export const CLAUDE_CODE_MODELS = {
17
- haiku: "Penny Haiku", // -> pinned anthropic/claude-haiku-4-5-20251001
18
- sonnet: "Penny Sonnet", // -> pinned anthropic/claude-sonnet-4-6
19
- opus: "Penny Opus", // -> pinned anthropic/claude-opus-4-8
20
- fable: "Penny Fable", // -> pinned anthropic/claude-fable-5
21
- custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
20
+ haiku: "Haiku 4.5 (PennyRouter)", // -> pinned anthropic/claude-haiku-4-5-20251001
21
+ sonnet: "Sonnet 5 (PennyRouter)", // -> pinned anthropic/claude-sonnet-5
22
+ opus: "Opus 5 (PennyRouter)", // -> pinned anthropic/claude-opus-5
23
+ fable: "Fable 5 (PennyRouter)", // -> pinned anthropic/claude-fable-5
24
+ custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
22
25
  };
23
- // Retired dynamic-bundle labels (pre-pinned-trunk). Not written by a fresh install; matched
24
- // only so uninstall/detect still recognize and clean up an older install.
25
- const LEGACY_PENNY_LABELS = ["Penny Speed", "Penny Core", "Penny Power"];
26
+ // Retired dynamic-bundle labels (pre-pinned-trunk) and the pre-rename pinned labels. Not
27
+ // written by a fresh install; matched only so uninstall/detect still recognize and clean up
28
+ // an older install.
29
+ const LEGACY_PENNY_LABELS = [
30
+ "Penny Speed", "Penny Core", "Penny Power",
31
+ "Penny Opus", "Penny Sonnet", "Penny Haiku", "Penny Fable",
32
+ ];
26
33
 
27
34
  export const claudeCodeHarness = {
28
35
  id: "claude-code",
@@ -60,6 +67,10 @@ export const claudeCodeHarness = {
60
67
  current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
61
68
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
62
69
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
70
+ // The pinned labels above aren't real Anthropic model ids, so CC can't infer their
71
+ // context window from the name and falls back to a conservative 200K default. Tell it
72
+ // directly: every pinned slot is a Claude model with the 1M-context beta enabled upstream.
73
+ current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = "1000000";
63
74
 
64
75
  const statusline = await configureClaudeCodeStatusline(current);
65
76
 
@@ -82,6 +93,7 @@ export const claudeCodeHarness = {
82
93
  "ANTHROPIC_DEFAULT_SONNET_MODEL",
83
94
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
84
95
  "ANTHROPIC_DEFAULT_FABLE_MODEL",
96
+ "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
85
97
  ],
86
98
  },
87
99
  };
@@ -105,6 +117,10 @@ export const claudeCodeHarness = {
105
117
  current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
106
118
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
107
119
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
120
+ // The pinned labels above aren't real Anthropic model ids, so CC can't infer their
121
+ // context window from the name and falls back to a conservative 200K default. Tell it
122
+ // directly: every pinned slot is a Claude model with the 1M-context beta enabled upstream.
123
+ current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = "1000000";
108
124
 
109
125
  const statusline = await configureClaudeCodeStatusline(current);
110
126
 
@@ -127,6 +143,7 @@ export const claudeCodeHarness = {
127
143
  "ANTHROPIC_DEFAULT_SONNET_MODEL",
128
144
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
129
145
  "ANTHROPIC_DEFAULT_FABLE_MODEL",
146
+ "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
130
147
  ],
131
148
  },
132
149
  };
@@ -146,6 +163,10 @@ export const claudeCodeHarness = {
146
163
  current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
147
164
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
148
165
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
166
+ // The pinned labels above aren't real Anthropic model ids, so CC can't infer their
167
+ // context window from the name and falls back to a conservative 200K default. Tell it
168
+ // directly: every pinned slot is a Claude model with the 1M-context beta enabled upstream.
169
+ current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = "1000000";
149
170
 
150
171
  const statusline = await configureClaudeCodeStatusline(current);
151
172
 
@@ -168,6 +189,7 @@ export const claudeCodeHarness = {
168
189
  "ANTHROPIC_DEFAULT_SONNET_MODEL",
169
190
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
170
191
  "ANTHROPIC_DEFAULT_FABLE_MODEL",
192
+ "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
171
193
  ],
172
194
  },
173
195
  };
@@ -202,6 +224,7 @@ export const claudeCodeHarness = {
202
224
  ]) {
203
225
  if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
204
226
  }
227
+ delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
205
228
  }
206
229
  restoreClaudeAiConnectors(current, record);
207
230
  if (current["anthropic.baseURL"] && isPennyRouterValue(current["anthropic.baseURL"])) {
package/src/launch.js CHANGED
@@ -344,6 +344,10 @@ export function buildPennyInvocation(harness, args, config) {
344
344
  if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnet;
345
345
  if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opus;
346
346
  if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = models.fable;
347
+ // The pinned labels above aren't real Anthropic model ids, so CC can't infer their
348
+ // context window from the name and falls back to a conservative 200K default. Tell it
349
+ // directly: every pinned slot is a Claude model with the 1M-context beta enabled upstream.
350
+ env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = "1000000";
347
351
  return {
348
352
  command: "claude",
349
353
  args: ["--settings", claudeLaunchSettingsPath(), ...args],
@@ -73,7 +73,7 @@ async function main() {
73
73
  apiKey: "pr-super-secret",
74
74
  gatewayBaseUrl: "https://gateway.example/",
75
75
  subscriptionAuth: true,
76
- models: { haiku: "Penny Haiku", sonnet: "Penny Sonnet", opus: "Penny Opus" },
76
+ models: { haiku: "Haiku 4.5 (PennyRouter)", sonnet: "Sonnet 5 (PennyRouter)", opus: "Opus 5 (PennyRouter)" },
77
77
  });
78
78
  await configurePennyHarness("codex", {
79
79
  apiKey: "pr-super-secret",
@@ -100,7 +100,7 @@ async function main() {
100
100
  assert.ok(!claude.args.includes("--plugin-dir"));
101
101
  assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
102
102
  assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
103
- assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Penny Opus");
103
+ assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Opus 5 (PennyRouter)");
104
104
 
105
105
  const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
106
106
  assert.deepEqual(codex.args, ["--profile", "pennyrouter", "resume", "--last"]);
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();
@@ -90,8 +97,8 @@ export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
90
97
  "anthropic-beta": "compact-2026-01-12",
91
98
  },
92
99
  body: JSON.stringify({
93
- model: "Penny Sonnet",
94
- max_tokens: 16,
100
+ model: "Sonnet 5 (PennyRouter)",
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