pennyrouter 0.3.17 → 0.3.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,7 +29,7 @@ Installing against a self-hosted gateway is its own flow, with no browser login:
29
29
  keeps its own accounts, so the key is minted there.
30
30
 
31
31
  ```bash
32
- npx pennyrouter install --private-gateway https://gateway.example.internal
32
+ npx pennyrouter install --private-gateway https://penny-router.apps-dev.gtri.org
33
33
  ```
34
34
 
35
35
  It asks for the gateway URL (unless given) and your email, and mints a key against that
@@ -44,11 +44,11 @@ ones:
44
44
  ```json
45
45
  {
46
46
  "harnesses": ["claude-code", "codex"],
47
- "gatewayBaseUrl": "https://gateway.example.internal",
47
+ "gatewayBaseUrl": "https://penny-router.apps-dev.gtri.org",
48
48
  "claudeFunding": ["anthropic-proxy"],
49
- "proxyBaseUrl": "https://litellm.example.internal",
49
+ "anthropicProxyBaseUrl": "https://litellm.core.gtri.org/v1",
50
50
  "codexFunding": ["openai-proxy"],
51
- "openaiProxyBaseUrl": "https://litellm.example.internal"
51
+ "openaiProxyBaseUrl": "https://litellm.core.gtri.org/v1"
52
52
  }
53
53
  ```
54
54
 
@@ -57,6 +57,13 @@ npx pennyrouter install --config team-install.json
57
57
  ```
58
58
 
59
59
  Each person is prompted for their email and their proxy key; everything else is prefilled.
60
+
61
+ For clarity, `proxyBaseUrl`/`proxyApiKey` are the legacy names for the Claude/Anthropic
62
+ wire lane. New files should use `anthropicProxyBaseUrl`/`anthropicProxyApiKey`; Codex uses
63
+ `openaiProxyBaseUrl`/`openaiProxyApiKey`. The `--private-server` spelling is a compatibility
64
+ alias for `--private-gateway`.
65
+
66
+
60
67
  Any field left out of the file is prompted for, and an unattended run that cannot prompt
61
68
  refuses by name rather than installing a half-configured harness. Add `email`, `proxyApiKey`
62
69
  and `openaiProxyApiKey` to the file to skip the prompts entirely.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
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
@@ -19,13 +19,14 @@ import {
19
19
  } from "./harnesses/claude-code.js";
20
20
  import { clineHarness } from "./harnesses/cline.js";
21
21
  import { codeGptHarness } from "./harnesses/codegpt.js";
22
- import { codexHarness, detectCodexChatgptAuth } from "./harnesses/codex.js";
22
+ import { codexHarness, detectCodexChatgptAuth, isHostedPennyRouterGateway } from "./harnesses/codex.js";
23
23
  import { fetchOpenCodeModels, opencodeHarness } from "./harnesses/opencode.js";
24
24
  import { windsurfHarness } from "./harnesses/windsurf.js";
25
25
  import { zedHarness } from "./harnesses/zed.js";
26
26
  import {
27
27
  APP_URL,
28
28
  createCliSession,
29
+ fetchGatewayCapabilities,
29
30
  forgetAnthropicToken,
30
31
  forgetUpstreamCredential,
31
32
  maskKey,
@@ -36,6 +37,14 @@ import {
36
37
  storeAnthropicToken,
37
38
  storeUpstreamCredential,
38
39
  } from "./session.js";
40
+
41
+ async function codexNativeAuthAvailable({ gatewayBaseUrl, apiKey, localAuth }) {
42
+ if (!isHostedPennyRouterGateway(gatewayBaseUrl)) return false;
43
+ const capabilities = await fetchGatewayCapabilities({ gatewayBaseUrl, apiKey });
44
+ const hasOpenAiCredential = Array.isArray(capabilities?.upstream_credentials)
45
+ && capabilities.upstream_credentials.includes("openai_api");
46
+ return Boolean(localAuth || hasOpenAiCredential);
47
+ }
39
48
  import {
40
49
  backupFile,
41
50
  compactHome,
@@ -225,6 +234,10 @@ function parseArgs(argv) {
225
234
  else if (arg.startsWith("--openai-proxy-base-url=")) flags.openaiProxyBaseUrl = arg.slice("--openai-proxy-base-url=".length);
226
235
  else if (arg === "--openai-proxy-api-key") flags.openaiProxyApiKey = rest[++i] || "";
227
236
  else if (arg.startsWith("--openai-proxy-api-key=")) flags.openaiProxyApiKey = arg.slice("--openai-proxy-api-key=".length);
237
+ else if (arg === "--anthropic-proxy-base-url") flags.proxyBaseUrl = rest[++i] || "";
238
+ else if (arg.startsWith("--anthropic-proxy-base-url=")) flags.proxyBaseUrl = arg.slice("--anthropic-proxy-base-url=".length);
239
+ else if (arg === "--anthropic-proxy-api-key") flags.proxyApiKey = rest[++i] || "";
240
+ else if (arg.startsWith("--anthropic-proxy-api-key=")) flags.proxyApiKey = arg.slice("--anthropic-proxy-api-key=".length);
228
241
  else if (arg === "--proxy-base-url") flags.proxyBaseUrl = rest[++i] || "";
229
242
  else if (arg.startsWith("--proxy-base-url=")) flags.proxyBaseUrl = arg.slice("--proxy-base-url=".length);
230
243
  else if (arg === "--proxy-api-key") flags.proxyApiKey = rest[++i] || "";
@@ -241,17 +254,17 @@ function parseArgs(argv) {
241
254
  // --gateway-base-url, which expects the URL and an admin-issued key up front, this asks
242
255
  // for what it needs (gateway URL, then email) and mints the key from the gateway itself,
243
256
  // so a user on a VPN can install without an administrator handing them a key first.
244
- else if (arg === "--private-gateway") {
257
+ else if (arg === "--private-gateway" || arg === "--private-server") {
245
258
  flags.privateGatewayFlow = true;
246
259
  flags.privateGateway = true;
247
260
  // Optional inline URL: `--private-gateway https://...` rather than a prompt. Only
