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 +16 -6
- package/package.json +1 -1
- package/src/cli.js +272 -28
- package/src/session.js +16 -9
- package/src/session.test.js +1 -0
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
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
|
|
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
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
|
|
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
|
|
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
|
-
?
|
|
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
|
|
593
|
-
|
|
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
|
|
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
|
|
50
|
-
// pr- key alone unlocks
|
|
51
|
-
//
|
|
52
|
-
export async function
|
|
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
|
|
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
|
|
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
|
|
77
|
-
//
|
|
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:
|
|
101
|
+
max_tokens: 1,
|
|
95
102
|
stream: false,
|
|
96
103
|
messages: [{ role: "user", content: "Reply OK." }],
|
|
97
104
|
tools: [{
|
package/src/session.test.js
CHANGED
|
@@ -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
|
|