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 +1 -1
- package/src/harnesses/codex.js +9 -22
- package/src/harnesses/codex.test.js +7 -7
- package/src/harnesses/opencode.js +34 -2
- package/src/harnesses/opencode.test.js +1 -0
- package/src/launch.js +154 -27
- package/src/launch.test.js +36 -9
package/package.json
CHANGED
package/src/harnesses/codex.js
CHANGED
|
@@ -51,8 +51,8 @@ export const codexHarness = {
|
|
|
51
51
|
openai_subscription_auth: subscriptionAuth,
|
|
52
52
|
previous_model_provider: previousModelProvider,
|
|
53
53
|
changes: {
|
|
54
|
-
provider:
|
|
55
|
-
auth: subscriptionAuth ? "openai-subscription+penny-
|
|
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
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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 = "
|
|
29
|
-
assert.ok(
|
|
30
|
-
assert.ok(next.includes('
|
|
31
|
-
assert.ok(next.includes(
|
|
32
|
-
assert.ok(next.includes('
|
|
33
|
-
assert.ok(next.includes('
|
|
34
|
-
assert.ok(
|
|
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
|
-
:
|
|
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,13 +24,17 @@ 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";
|
|
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
|
-
/**
|
|
179
|
-
|
|
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
|
|
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(
|
|
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:
|
|
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,
|
|
806
|
+
async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profile }) {
|
|
777
807
|
const path = codexLaunchProfilePath(profile);
|
|
778
808
|
const existing = await readText(path, "");
|
|
779
|
-
if (existing.trim() && !
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
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,
|
|
@@ -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 = "
|
|
187
|
-
assert.
|
|
188
|
-
assert.match(desktopConfig, /
|
|
189
|
-
assert.match(desktopConfig, /
|
|
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,
|
|
194
|
-
assert.match(codexProfile, /openai_base_url = "https:\/\/gateway\.example
|
|
195
|
-
assert.
|
|
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(
|
|
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) => {
|