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 +1 -1
- package/src/launch.js +48 -18
- package/src/launch.test.js +31 -8
package/package.json
CHANGED
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 {
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
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.
|
|
1005
|
-
* profile
|
|
1006
|
-
*
|
|
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
|
-
|
|
1033
|
-
`PennyRouter could not
|
|
1034
|
-
+ `
|
|
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, {
|
package/src/launch.test.js
CHANGED
|
@@ -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.
|
|
194
|
-
assert.match(codexProfile,
|
|
195
|
-
|
|
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) => {
|