pennyrouter 0.3.16 → 0.3.18

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.16",
3
+ "version": "0.3.18",
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,
@@ -832,7 +841,13 @@ export async function install(flags = {}) {
832
841
 
833
842
  const harnessNeededAuth = needsAuth.includes(harness);
834
843
  const codexSubscriptionAuth = harness.id === "codex"
835
- ? (harnessNeededAuth ? funding.codexSubscriptionAuth : Boolean(existingRecord?.openai_subscription_auth))
844
+ ? await codexNativeAuthAvailable({
845
+ gatewayBaseUrl: gatewayBaseUrl,
846
+ apiKey: claim.api_key,
847
+ localAuth: harnessNeededAuth
848
+ ? funding.codexSubscriptionAuth
849
+ : Boolean(existingRecord?.openai_subscription_auth),
850
+ })
836
851
  : false;
837
852
  const claudeSubscriptionAuth = harness.id === "claude-code"
838
853
  && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
@@ -1269,7 +1284,11 @@ async function update(flags) {
1269
1284
  const claudeSubscriptionAuth = record.harness === "claude-code"
1270
1285
  && Boolean(record.anthropic_token_stored);
1271
1286
  const codexSubscriptionAuth = record.harness === "codex"
1272
- ? await detectCodexChatgptAuth()
1287
+ ? await codexNativeAuthAvailable({
1288
+ gatewayBaseUrl,
1289
+ apiKey,
1290
+ localAuth: await detectCodexChatgptAuth(),
1291
+ })
1273
1292
  : false;
1274
1293
 
1275
1294
  if (supportsPennyCommand) {
@@ -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();
package/src/launch.js CHANGED
@@ -24,7 +24,11 @@ import {
24
24
  writeClaudeCodeModelPresentation,
25
25
  } from "./harnesses/claude-code.js";
26
26
  import { renderCodexConfig } from "./harnesses/codex.js";
27
- import { fetchOpenCodeModels, stripPersistentOpenCodeProvider } from "./harnesses/opencode.js";
27
+ import {
28
+ OPENCODE_MODEL_CHOICES,
29
+ fetchOpenCodeModels,
30
+ stripPersistentOpenCodeProvider,
31
+ } from "./harnesses/opencode.js";
28
32
  import { atomicWrite, homeDir, readJsonFile, readText, writeJsonFile } from "./state.js";
29
33
 
30
34
  const CONFIG_VERSION = 1;
@@ -386,16 +390,31 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
386
390
  }
387
391
 
388
392
  export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
389
- const authLines = subscriptionAuth
390
- ? [
391
- "requires_openai_auth = true",
392
- 'env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY", "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID" }',
393
- 'http_headers = { "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" }',
394
- ]
395
- : [
396
- 'env_key = "PENNYROUTER_API_KEY"',
397
- 'http_headers = { "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" }',
398
- ];
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
+ }
403
+ if (subscriptionAuth) {
404
+ return [
405
+ CODEX_PROFILE_MARKER,
406
+ 'model_provider = "openai"',
407
+ `openai_base_url = ${JSON.stringify(String(gatewayBaseUrl).replace(/\/+$/, ""))}`,
408
+ "",
409
+ "[tui]",
410
+ "terminal_title = []",
411
+ "",
412
+ ].join("\n");
413
+ }
414
+ const authLines = [
415
+ 'env_key = "PENNYROUTER_API_KEY"',
416
+ 'http_headers = { "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" }',
417
+ ];
399
418
  return [
400
419
  CODEX_PROFILE_MARKER,
401
420
  'model_provider = "pennyrouter"',
@@ -998,12 +1017,15 @@ function openCodeModelCachePath(profile) {
998
1017
  }
999
1018
 
1000
1019
  /**
1001
- * Model catalog for one OpenCode launch, newest first.
1020
+ * Model catalog for one OpenCode launch.
1002
1021
  *
1003
1022
  * The gateway is the authority, but it is not always reachable: a private gateway sits
1004
- * behind a VPN the user is not always on. Falling back to the last catalog seen for this
1005
- * profile keeps OpenCode launchable offline, and is why the catalog is cached per profile
1006
- * rather than globally -- a cloud catalog must never stand in for a private one.
1023
+ * behind a VPN the user is not always on. Fall back to the last catalog seen for this
1024
+ * profile, which is why the cache is per profile rather than global -- a cloud catalog
1025
+ * must never stand in for a private one.
1026
+ *
1027
+ * Discovery never blocks the launch. A stale or built-in list still lets the user work,
1028
+ * whereas refusing to start leaves them with no OpenCode at all over a model menu.
1007
1029
  */
1008
1030
  export async function resolveOpenCodeLaunchModels(config) {
1009
1031
  // One-time migration off the install-time provider block. Harmless once it is gone.
@@ -1029,10 +1051,12 @@ export async function resolveOpenCodeLaunchModels(config) {
1029
1051
  );
1030
1052
  return cached.models;
1031
1053
  }
1032
- throw new Error(
1033
- `PennyRouter could not fetch the model catalog from ${config.gateway_base_url} `
1034
- + `(${error.message}), and no cached catalog exists for the "${config.profile}" profile.`,
1054
+ console.error(
1055
+ `PennyRouter: could not reach ${config.gateway_base_url} (${error.message}) and no `
1056
+ + `catalog is cached for the "${config.profile}" profile. Falling back to the built-in `
1057
+ + "model list; some entries may not be available on this gateway.",
1035
1058
  );
1059
+ return OPENCODE_MODEL_CHOICES;
1036
1060
  }
1037
1061
  }
1038
1062
 
@@ -1106,6 +1130,12 @@ export function buildPennyInvocation(harness, args, config, models = null) {
1106
1130
  // identity. Give both sides a launch-local join token so title warmth cannot bleed between
1107
1131
  // two Codex TUIs using the same Penny key.
1108
1132
  env.PENNYROUTER_TITLE_SESSION_ID = randomUUID();
1133
+ if (resolveSubscriptionAuth(config, "codex")) {
1134
+ // The built-in OpenAI provider must retain Authorization for Codex's native OAuth.
1135
+ // OpenAI-Project is a standard built-in-provider header and carries the existing Penny
1136
+ // credential only as far as our gateway, which removes it before relaying upstream.
1137
+ env.OPENAI_PROJECT = config.api_key;
1138
+ }
1109
1139
  return {
1110
1140
  command: "codex",
1111
1141
  args: ["--profile", codexProfileName(config.profile), ...args],
@@ -1197,6 +1227,16 @@ export async function runPenny(argv = process.argv.slice(2)) {
1197
1227
  }
1198
1228
  return runCodexDesktop(config, { logs, mode });
1199
1229
  }
1230
+ if (harness === "codex") {
1231
+ // Re-render on every launch so an upgraded Penny command moves existing subscription
1232
+ // profiles onto Codex's built-in OpenAI provider without requiring a reinstall. Profiles
1233
+ // using API/proxy/OpenRouter funding retain the custom HTTP provider for now.
1234
+ await writeCodexLaunchProfile({
1235
+ gatewayBaseUrl: config.gateway_base_url,
1236
+ subscriptionAuth: resolveSubscriptionAuth(config, "codex"),
1237
+ profile: config.profile,
1238
+ });
1239
+ }
1200
1240
  const models = harness === "opencode" ? await resolveOpenCodeLaunchModels(config) : null;
1201
1241
  const invocation = buildPennyInvocation(harness, argv.slice(1), config, models);
1202
1242
  const child = spawn(invocation.command, invocation.args, {
@@ -30,6 +30,8 @@ import {
30
30
  selectClaudeDesktopNativeMode,
31
31
  renderCodexLaunchProfile,
32
32
  extractProfileArgs,
33
+ resolveOpenCodeLaunchModels,
34
+ buildOpenCodeSessionConfig,
33
35
  resolvePennyProfile,
34
36
  setPennyProfile,
35
37
  savePennyConfig,
@@ -185,20 +187,15 @@ async function main() {
185
187
  const desktopConfig = fs.readFileSync(path.join(desktopHome, "config.toml"), "utf8");
186
188
  assert.match(desktopConfig, /^model_provider = "pennyrouter"/);
187
189
  assert.match(desktopConfig, /\[model_providers\.pennyrouter\]/);
188
- assert.match(desktopConfig, /"x-pennyrouter-key" = "pr-super-secret"/);
190
+ assert.match(desktopConfig, /experimental_bearer_token = "pr-super-secret"/);
189
191
  assert.match(desktopConfig, /"x-pennyrouter-surface" = "codex-desktop"/);
190
192
  assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
191
193
  fs.rmSync(desktopHome, { recursive: true, force: true });
192
194
  const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
193
- assert.ok(codexProfile.includes('"x-pennyrouter-key" = "PENNYROUTER_API_KEY"'));
194
- assert.match(codexProfile,
195
- /env_http_headers = \{ "x-pennyrouter-key" = "PENNYROUTER_API_KEY", "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID" \}/);
196
- assert.match(codexProfile,
197
- /http_headers = \{ "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" \}/);
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
- assert.ok(codexProfile.includes('"x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID"'));
200
- assert.ok(codexProfile.includes('"x-pennyrouter-harness" = "codex"'));
201
- assert.ok(codexProfile.includes('"x-pennyrouter-surface" = "codex-terminal"'));
202
199
  assert.ok(!codexProfile.includes("pr-super-secret"));
203
200
  assert.ok(!rc.includes("pr-super-secret"));
204
201
 
@@ -228,6 +225,7 @@ async function main() {
228
225
  const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
229
226
  assert.deepEqual(codex.args, ["--profile", "pennyrouter-cloud", "resume", "--last"]);
230
227
  assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
228
+ assert.equal(codex.env.OPENAI_PROJECT, "pr-super-secret");
231
229
  const opencode = buildPennyInvocation("opencode", ["--continue"], config);
232
230
  assert.equal(opencode.env.PENNYROUTER_API_KEY, "pr-super-secret");
233
231
  assert.deepEqual(opencode, {
@@ -372,6 +370,31 @@ async function testProfiles() {
372
370
  assert.deepEqual(invocation.args, ["--profile", "pennyrouter-private"]);
373
371
  assert.ok(codexLaunchProfilePath("private").endsWith("pennyrouter-private.config.toml"));
374
372
  assert.notEqual(codexLaunchProfilePath("private"), codexLaunchProfilePath("cloud"));
373
+
374
+ // An unreachable gateway must not prevent OpenCode from starting: a built-in model
375
+ // list is worse than a fresh one, but far better than no editor.
376
+ const offline = { gateway_base_url: "http://127.0.0.1:1", api_key: "pr-x", profile: "cloud" };
377
+ const fallback = await resolveOpenCodeLaunchModels(offline);
378
+ assert.ok(fallback.length > 0);
379
+
380
+ // The launch config carries this profile's gateway and key, so two profiles can run
381
+ // side by side without either writing to OpenCode's shared config file.
382
+ const session = buildOpenCodeSessionConfig(
383
+ { gateway_base_url: "https://gw.example/", api_key: "pr-private" },
384
+ [{ id: "gpt-4o", name: "gpt-4o" }],
385
+ );
386
+ assert.equal(session.provider.pennyrouter.options.baseURL, "https://gw.example/v1");
387
+ assert.equal(session.provider.pennyrouter.options.apiKey, "pr-private");
388
+ assert.deepEqual(Object.keys(session.provider.pennyrouter.models), ["gpt-4o"]);
389
+
390
+ const opencodeInvocation = buildPennyInvocation(
391
+ "opencode", [], { ...resolvePennyProfile(config, "private"), harnesses: { opencode: { enabled: true } } },
392
+ [{ id: "gpt-4o", name: "gpt-4o" }],
393
+ );
394
+ assert.equal(
395
+ JSON.parse(opencodeInvocation.env.OPENCODE_CONFIG_CONTENT).provider.pennyrouter.options.apiKey,
396
+ "pr-private",
397
+ );
375
398
  }
376
399
 
377
400
  main().catch((error) => {
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