pennyrouter 0.3.25 → 0.3.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.3.25",
3
+ "version": "0.3.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
@@ -19,7 +19,13 @@ 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, isHostedPennyRouterGateway } from "./harnesses/codex.js";
22
+ import {
23
+ codexHarness,
24
+ detectCodexChatgptAuth,
25
+ isCodexSubscriptionAuthGateway,
26
+ isHostedPennyRouterGateway,
27
+ isLocalPennyRouterGateway,
28
+ } from "./harnesses/codex.js";
23
29
  import { fetchOpenCodeModels, opencodeHarness } from "./harnesses/opencode.js";
24
30
  import { windsurfHarness } from "./harnesses/windsurf.js";
25
31
  import { zedHarness } from "./harnesses/zed.js";
@@ -38,8 +44,12 @@ import {
38
44
  storeUpstreamCredential,
39
45
  } from "./session.js";
40
46
 
41
- async function codexNativeAuthAvailable({ gatewayBaseUrl, apiKey, localAuth }) {
42
- if (!isHostedPennyRouterGateway(gatewayBaseUrl)) return false;
47
+ async function codexNativeAuthAvailable({ gatewayBaseUrl, apiKey, localAuth, privateGateway = false }) {
48
+ if (privateGateway) return false;
49
+ if (!isCodexSubscriptionAuthGateway(gatewayBaseUrl)) return false;
50
+ // A local gateway should use Codex's detected login as the opt-in. Do not infer
51
+ // subscription auth from some other upstream credential stored on the local gateway.
52
+ if (isLocalPennyRouterGateway(gatewayBaseUrl)) return Boolean(localAuth);
43
53
  const capabilities = await fetchGatewayCapabilities({ gatewayBaseUrl, apiKey });
44
54
  const hasOpenAiCredential = Array.isArray(capabilities?.upstream_credentials)
45
55
  && capabilities.upstream_credentials.includes("openai_api");
@@ -535,7 +545,7 @@ async function authProvider(flags) {
535
545
  apiKey: pennyKey,
536
546
  gatewayBaseUrl,
537
547
  subscriptionAuth: true,
538
- models: CLAUDE_CODE_MODELS,
548
+ models: flags.profile === "private" ? null : CLAUDE_CODE_MODELS,
539
549
  profile: flags.profile,
540
550
  });
541
551
  let record = existingRecord || {
@@ -552,6 +562,7 @@ async function authProvider(flags) {
552
562
  ...record,
553
563
  ...(await claudeCodeHarness.installStoredToken({
554
564
  apiKey: pennyKey, gatewayBaseUrl, plan, existingRecord,
565
+ privateGateway: flags.profile === "private",
555
566
  })),
556
567
  };
557
568
  }
@@ -883,6 +894,7 @@ export async function install(flags = {}) {
883
894
  ? await codexNativeAuthAvailable({
884
895
  gatewayBaseUrl: gatewayBaseUrl,
885
896
  apiKey: claim.api_key,
897
+ privateGateway: flags.privateGatewayFlow,
886
898
  localAuth: harnessNeededAuth
887
899
  ? funding.codexSubscriptionAuth
888
900
  : Boolean(existingRecord?.openai_subscription_auth),
@@ -898,7 +910,11 @@ export async function install(flags = {}) {
898
910
  subscriptionAuth: harness.id === "claude-code"
899
911
  ? claudeSubscriptionAuth
900
912
  : codexSubscriptionAuth,
901
- models: harness.id === "claude-code" ? CLAUDE_CODE_MODELS : null,
913
+ privateGateway: flags.privateGatewayFlow,
914
+ // Private gateways advertise their complete upstream catalog. Leave the
915
+ // Claude defaults unset there so the picker does not merge hosted-only ids.
916
+ models: harness.id === "claude-code" && !flags.privateGatewayFlow
917
+ ? CLAUDE_CODE_MODELS : null,
902
918
  profile: flags.profile,
903
919
  });
904
920
  }
@@ -911,9 +927,11 @@ export async function install(flags = {}) {
911
927
  record = harness.id === "claude-code" && claudeSubscriptionAuth
912
928
  ? await harness.installStoredToken({
913
929
  apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
930
+ privateGateway: flags.privateGatewayFlow,
914
931
  })
915
932
  : await harness.install({
916
933
  apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord, models: opencodeModels,
934
+ privateGateway: flags.privateGatewayFlow,
917
935
  });
918
936
  } else {
919
937
  if (hadGlobalRouting) {
@@ -1364,6 +1382,7 @@ async function update(flags) {
1364
1382
  ? await codexNativeAuthAvailable({
1365
1383
  gatewayBaseUrl,
1366
1384
  apiKey,
1385
+ privateGateway: profile === "private",
1367
1386
  localAuth: await detectCodexChatgptAuth(),
1368
1387
  })
1369
1388
  : false;
@@ -1375,7 +1394,9 @@ async function update(flags) {
1375
1394
  subscriptionAuth: record.harness === "claude-code"
1376
1395
  ? claudeSubscriptionAuth
1377
1396
  : codexSubscriptionAuth,
1378
- models: record.harness === "claude-code" ? CLAUDE_CODE_MODELS : null,
1397
+ privateGateway: profile === "private",
1398
+ models: record.harness === "claude-code" && profile !== "private"
1399
+ ? CLAUDE_CODE_MODELS : null,
1379
1400
  profile,
1380
1401
  });
1381
1402
  }
@@ -1387,8 +1408,14 @@ async function update(flags) {
1387
1408
  // (previous_model_provider, claude_ai_connectors_before) survives the rewrite
1388
1409
  // and a later uninstall still restores the user's original values.
1389
1410
  const installed = record.harness === "claude-code" && claudeSubscriptionAuth
1390
- ? await harness.installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord: record })
1391
- : await harness.install({ apiKey, gatewayBaseUrl, plan, existingRecord: record });
1411
+ ? await harness.installStoredToken({
1412
+ apiKey, gatewayBaseUrl, plan, existingRecord: record,
1413
+ privateGateway: profile === "private",
1414
+ })
1415
+ : await harness.install({
1416
+ apiKey, gatewayBaseUrl, plan, existingRecord: record,
1417
+ privateGateway: profile === "private",
1418
+ });
1392
1419
  next = { ...record, ...installed, installed_at: record.installed_at };
1393
1420
  }
1394
1421
 
@@ -79,7 +79,7 @@ export const claudeCodeHarness = {
79
79
  };
80
80
  },
