pennyrouter 0.3.14 → 0.3.16

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.14",
3
+ "version": "0.3.16",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -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,7 +24,8 @@ 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 { fetchOpenCodeModels, stripPersistentOpenCodeProvider } from "./harnesses/opencode.js";
28
+ import { atomicWrite, homeDir, readJsonFile, readText, writeJsonFile } from "./state.js";
28
29
 
29
30
  const CONFIG_VERSION = 1;
30
31
  const PATH_START = "# >>> pennyrouter PATH >>>";
@@ -171,21 +172,40 @@ export function resolvePennyProfile(config, name = null, { requireExisting = fal
171
172
  profile: requested,
172
173
  api_key: entry.api_key,
173
174
  gateway_base_url: entry.gateway_base_url,
175
+ subscription_auth: entry.subscription_auth,
174
176
  };
175
177
  }
176
178
 
177
- /** Write credentials into a named profile and make it active. */
178
- export function setPennyProfile(config, name, { apiKey, gatewayBaseUrl }) {
179
+ /**
180
+ * Write credentials into a named profile and make it active. Existing keys on the profile
181
+ * are preserved: `subscription_auth` is profile-scoped and outlives a credential refresh.
182
+ */
183
+ export function setPennyProfile(config, name, { apiKey, gatewayBaseUrl, subscriptionAuth }) {
184
+ const existing = (config.profiles || {})[name] || {};
185
+ const next = { ...existing, api_key: apiKey, gateway_base_url: gatewayBaseUrl };
186
+ if (subscriptionAuth !== undefined) next.subscription_auth = Boolean(subscriptionAuth);
179
187
  return {
180
188
  ...config,
181
- profiles: {
182
- ...(config.profiles || {}),
183
- [name]: { api_key: apiKey, gateway_base_url: gatewayBaseUrl },
184
- },
189
+ profiles: { ...(config.profiles || {}), [name]: next },
185
190
  active_profile: name,
186
191
  };
187
192
  }
188
193
 
194
+ /**
195
+ * Resolve whether a harness may use subscription-backed upstream auth.
196
+ *
197
+ * Subscription auth is a property of the gateway, not the harness: a private/proxy gateway
198
+ * (Bedrock, corporate egress) has no subscription lane to route to, and asking a harness to
199
+ * attach a ChatGPT/Anthropic token there produces upstream calls it cannot complete. The
200
+ * profile value therefore wins; the harness-level value is the pre-profile fallback for
201
+ * installs written before the flag moved. Absent both, it is off.
202
+ */
203
+ export function resolveSubscriptionAuth(config, harness) {
204
+ const profileValue = config?.subscription_auth;
205
+ if (profileValue !== undefined) return Boolean(profileValue);
206
+ return Boolean(config?.harnesses?.[harness]?.subscription_auth);
207
+ }
208
+
189
209
  export async function loadPennyConfig({ profile = null, requireExisting = false } = {}) {
190
210
  const config = (await readJsonFile(pennyConfigPath(), null)) || {
191
211
  version: CONFIG_VERSION,
@@ -369,7 +389,8 @@ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
369
389
  const authLines = subscriptionAuth
370
390
  ? [
371
391
  "requires_openai_auth = true",
372
- 'env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY", "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" }',
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" }',
373
394
  ]
374
395
  : [
375
396
  'env_key = "PENNYROUTER_API_KEY"',
@@ -618,7 +639,7 @@ export async function createCodexDesktopHome({ config, realHome }) {
618
639
  const desktopConfig = setCodexDesktopDefault(renderCodexConfig(removeCodexPennyProvider(nativeConfig), {
619
640
  apiKey: config.api_key,
620
641
  gatewayBaseUrl: config.gateway_base_url,
621
- subscriptionAuth: Boolean(config.harnesses?.codex?.subscription_auth),
642
+ subscriptionAuth: resolveSubscriptionAuth(config, "codex"),
622
643
  surface: "codex-desktop",
623
644
  }));
624
645
  await privateWrite(join(overlay, "config.toml"), desktopConfig);
@@ -972,7 +993,82 @@ function rewriteClaudeAuthStartup(args) {
972
993
  return args;
973
994
  }
974
995
 
975
- export function buildPennyInvocation(harness, args, config) {
996
+ function openCodeModelCachePath(profile) {
997
+ return join(pennyDataDir(), `opencode-models-${profile || DEFAULT_PROFILE}.json`);
998
+ }
999
+
1000
+ /**
1001
+ * Model catalog for one OpenCode launch, newest first.
1002
+ *
1003
+ * 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.
1007
+ */
1008
+ export async function resolveOpenCodeLaunchModels(config) {
1009
+ // One-time migration off the install-time provider block. Harmless once it is gone.
1010
+ if (await stripPersistentOpenCodeProvider()) {
1011
+ console.error(
1012
+ "PennyRouter: removed the stored OpenCode provider block; "
1013
+ + "`penny opencode` now configures the provider per launch.",
1014
+ );
1015
+ }
1016
+ const cachePath = openCodeModelCachePath(config.profile);
1017
+ try {
1018
+ const models = await fetchOpenCodeModels(config.gateway_base_url, config.api_key, {
1019
+ requireFresh: true,
1020
+ });
1021
+ await writeJsonFile(cachePath, { models, fetched_at: new Date().toISOString() });
1022
+ return models;
1023
+ } catch (error) {
1024
+ const cached = await readJsonFile(cachePath, null);
1025
+ if (cached?.models?.length) {
1026
+ console.error(
1027
+ `PennyRouter: could not reach ${config.gateway_base_url} (${error.message}). `
1028
+ + `Using the model list cached ${cached.fetched_at}.`,
1029
+ );
1030
+ return cached.models;
1031
+ }
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.`,
1035
+ );
1036
+ }
1037
+ }
1038
+
1039
+ /**
1040
+ * OpenCode config declaring the PennyRouter provider for one launch.
1041
+ *
1042
+ * OpenCode deep-merges this over the on-disk config rather than replacing it, so the
1043
+ * models map here unions with any `provider.pennyrouter.models` left in opencode.json:
1044
+ * `penny opencode` removes that block so a retired model cannot survive in the picker.
1045
+ */
1046
+ export function buildOpenCodeSessionConfig(config, models) {
1047
+ return {
1048
+ provider: {
1049
+ pennyrouter: {
1050
+ npm: "@ai-sdk/openai-compatible",
1051
+ name: "PennyRouter",
1052
+ options: {
1053
+ baseURL: `${String(config.gateway_base_url).replace(/\/+$/, "")}/v1`,
1054
+ apiKey: config.api_key,
1055
+ headers: {
1056
+ "x-pennyrouter-title-session": "{env:PENNYROUTER_TITLE_SESSION_ID}",
1057
+ "x-pennyrouter-harness": "opencode",
1058
+ },
1059
+ },
1060
+ models: Object.fromEntries(
1061
+ models.map((model) => [
1062
+ model.id,
1063
+ { name: model.name, ...(model.description ? { description: model.description } : {}) },
1064
+ ]),
1065
+ ),
1066
+ },
1067
+ },
1068
+ };
1069
+ }
1070
+
1071
+ export function buildPennyInvocation(harness, args, config, models = null) {
976
1072
  const item = config.harnesses?.[harness];
977
1073
  if (!item || !item.enabled) {
978
1074
  throw new Error(`PennyRouter is not enabled for ${harness}. Run npx pennyrouter install.`);
@@ -1018,6 +1114,11 @@ export function buildPennyInvocation(harness, args, config) {
1018
1114
  }
1019
1115
  if (harness === "opencode") {
1020
1116
  env.PENNYROUTER_TITLE_SESSION_ID = randomUUID();
1117
+ // Bind this launch to the resolved profile through OpenCode's session-scoped config
1118
+ // channel. A persistent provider block cannot express "this profile for this window":
1119
+ // it is one file shared by every OpenCode process, so two profiles cannot run at once
1120
+ // and a plain `opencode` would inherit whichever gateway was installed last.
1121
+ if (models) env.OPENCODE_CONFIG_CONTENT = JSON.stringify(buildOpenCodeSessionConfig(config, models));
1021
1122
  return { command: "opencode", args, env };
1022
1123
  }
1023
1124
  throw new Error(`Unknown Penny harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
@@ -1096,7 +1197,8 @@ export async function runPenny(argv = process.argv.slice(2)) {
1096
1197
  }
1097
1198
  return runCodexDesktop(config, { logs, mode });
1098
1199
  }
1099
- const invocation = buildPennyInvocation(harness, argv.slice(1), config);
1200
+ const models = harness === "opencode" ? await resolveOpenCodeLaunchModels(config) : null;
1201
+ const invocation = buildPennyInvocation(harness, argv.slice(1), config, models);
1100
1202
  const child = spawn(invocation.command, invocation.args, {
1101
1203
  cwd: process.cwd(),
1102
1204
  env: invocation.env,
@@ -191,6 +191,10 @@ async function main() {
191
191
  fs.rmSync(desktopHome, { recursive: true, force: true });
192
192
  const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
193
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" \}/);
194
198
  assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
195
199
  assert.ok(codexProfile.includes('"x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID"'));
196
200
  assert.ok(codexProfile.includes('"x-pennyrouter-harness" = "codex"'));