pennyrouter 0.3.15 → 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.15",
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": {
@@ -51,8 +51,8 @@ export const codexHarness = {
51
51
  openai_subscription_auth: subscriptionAuth,
52
52
  previous_model_provider: previousModelProvider,
53
53
  changes: {
54
- provider: subscriptionAuth ? "openai" : PROVIDER_ID,
55
- auth: subscriptionAuth ? "openai-subscription+penny-metadata" : "penny-bearer",
54
+ provider: PROVIDER_ID,
55
+ auth: subscriptionAuth ? "openai-subscription+penny-header" : "penny-bearer",
56
56
  },
57
57
  };
58
58
  },
@@ -95,10 +95,9 @@ export async function detectCodexChatgptAuth() {
95
95
  * must live here rather than at EOF. This also migrates the old undocumented root-key
96
96
  * install (openai_base_url/openai_api_key).
97
97
  *
98
- * In subscription mode keep Codex's reserved built-in OpenAI provider selected. That is what
99
- * installs its native base instructions, additional_tools packet, and local `exec` dispatcher.
100
- * Penny authentication travels in bounded Responses metadata, leaving Codex's OAuth bearer
101
- * untouched. In Penny-only mode the custom provider contract remains unchanged.
98
+ * In subscription mode Codex owns and refreshes the OpenAI bearer. The independent
99
+ * x-pennyrouter-key header authenticates the account to our gateway. In Penny-only mode
100
+ * the pr- key is the provider bearer, matching the isolated probe's existing behavior.
102
101
  */
103
102
  export function renderCodexConfig(
104
103
  text,
@@ -113,23 +112,11 @@ export function renderCodexConfig(
113
112
  }
114
113
  const escapedKey = tomlString(apiKey);
115
114
  const escapedUrl = tomlString(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`);
116
- if (subscriptionAuth) {
117
- const block = [
118
- MANAGED_START,
119
- 'model_provider = "openai"',
120
- `openai_base_url = ${escapedUrl}`,
121
- `responses_api_metadata = { pennyrouter_key = ${escapedKey}, pennyrouter_harness = "codex", pennyrouter_surface = ${tomlString(surface)} }`,
122
- MANAGED_END,
123
- ].join("\n");
124
- const lines = clean ? clean.split("\n") : [];
125
- const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
126
- const at = firstTable < 0 ? lines.length : firstTable;
127
- lines.splice(at, 0, ...(at > 0 && lines[at - 1].trim() ? [""] : []), block,
128
- ...(at < lines.length && lines[at + 1]?.trim() ? [""] : []));
129
- return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
130
- }
131
115
  const authLines = subscriptionAuth
132
- ? []
116
+ ? [
117
+ "requires_openai_auth = true",
118
+ `http_headers = { "x-pennyrouter-key" = ${escapedKey}, "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = ${tomlString(surface)} }`,
119
+ ]
133
120
  : [
134
121
  `experimental_bearer_token = ${escapedKey}`,
135
122
  `http_headers = { "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = ${tomlString(surface)} }`,
@@ -25,13 +25,13 @@ function testSubscriptionProviderIsRootSafe() {
25
25
  subscriptionAuth: true,
26
26
  });
27
27
 
28
- assert.ok(next.indexOf('model_provider = "openai"') < next.indexOf("[features]"));
29
- assert.ok(!next.includes('[model_providers.pennyrouter]'));
30
- assert.ok(next.includes('openai_base_url = "http://localhost:8400/v1"'));
31
- assert.ok(next.includes('pennyrouter_key = "pr-test-secret"'));
32
- assert.ok(next.includes('pennyrouter_harness = "codex"'));
33
- assert.ok(next.includes('pennyrouter_surface = "codex-terminal"'));
34
- assert.ok(!next.includes("requires_openai_auth"));
28
+ assert.ok(next.indexOf('model_provider = "pennyrouter"') < next.indexOf("[features]"));
29
+ assert.ok(next.includes('[model_providers.pennyrouter]'));
30
+ assert.ok(next.includes('base_url = "http://localhost:8400/v1"'));
31
+ assert.ok(next.includes("requires_openai_auth = true"));
32
+ assert.ok(next.includes('"x-pennyrouter-key" = "pr-test-secret"'));
33
+ assert.ok(next.includes('"x-pennyrouter-harness" = "codex"'));
34
+ assert.ok(next.includes('"x-pennyrouter-surface" = "codex-terminal"'));
35
35
  assert.ok(!next.includes("experimental_bearer_token"));
36
36
  assert.equal((next.match(/# --- PennyRouter managed Codex provider ---/g) || []).length, 1);
37
37
  }
@@ -146,6 +146,36 @@ export const opencodeHarness = {
146
146
  },
147
147
  };
148
148
 
149
+ /**
150
+ * Drop the persistent PennyRouter provider block from OpenCode's config.
151
+ *
152
+ * `penny opencode` supplies the provider per launch instead. OpenCode deep-merges the
153
+ * launch config over this file rather than replacing it, so a block left here would
154
+ * union its retired models into the picker and pin every session to whichever gateway
155
+ * was installed last. It also holds an API key in plaintext that the launch path keeps
156
+ * in the process environment instead.
157
+ */
158
+ export async function stripPersistentOpenCodeProvider() {
159
+ const configPath = (await exists(JSONC_PATH)) ? JSONC_PATH : JSON_PATH;
160
+ if (!(await exists(configPath))) return false;
161
+ const current = await readConfig(configPath);
162
+ const hadProvider = Boolean(current.provider?.pennyrouter);
163
+ const hadEnabled = Array.isArray(current.enabled_providers)
164
+ && current.enabled_providers.includes("pennyrouter");
165
+ if (!hadProvider && !hadEnabled) return false;
166
+
167
+ if (hadProvider) delete current.provider.pennyrouter;
168
+ if (current.provider && Object.keys(current.provider).length === 0) delete current.provider;
169
+ if (hadEnabled) {
170
+ // Installing wrote this to surface the Penny models. Left behind it would hide every
171
+ // other provider the user configured from a plain `opencode`.
172
+ current.enabled_providers = current.enabled_providers.filter((p) => p !== "pennyrouter");
173
+ if (current.enabled_providers.length === 0) delete current.enabled_providers;
174
+ }
175
+ await atomicWrite(configPath, `${JSON.stringify(current, null, 2)}\n`);
176
+ return true;
177
+ }
178
+
149
179
  async function removeGeneratedModelCache() {
150
180
  try {
151
181
  await unlink(expandHome(MODEL_CACHE_PATH));
@@ -210,9 +240,11 @@ export function opencodeModelId(modelId) {
210
240
 
211
241
  export async function fetchOpenCodeModels(gatewayBaseUrl, apiKey, { requireFresh = false } = {}) {
212
242
  try {
243
+ // The harness header selects the gateway's OpenAI-wire catalog: it relays the
244
+ // upstream proxy's real allowlist rather than PennyRouter's discovery-only entries.
213
245
  const headers = apiKey
214
- ? { Authorization: `Bearer ${apiKey}`, "x-api-key": apiKey }
215
- : undefined;
246
+ ? { Authorization: `Bearer ${apiKey}`, "x-api-key": apiKey, "x-pennyrouter-harness": "opencode" }
247
+ : { "x-pennyrouter-harness": "opencode" };
216
248
  const response = await fetch(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1/models`, { headers });
217
249
  if (!response.ok) throw new Error(`model catalog returned ${response.status}`);
218
250
  const payload = await response.json();
@@ -66,6 +66,7 @@ async function main() {
66
66
  assert.deepEqual(request.options.headers, {
67
67
  Authorization: "Bearer pr-test",
68
68
  "x-api-key": "pr-test",
69
+ "x-pennyrouter-harness": "opencode",
69
70
  });
70
71
 
71
72
  const plan = await opencodeHarness.planInstall();
package/src/launch.js CHANGED
@@ -24,13 +24,17 @@ import {
24
24
  writeClaudeCodeModelPresentation,
25
25
  } from "./harnesses/claude-code.js";
26
26
  import { renderCodexConfig } from "./harnesses/codex.js";
27
- import { atomicWrite, homeDir, readJsonFile, readText } from "./state.js";
27
+ import {
28
+ OPENCODE_MODEL_CHOICES,
29
+ fetchOpenCodeModels,
30
+ stripPersistentOpenCodeProvider,
31
+ } from "./harnesses/opencode.js";
32
+ import { atomicWrite, homeDir, readJsonFile, readText, writeJsonFile } from "./state.js";
28
33
 
29
34
  const CONFIG_VERSION = 1;
30
35
  const PATH_START = "# >>> pennyrouter PATH >>>";
31
36
  const PATH_END = "# <<< pennyrouter PATH <<<";
32
- const CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed)";
33
- const LEGACY_CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed; no secrets)";
37
+ const CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed; no secrets)";
34
38
  const CLAUDE_COMMAND_MARKER = "<!-- PennyRouter managed Claude command -->";
35
39
  // A stable UUID keeps upgrades idempotent and avoids creating a new visible profile on
36
40
  // every launch. It is deliberately not the user's hand-authored profile id.
@@ -172,21 +176,40 @@ export function resolvePennyProfile(config, name = null, { requireExisting = fal
172
176
  profile: requested,
173
177
  api_key: entry.api_key,
174
178
  gateway_base_url: entry.gateway_base_url,
179
+ subscription_auth: entry.subscription_auth,
175
180
  };
176
181
  }
177
182
 
178
- /** Write credentials into a named profile and make it active. */
179
- export function setPennyProfile(config, name, { apiKey, gatewayBaseUrl }) {
183
+ /**
184
+ * Write credentials into a named profile and make it active. Existing keys on the profile
185
+ * are preserved: `subscription_auth` is profile-scoped and outlives a credential refresh.
186
+ */
187
+ export function setPennyProfile(config, name, { apiKey, gatewayBaseUrl, subscriptionAuth }) {
188
+ const existing = (config.profiles || {})[name] || {};
189
+ const next = { ...existing, api_key: apiKey, gateway_base_url: gatewayBaseUrl };
190
+ if (subscriptionAuth !== undefined) next.subscription_auth = Boolean(subscriptionAuth);
180
191
  return {
181
192
  ...config,
182
- profiles: {
183
- ...(config.profiles || {}),
184
- [name]: { api_key: apiKey, gateway_base_url: gatewayBaseUrl },
185
- },
193
+ profiles: { ...(config.profiles || {}), [name]: next },
186
194
  active_profile: name,
187
195
  };
188
196
  }
189
197
 
198
+ /**
199
+ * Resolve whether a harness may use subscription-backed upstream auth.
200
+ *
201
+ * Subscription auth is a property of the gateway, not the harness: a private/proxy gateway
202
+ * (Bedrock, corporate egress) has no subscription lane to route to, and asking a harness to
203
+ * attach a ChatGPT/Anthropic token there produces upstream calls it cannot complete. The
204
+ * profile value therefore wins; the harness-level value is the pre-profile fallback for
205
+ * installs written before the flag moved. Absent both, it is off.
206
+ */
207
+ export function resolveSubscriptionAuth(config, harness) {
208
+ const profileValue = config?.subscription_auth;
209
+ if (profileValue !== undefined) return Boolean(profileValue);
210
+ return Boolean(config?.harnesses?.[harness]?.subscription_auth);
211
+ }
212
+
190
213
  export async function loadPennyConfig({ profile = null, requireExisting = false } = {}) {
191
214
  const config = (await readJsonFile(pennyConfigPath(), null)) || {
192
215
  version: CONFIG_VERSION,
@@ -366,14 +389,12 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
366
389
  return { supported: true, ...path, shim_path: pennyShimPath() };
367
390
  }
368
391
 
369
- export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, apiKey }) {
392
+ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
370
393
  if (subscriptionAuth) {
371
- if (!apiKey) throw new Error("PennyRouter credential is required for Codex subscription mode.");
372
394
  return [
373
395
  CODEX_PROFILE_MARKER,
374
396
  'model_provider = "openai"',
375
- `openai_base_url = ${JSON.stringify(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`)}`,
376
- `responses_api_metadata = { pennyrouter_key = ${JSON.stringify(apiKey)}, pennyrouter_harness = "codex", pennyrouter_surface = "codex-terminal" }`,
397
+ `openai_base_url = ${JSON.stringify(String(gatewayBaseUrl).replace(/\/+$/, ""))}`,
377
398
  "",
378
399
  "[tui]",
379
400
  "terminal_title = []",
@@ -624,12 +645,12 @@ export async function createCodexDesktopHome({ config, realHome }) {
624
645
  if (!config.api_key) throw new Error("No PennyRouter key found. Run `npx pennyrouter install` first.");
625
646
  if (!config.gateway_base_url) throw new Error("No PennyRouter gateway configured. Run `npx pennyrouter install` first.");
626
647
  const nativeConfig = await readText(join(realHome, "config.toml"), "");
627
- const desktopConfig = renderCodexConfig(removeCodexPennyProvider(nativeConfig), {
648
+ const desktopConfig = setCodexDesktopDefault(renderCodexConfig(removeCodexPennyProvider(nativeConfig), {
628
649
  apiKey: config.api_key,
629
650
  gatewayBaseUrl: config.gateway_base_url,
630
- subscriptionAuth: Boolean(config.harnesses?.codex?.subscription_auth),
651
+ subscriptionAuth: resolveSubscriptionAuth(config, "codex"),
631
652
  surface: "codex-desktop",
632
- });
653
+ }));
633
654
  await privateWrite(join(overlay, "config.toml"), desktopConfig);
634
655
  return overlay;
635
656
  } catch (error) {
@@ -638,6 +659,15 @@ export async function createCodexDesktopHome({ config, realHome }) {
638
659
  }
639
660
  }
640
661
 
662
+ function setCodexDesktopDefault(text) {
663
+ const lines = String(text).split("\n");
664
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
665
+ const rootEnd = firstTable < 0 ? lines.length : firstTable;
666
+ const root = lines.slice(0, rootEnd).filter((line) => !/^\s*model_provider\s*=/.test(line));
667
+ root.unshift('model_provider = "pennyrouter"');
668
+ return [...root, ...lines.slice(rootEnd)].join("\n");
669
+ }
670
+
641
671
  function removeCodexPennyProvider(text) {
642
672
  const lines = String(text).split("\n");
643
673
  const start = lines.findIndex((line) => /^\s*\[model_providers\.pennyrouter\]\s*$/.test(line));
@@ -773,19 +803,15 @@ async function writeClaudeUserSkill() {
773
803
  }
774
804
  }
775
805
 
776
- async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, apiKey, profile }) {
806
+ async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profile }) {
777
807
  const path = codexLaunchProfilePath(profile);
778
808
  const existing = await readText(path, "");
779
- if (existing.trim() && !isManagedCodexLaunchProfile(existing)) {
809
+ if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
780
810
  throw new Error(
781
811
  `${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
782
812
  );
783
813
  }
784
- await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, apiKey }));
785
- }
786
-
787
- function isManagedCodexLaunchProfile(text) {
788
- return text.startsWith(CODEX_PROFILE_MARKER) || text.startsWith(LEGACY_CODEX_PROFILE_MARKER);
814
+ await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }));
789
815
  }
790
816
 
791
817
  export async function configurePennyHarness(harness, {
@@ -817,7 +843,6 @@ export async function configurePennyHarness(harness, {
817
843
  await writeCodexLaunchProfile({
818
844
  gatewayBaseUrl,
819
845
  subscriptionAuth,
820
- apiKey,
821
846
  profile: config.active_profile,
822
847
  });
823
848
  config.harnesses.codex = {
@@ -864,7 +889,7 @@ export async function removePennyHarness(harness) {
864
889
  for (const name of listPennyProfiles(config)) {
865
890
  const path = codexLaunchProfilePath(name);
866
891
  const existing = await readText(path, "");
867
- if (!isManagedCodexLaunchProfile(existing)) continue;
892
+ if (!existing.startsWith(CODEX_PROFILE_MARKER)) continue;
868
893
  await unlink(path).catch((error) => {
869
894
  if (error.code !== "ENOENT") throw error;
870
895
  });
@@ -977,7 +1002,87 @@ function rewriteClaudeAuthStartup(args) {
977
1002
  return args;
978
1003
  }
979
1004
 
980
- export function buildPennyInvocation(harness, args, config) {
1005
+ function openCodeModelCachePath(profile) {
1006
+ return join(pennyDataDir(), `opencode-models-${profile || DEFAULT_PROFILE}.json`);
1007
+ }
1008
+
1009
+ /**
1010
+ * Model catalog for one OpenCode launch.
1011
+ *
1012
+ * The gateway is the authority, but it is not always reachable: a private gateway sits
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.
1019
+ */
1020
+ export async function resolveOpenCodeLaunchModels(config) {
1021
+ // One-time migration off the install-time provider block. Harmless once it is gone.
1022
+ if (await stripPersistentOpenCodeProvider()) {
1023
+ console.error(
1024
+ "PennyRouter: removed the stored OpenCode provider block; "
1025
+ + "`penny opencode` now configures the provider per launch.",
1026
+ );
1027
+ }
1028
+ const cachePath = openCodeModelCachePath(config.profile);
1029
+ try {
1030
+ const models = await fetchOpenCodeModels(config.gateway_base_url, config.api_key, {
1031
+ requireFresh: true,
1032
+ });
1033
+ await writeJsonFile(cachePath, { models, fetched_at: new Date().toISOString() });
1034
+ return models;
1035
+ } catch (error) {
1036
+ const cached = await readJsonFile(cachePath, null);
1037
+ if (cached?.models?.length) {
1038
+ console.error(
1039
+ `PennyRouter: could not reach ${config.gateway_base_url} (${error.message}). `
1040
+ + `Using the model list cached ${cached.fetched_at}.`,
1041
+ );
1042
+ return cached.models;
1043
+ }
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.",
1048
+ );
1049
+ return OPENCODE_MODEL_CHOICES;
1050
+ }
1051
+ }
1052
+
1053
+ /**
1054
+ * OpenCode config declaring the PennyRouter provider for one launch.
1055
+ *
1056
+ * OpenCode deep-merges this over the on-disk config rather than replacing it, so the
1057
+ * models map here unions with any `provider.pennyrouter.models` left in opencode.json:
1058
+ * `penny opencode` removes that block so a retired model cannot survive in the picker.
1059
+ */
1060
+ export function buildOpenCodeSessionConfig(config, models) {
1061
+ return {
1062
+ provider: {
1063
+ pennyrouter: {
1064
+ npm: "@ai-sdk/openai-compatible",
1065
+ name: "PennyRouter",
1066
+ options: {
1067
+ baseURL: `${String(config.gateway_base_url).replace(/\/+$/, "")}/v1`,
1068
+ apiKey: config.api_key,
1069
+ headers: {
1070
+ "x-pennyrouter-title-session": "{env:PENNYROUTER_TITLE_SESSION_ID}",
1071
+ "x-pennyrouter-harness": "opencode",
1072
+ },
1073
+ },
1074
+ models: Object.fromEntries(
1075
+ models.map((model) => [
1076
+ model.id,
1077
+ { name: model.name, ...(model.description ? { description: model.description } : {}) },
1078
+ ]),
1079
+ ),
1080
+ },
1081
+ },
1082
+ };
1083
+ }
1084
+
1085
+ export function buildPennyInvocation(harness, args, config, models = null) {
981
1086
  const item = config.harnesses?.[harness];
982
1087
  if (!item || !item.enabled) {
983
1088
  throw new Error(`PennyRouter is not enabled for ${harness}. Run npx pennyrouter install.`);
@@ -1015,6 +1120,12 @@ export function buildPennyInvocation(harness, args, config) {
1015
1120
  // identity. Give both sides a launch-local join token so title warmth cannot bleed between
1016
1121
  // two Codex TUIs using the same Penny key.
1017
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
+ }
1018
1129
  return {
1019
1130
  command: "codex",
1020
1131
  args: ["--profile", codexProfileName(config.profile), ...args],
@@ -1023,6 +1134,11 @@ export function buildPennyInvocation(harness, args, config) {
1023
1134
  }
1024
1135
  if (harness === "opencode") {
1025
1136
  env.PENNYROUTER_TITLE_SESSION_ID = randomUUID();
1137
+ // Bind this launch to the resolved profile through OpenCode's session-scoped config
1138
+ // channel. A persistent provider block cannot express "this profile for this window":
1139
+ // it is one file shared by every OpenCode process, so two profiles cannot run at once
1140
+ // and a plain `opencode` would inherit whichever gateway was installed last.
1141
+ if (models) env.OPENCODE_CONFIG_CONTENT = JSON.stringify(buildOpenCodeSessionConfig(config, models));
1026
1142
  return { command: "opencode", args, env };
1027
1143
  }
1028
1144
  throw new Error(`Unknown Penny harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
@@ -1101,7 +1217,18 @@ export async function runPenny(argv = process.argv.slice(2)) {
1101
1217
  }
1102
1218
  return runCodexDesktop(config, { logs, mode });
1103
1219
  }
1104
- const invocation = buildPennyInvocation(harness, argv.slice(1), config);
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
+ }
1230
+ const models = harness === "opencode" ? await resolveOpenCodeLaunchModels(config) : null;
1231
+ const invocation = buildPennyInvocation(harness, argv.slice(1), config, models);
1105
1232
  const child = spawn(invocation.command, invocation.args, {
1106
1233
  cwd: process.cwd(),
1107
1234
  env: invocation.env,
@@ -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,
@@ -183,19 +185,18 @@ async function main() {
183
185
  realHome: path.join(home, ".codex"),
184
186
  });
185
187
  const desktopConfig = fs.readFileSync(path.join(desktopHome, "config.toml"), "utf8");
186
- assert.match(desktopConfig, /^model_provider = "openai"/m);
187
- assert.doesNotMatch(desktopConfig, /\[model_providers\.pennyrouter\]/);
188
- assert.match(desktopConfig, /pennyrouter_key = "pr-super-secret"/);
189
- assert.match(desktopConfig, /pennyrouter_surface = "codex-desktop"/);
188
+ assert.match(desktopConfig, /^model_provider = "pennyrouter"/);
189
+ assert.match(desktopConfig, /\[model_providers\.pennyrouter\]/);
190
+ assert.match(desktopConfig, /"x-pennyrouter-key" = "pr-super-secret"/);
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.match(codexProfile, /^# PennyRouter per-command profile \(managed\)\nmodel_provider = "openai"/);
194
- assert.match(codexProfile, /openai_base_url = "https:\/\/gateway\.example\/v1"/);
195
- assert.match(codexProfile, /pennyrouter_key = "pr-super-secret"/);
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]"));
196
198
  assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
197
- assert.ok(codexProfile.includes('pennyrouter_harness = "codex"'));
198
- assert.ok(codexProfile.includes('pennyrouter_surface = "codex-terminal"'));
199
+ assert.ok(!codexProfile.includes("pr-super-secret"));
199
200
  assert.ok(!rc.includes("pr-super-secret"));
200
201
 
201
202
  const claude = buildPennyInvocation("claude", ["--resume", "abc"], config);
@@ -224,6 +225,7 @@ async function main() {
224
225
  const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
225
226
  assert.deepEqual(codex.args, ["--profile", "pennyrouter-cloud", "resume", "--last"]);
226
227
  assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
228
+ assert.equal(codex.env.OPENAI_PROJECT, "pr-super-secret");
227
229
  const opencode = buildPennyInvocation("opencode", ["--continue"], config);
228
230
  assert.equal(opencode.env.PENNYROUTER_API_KEY, "pr-super-secret");
229
231
  assert.deepEqual(opencode, {
@@ -368,6 +370,31 @@ async function testProfiles() {
368
370
  assert.deepEqual(invocation.args, ["--profile", "pennyrouter-private"]);
369
371
  assert.ok(codexLaunchProfilePath("private").endsWith("pennyrouter-private.config.toml"));
370
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
+ );
371
398
  }
372
399
 
373
400
  main().catch((error) => {