81
81
 
82
- async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
82
+ async install({ apiKey, gatewayBaseUrl, plan, existingRecord, privateGateway = false }) {
83
83
  const backupPath = await backupFile(CONFIG_PATH, "claude-code");
84
84
  const current = (await readJsonFile(CONFIG_PATH, null)) || {};
85
85
  const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
@@ -90,13 +90,23 @@ export const claudeCodeHarness = {
90
90
  current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
91
91
  delete current.env.ANTHROPIC_API_KEY;
92
92
  current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
93
- // Claude Code displays these env values directly in its model picker; the
94
- // gateway decodes the friendly labels into bundle/profile overrides.
95
- current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
96
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
97
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
98
- current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
99
- writeClaudeCodeModelPresentation(current.env);
93
+ if (privateGateway) {
94
+ // A private gateway's /v1/models response is the complete model catalog. Do not
95
+ // merge the hosted Claude defaults into that picker: they may not exist upstream.
96
+ for (const slot of ["OPUS", "SONNET", "HAIKU", "FABLE"]) {
97
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL`];
98
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_NAME`];
99
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_DESCRIPTION`];
100
+ }
101
+ } else {
102
+ // Claude Code displays these env values directly in its model picker; the
103
+ // gateway decodes the friendly labels into bundle/profile overrides.
104
+ current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
105
+ current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
106
+ current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
107
+ current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
108
+ writeClaudeCodeModelPresentation(current.env);
109
+ }
100
110
  delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
101
111
  delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
102
112
  delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
@@ -136,7 +146,7 @@ export const claudeCodeHarness = {
136
146
  // config therefore carries only the pr- bearer + base URL, not the Anthropic token.
137
147
  // The gateway pulls the stored token per request. This avoids putting the Anthropic
138
148
  // credential on disk and avoids a dual-credential configuration.
139
- async installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
149
+ async installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord, privateGateway = false }) {
140
150
  const backupPath = await backupFile(CONFIG_PATH, "claude-code");
141
151
  const current = (await readJsonFile(CONFIG_PATH, null)) || {};
142
152
  const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
@@ -146,11 +156,19 @@ export const claudeCodeHarness = {
146
156
  // Single credential only: no stale api key, no separate Anthropic bearer.
147
157
  delete current.env.ANTHROPIC_API_KEY;
148
158
  current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
149
- current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
150
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
151
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
152
- current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
153
- writeClaudeCodeModelPresentation(current.env);
159
+ if (privateGateway) {
160
+ for (const slot of ["OPUS", "SONNET", "HAIKU", "FABLE"]) {
161
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL`];
162
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_NAME`];
163
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_DESCRIPTION`];
164
+ }
165
+ } else {
166
+ current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
167
+ current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
168
+ current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
169
+ current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
170
+ writeClaudeCodeModelPresentation(current.env);
171
+ }
154
172
  delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
155
173
  delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
156
174
  delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
@@ -27,9 +27,9 @@ export const codexHarness = {
27
27
  };
28
28
  },
29
29
 
