pennyrouter 0.3.15 → 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/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 +138 -41
- package/src/launch.test.js +13 -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,13 @@ 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 >>>";
|
|
31
32
|
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)";
|
|
33
|
+
const CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed; no secrets)";
|
|
34
34
|
const CLAUDE_COMMAND_MARKER = "<!-- PennyRouter managed Claude command -->";
|
|
35
35
|
// A stable UUID keeps upgrades idempotent and avoids creating a new visible profile on
|
|
36
36
|
// every launch. It is deliberately not the user's hand-authored profile id.
|
|
@@ -172,21 +172,40 @@ export function resolvePennyProfile(config, name = null, { requireExisting = fal
|
|
|
172
172
|
profile: requested,
|
|
173
173
|
api_key: entry.api_key,
|
|
174
174
|
gateway_base_url: entry.gateway_base_url,
|
|
175
|
+
subscription_auth: entry.subscription_auth,
|
|
175
176
|
};
|
|
176
177
|
}
|
|
177
178
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
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);
|
|
180
187
|
return {
|
|
181
188
|
...config,
|
|
182
|
-
profiles: {
|
|
183
|
-
...(config.profiles || {}),
|
|
184
|
-
[name]: { api_key: apiKey, gateway_base_url: gatewayBaseUrl },
|
|
185
|
-
},
|
|
189
|
+
profiles: { ...(config.profiles || {}), [name]: next },
|
|
186
190
|
active_profile: name,
|
|
187
191
|
};
|
|
188
192
|
}
|
|
189
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
|
+
|
|
190
209
|
export async function loadPennyConfig({ profile = null, requireExisting = false } = {}) {
|
|
191
210
|
const config = (await readJsonFile(pennyConfigPath(), null)) || {
|
|
192
211
|
version: CONFIG_VERSION,
|
|
@@ -366,24 +385,17 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
|
|
|
366
385
|
return { supported: true, ...path, shim_path: pennyShimPath() };
|
|
367
386
|
}
|
|
368
387
|
|
|
369
|
-
export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
"",
|
|
381
|
-
].join("\n");
|
|
382
|
-
}
|
|
383
|
-
const authLines = [
|
|
384
|
-
'env_key = "PENNYROUTER_API_KEY"',
|
|
385
|
-
'http_headers = { "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" }',
|
|
386
|
-
];
|
|
388
|
+
export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
389
|
+
const authLines = subscriptionAuth
|
|
390
|
+
? [
|
|
391
|
+
"requires_openai_auth = true",
|
|
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" }',
|
|
394
|
+
]
|
|
395
|
+
: [
|
|
396
|
+
'env_key = "PENNYROUTER_API_KEY"',
|
|
397
|
+
'http_headers = { "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" }',
|
|
398
|
+
];
|
|
387
399
|
return [
|
|
388
400
|
CODEX_PROFILE_MARKER,
|
|
389
401
|
'model_provider = "pennyrouter"',
|
|
@@ -624,12 +636,12 @@ export async function createCodexDesktopHome({ config, realHome }) {
|
|
|
624
636
|
if (!config.api_key) throw new Error("No PennyRouter key found. Run `npx pennyrouter install` first.");
|
|
625
637
|
if (!config.gateway_base_url) throw new Error("No PennyRouter gateway configured. Run `npx pennyrouter install` first.");
|
|
626
638
|
const nativeConfig = await readText(join(realHome, "config.toml"), "");
|
|
627
|
-
const desktopConfig = renderCodexConfig(removeCodexPennyProvider(nativeConfig), {
|
|
639
|
+
const desktopConfig = setCodexDesktopDefault(renderCodexConfig(removeCodexPennyProvider(nativeConfig), {
|
|
628
640
|
apiKey: config.api_key,
|
|
629
641
|
gatewayBaseUrl: config.gateway_base_url,
|
|
630
|
-
subscriptionAuth:
|
|
642
|
+
subscriptionAuth: resolveSubscriptionAuth(config, "codex"),
|
|
631
643
|
surface: "codex-desktop",
|
|
632
|
-
});
|
|
644
|
+
}));
|
|
633
645
|
await privateWrite(join(overlay, "config.toml"), desktopConfig);
|
|
634
646
|
return overlay;
|
|
635
647
|
} catch (error) {
|
|
@@ -638,6 +650,15 @@ export async function createCodexDesktopHome({ config, realHome }) {
|
|
|
638
650
|
}
|
|
639
651
|
}
|
|
640
652
|
|
|
653
|
+
function setCodexDesktopDefault(text) {
|
|
654
|
+
const lines = String(text).split("\n");
|
|
655
|
+
const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
|
|
656
|
+
const rootEnd = firstTable < 0 ? lines.length : firstTable;
|
|
657
|
+
const root = lines.slice(0, rootEnd).filter((line) => !/^\s*model_provider\s*=/.test(line));
|
|
658
|
+
root.unshift('model_provider = "pennyrouter"');
|
|
659
|
+
return [...root, ...lines.slice(rootEnd)].join("\n");
|
|
660
|
+
}
|
|
661
|
+
|
|
641
662
|
function removeCodexPennyProvider(text) {
|
|
642
663
|
const lines = String(text).split("\n");
|
|
643
664
|
const start = lines.findIndex((line) => /^\s*\[model_providers\.pennyrouter\]\s*$/.test(line));
|
|
@@ -773,19 +794,15 @@ async function writeClaudeUserSkill() {
|
|
|
773
794
|
}
|
|
774
795
|
}
|
|
775
796
|
|
|
776
|
-
async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth,
|
|
797
|
+
async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profile }) {
|
|
777
798
|
const path = codexLaunchProfilePath(profile);
|
|
778
799
|
const existing = await readText(path, "");
|
|
779
|
-
if (existing.trim() && !
|
|
800
|
+
if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
|
|
780
801
|
throw new Error(
|
|
781
802
|
`${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
|
|
782
803
|
);
|
|
783
804
|
}
|
|
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);
|
|
805
|
+
await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }));
|
|
789
806
|
}
|
|
790
807
|
|
|
791
808
|
export async function configurePennyHarness(harness, {
|
|
@@ -817,7 +834,6 @@ export async function configurePennyHarness(harness, {
|
|
|
817
834
|
await writeCodexLaunchProfile({
|
|
818
835
|
gatewayBaseUrl,
|
|
819
836
|
subscriptionAuth,
|
|
820
|
-
apiKey,
|
|
821
837
|
profile: config.active_profile,
|
|
822
838
|
});
|
|
823
839
|
config.harnesses.codex = {
|
|
@@ -864,7 +880,7 @@ export async function removePennyHarness(harness) {
|
|
|
864
880
|
for (const name of listPennyProfiles(config)) {
|
|
865
881
|
const path = codexLaunchProfilePath(name);
|
|
866
882
|
const existing = await readText(path, "");
|
|
867
|
-
if (!
|
|
883
|
+
if (!existing.startsWith(CODEX_PROFILE_MARKER)) continue;
|
|
868
884
|
await unlink(path).catch((error) => {
|
|
869
885
|
if (error.code !== "ENOENT") throw error;
|
|
870
886
|
});
|
|
@@ -977,7 +993,82 @@ function rewriteClaudeAuthStartup(args) {
|
|
|
977
993
|
return args;
|
|
978
994
|
}
|
|
979
995
|
|
|
980
|
-
|
|
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) {
|
|
981
1072
|
const item = config.harnesses?.[harness];
|
|
982
1073
|
if (!item || !item.enabled) {
|
|
983
1074
|
throw new Error(`PennyRouter is not enabled for ${harness}. Run npx pennyrouter install.`);
|
|
@@ -1023,6 +1114,11 @@ export function buildPennyInvocation(harness, args, config) {
|
|
|
1023
1114
|
}
|
|
1024
1115
|
if (harness === "opencode") {
|
|
1025
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));
|
|
1026
1122
|
return { command: "opencode", args, env };
|
|
1027
1123
|
}
|
|
1028
1124
|
throw new Error(`Unknown Penny harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
|
|
@@ -1101,7 +1197,8 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
1101
1197
|
}
|
|
1102
1198
|
return runCodexDesktop(config, { logs, mode });
|
|
1103
1199
|
}
|
|
1104
|
-
const
|
|
1200
|
+
const models = harness === "opencode" ? await resolveOpenCodeLaunchModels(config) : null;
|
|
1201
|
+
const invocation = buildPennyInvocation(harness, argv.slice(1), config, models);
|
|
1105
1202
|
const child = spawn(invocation.command, invocation.args, {
|
|
1106
1203
|
cwd: process.cwd(),
|
|
1107
1204
|
env: invocation.env,
|
package/src/launch.test.js
CHANGED
|
@@ -183,19 +183,23 @@ async function main() {
|
|
|
183
183
|
realHome: path.join(home, ".codex"),
|
|
184
184
|
});
|
|
185
185
|
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, /
|
|
186
|
+
assert.match(desktopConfig, /^model_provider = "pennyrouter"/);
|
|
187
|
+
assert.match(desktopConfig, /\[model_providers\.pennyrouter\]/);
|
|
188
|
+
assert.match(desktopConfig, /"x-pennyrouter-key" = "pr-super-secret"/);
|
|
189
|
+
assert.match(desktopConfig, /"x-pennyrouter-surface" = "codex-desktop"/);
|
|
190
190
|
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
191
191
|
fs.rmSync(desktopHome, { recursive: true, force: true });
|
|
192
192
|
const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
|
|
193
|
-
assert.
|
|
194
|
-
assert.match(codexProfile,
|
|
195
|
-
|
|
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" \}/);
|
|
196
198
|
assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
|
|
197
|
-
assert.ok(codexProfile.includes('
|
|
198
|
-
assert.ok(codexProfile.includes('
|
|
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
|
+
assert.ok(!codexProfile.includes("pr-super-secret"));
|
|
199
203
|
assert.ok(!rc.includes("pr-super-secret"));
|
|
200
204
|
|
|
201
205
|
const claude = buildPennyInvocation("claude", ["--resume", "abc"], config);
|