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 +57 -10
- package/package.json +1 -1
- package/src/cli.js +421 -28
- package/src/harnesses/claude-code.js +37 -14
- package/src/launch.js +4 -0
- package/src/launch.test.js +2 -2
- package/src/session.js +17 -10
- package/src/session.test.js +1 -0
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
-
-
|
|
99
|
-
-
|
|
100
|
-
-
|
|
101
|
-
-
|
|
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
|
|
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
|
|
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
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
|
|
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
|
|
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
|
-
?
|
|
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
|
|
593
|
-
|
|
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
|
|
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
|
-
// ("
|
|
11
|
-
// runs on the model they chose, no dynamic tier switching on the
|
|
12
|
-
// calls still route cheap, which is where the savings come from).
|
|
13
|
-
//
|
|
14
|
-
// "Penny
|
|
15
|
-
//
|
|
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: "
|
|
18
|
-
sonnet: "
|
|
19
|
-
opus: "
|
|
20
|
-
fable: "
|
|
21
|
-
custom: "Penny Custom",
|
|
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)
|
|
24
|
-
// only so uninstall/detect still recognize and clean up
|
|
25
|
-
|
|
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],
|
package/src/launch.test.js
CHANGED
|
@@ -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: "
|
|
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, "
|
|
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
|
|
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();
|
|
@@ -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: "
|
|
94
|
-
max_tokens:
|
|
100
|
+
model: "Sonnet 5 (PennyRouter)",
|
|
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
|
|