pennyrouter 0.2.25 → 0.2.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
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
@@ -26,6 +26,7 @@ import {
26
26
  APP_URL,
27
27
  createCliSession,
28
28
  forgetAnthropicToken,
29
+ forgetUpstreamCredential,
29
30
  maskKey,
30
31
  pollForKey,
31
32
  probeClaudeCodeGateway,
@@ -72,6 +73,7 @@ const CLAUDE_PROVIDER_OPTIONS = [
72
73
  { id: "subscription", name: "Claude subscription" },
73
74
  { id: "anthropic-api", name: "Anthropic API key" },
74
75
  { id: "openrouter", name: "OpenRouter API key" },
76
+ { id: "anthropic-proxy", name: "Other proxy (custom base URL)" },
75
77
  ];
76
78
  const CODEX_PROVIDER_OPTIONS = [
77
79
  { id: "subscription", name: "ChatGPT subscription" },
@@ -169,6 +171,10 @@ function parseArgs(argv) {
169
171
  else if (arg === "--openai-api-key") flags.openaiApiKey = rest[++i] || "";
170
172
  else if (arg.startsWith("--openai-api-key=")) flags.openaiApiKey = arg.slice("--openai-api-key=".length);
171
173
  else if (arg === "--openrouter-api-key") flags.openrouterApiKey = rest[++i] || "";
174
+ else if (arg === "--proxy-base-url") flags.proxyBaseUrl = rest[++i] || "";
175
+ else if (arg.startsWith("--proxy-base-url=")) flags.proxyBaseUrl = arg.slice("--proxy-base-url=".length);
176
+ else if (arg === "--proxy-api-key") flags.proxyApiKey = rest[++i] || "";
177
+ else if (arg.startsWith("--proxy-api-key=")) flags.proxyApiKey = arg.slice("--proxy-api-key=".length);
172
178
  else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
173
179
  else if (arg === "--harness") flags.harness = rest[++i] || "";
174
180
  else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
@@ -230,6 +236,10 @@ function applyConfigFile(flags) {
230
236
  if (codexFunding.has("openai-api") && flags.openaiApiKey === undefined) {
231
237
  flags.openaiApiKey = config.openaiApiKey || "";
232
238
  }
239
+ if (claudeFunding.has("anthropic-proxy")) {
240
+ if (flags.proxyBaseUrl === undefined) flags.proxyBaseUrl = config.proxyBaseUrl || "";
241
+ if (flags.proxyApiKey === undefined) flags.proxyApiKey = config.proxyApiKey || "";
242
+ }
233
243
 
234
244
  flags.yes = true;
235
245
  flags.fromConfig = true;
@@ -284,11 +294,21 @@ async function authProvider(flags) {
284
294
  "No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
285
295
  );
286
296
  }
287
- const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || DEFAULT_GATEWAY_BASE_URL;
297
+ // Resolve against the INSTALLED config before falling back to the prod default.
298
+ // This is a destructive call against whichever gateway it reaches, so defaulting to
299
+ // production while the user is working against a local gateway would silently delete
300
+ // the wrong account's token (and leave the local one in place, looking like a no-op).
301
+ const globalAuth = await readClaudeCodeGatewayAuth();
302
+ const gatewayBaseUrl = flags.gatewayBaseUrl
303
+ || process.env.PENNYROUTER_GATEWAY_BASE_URL
304
+ || launchConfig.gateway_base_url
305
+ || globalAuth.gatewayBaseUrl
306
+ || DEFAULT_GATEWAY_BASE_URL;
288
307
  if (flags.dryRun) {
289
308
  console.log(`Will delete stored Anthropic token from gateway: ${gatewayBaseUrl}`);
290
309
  return;
291
310
  }
311
+ console.log(`Gateway: ${gatewayBaseUrl}`);
292
312
  const ok = await forgetAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey });
293
313
  if (ok) {
294
314
  console.log("Successfully forgot stored Anthropic token on server.");
@@ -579,6 +599,7 @@ export async function install(flags = {}) {
579
599
  apiKey: claim.api_key,
580
600
  provider: credential.provider,
581
601
  token: credential.token,
602
+ metadata: credential.metadata,
582
603
  });
583
604
  }
