pennyrouter 0.3.26 → 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.26",
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
@@ -44,7 +44,8 @@ import {
44
44
  storeUpstreamCredential,
45
45
  } from "./session.js";
46
46
 
47
- async function codexNativeAuthAvailable({ gatewayBaseUrl, apiKey, localAuth }) {
47
+ async function codexNativeAuthAvailable({ gatewayBaseUrl, apiKey, localAuth, privateGateway = false }) {
48
+ if (privateGateway) return false;
48
49
  if (!isCodexSubscriptionAuthGateway(gatewayBaseUrl)) return false;
49
50
  // A local gateway should use Codex's detected login as the opt-in. Do not infer
50
51
  // subscription auth from some other upstream credential stored on the local gateway.
@@ -544,7 +545,7 @@ async function authProvider(flags) {
544
545
  apiKey: pennyKey,
545
546
  gatewayBaseUrl,
546
547
  subscriptionAuth: true,
547
- models: CLAUDE_CODE_MODELS,
548
+ models: flags.profile === "private" ? null : CLAUDE_CODE_MODELS,
548
549
  profile: flags.profile,
549
550
  });
550
551
  let record = existingRecord || {
@@ -561,6 +562,7 @@ async function authProvider(flags) {
561
562
  ...record,
562
563
  ...(await claudeCodeHarness.installStoredToken({
563
564
  apiKey: pennyKey, gatewayBaseUrl, plan, existingRecord,
565
+ privateGateway: flags.profile === "private",
564
566
  })),
565
567
  };
566
568
  }
@@ -892,6 +894,7 @@ export async function install(flags = {}) {
892
894
  ? await codexNativeAuthAvailable({
893
895
  gatewayBaseUrl: gatewayBaseUrl,
894
896
  apiKey: claim.api_key,
897
+ privateGateway: flags.privateGatewayFlow,
895
898
  localAuth: harnessNeededAuth
896
899
  ? funding.codexSubscriptionAuth
897
900
  : Boolean(existingRecord?.openai_subscription_auth),
@@ -907,7 +910,11 @@ export async function install(flags = {}) {
907
910
  subscriptionAuth: harness.id === "claude-code"
908
911
  ? claudeSubscriptionAuth
909
912
  : codexSubscriptionAuth,
910
- 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,
911
918
  profile: flags.profile,
912
919
  });
913
920
  }
@@ -920,9 +927,11 @@ export async function install(flags = {}) {
920
927
  record = harness.id === "claude-code" && claudeSubscriptionAuth
921
928
  ? await harness.installStoredToken({
922
929
  apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
930
+ privateGateway: flags.privateGatewayFlow,
923
931
  })
924
932
  : await harness.install({
925
933
  apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord, models: opencodeModels,
934
+ privateGateway: flags.privateGatewayFlow,
926
935
  });
927
936
  } else {
928
937
  if (hadGlobalRouting) {
@@ -1373,6 +1382,7 @@ async function update(flags) {
1373
1382
  ? await codexNativeAuthAvailable({
1374
1383
  gatewayBaseUrl,
1375
1384
  apiKey,
1385
+ privateGateway: profile === "private",
1376
1386
  localAuth: await detectCodexChatgptAuth(),
1377
1387
  })
1378
1388
  : false;
@@ -1384,7 +1394,9 @@ async function update(flags) {
1384
1394
  subscriptionAuth: record.harness === "claude-code"
1385
1395
  ? claudeSubscriptionAuth
1386
1396
  : codexSubscriptionAuth,
1387
- 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,
1388
1400
  profile,
1389
1401
  });
1390
1402
  }
@@ -1396,8 +1408,14 @@ async function update(flags) {
1396
1408
  // (previous_model_provider, claude_ai_connectors_before) survives the rewrite
1397
1409
  // and a later uninstall still restores the user's original values.
1398
1410
  const installed = record.harness === "claude-code" && claudeSubscriptionAuth
1399
- ? await harness.installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord: record })
1400
- : 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
+ });
1401
1419
  next = { ...record, ...installed, installed_at: record.installed_at };
1402
1420
  }
1403
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,14 @@ 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
107
  // Native Codex ChatGPT subscription auth is allowed through the hosted gateway and the
107
108
  // explicitly supported local development gateway. Other custom gateways use Penny auth.
108
109
  // Keep this guard at the final renderer so every install/update/desktop path gets the
109
110
  // same safety rule regardless of which profile selected it.
110
111
  subscriptionAuth = Boolean(
111
- subscriptionAuth && isCodexSubscriptionAuthGateway(gatewayBaseUrl),
112
+ subscriptionAuth && !privateGateway && isCodexSubscriptionAuthGateway(gatewayBaseUrl),
112
113
  );
113
114
  const clean = removeManagedCodexConfig(text, { removeRootProvider: true }).trimEnd();
114
115
  if (new RegExp(`^\\s*\\[model_providers\\.${escapeRegex(PROVIDER_ID)}\\]\\s*$`, "m").test(clean)) {
@@ -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
@@ -442,12 +442,12 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
442
442
  return { supported: true, ...path, shim_path: pennyShimPath(), compare };
443
443
  }
444
444
 
445
- export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
445
+ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, privateGateway = false }) {
446
446
  // Native ChatGPT subscription auth is allowed for the hosted gateway and the explicitly
447
447
  // supported local development gateway. Apply the same rule as the config renderer at this
448
448
  // boundary, since this path is what bare `penny codex` selects at runtime.
449
449
  subscriptionAuth = Boolean(
450
- subscriptionAuth && isCodexSubscriptionAuthGateway(gatewayBaseUrl),
450
+ subscriptionAuth && !privateGateway && isCodexSubscriptionAuthGateway(gatewayBaseUrl),
451
451
  );
452
452
  if (subscriptionAuth) {
453
453
  return [
@@ -879,7 +879,7 @@ async function writeClaudeUserSkill() {
879
879
  }
880
880
  }
881
881
 
882
- async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profile }) {
882
+ async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profile, privateGateway = false }) {
883
883
  const path = codexLaunchProfilePath(profile);
884
884
  const existing = await readText(path, "");
885
885
  if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
@@ -887,13 +887,14 @@ async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profi
887
887
  `${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
888
888
  );
889
889
  }
890
- await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }));
890
+ await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, privateGateway }));
891
891
  }
892
892
 
893
893
  export async function configurePennyHarness(harness, {
894
894
  apiKey,
895
895
  gatewayBaseUrl,
896
896
  subscriptionAuth = false,
897
+ privateGateway = false,
897
898
  models = null,
898
899
  profile = null,
899
900
  }) {
@@ -919,6 +920,7 @@ export async function configurePennyHarness(harness, {
919
920
  await writeCodexLaunchProfile({
920
921
  gatewayBaseUrl,
921
922
  subscriptionAuth,
923
+ privateGateway,
922
924
  profile: config.active_profile,
923
925
  });
924
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.