pennyrouter 0.3.16 → 0.3.17

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.17",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
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,21 @@ 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
+ if (subscriptionAuth) {
394
+ return [
395
+ CODEX_PROFILE_MARKER,
396
+ 'model_provider = "openai"',
397
+ `openai_base_url = ${JSON.stringify(String(gatewayBaseUrl).replace(/\/+$/, ""))}`,
398
+ "",
399
+ "[tui]",
400
+ "terminal_title = []",
401
+ "",
402
+ ].join("\n");
403
+ }
404
+ const authLines = [
405
+ 'env_key = "PENNYROUTER_API_KEY"',
406
+ 'http_headers = { "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" }',
407
+ ];
399
408
  return [
400
409
  CODEX_PROFILE_MARKER,
401
410
  'model_provider = "pennyrouter"',
@@ -998,12 +1007,15 @@ function openCodeModelCachePath(profile) {
998
1007
  }
999
1008
 
1000
1009
  /**
1001
- * Model catalog for one OpenCode launch, newest first.
1010
+ * Model catalog for one OpenCode launch.
1002
1011
  *
1003
1012
  * 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.
1013
+ * behind a VPN the user is not always on. Fall back to the last catalog seen for this
1014
+ * profile, which is why the cache is per profile rather than global -- a cloud catalog
1015
+ * must never stand in for a private one.
1016
+ *
1017
+ * Discovery never blocks the launch. A stale or built-in list still lets the user work,
1018
+ * whereas refusing to start leaves them with no OpenCode at all over a model menu.
1007
1019
  */
1008
1020
  export async function resolveOpenCodeLaunchModels(config) {
1009
1021
  // One-time migration off the install-time provider block. Harmless once it is gone.
@@ -1029,10 +1041,12 @@ export async function resolveOpenCodeLaunchModels(config) {
1029
1041
  );
1030
1042
  return cached.models;
1031
1043
  }
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.`,
1044
+ console.error(
1045
+ `PennyRouter: could not reach ${config.gateway_base_url} (${error.message}) and no `
1046
+ + `catalog is cached for the "${config.profile}" profile. Falling back to the built-in `
1047
+ + "model list; some entries may not be available on this gateway.",
1035
1048
  );
1049
+ return OPENCODE_MODEL_CHOICES;
1036
1050
  }
1037
1051
  }
1038
1052
 
@@ -1106,6 +1120,12 @@ export function buildPennyInvocation(harness, args, config, models = null) {
1106
1120
  // identity. Give both sides a launch-local join token so title warmth cannot bleed between
1107
1121
  // two Codex TUIs using the same Penny key.
1108
1122
  env.PENNYROUTER_TITLE_SESSION_ID = randomUUID();
1123
+ if (resolveSubscriptionAuth(config, "codex")) {
1124
+ // The built-in OpenAI provider must retain Authorization for Codex's native OAuth.
1125
+ // OpenAI-Project is a standard built-in-provider header and carries the existing Penny
1126
+ // credential only as far as our gateway, which removes it before relaying upstream.
1127
+ env.OPENAI_PROJECT = config.api_key;
1128
+ }
1109
1129
  return {
1110
1130
  command: "codex",
1111
1131
  args: ["--profile", codexProfileName(config.profile), ...args],
@@ -1197,6 +1217,16 @@ export async function runPenny(argv = process.argv.slice(2)) {
1197
1217
  }
1198
1218
  return runCodexDesktop(config, { logs, mode });
1199
1219
  }
1220
+ if (harness === "codex") {
1221
+ // Re-render on every launch so an upgraded Penny command moves existing subscription
1222
+ // profiles onto Codex's built-in OpenAI provider without requiring a reinstall. Profiles
1223
+ // using API/proxy/OpenRouter funding retain the custom HTTP provider for now.
1224
+ await writeCodexLaunchProfile({
1225
+ gatewayBaseUrl: config.gateway_base_url,
1226
+ subscriptionAuth: resolveSubscriptionAuth(config, "codex"),
1227
+ profile: config.profile,
1228
+ });
1229
+ }
1200
1230
  const models = harness === "opencode" ? await resolveOpenCodeLaunchModels(config) : null;
1201
1231
  const invocation = buildPennyInvocation(harness, argv.slice(1), config, models);
1202
1232
  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,
@@ -190,15 +192,10 @@ async function main() {
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 = "openai"/);
196
+ assert.match(codexProfile, /openai_base_url = "https:\/\/gateway\.example"/);
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) => {