584
605
  }
@@ -669,6 +690,11 @@ export async function install(flags = {}) {
669
690
  record.user_funded_provider = harnessNeededAuth
670
691
  ? (funding.claudeCredentialProvider || existingRecord?.user_funded_provider)
671
692
  : existingRecord?.user_funded_provider;
693
+ // Host only, never the key — the key lives encrypted on the gateway.
694
+ const proxyBaseUrl = harnessNeededAuth
695
+ ? (funding.claudeProxyBaseUrl ?? existingRecord?.proxy_base_url ?? null)
696
+ : (existingRecord?.proxy_base_url ?? null);
697
+ if (proxyBaseUrl) record.proxy_base_url = proxyBaseUrl;
672
698
  }
673
699
  if (harness.id === "codex") {
674
700
  record.user_funded_provider = harnessNeededAuth
@@ -1055,14 +1081,19 @@ async function selectClaudeFundingProviders(flags, selected) {
1055
1081
  || process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
1056
1082
  if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
1057
1083
  if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
1084
+ if ((flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL)
1085
+ && (flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY)) {
1086
+ supplied.add("anthropic-proxy");
1087
+ }
1058
1088
 
1059
1089
  // Explicit flags are the automation selector. Interactive installs always default to the
1060
1090
  // subscription alone; environment keys are used if the user checks their provider.
1061
1091
  const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
1062
- || flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey;
1092
+ || flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey
1093
+ || flags.proxyBaseUrl || flags.proxyApiKey;
1063
1094
  if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
1064
1095
 
1065
- console.log("PennyRouter can use your Claude subscription, an Anthropic API key, or an OpenRouter API key to run Claude Code.");
1096
+ console.log("PennyRouter can use your Claude subscription, an Anthropic API key, an OpenRouter API key, or your own Anthropic-compatible proxy to run Claude Code.");
1066
1097
  console.log("Choose one or more providers. PennyRouter will try them in order when available.");
1067
1098
  return promptFundingProviderCheckboxes(
1068
1099
  CLAUDE_PROVIDER_OPTIONS,
@@ -1165,6 +1196,12 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1165
1196
  let openrouterApiKey = String(
1166
1197
  flags.openrouterApiKey || process.env.OPENROUTER_API_KEY || "",
1167
1198
  ).trim();
1199
+ let proxyBaseUrl = String(
1200
+ flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL || "",
1201
+ ).trim();
1202
+ let proxyApiKey = String(
1203
+ flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY || "",
1204
+ ).trim();
1168
1205
  const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
1169
1206
  ? await detectCodexChatgptAuth()
1170
1207
  : false;
@@ -1177,6 +1214,19 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1177
1214
  if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
1178
1215
  openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
1179
1216
  }
1217
+ if (wantsClaude && claudeProviders.has("anthropic-proxy") && !(proxyBaseUrl && proxyApiKey)) {
1218
+ if (!interactive) {
1219
+ throw new Error(
1220
+ "Other proxy was selected, but --proxy-base-url and --proxy-api-key were not both provided.",
1221
+ );
1222
+ }
1223
+ // The URL is not a secret and is easy to mistype, so it echoes; the key does not.
1224
+ if (!proxyBaseUrl) proxyBaseUrl = await promptText("Proxy base URL (e.g. https://proxy.example.com): ");
1225
+ if (!proxyApiKey) proxyApiKey = (await promptSecretish("Proxy API key: ")).trim();
1226
+ if (!proxyBaseUrl || !proxyApiKey) {
1227
+ throw new Error("Other proxy requires both a base URL and an API key.");
1228
+ }
1229
+ }
1180
1230
  if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
1181
1231
  throw new Error(
1182
1232
  "ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
@@ -1191,7 +1241,9 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1191
1241
  openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
1192
1242
  }
1193
1243
 
1194
- if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey) {
1244
+ const claudeProxyConfigured = Boolean(proxyBaseUrl && proxyApiKey);
1245
+ if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey
1246
+ && !claudeProxyConfigured) {
1195
1247
  if (!interactive) {
1196
1248
  throw new Error(
1197
1249
  "Claude Code requires a Claude subscription, --anthropic-api-key (or ANTHROPIC_API_KEY), "
@@ -1244,13 +1296,23 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1244
1296
  if ((wantsClaude || wantsCodex) && openrouterApiKey) {
1245
1297
  credentials.push({ provider: "openrouter", token: openrouterApiKey });
1246
1298
  }
1299
+ if (wantsClaude && claudeProxyConfigured) {
1300
+ credentials.push({
1301
+ provider: "anthropic_proxy",
1302
+ token: proxyApiKey,
1303
+ metadata: { base_url: proxyBaseUrl },
1304
+ });
1305
+ }
1247
1306
 
1248
1307
  return {
1249
1308
  credentials,
1250
1309
  codexSubscriptionAuth,
1251
1310
  claudeCredentialProvider: claudeAnthropicAuth
1252
1311
  ? "claude-subscription"
1253
- : anthropicApiKey ? "anthropic-api" : openrouterApiKey ? "openrouter" : null,
1312
+ : anthropicApiKey ? "anthropic-api"
1313
+ : claudeProxyConfigured ? "anthropic-proxy"
1314
+ : openrouterApiKey ? "openrouter" : null,
1315
+ claudeProxyBaseUrl: claudeProxyConfigured ? proxyBaseUrl : null,
1254
1316
  codexCredentialProvider: codexSubscriptionAuth
1255
1317
  ? "chatgpt-subscription"
1256
1318
  : openaiApiKey ? "openai-api" : openrouterApiKey ? "openrouter" : null,
@@ -1423,7 +1485,7 @@ async function uninstall(flags) {
1423
1485
  const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
1424
1486
  if (flags.forgetToken) {
1425
1487
  await forgetStoredAnthropicToken(record);
1426
- } else if (record.anthropic_token_stored) {
1488
+ } else if (record.anthropic_token_stored || record.proxy_base_url) {
1427
1489
  anthropicTokenNotice = true;
1428
1490
  }
1429
1491
  if (record.launch_available) await removePennyHarness(record.harness);
@@ -1443,7 +1505,7 @@ async function uninstall(flags) {
1443
1505
  await runHarnessJob(failures, record.harness, "uninstall", async () => {
1444
1506
  if (flags.forgetToken) {
1445
1507
  await forgetStoredAnthropicToken(record);
1446
- } else if (record.anthropic_token_stored) {
1508
+ } else if (record.anthropic_token_stored || record.proxy_base_url) {
1447
1509
  anthropicTokenNotice = true;
1448
1510
  }
1449
1511
  if (record.launch_available) await removePennyHarness(record.harness);
@@ -1537,6 +1599,10 @@ async function status() {
1537
1599
  if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
1538
1600
  if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
1539
1601
  if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
1602
+ if (record.proxy_base_url) {
1603
+ const note = record.anthropic_oauth ? " (subscription takes precedence)" : "";
1604
+ console.log(` Upstream proxy: ${record.proxy_base_url}${note}`);
1605
+ }
1540
1606
  if (record.launch_available) {
1541
1607
  const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
1542
1608
  console.log(` command: ${command}`);
@@ -1847,14 +1913,24 @@ function formatError(error) {
1847
1913
  // live config BEFORE harness.uninstall strips them. Never throws — a local uninstall
1848
1914
  // must not be blocked by a network hiccup; the record is going away regardless.
1849
1915
  async function forgetStoredAnthropicToken(record) {
1850
- if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
1916
+ if (record.harness !== "claude-code") return;
1917
+ if (!record.anthropic_token_stored && !record.proxy_base_url) return;
1851
1918
  try {
1852
1919
  const launchConfig = await loadPennyConfig();
1853
1920
  const globalAuth = await readClaudeCodeGatewayAuth();
1854
1921
  const gatewayBaseUrl = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
1855
1922
  const apiKey = launchConfig.api_key || globalAuth.apiKey;
1856
1923
  if (!gatewayBaseUrl || !apiKey) return;
1857
- await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
1924
+ if (record.anthropic_token_stored) {
1925
+ await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
1926
+ }
1927
+ // The proxy key is stored server-side exactly like the subscription token, so the
1928
+ // same "forget my credentials" gesture has to clear it too.
1929
+ if (record.proxy_base_url) {
1930
+ await forgetUpstreamCredential({
1931
+ gatewayBaseUrl, apiKey, provider: "anthropic_proxy",
1932
+ });
1933
+ }
1858
1934
  } catch {
1859
1935
  // swallow — see note above
1860
1936
  }
@@ -1867,6 +1943,15 @@ async function confirm(question) {
1867
1943
  return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
1868
1944
  }
1869
1945
 
1946
+ // Visible-echo input, for values that are not secrets. A mistyped proxy URL is a
1947
+ // confusing failure much later, so the user gets to see what they typed.
1948
+ async function promptText(question) {
1949
+ const rl = createInterface({ input, output });
1950
+ const answer = await rl.question(question);
1951
+ rl.close();
1952
+ return answer.trim();
1953
+ }
1954
+
1870
1955
  async function promptSecretish(question) {
1871
1956
  if (!input.isTTY || !output.isTTY) {
1872
1957
  const rl = createInterface({ input, output });
@@ -1986,7 +2071,7 @@ function printHelp() {
1986
2071
  console.log(`PennyRouter CLI
1987
2072
 
1988
2073
  Usage:
1989
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--local[=URL]|--prod] [--config PATH] [--allow-running-harnesses]
2074
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--local[=URL]|--prod] [--config PATH] [--allow-running-harnesses]
1990
2075
  pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp] [--allow-running-harnesses]
1991
2076
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1992
2077
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
@@ -2023,6 +2108,7 @@ Examples:
2023
2108
  npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
2024
2109
  npx pennyrouter install --harness claude-code --anthropic-api-key <key>
2025
2110
  npx pennyrouter install --harness codex --openai-api-key <key>
2111
+ npx pennyrouter install --harness claude-code --proxy-base-url https://proxy.example.com --proxy-api-key <key>
2026
2112
  npx pennyrouter install --openrouter-api-key <key>
2027
2113
  npx pennyrouter auth anthropic
2028
2114
  npx pennyrouter install --all
package/src/cli.test.js CHANGED
@@ -121,3 +121,76 @@ test("harnessFundingChanged: no requested funding for an existing harness is not
121
121
  };
122
122
  assert.equal(harnessFundingChanged({}, claudeHarness, manifest), false);
123
123
  });
124
+
125
+ // --- Other proxy (user-supplied Anthropic-compatible base URL) ----------------
126
+
127
+ test("--proxy-base-url and --proxy-api-key parse in both forms", async () => {
128
+ for (const args of [
129
+ ["--proxy-base-url", "https://proxy.example.com", "--proxy-api-key", "k"],
130
+ ["--proxy-base-url=https://proxy.example.com", "--proxy-api-key=k"],
131
+ ]) {
132
+ const lines = await captureLogs(() => main([
133
+ "install", "--harness", "claude-code", "--dry-run", ...args,
134
+ ]));
135
+ assert.ok(lines.length > 0);
136
+ }
137
+ });
138
+
139
+ test("--config maps anthropic-proxy funding to the proxy flags", async () => {
140
+ await withConfigFile(
141
+ {
142
+ harnesses: ["claude-code"],
143
+ claudeFunding: ["anthropic-proxy"],
144
+ proxyBaseUrl: "https://proxy.example.com",
145
+ proxyApiKey: "secret-key",
146
+ },
147
+ async (path) => {
148
+ const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
149
+ assert.ok(lines.some((line) => line.includes("Claude Code")));
150
+ // The key must never be echoed to the terminal.
151
+ assert.ok(!lines.some((line) => line.includes("secret-key")));
152
+ },
153
+ );
154
+ });
155
+
156
+ test("harnessFundingChanged sees a switch to the proxy as a change", () => {
157
+ const manifest = {
158
+ installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
159
+ };
160
+ const changed = harnessFundingChanged(
161
+ { configClaudeFunding: new Set(["anthropic-proxy"]) },
162
+ { id: "claude-code" },
163
+ manifest,
164
+ );
165
+ assert.equal(changed, true);
166
+ });
167
+
168
+ test("harnessFundingChanged sees an unchanged proxy install as unchanged", () => {
169
+ const manifest = {
170
+ installations: [{ harness: "claude-code", user_funded_provider: "anthropic-proxy" }],
171
+ };
172
+ const changed = harnessFundingChanged(
173
+ { configClaudeFunding: new Set(["anthropic-proxy"]) },
174
+ { id: "claude-code" },
175
+ manifest,
176
+ );
177
+ assert.equal(changed, false);
178
+ });
179
+
180
+ test("auth anthropic --forget targets the installed gateway, not prod", async () => {
181
+ // Regression: this defaulted to https://api.pennyrouter.com even when the machine was
182
+ // installed against a local gateway, silently deleting the wrong account's token.
183
+ const lines = await captureLogs(() => main([
184
+ "auth", "anthropic", "--forget", "--dry-run",
185
+ "--penny-key", "pr-test", "--gateway-base-url", "http://localhost:8400",
186
+ ]));
187
+ assert.ok(lines.some((line) => line.includes("http://localhost:8400")));
188
+ assert.ok(!lines.some((line) => line.includes("api.pennyrouter.com")));
189
+ });
190
+
191
+ test("auth anthropic --forget honors --local", async () => {
192
+ const lines = await captureLogs(() => main([
193
+ "auth", "anthropic", "--forget", "--dry-run", "--local", "--penny-key", "pr-test",
194
+ ]));
195
+ assert.ok(lines.some((line) => line.includes("localhost:8400")));
196
+ });
@@ -17,15 +17,18 @@ export const CLAUDE_CODE_MODELS = {
17
17
  sonnet: "claude-sonnet-5[1m]",
18
18
  opus: "claude-opus-5[1m]",
19
19
  fable: "claude-fable-5[1m]",
20
- custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
21
20
  };
22
21
  const CLAUDE_CODE_MODEL_PRESENTATION = {
23
- haiku: { name: "Haiku 4.5 (PennyRouter)", description: "Fast, economical Claude model · 200K context" },
24
- sonnet: { name: "Sonnet 5 (PennyRouter)", description: "Balanced Claude model · 1M context" },
25
- opus: { name: "Opus 5 (PennyRouter)", description: "Most capable Claude model · 1M context" },
26
- fable: { name: "Fable 5 (PennyRouter)", description: "Claude model · 1M context" },
22
+ haiku: { name: "Haiku 4.5", description: "Fast, economical Claude model · 200K context" },
23
+ sonnet: { name: "Sonnet 5", description: "Balanced Claude model · 1M context" },
24
+ opus: { name: "Opus 5", description: "Most capable Claude model · 1M context" },
25
+ fable: { name: "Fable 5", description: "Claude model · 1M context" },
27
26
  };
28
27
  const LEGACY_PINNED_MODEL_IDS = {
28
+ "Haiku 4.5": CLAUDE_CODE_MODELS.haiku,
29
+ "Sonnet 5": CLAUDE_CODE_MODELS.sonnet,
30
+ "Opus 5": CLAUDE_CODE_MODELS.opus,
31
+ "Fable 5": CLAUDE_CODE_MODELS.fable,
29
32
  "Haiku 4.5 (PennyRouter)": CLAUDE_CODE_MODELS.haiku,
30
33
  "Sonnet 5 (PennyRouter)": CLAUDE_CODE_MODELS.sonnet,
31
34
  "Opus 5 (PennyRouter)": CLAUDE_CODE_MODELS.opus,
@@ -86,6 +89,7 @@ export const claudeCodeHarness = {
86
89
  // Anthropic API key. Clear any stale key so it doesn't take precedence.
87
90
  current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
88
91
  delete current.env.ANTHROPIC_API_KEY;
92
+ current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
89
93
  // Claude Code displays these env values directly in its model picker; the
90
94
  // gateway decodes the friendly labels into bundle/profile overrides.
91
95
  current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
@@ -93,6 +97,9 @@ export const claudeCodeHarness = {
93
97
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
94
98
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
95
99
  writeClaudeCodeModelPresentation(current.env);
100
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
101
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
102
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
96
103
  // This is global, not per-model. Leave it unset so Claude Code uses each native id's
97
104
  // real context limit instead of incorrectly giving Haiku a 1M window.
98
105
  delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
@@ -138,11 +145,15 @@ export const claudeCodeHarness = {
138
145
  current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
139
146
  // Single credential only: no stale api key, no separate Anthropic bearer.
140
147
  delete current.env.ANTHROPIC_API_KEY;
148
+ current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
141
149
  current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
142
150
  current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
143
151
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
144
152
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
145
153
  writeClaudeCodeModelPresentation(current.env);
154
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
155
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
156
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
146
157
  // This is global, not per-model. Leave it unset so Claude Code uses each native id's
147
158
  // real context limit instead of incorrectly giving Haiku a 1M window.
148
159
  delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
@@ -184,11 +195,15 @@ export const claudeCodeHarness = {
184
195
  // Authorization bearer token is forwarded by the gateway to Anthropic.
185
196
  current.env.ANTHROPIC_API_KEY = pennyKey;
186
197
  current.env.ANTHROPIC_AUTH_TOKEN = anthropicAuthToken;
198
+ current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
187
199
  current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
188
200
  current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
189
201
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
190
202
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
191
203
  writeClaudeCodeModelPresentation(current.env);
204
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
205
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
206
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
192
207
  // This is global, not per-model. Leave it unset so Claude Code uses each native id's
193
208
  // real context limit instead of incorrectly giving Haiku a 1M window.
194
209
  delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
package/src/launch.js CHANGED
@@ -433,11 +433,17 @@ export function buildPennyInvocation(harness, args, config) {
433
433
  const models = item.models || {};
434
434
  env.ANTHROPIC_BASE_URL = config.gateway_base_url;
435
435
  env.ANTHROPIC_AUTH_TOKEN = config.api_key;
436
+ // Ask Claude Code to discover the gateway's model catalog at startup. The gateway
437
+ // advertises Penny-facing ids in /v1/models; this is separate from inference routing.
438
+ env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
436
439
  delete env.ANTHROPIC_API_KEY;
437
440
  if (models.haiku) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = upgradeClaudeCodeModelValue(models.haiku);
438
441
  if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = upgradeClaudeCodeModelValue(models.sonnet);
439
442
  if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = upgradeClaudeCodeModelValue(models.opus);
440
443
  if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = upgradeClaudeCodeModelValue(models.fable);
444
+ delete env.ANTHROPIC_CUSTOM_MODEL_OPTION;
445
+ delete env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
446
+ delete env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
441
447
  writeClaudeCodeModelPresentation(env);
442
448
  // Per-model limits must come from Claude Code's recognized native model ids. This
443
449
  // global override would incorrectly give Haiku the 1M window.
@@ -103,12 +103,13 @@ async function main() {
103
103
  assert.ok(!claude.args.includes("--plugin-dir"));
104
104
  assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
105
105
  assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
106
+ assert.equal(claude.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY, "1");
106
107
  // 1M-capable slots carry the `[1m]` suffix; Haiku has no 1M variant and stays bare.
107
108
  // Per-slot suffixes, not the global CLAUDE_CODE_MAX_CONTEXT_TOKENS override, which
108
109
  // would wrongly hand Haiku a 1M window too.
109
110
  assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "claude-opus-5[1m]");
110
111
  assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
111
- assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME, "Haiku 4.5 (PennyRouter)");
112
+ assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME, "Haiku 4.5");
112
113
  assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION, "Fast, economical Claude model · 200K context");
113
114
  assert.equal(claude.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, undefined);
114
115
 
package/src/session.js CHANGED
@@ -49,7 +49,7 @@ export function sleep(ms) {
49
49
  // Encrypt and store a user-owned upstream credential against the account server-side,
50
50
  // so the pr- key alone unlocks the selected harness and the provider secret is never
51
51
  // written to harness configuration.
52
- export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider, token }) {
52
+ export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider, token, metadata }) {
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 storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider
57
57
  "content-type": "application/json",
58
58
  authorization: `Bearer ${apiKey}`,
59
59
  },
60
- body: JSON.stringify({ provider, token }),
60
+ body: JSON.stringify({ provider, token, ...(metadata ? { metadata } : {}) }),
61
61
  });
62
62
  if (!response.ok) {
63
63
  let detail = "";
@@ -97,7 +97,7 @@ export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
97
97
  "anthropic-beta": "compact-2026-01-12",
98
98
  },
99
99
  body: JSON.stringify({
100
- model: "Sonnet 5 (PennyRouter)",
100
+ model: "Sonnet 5",
101
101
  max_tokens: 1,
102
102
  stream: false,
103
103
  messages: [{ role: "user", content: "Reply OK." }],
@@ -135,10 +135,10 @@ export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
135
135
 
136
136
  // Best-effort "forget my Claude token" on uninstall. Never throws — a cleanup that
137
137
  // can't reach the network shouldn't block removing the local integration.
138
- export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
138
+ export async function forgetUpstreamCredential({ gatewayBaseUrl, apiKey, provider }) {
139
139
  if (!gatewayBaseUrl || !apiKey) return false;
140
140
  try {
141
- const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials/anthropic`;
141
+ const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials/${encodeURIComponent(provider)}`;
142
142
  const response = await fetch(url, {
143
143
  method: "DELETE",
144
144
  headers: { authorization: `Bearer ${apiKey}` },
@@ -149,6 +149,10 @@ export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
149
149
  }
150
150
  }
151
151
 
152
+ export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
153
+ return forgetUpstreamCredential({ gatewayBaseUrl, apiKey, provider: "anthropic" });
154
+ }
155
+
152
156
  function platformName() {
153
157
  if (process.platform === "darwin") return "Mac";
154
158
  if (process.platform === "win32") return "Windows";
package/src/statusline.js CHANGED
@@ -11,6 +11,10 @@
11
11
  // authed with the pr- key already in Claude Code's settings). The gateway keys that
12
12
  // endpoint on the CC session uuid it sees in every request's metadata.user_id — the
13
13
  // same uuid Claude Code hands the statusline script on stdin.
14
+ //
15
+ // A wrapped user statusline command is cached the same way: it can be slow to spawn
16
+ // (npx especially, and especially on Windows — see WRAP_REFRESH_MS below), so it's never
17
+ // run inline on Claude Code's ~1s render loop.
14
18
 
15
19
  import { statePath, atomicWrite, readJsonFile, writeJsonFile } from "./state.js";
16
20
 
@@ -87,6 +91,14 @@ const stateDir = join(
87
91
  "pennyrouter",
88
92
  );
89
93
  const cacheDir = join(stateDir, "statusline-cache");
94
+ // A wrapped user statusline command can be slow to spawn (npx especially: on Windows
95
+ // it goes through an extra cmd.exe/npx.cmd hop and can take 2-4s per invocation, vs
96
+ // well under 1s on macOS/Linux). Claude Code re-invokes this script every ~1s
97
+ // (refreshInterval), so running the wrap command inline on every render blocked past
98
+ // that interval on Windows, renders piled up, and the statusline never finished in
99
+ // time to display at all. Cache its output and refresh it out-of-band instead, same
100
+ // as the Penny gateway segment below.
101
+ const WRAP_REFRESH_MS = 2000;
90
102
 
91
103
  function readJson(path) {
92
104
  try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
@@ -109,6 +121,8 @@ function claudeEnv() {
109
121
  return { base, key };
110
122
  }
111
123
 
124
+ const config = readJson(join(stateDir, "claude-statusline.json")) || {};
125
+
112
126
  // --refresh <sessionId>: fetch gateway status into the cache file, then exit.
113
127
  if (process.argv[2] === "--refresh") {
114
128
  const sessionId = process.argv[3];
@@ -131,11 +145,28 @@ if (process.argv[2] === "--refresh") {
131
145
  process.exit(0);
132
146
  }
133
147
 
148
+ // --refresh-wrap <sessionId>: run the (possibly slow) wrapped statusline command out of
149
+ // band and cache its output. Reads the stdin JSON the main render last saw, persisted to
150
+ // a per-session file since a detached child has no stdin of its own to read.
151
+ if (process.argv[2] === "--refresh-wrap") {
152
+ const sessionId = process.argv[3];
153
+ if (!sessionId || !config.wrap_command) process.exit(0);
154
+ let wrapInput = "{}";
155
+ try { wrapInput = readFileSync(join(cacheDir, sessionId + ".wrap-input.json"), "utf8"); } catch { /* none yet */ }
156
+ const run = spawnSync(config.wrap_command, {
157
+ shell: true, input: wrapInput, encoding: "utf8", timeout: 20000, windowsHide: true,
158
+ });
159
+ let wrapLines = (run.stdout || "").replace(/\\n$/, "").split("\\n");
160
+ if (wrapLines.length === 1 && wrapLines[0] === "") wrapLines = [];
161
+ mkdirSync(cacheDir, { recursive: true });
162
+ writeFileSync(join(cacheDir, sessionId + ".wrap.json"), JSON.stringify({ lines: wrapLines, at: Date.now() }));
163
+ process.exit(0);
164
+ }
165
+
134
166
  const raw = readFileSync(0, "utf8");
135
167
  let input = {};
136
168
  try { input = JSON.parse(raw); } catch { /* keep {} */ }
137
169
  const sessionId = input.session_id || "";
138
- const config = readJson(join(stateDir, "claude-statusline.json")) || {};
139
170
  const refreshMs = (config.refresh_seconds ?? 15) * 1000;
140
171
 
141
172
  const cachePath = join(cacheDir, sessionId + ".json");
@@ -148,7 +179,7 @@ const { base, key } = claudeEnv();
148
179
  const pennyInstalled = Boolean(base) && Boolean(key);
149
180
  if (sessionId && pennyInstalled && cacheAge > refreshMs) {
150
181
  spawn(process.execPath, [process.argv[1], "--refresh", sessionId], {
151
- detached: true, stdio: "ignore",
182
+ detached: true, stdio: "ignore", windowsHide: true,
152
183
  }).unref();
153
184
  }
154
185
 
@@ -193,9 +224,27 @@ if (!pennyInstalled) {
193
224
  }
194
225
 
195
226
  let lines;
196
- if (config.wrap_command) {
227
+ if (config.wrap_command && sessionId) {
228
+ const wrapCachePath = join(cacheDir, sessionId + ".wrap.json");
229
+ const wrapCache = readJson(wrapCachePath);
230
+ const wrapAge = wrapCache && typeof wrapCache.at === "number" ? Date.now() - wrapCache.at : Infinity;
231
+ if (wrapAge > WRAP_REFRESH_MS) {
232
+ mkdirSync(cacheDir, { recursive: true });
233
+ writeFileSync(join(cacheDir, sessionId + ".wrap-input.json"), raw);
234
+ // Bump the cache timestamp before spawning: Claude Code polls every ~1s, so
235
+ // without this every render in the next couple seconds would see a still-stale
236
+ // cache and fire its own overlapping refresh while the first one is still running.
237
+ writeFileSync(wrapCachePath, JSON.stringify({ lines: (wrapCache && wrapCache.lines) || [], at: Date.now() }));
238
+ spawn(process.execPath, [process.argv[1], "--refresh-wrap", sessionId], {
239
+ detached: true, stdio: "ignore", windowsHide: true,
240
+ }).unref();
241
+ }
242
+ lines = (wrapCache && wrapCache.lines) || [];
243
+ } else if (config.wrap_command) {
244
+ // No session id (e.g. a manual/interactive invocation outside Claude Code): there's
245
+ // no cache key to keep state under, so run inline like before caching existed.
197
246
  const run = spawnSync(config.wrap_command, {
198
- shell: true, input: raw, encoding: "utf8", timeout: 5000,
247
+ shell: true, input: raw, encoding: "utf8", timeout: 5000, windowsHide: true,
199
248
  });
200
249
  lines = (run.stdout || "").replace(/\\n$/, "").split("\\n");
201
250
  if (lines.length === 1 && lines[0] === "") lines = [];