30
- async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
30
+ async install({ apiKey, gatewayBaseUrl, plan, existingRecord, privateGateway = false }) {
31
31
  const current = await readText(CONFIG_PATH, "");
32
- const subscriptionAuth = await detectCodexChatgptAuth();
32
+ const subscriptionAuth = !privateGateway && await detectCodexChatgptAuth();
33
33
  const previousModelProvider = existingRecord?.previous_model_provider
34
34
  || rootAssignment(removeManagedCodexConfig(current), "model_provider");
35
35
  const backupPath = await backupFile(CONFIG_PATH, "codex");
@@ -37,6 +37,7 @@ export const codexHarness = {
37
37
  apiKey,
38
38
  gatewayBaseUrl,
39
39
  subscriptionAuth,
40
+ privateGateway,
40
41
  surface: "codex-terminal",
41
42
  });
42
43
 
@@ -101,14 +102,15 @@ export async function detectCodexChatgptAuth() {
101
102
  */
102
103
  export function renderCodexConfig(
103
104
  text,
104
- { apiKey, gatewayBaseUrl, subscriptionAuth, surface = "codex-terminal" },
105
+ { apiKey, gatewayBaseUrl, subscriptionAuth, privateGateway = false, surface = "codex-terminal" },
105
106
  ) {
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.
107
+ // Native Codex ChatGPT subscription auth is allowed through the hosted gateway and the
108
+ // explicitly supported local development gateway. Other custom gateways use Penny auth.
109
109
  // Keep this guard at the final renderer so every install/update/desktop path gets the
110
110
  // same safety rule regardless of which profile selected it.
111
- subscriptionAuth = Boolean(subscriptionAuth && isHostedPennyRouterGateway(gatewayBaseUrl));
111
+ subscriptionAuth = Boolean(
112
+ subscriptionAuth && !privateGateway && isCodexSubscriptionAuthGateway(gatewayBaseUrl),
113
+ );
112
114
  const clean = removeManagedCodexConfig(text, { removeRootProvider: true }).trimEnd();
113
115
  if (new RegExp(`^\\s*\\[model_providers\\.${escapeRegex(PROVIDER_ID)}\\]\\s*$`, "m").test(clean)) {
114
116
  throw new Error(
@@ -156,6 +158,20 @@ export function isHostedPennyRouterGateway(gatewayBaseUrl) {
156
158
  }
157
159
  }
158
160
 
161
+ export function isLocalPennyRouterGateway(gatewayBaseUrl) {
162
+ try {
163
+ const url = new URL(String(gatewayBaseUrl));
164
+ return url.hostname.toLowerCase() === "localhost" && url.port === "8400";
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ export function isCodexSubscriptionAuthGateway(gatewayBaseUrl) {
171
+ return isHostedPennyRouterGateway(gatewayBaseUrl)
172
+ || isLocalPennyRouterGateway(gatewayBaseUrl);
173
+ }
174
+
159
175
  export function removeManagedCodexConfig(text, { removeRootProvider = false } = {}) {
160
176
  let out = String(text || "").replace(
161
177
  new RegExp(`${escapeRegex(MANAGED_START)}[\\s\\S]*?${escapeRegex(MANAGED_END)}\\n?`, "g"),
@@ -40,7 +40,6 @@ function testSubscriptionProviderIsRootSafe() {
40
40
  function testPrivateAndProxyGatewaysNeverEnableNativeSubscription() {
41
41
  assert.equal(isHostedPennyRouterGateway("https://api.pennyrouter.com"), true);
42
42
  for (const gateway of [
43
- "http://localhost:8400",
44
43
  "https://private.example.test",
45
44
  "https://openrouter.example.test",
46
45
  ]) {
@@ -49,6 +48,7 @@ function testPrivateAndProxyGatewaysNeverEnableNativeSubscription() {
49
48
  apiKey: "pr-private",
50
49
  gatewayBaseUrl: gateway,
51
50
  subscriptionAuth: true,
51
+ privateGateway: true,
52
52
  });
53
53
  assert.ok(next.includes('model_provider = "pennyrouter"'));
54
54
  assert.ok(next.includes('experimental_bearer_token = "pr-private"'));
package/src/launch.js CHANGED
@@ -24,7 +24,10 @@ import {
24
24
  upgradeClaudeCodeModelValue,
25
25
  writeClaudeCodeModelPresentation,
26
26
  } from "./harnesses/claude-code.js";
27
- import { renderCodexConfig } from "./harnesses/codex.js";
27
+ import {
28
+ isCodexSubscriptionAuthGateway,
29
+ renderCodexConfig,
30
+ } from "./harnesses/codex.js";
28
31
  import {
29
32
  OPENCODE_MODEL_CHOICES,
30
33
  fetchOpenCodeModels,
@@ -439,17 +442,13 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
439
442
  return { supported: true, ...path, shim_path: pennyShimPath(), compare };
440
443
  }
441
444
 
442
- export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
443
- // Private/proxy/custom gateways never use Codex's native subscription/WebSocket lane.
444
- // Apply the same hosted-only rule as the Codex config renderer at this launch-profile
445
+ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, privateGateway = false }) {
446
+ // Native ChatGPT subscription auth is allowed for the hosted gateway and the explicitly
447
+ // supported local development gateway. Apply the same rule as the config renderer at this
445
448
  // boundary, since this path is what bare `penny codex` selects at runtime.
446
- try {
447
- subscriptionAuth = Boolean(
448
- subscriptionAuth && new URL(String(gatewayBaseUrl)).hostname.toLowerCase() === "api.pennyrouter.com",
449
- );
450
- } catch {
451
- subscriptionAuth = false;
452
- }
449
+ subscriptionAuth = Boolean(
450
+ subscriptionAuth && !privateGateway && isCodexSubscriptionAuthGateway(gatewayBaseUrl),
451
+ );
453
452
  if (subscriptionAuth) {
454
453
  return [
455
454
  CODEX_PROFILE_MARKER,
@@ -880,7 +879,7 @@ async function writeClaudeUserSkill() {
880
879
  }
881
880
  }
882
881
 
883
- async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profile }) {
882
+ async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profile, privateGateway = false }) {
884
883
  const path = codexLaunchProfilePath(profile);
885
884
  const existing = await readText(path, "");
886
885
  if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
@@ -888,13 +887,14 @@ async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profi
888
887
  `${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
889
888
  );
890
889
  }
891
- await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }));
890
+ await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, privateGateway }));
892
891
  }
893
892
 
894
893
  export async function configurePennyHarness(harness, {
895
894
  apiKey,
896
895
  gatewayBaseUrl,
897
896
  subscriptionAuth = false,
897
+ privateGateway = false,
898
898
  models = null,
899
899
  profile = null,
900
900
  }) {
@@ -920,6 +920,7 @@ export async function configurePennyHarness(harness, {
920
920
  await writeCodexLaunchProfile({
921
921
  gatewayBaseUrl,
922
922
  subscriptionAuth,
923
+ privateGateway,
923
924
  profile: config.active_profile,
924
925
  });
925
926
  config.harnesses.codex = {
@@ -266,6 +266,25 @@ async function main() {
266
266
  assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "claude-sonnet-5[1m]");
267
267
  assert.equal("CLAUDE_CODE_MAX_CONTEXT_TOKENS" in persistedClaude.env, false);
268
268
 
269
+ // Private gateways own the model catalog. Their picker must not merge the
270
+ // hosted Claude defaults, which may not exist in the configured proxy.
271
+ await claudeCodeHarness.install({
272
+ apiKey: "pr-super-secret",
273
+ gatewayBaseUrl: "http://localhost:8400",
274
+ plan: { configPath: path.join(home, ".claude", "settings.json") },
275
+ privateGateway: true,
276
+ });
277
+ const persistedPrivateClaude = JSON.parse(
278
+ fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"),
279
+ );
280
+ for (const slot of ["OPUS", "SONNET", "HAIKU", "FABLE"]) {
281
+ assert.equal(persistedPrivateClaude.env[`ANTHROPIC_DEFAULT_${slot}_MODEL`], undefined);
282
+ assert.equal(persistedPrivateClaude.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_NAME`], undefined);
283
+ assert.equal(
284
+ persistedPrivateClaude.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_DESCRIPTION`], undefined,
285
+ );
286
+ }
287
+
269
288
  await removePennyHarness("claude-code");
270
289
  assert.ok(!fs.existsSync(claudeUserSkillPath()));
271
290
  fs.mkdirSync(path.dirname(claudeUserSkillPath()), { recursive: true });
@@ -300,6 +319,15 @@ async function main() {
300
319
  });
301
320
  assert.ok(paid.includes('env_key = "PENNYROUTER_API_KEY"'));
302
321
  assert.ok(!paid.includes("requires_openai_auth"));
322
+ const localSubscription = renderCodexLaunchProfile({
323
+ gatewayBaseUrl: "http://localhost:8400", subscriptionAuth: true,
324
+ });
325
+ assert.ok(localSubscription.includes('model_provider = "openai"'));
326
+ const localPrivate = renderCodexLaunchProfile({
327
+ gatewayBaseUrl: "http://localhost:8400", subscriptionAuth: true, privateGateway: true,
328
+ });
329
+ assert.ok(localPrivate.includes('model_provider = "pennyrouter"'));
330
+ assert.ok(localPrivate.includes('env_key = "PENNYROUTER_API_KEY"'));
303
331
 
304
332
  // Inside the fixture: testProfiles persists a config, which must land in the
305
333
  // temporary HOME and never in the developer's real ~/.config/pennyrouter.