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 +1 -1
- package/src/harnesses/opencode.js +34 -2
- package/src/harnesses/opencode.test.js +1 -0
- package/src/launch.js +113 -11
- package/src/launch.test.js +4 -0
package/package.json
CHANGED
|
@@ -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
|
-
:
|
|
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();
|
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 {
|
|
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
|
-
/**
|
|
178
|
-
|
|
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"
|
|
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:
|
|
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
|
-
|
|
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
|
|
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,
|
package/src/launch.test.js
CHANGED
|
@@ -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"'));
|