248
261
  // consume the next argument when it is not itself a flag.
249
262
  if (rest[i + 1] && !rest[i + 1].startsWith("-")) flags.gatewayBaseUrl = rest[++i];
250
263
  }
251
- else if (arg.startsWith("--private-gateway=")) {
264
+ else if (arg.startsWith("--private-gateway=") || arg.startsWith("--private-server=")) {
252
265
  flags.privateGatewayFlow = true;
253
266
  flags.privateGateway = true;
254
- flags.gatewayBaseUrl = arg.slice("--private-gateway=".length);
267
+ flags.gatewayBaseUrl = arg.slice(arg.indexOf("=") + 1);
255
268
  }
256
269
  // Email the private gateway mints the key against; prompted for when absent.
257
270
  else if (arg === "--email") flags.email = rest[++i] || "";
@@ -319,6 +332,15 @@ export function applyConfigFile(flags) {
319
332
  if (config.harnesses && flags.harness === undefined) {
320
333
  flags.harness = config.harnesses.join(",");
321
334
  }
335
+ // Migrate the ambiguous Claude proxy keys in-memory. Existing shared install files remain
336
+ // valid while new files can say explicitly which wire lane they configure.
337
+ if (config.anthropicProxyBaseUrl === undefined && config.proxyBaseUrl !== undefined) {
338
+ config.anthropicProxyBaseUrl = config.proxyBaseUrl;
339
+ }
340
+ if (config.anthropicProxyApiKey === undefined && config.proxyApiKey !== undefined) {
341
+ config.anthropicProxyApiKey = config.proxyApiKey;
342
+ }
343
+
322
344
  const claudeFunding = new Set(config.claudeFunding || []);
323
345
  const codexFunding = new Set(config.codexFunding || []);
324
346
  if (claudeFunding.has("subscription") && flags.anthropicAuth === undefined) {
@@ -349,6 +371,15 @@ export function applyConfigFile(flags) {
349
371
  const value = String(config[field.config_key] || "").trim();
350
372
  if (value && flags[field.config_key] === undefined) flags[field.config_key] = value;
351
373
  }
374
+ // ``proxy*`` was the original Claude/Anthropic naming. Keep it as an input alias, but
375
+ // normalize the explicit JSON names back to the internal flags used by the installer.
376
+ if (flags.proxyBaseUrl === undefined && flags.anthropicProxyBaseUrl !== undefined) {
377
+ flags.proxyBaseUrl = flags.anthropicProxyBaseUrl;
378
+ }
379
+ if (flags.proxyApiKey === undefined && flags.anthropicProxyApiKey !== undefined) {
380
+ flags.proxyApiKey = flags.anthropicProxyApiKey;
381
+ }
382
+
352
383
  if (flags.gatewayBaseUrl) {
353
384
  flags.privateGateway = true;
354
385
  // Take the self-hosted install path rather than the hosted browser claim, whose key is
@@ -832,7 +863,13 @@ export async function install(flags = {}) {
832
863
 
833
864
  const harnessNeededAuth = needsAuth.includes(harness);
834
865
  const codexSubscriptionAuth = harness.id === "codex"
835
- ? (harnessNeededAuth ? funding.codexSubscriptionAuth : Boolean(existingRecord?.openai_subscription_auth))
866
+ ? await codexNativeAuthAvailable({
867
+ gatewayBaseUrl: gatewayBaseUrl,
868
+ apiKey: claim.api_key,
869
+ localAuth: harnessNeededAuth
870
+ ? funding.codexSubscriptionAuth
871
+ : Boolean(existingRecord?.openai_subscription_auth),
872
+ })
836
873
  : false;
837
874
  const claudeSubscriptionAuth = harness.id === "claude-code"
838
875
  && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
@@ -1269,7 +1306,11 @@ async function update(flags) {
1269
1306
  const claudeSubscriptionAuth = record.harness === "claude-code"
1270
1307
  && Boolean(record.anthropic_token_stored);
1271
1308
  const codexSubscriptionAuth = record.harness === "codex"
1272
- ? await detectCodexChatgptAuth()
1309
+ ? await codexNativeAuthAvailable({
1310
+ gatewayBaseUrl,
1311
+ apiKey,
1312
+ localAuth: await detectCodexChatgptAuth(),
1313
+ })
1273
1314
  : false;
1274
1315
 
1275
1316
  if (supportsPennyCommand) {
@@ -1404,8 +1445,10 @@ async function selectClaudeFundingProviders(flags, selected) {
1404
1445
  || process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
1405
1446
  if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
1406
1447
  if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
1407
- if ((flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL)
1408
- && (flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY)) {
1448
+ if ((flags.proxyBaseUrl || process.env.PENNYROUTER_ANTHROPIC_PROXY_BASE_URL
1449
+ || process.env.PENNYROUTER_PROXY_BASE_URL)
1450
+ && (flags.proxyApiKey || process.env.PENNYROUTER_ANTHROPIC_PROXY_API_KEY
1451
+ || process.env.PENNYROUTER_PROXY_API_KEY)) {
1409
1452
  supplied.add("anthropic-proxy");
1410
1453
  }
1411
1454
  // A --config file states its providers outright, and that choice stands whether or not the
@@ -1540,10 +1583,14 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1540
1583
  flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY || "",
1541
1584
  ).trim();
1542
1585
  let proxyBaseUrl = String(
1543
- flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL || "",
1586
+ flags.proxyBaseUrl || flags.anthropicProxyBaseUrl
1587
+ || process.env.PENNYROUTER_ANTHROPIC_PROXY_BASE_URL
1588
+ || process.env.PENNYROUTER_PROXY_BASE_URL || "",
1544
1589
  ).trim();
1545
1590
  let proxyApiKey = String(
1546
- flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY || "",
1591
+ flags.proxyApiKey || flags.anthropicProxyApiKey
1592
+ || process.env.PENNYROUTER_ANTHROPIC_PROXY_API_KEY
1593
+ || process.env.PENNYROUTER_PROXY_API_KEY || "",
1547
1594
  ).trim();
1548
1595
  const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
1549
1596
  ? await detectCodexChatgptAuth()
@@ -2382,7 +2429,7 @@ function printHelp() {
2382
2429
  console.log(`PennyRouter CLI
2383
2430
 
2384
2431
  Usage:
2385
- 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] [--openai-proxy-base-url URL --openai-proxy-api-key KEY] [--openrouter-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--private-gateway[=URL] [--email you@example.com]] [--gateway-base-url URL] [--private-key pr-...] [--local[=URL]|--prod] [--config PATH]
2432
+ 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] [--openai-proxy-base-url URL --openai-proxy-api-key KEY] [--openrouter-api-key KEY] [--anthropic-proxy-base-url URL --anthropic-proxy-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--private-gateway|--private-server[=URL] [--email you@example.com]] [--gateway-base-url URL] [--private-key pr-...] [--local[=URL]|--prod] [--config PATH]
2386
2433
  pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp]
2387
2434
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
2388
2435
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
@@ -103,6 +103,12 @@ export function renderCodexConfig(
103
103
  text,
104
104
  { apiKey, gatewayBaseUrl, subscriptionAuth, surface = "codex-terminal" },
105
105
  ) {
106
+ // Native Codex subscription/WebSocket auth is valid only through PennyRouter's hosted
107
+ // cloud gateway. Local, private, proxy, and other custom gateways must always use the
108
+ // ordinary PennyRouter bearer provider, even when Codex itself is logged into ChatGPT.
109
+ // Keep this guard at the final renderer so every install/update/desktop path gets the
110
+ // same safety rule regardless of which profile selected it.
111
+ subscriptionAuth = Boolean(subscriptionAuth && isHostedPennyRouterGateway(gatewayBaseUrl));
106
112
  const clean = removeManagedCodexConfig(text, { removeRootProvider: true }).trimEnd();
107
113
  if (new RegExp(`^\\s*\\[model_providers\\.${escapeRegex(PROVIDER_ID)}\\]\\s*$`, "m").test(clean)) {
108
114
  throw new Error(
@@ -142,6 +148,14 @@ export function renderCodexConfig(
142
148
  return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
143
149
  }
144
150
 
151
+ export function isHostedPennyRouterGateway(gatewayBaseUrl) {
152
+ try {
153
+ return new URL(String(gatewayBaseUrl)).hostname.toLowerCase() === "api.pennyrouter.com";
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
145
159
  export function removeManagedCodexConfig(text, { removeRootProvider = false } = {}) {
146
160
  let out = String(text || "").replace(
147
161
  new RegExp(`${escapeRegex(MANAGED_START)}[\\s\\S]*?${escapeRegex(MANAGED_END)}\\n?`, "g"),
@@ -8,6 +8,7 @@ import {
8
8
  codexUsesChatgptAuth,
9
9
  removeManagedCodexConfig,
10
10
  renderCodexConfig,
11
+ isHostedPennyRouterGateway,
11
12
  } from "./codex.js";
12
13
 
13
14
  function testAuthDetection() {
@@ -21,13 +22,13 @@ function testSubscriptionProviderIsRootSafe() {
21
22
  const original = `model = "gpt-test"\n\n[features]\nplugins = false\n`;
22
23
  const next = renderCodexConfig(original, {
23
24
  apiKey: "pr-test-secret",
24
- gatewayBaseUrl: "http://localhost:8400/",
25
+ gatewayBaseUrl: "https://api.pennyrouter.com/",
25
26
  subscriptionAuth: true,
26
27
  });
27
28
 
28
29
  assert.ok(next.indexOf('model_provider = "pennyrouter"') < next.indexOf("[features]"));
29
30
  assert.ok(next.includes('[model_providers.pennyrouter]'));
30
- assert.ok(next.includes('base_url = "http://localhost:8400/v1"'));
31
+ assert.ok(next.includes('base_url = "https://api.pennyrouter.com/v1"'));
31
32
  assert.ok(next.includes("requires_openai_auth = true"));
32
33
  assert.ok(next.includes('"x-pennyrouter-key" = "pr-test-secret"'));
33
34
  assert.ok(next.includes('"x-pennyrouter-harness" = "codex"'));
@@ -36,6 +37,26 @@ function testSubscriptionProviderIsRootSafe() {
36
37
  assert.equal((next.match(/# --- PennyRouter managed Codex provider ---/g) || []).length, 1);
37
38
  }
38
39
 
40
+ function testPrivateAndProxyGatewaysNeverEnableNativeSubscription() {
41
+ assert.equal(isHostedPennyRouterGateway("https://api.pennyrouter.com"), true);
42
+ for (const gateway of [
43
+ "http://localhost:8400",
44
+ "https://private.example.test",
45
+ "https://openrouter.example.test",
46
+ ]) {
47
+ assert.equal(isHostedPennyRouterGateway(gateway), false);
48
+ const next = renderCodexConfig("", {
49
+ apiKey: "pr-private",
50
+ gatewayBaseUrl: gateway,
51
+ subscriptionAuth: true,
52
+ });
53
+ assert.ok(next.includes('model_provider = "pennyrouter"'));
54
+ assert.ok(next.includes('experimental_bearer_token = "pr-private"'));
55
+ assert.ok(!next.includes("requires_openai_auth"));
56
+ assert.ok(next.includes('supports_websockets = false'));
57
+ }
58
+ }
59
+
39
60
  function testLegacyMigrationAndIdempotence() {
40
61
  const legacy = `[features]\nplugins = false\nopenai_base_url = "http://old/v1"\nopenai_api_key = "pr-old"\n`;
41
62
  const once = renderCodexConfig(legacy, {
@@ -115,6 +136,7 @@ async function testReinstallPreservesRecordedProvider() {
115
136
 
116
137
  testAuthDetection();
117
138
  testSubscriptionProviderIsRootSafe();
139
+ testPrivateAndProxyGatewaysNeverEnableNativeSubscription();
118
140
  testLegacyMigrationAndIdempotence();
119
141
  testProviderCollisionFailsClosed();
120
142
  testUninstallLeavesUserConfig();
@@ -56,12 +56,12 @@
56
56
  "name": "Proxy (e.g., Bedrock, Azure, private server)",
57
57
  "fields": [
58
58
  {
59
- "config_key": "proxyBaseUrl",
60
- "label": "Proxy base URL",
59
+ "config_key": "anthropicProxyBaseUrl",
60
+ "label": "Anthropic proxy base URL",
61
61
  "secret": false,
62
62
  "placeholder": "https://proxy.example.com"
63
63
  },
64
- { "config_key": "proxyApiKey", "label": "Proxy API key", "secret": true }
64
+ { "config_key": "anthropicProxyApiKey", "label": "Anthropic proxy API key", "secret": true }
65
65
  ]
66
66
  }
67
67
  ]
package/src/launch.js CHANGED
@@ -390,6 +390,16 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
390
390
  }
391
391
 
392
392
  export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
393
+ // Private/proxy/custom gateways never use Codex's native subscription/WebSocket lane.
394
+ // Apply the same hosted-only rule as the Codex config renderer at this launch-profile
395
+ // boundary, since this path is what bare `penny codex` selects at runtime.
396
+ try {
397
+ subscriptionAuth = Boolean(
398
+ subscriptionAuth && new URL(String(gatewayBaseUrl)).hostname.toLowerCase() === "api.pennyrouter.com",
399
+ );
400
+ } catch {
401
+ subscriptionAuth = false;
402
+ }
393
403
  if (subscriptionAuth) {
394
404
  return [
395
405
  CODEX_PROFILE_MARKER,
@@ -187,14 +187,14 @@ async function main() {
187
187
  const desktopConfig = fs.readFileSync(path.join(desktopHome, "config.toml"), "utf8");
188
188
  assert.match(desktopConfig, /^model_provider = "pennyrouter"/);
189
189
  assert.match(desktopConfig, /\[model_providers\.pennyrouter\]/);
190
- assert.match(desktopConfig, /"x-pennyrouter-key" = "pr-super-secret"/);
190
+ assert.match(desktopConfig, /experimental_bearer_token = "pr-super-secret"/);
191
191
  assert.match(desktopConfig, /"x-pennyrouter-surface" = "codex-desktop"/);
192
192
  assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
193
193
  fs.rmSync(desktopHome, { recursive: true, force: true });
194
194
  const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
195
- assert.match(codexProfile, /model_provider = "openai"/);
196
- assert.match(codexProfile, /openai_base_url = "https:\/\/gateway\.example"/);
197
- assert.ok(!codexProfile.includes("[model_providers.pennyrouter]"));
195
+ assert.match(codexProfile, /model_provider = "pennyrouter"/);
196
+ assert.match(codexProfile, /base_url = "https:\/\/gateway\.example\/v1"/);
197
+ assert.ok(codexProfile.includes("[model_providers.pennyrouter]"));
198
198
  assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
199
199
  assert.ok(!codexProfile.includes("pr-super-secret"));
200
200
  assert.ok(!rc.includes("pr-super-secret"));
package/src/session.js CHANGED
@@ -71,6 +71,17 @@ export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider
71
71
  return response.json();
72
72
  }
73
73
 
74
+ export async function fetchGatewayCapabilities({ gatewayBaseUrl, apiKey }) {
75
+ const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/auth/capabilities`;
76
+ try {
77
+ const response = await fetch(url, { headers: { authorization: `Bearer ${apiKey}` } });
78
+ if (!response.ok) return null;
79
+ return await response.json();
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
74
85
  // Mint a key on a private gateway that has self-service provisioning enabled. The hosted
75
86
  // browser claim mints against pennyrouter.com's key store, which a private gateway does not
76
87
  // share, so this is the equivalent step for a self-hosted install. A gateway that has not