pennyrouter 0.3.55 → 0.3.57
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 +2 -2
- package/src/cli.js +40 -35
- package/src/codex-resume-hint.js +80 -0
- package/src/codex-resume-hint.test.js +69 -0
- package/src/launch.js +30 -3
- package/src/session.js +23 -0
- package/src/session.test.js +23 -1
- package/standalone/web-demo/compare.html +17 -14
- package/standalone/web-demo/model-choices.mjs +12 -0
- package/standalone/web-demo/proxy-models.mjs +93 -0
- package/standalone/web-demo/server.mjs +31 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pennyrouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.57",
|
|
4
4
|
"description": "Install and manage PennyRouter local coding-agent integrations.",
|
|
5
5
|
"homepage": "https://pennyrouter.com",
|
|
6
6
|
"bugs": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
27
|
"start": "node ./bin/pennyrouter.js",
|
|
28
|
-
"check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node --test src/cli.test.js src/remote.test.js src/pilots.test.js && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/harnesses/opencode.test.js && node src/launch.test.js && node src/thread-session.test.js && node src/thread-session-codex.test.js && node src/mcp-register.test.js && node src/mcp.test.js"
|
|
28
|
+
"check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node --test src/cli.test.js src/remote.test.js src/pilots.test.js && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/harnesses/opencode.test.js && node src/launch.test.js && node src/thread-session.test.js && node src/thread-session-codex.test.js && node src/codex-resume-hint.test.js && node src/mcp-register.test.js && node src/mcp.test.js"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@xterm/addon-fit": "^0.10.0",
|
package/src/cli.js
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
maskKey,
|
|
40
40
|
pollForKey,
|
|
41
41
|
probeClaudeCodeGateway,
|
|
42
|
+
checkGatewayKey,
|
|
42
43
|
provisionPrivateGatewayKey,
|
|
43
44
|
sleep,
|
|
44
45
|
storeAnthropicToken,
|
|
@@ -691,13 +692,10 @@ export async function install(flags = {}) {
|
|
|
691
692
|
const selected = await selectHarnesses(flags);
|
|
692
693
|
if (selected.length === 0) {
|
|
693
694
|
console.log("No supported harnesses selected.");
|
|
694
|
-
await printAdditionalHarnessesHint([]);
|
|
695
695
|
return;
|
|
696
696
|
}
|
|
697
697
|
|
|
698
698
|
console.log("PennyRouter install");
|
|
699
|
-
console.log("PennyRouter can be installed into multiple coding harnesses on this machine.");
|
|
700
|
-
await printAdditionalHarnessesHint(selected);
|
|
701
699
|
console.log("");
|
|
702
700
|
|
|
703
701
|
const manifest = await loadManifest();
|
|
@@ -1025,19 +1023,19 @@ export async function install(flags = {}) {
|
|
|
1025
1023
|
const mcp = await registerMcp(records.map((r) => r.harness), {
|
|
1026
1024
|
gatewayBaseUrl: customGatewayBaseUrl(gatewayBaseUrl),
|
|
1027
1025
|
});
|
|
1028
|
-
const registered = mcp.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
|
|
1029
|
-
if (registered.length) {
|
|
1030
|
-
console.log("");
|
|
1031
|
-
console.log(color("Threads MCP", "bold"));
|
|
1032
|
-
console.log(` Registered on ${registered.join(" and ")}.`);
|
|
1033
|
-
console.log(" Ask your agent to read or resume an AI share link:");
|
|
1034
|
-
console.log(' "code based on this chat: <paste a ChatGPT/Claude/Gemini/Perplexity link>"');
|
|
1035
|
-
}
|
|
1036
1026
|
for (const result of mcp.filter((r) => r.error)) {
|
|
1037
1027
|
console.log(` MCP registration skipped for ${result.harness}: ${result.error}`);
|
|
1038
1028
|
}
|
|
1039
1029
|
}
|
|
1040
1030
|
|
|
1031
|
+
if (records.some((record) => record.launch_available)) {
|
|
1032
|
+
console.log("");
|
|
1033
|
+
console.log(color("See what PennyRouter saves you", "bold"));
|
|
1034
|
+
console.log("");
|
|
1035
|
+
console.log(` ${color("penny claude --stats", "bold")} → live token usage for your session`);
|
|
1036
|
+
console.log(` ${color("penny compare", "bold")} → run native and PennyRouter head to head on this machine`);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1041
1039
|
console.log("");
|
|
1042
1040
|
reportFailures(failures);
|
|
1043
1041
|
}
|
|
@@ -1115,9 +1113,14 @@ async function obtainPrivateGatewayKey(flags, gatewayBaseUrl, existingApiKey = n
|
|
|
1115
1113
|
}
|
|
1116
1114
|
|
|
1117
1115
|
if (existingApiKey) {
|
|
1118
|
-
|
|
1116
|
+
const status = await checkGatewayKey({ gatewayBaseUrl, apiKey: existingApiKey });
|
|
1117
|
+
if (status !== "rejected") {
|
|
1118
|
+
console.log(`Using existing PennyRouter key for ${gatewayBaseUrl}.`);
|
|
1119
|
+
console.log("");
|
|
1120
|
+
return existingApiKey;
|
|
1121
|
+
}
|
|
1122
|
+
console.log(`The saved PennyRouter key for ${gatewayBaseUrl} is no longer accepted; requesting a new one.`);
|
|
1119
1123
|
console.log("");
|
|
1120
|
-
return existingApiKey;
|
|
1121
1124
|
}
|
|
1122
1125
|
|
|
1123
1126
|
let email = (flags.email || "").trim();
|
|
@@ -1536,6 +1539,13 @@ async function chooseDefaultRouting(flags, selected, manifest) {
|
|
|
1536
1539
|
async function selectClaudeFundingProviders(flags, selected) {
|
|
1537
1540
|
if (!selected.some((harness) => harness.id === "claude-code")) return new Set();
|
|
1538
1541
|
|
|
1542
|
+
// A --config file states its providers outright, and that choice stands whether or not the
|
|
1543
|
+
// file also carried their secrets: a file shared with a team deliberately omits those, and
|
|
1544
|
+
// configureUserFunding prompts for what is missing (or refuses, with no TTY to prompt on).
|
|
1545
|
+
// Ambient credentials (an exported
|
|
1546
|
+
// ANTHROPIC_AUTH_TOKEN, a leftover API key) must not add providers the file did not ask for.
|
|
1547
|
+
if (flags.configClaudeFunding?.size) return new Set(flags.configClaudeFunding);
|
|
1548
|
+
|
|
1539
1549
|
const supplied = new Set();
|
|
1540
1550
|
if (!flags.noAnthropicAuth && (flags.anthropicAuth || flags.token || flags.tokenCommand
|
|
1541
1551
|
|| process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
|
|
@@ -1547,13 +1557,6 @@ async function selectClaudeFundingProviders(flags, selected) {
|
|
|
1547
1557
|
|| process.env.PENNYROUTER_PROXY_API_KEY)) {
|
|
1548
1558
|
supplied.add("anthropic-proxy");
|
|
1549
1559
|
}
|
|
1550
|
-
// A --config file states its providers outright, and that choice stands whether or not the
|
|
1551
|
-
// file also carried their secrets: a file shared with a team deliberately omits those, and
|
|
1552
|
-
// configureUserFunding prompts for what is missing (or refuses, with no TTY to prompt on).
|
|
1553
|
-
// Without this, an option whose secret was left out is silently dropped and the install
|
|
1554
|
-
// completes having configured nothing.
|
|
1555
|
-
for (const id of flags.configClaudeFunding || []) supplied.add(id);
|
|
1556
|
-
|
|
1557
1560
|
// Explicit flags are the automation selector. Interactive installs always default to the
|
|
1558
1561
|
// subscription alone; environment keys are used if the user checks their provider.
|
|
1559
1562
|
const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
|
|
@@ -1587,12 +1590,13 @@ async function selectClaudeFundingProviders(flags, selected) {
|
|
|
1587
1590
|
async function selectCodexFundingProviders(flags, selected) {
|
|
1588
1591
|
if (!selected.some((harness) => harness.id === "codex")) return new Set();
|
|
1589
1592
|
|
|
1593
|
+
if (flags.configCodexFunding?.size) return new Set(flags.configCodexFunding);
|
|
1594
|
+
|
|
1590
1595
|
const supplied = new Set();
|
|
1591
1596
|
if (flags.openaiApiKey || process.env.OPENAI_API_KEY) supplied.add("openai-api");
|
|
1592
1597
|
if ((flags.openaiProxyBaseUrl || process.env.PENNYROUTER_OPENAI_PROXY_BASE_URL)
|
|
1593
1598
|
&& (flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY)) supplied.add("openai-proxy");
|
|
1594
1599
|
if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
|
|
1595
|
-
for (const id of flags.configCodexFunding || []) supplied.add(id);
|
|
1596
1600
|
const hasChatgptLogin = await detectCodexChatgptAuth();
|
|
1597
1601
|
if (hasChatgptLogin) supplied.add("subscription");
|
|
1598
1602
|
|
|
@@ -1711,6 +1715,21 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1711
1715
|
? await detectCodexChatgptAuth()
|
|
1712
1716
|
: false;
|
|
1713
1717
|
|
|
1718
|
+
// A key only counts when its provider was chosen: one left in the environment must not be
|
|
1719
|
+
// stored on the gateway for an install that asked for a different provider.
|
|
1720
|
+
if (!(wantsClaude && claudeProviders.has("anthropic-api"))) anthropicApiKey = "";
|
|
1721
|
+
if (!(wantsCodex && codexProviders.has("openai-api"))) openaiApiKey = "";
|
|
1722
|
+
if (!(wantsClaude && claudeProviders.has("openrouter"))
|
|
1723
|
+
&& !(wantsCodex && codexProviders.has("openrouter"))) openrouterApiKey = "";
|
|
1724
|
+
if (!(wantsCodex && codexProviders.has("openai-proxy"))) {
|
|
1725
|
+
openaiProxyBaseUrl = "";
|
|
1726
|
+
openaiProxyApiKey = "";
|
|
1727
|
+
}
|
|
1728
|
+
if (!(wantsClaude && claudeProviders.has("anthropic-proxy"))) {
|
|
1729
|
+
proxyBaseUrl = "";
|
|
1730
|
+
proxyApiKey = "";
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1714
1733
|
if (wantsClaude && claudeProviders.has("anthropic-api") && !anthropicApiKey) {
|
|
1715
1734
|
if (!interactive) throw new Error("Anthropic API was selected, but no key was provided.");
|
|
1716
1735
|
anthropicApiKey = (await promptSecretish("Anthropic API key: ")).trim();
|
|
@@ -2150,20 +2169,6 @@ async function selectHarnesses(flags) {
|
|
|
2150
2169
|
return promptHarnessCheckboxes(available, detected);
|
|
2151
2170
|
}
|
|
2152
2171
|
|
|
2153
|
-
async function printAdditionalHarnessesHint(selected) {
|
|
2154
|
-
const selectedIds = new Set(selected.map(({ id }) => id));
|
|
2155
|
-
const available = [];
|
|
2156
|
-
for (const harness of Object.values(HARNESS_BY_ID)) {
|
|
2157
|
-
if (selectedIds.has(harness.id)) continue;
|
|
2158
|
-
if (!(await safeDetect(harness))) available.push(harness.name);
|
|
2159
|
-
}
|
|
2160
|
-
if (available.length === 0) return;
|
|
2161
|
-
const names = available.length === 1
|
|
2162
|
-
? available[0]
|
|
2163
|
-
: `${available.slice(0, -1).join(", ")}, or ${available.at(-1)}`;
|
|
2164
|
-
console.log(`To use PennyRouter with ${names}, install ${available.length === 1 ? "it" : "them"} and re-run \`npx pennyrouter install\`.`);
|
|
2165
|
-
}
|
|
2166
|
-
|
|
2167
2172
|
async function promptHarnessCheckboxes(available, detected) {
|
|
2168
2173
|
const detectedIds = new Set(detected.map((harness) => harness.id));
|
|
2169
2174
|
const selectedIds = new Set(detectedIds);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Find the Codex sessions a `penny codex` launch wrote to: rollout files under
|
|
6
|
+
* `<codexHome>/sessions` modified since the launch began whose session_meta records the
|
|
7
|
+
* launch directory. Resumed sessions keep their original date directory, so the whole
|
|
8
|
+
* tree is walked rather than just today's.
|
|
9
|
+
*/
|
|
10
|
+
export async function findCodexSessionsSince(codexHome, sinceMs, cwd) {
|
|
11
|
+
const found = [];
|
|
12
|
+
for (const path of await rolloutFiles(join(codexHome, "sessions"))) {
|
|
13
|
+
let info;
|
|
14
|
+
try {
|
|
15
|
+
info = await stat(path);
|
|
16
|
+
} catch {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (info.mtimeMs < sinceMs) continue;
|
|
20
|
+
const meta = await readSessionMeta(path);
|
|
21
|
+
if (meta?.id && meta.cwd === cwd) {
|
|
22
|
+
found.push({ id: meta.id, modelProvider: meta.model_provider || null, mtimeMs: info.mtimeMs });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return found.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Commands to continue a session either through Penny or in plain Codex. `profile` is
|
|
30
|
+
* null when the launch used the active profile, which a bare `penny codex` selects. A session
|
|
31
|
+
* recorded under Penny's custom provider names a provider plain Codex does not define,
|
|
32
|
+
* so the plain command switches it to the built-in OpenAI provider.
|
|
33
|
+
*/
|
|
34
|
+
export function renderCodexResumeHint(session, profile) {
|
|
35
|
+
const profileFlag = !profile ? ""
|
|
36
|
+
: profile === "cloud" || profile === "private" ? ` --${profile}`
|
|
37
|
+
: ` --penny-profile ${profile}`;
|
|
38
|
+
const native = session.modelProvider && session.modelProvider !== "openai"
|
|
39
|
+
? `codex resume ${session.id} -c model_provider=openai`
|
|
40
|
+
: `codex resume ${session.id}`;
|
|
41
|
+
return [
|
|
42
|
+
"Resume this session:",
|
|
43
|
+
` through PennyRouter: penny codex${profileFlag} resume ${session.id}`,
|
|
44
|
+
` in plain Codex: ${native}`,
|
|
45
|
+
].join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function rolloutFiles(dir) {
|
|
49
|
+
let entries;
|
|
50
|
+
try {
|
|
51
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const files = [];
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const path = join(dir, entry.name);
|
|
58
|
+
if (entry.isDirectory()) files.push(...await rolloutFiles(path));
|
|
59
|
+
else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) files.push(path);
|
|
60
|
+
}
|
|
61
|
+
return files;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function readSessionMeta(path) {
|
|
65
|
+
let handle;
|
|
66
|
+
try {
|
|
67
|
+
handle = await open(path, "r");
|
|
68
|
+
let text = "";
|
|
69
|
+
for await (const line of handle.readLines()) {
|
|
70
|
+
text = line;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
const record = JSON.parse(text);
|
|
74
|
+
return record?.type === "session_meta" ? record.payload : null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
} finally {
|
|
78
|
+
await handle?.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { findCodexSessionsSince, renderCodexResumeHint } from "./codex-resume-hint.js";
|
|
7
|
+
|
|
8
|
+
function writeRollout(codexHome, day, id, { cwd, modelProvider = "pennyrouter", mtimeMs }) {
|
|
9
|
+
const dir = path.join(codexHome, "sessions", ...day.split("/"));
|
|
10
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
11
|
+
const file = path.join(dir, `rollout-${day.replaceAll("/", "-")}T10-00-00-${id}.jsonl`);
|
|
12
|
+
const meta = { type: "session_meta", payload: { id, cwd, model_provider: modelProvider } };
|
|
13
|
+
fs.writeFileSync(file, `${JSON.stringify(meta)}\n{"type":"response_item"}\n`);
|
|
14
|
+
fs.utimesSync(file, mtimeMs / 1000, mtimeMs / 1000);
|
|
15
|
+
return file;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function testFindsOnlySessionsWrittenSinceLaunchInCwd() {
|
|
19
|
+
const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "penny-codex-hint-"));
|
|
20
|
+
const launchedAt = Date.now() - 60_000;
|
|
21
|
+
writeRollout(codexHome, "2026/10/07", "fresh", { cwd: "/work/a", mtimeMs: launchedAt + 10_000 });
|
|
22
|
+
// A resumed session keeps its original date directory.
|
|
23
|
+
writeRollout(codexHome, "2026/09/01", "resumed", { cwd: "/work/a", mtimeMs: launchedAt + 20_000 });
|
|
24
|
+
writeRollout(codexHome, "2026/10/07", "stale", { cwd: "/work/a", mtimeMs: launchedAt - 10_000 });
|
|
25
|
+
writeRollout(codexHome, "2026/10/07", "elsewhere", { cwd: "/work/b", mtimeMs: launchedAt + 10_000 });
|
|
26
|
+
const junkDir = path.join(codexHome, "sessions", "2026", "10", "07");
|
|
27
|
+
fs.writeFileSync(path.join(junkDir, "rollout-broken.jsonl"), "not json\n");
|
|
28
|
+
fs.writeFileSync(path.join(junkDir, "notes.txt"), "ignore me\n");
|
|
29
|
+
|
|
30
|
+
const sessions = await findCodexSessionsSince(codexHome, launchedAt, "/work/a");
|
|
31
|
+
assert.deepEqual(sessions.map((s) => s.id), ["resumed", "fresh"]);
|
|
32
|
+
assert.equal(sessions[0].modelProvider, "pennyrouter");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function testMissingSessionsDirectory() {
|
|
36
|
+
const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "penny-codex-hint-empty-"));
|
|
37
|
+
assert.deepEqual(await findCodexSessionsSince(codexHome, 0, "/work/a"), []);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function testRenderHint() {
|
|
41
|
+
assert.match(
|
|
42
|
+
renderCodexResumeHint({ id: "abc", modelProvider: "pennyrouter" }, null),
|
|
43
|
+
/through PennyRouter: +penny codex resume abc$/m,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const penny = renderCodexResumeHint({ id: "abc", modelProvider: "pennyrouter" }, "cloud");
|
|
47
|
+
assert.match(penny, /penny codex --cloud resume abc/);
|
|
48
|
+
assert.match(penny, /codex resume abc -c model_provider=openai$/m);
|
|
49
|
+
|
|
50
|
+
const subscription = renderCodexResumeHint({ id: "abc", modelProvider: "openai" }, "private");
|
|
51
|
+
assert.match(subscription, /penny codex --private resume abc/);
|
|
52
|
+
assert.match(subscription, /in plain Codex: +codex resume abc$/m);
|
|
53
|
+
|
|
54
|
+
assert.match(
|
|
55
|
+
renderCodexResumeHint({ id: "abc", modelProvider: "pennyrouter" }, "work"),
|
|
56
|
+
/penny codex --penny-profile work resume abc/,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function main() {
|
|
61
|
+
await testFindsOnlySessionsWrittenSinceLaunchInCwd();
|
|
62
|
+
await testMissingSessionsDirectory();
|
|
63
|
+
testRenderHint();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
main().catch((error) => {
|
|
67
|
+
console.error(error);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
});
|
package/src/launch.js
CHANGED
|
@@ -24,6 +24,7 @@ import { configureClaudeCodeStatusline } from "./statusline.js";
|
|
|
24
24
|
import { runPennyRemote } from "./remote.js";
|
|
25
25
|
import { extractPilotArgs, resolvePilotCapabilities, pilotSettings, pilotClaudeArgs } from "./pilots.js";
|
|
26
26
|
import { quickSystemPrompt } from "./quick-context.js";
|
|
27
|
+
import { findCodexSessionsSince, renderCodexResumeHint } from "./codex-resume-hint.js";
|
|
27
28
|
import {
|
|
28
29
|
fetchClaudeCodeModels,
|
|
29
30
|
upgradeClaudeCodeModelValue,
|
|
@@ -1148,13 +1149,15 @@ async function writeCodexLaunchProfile({
|
|
|
1148
1149
|
rootTomlString(native, "model"),
|
|
1149
1150
|
)
|
|
1150
1151
|
: null;
|
|
1151
|
-
|
|
1152
|
+
const text = renderCodexLaunchProfile({
|
|
1152
1153
|
gatewayBaseUrl,
|
|
1153
1154
|
subscriptionAuth,
|
|
1154
1155
|
privateGateway,
|
|
1155
1156
|
modelCatalogPath,
|
|
1156
1157
|
model,
|
|
1157
|
-
})
|
|
1158
|
+
});
|
|
1159
|
+
await privateWrite(path, text);
|
|
1160
|
+
return { modelProvider: rootTomlString(text, "model_provider") };
|
|
1158
1161
|
}
|
|
1159
1162
|
|
|
1160
1163
|
export async function configurePennyHarness(harness, {
|
|
@@ -1688,13 +1691,14 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
1688
1691
|
}
|
|
1689
1692
|
return runCodexDesktop(config, { logs, mode });
|
|
1690
1693
|
}
|
|
1694
|
+
let codexProfile = null;
|
|
1691
1695
|
if (harness === "codex") {
|
|
1692
1696
|
// Re-render on every launch so an upgraded Penny command moves existing subscription
|
|
1693
1697
|
// profiles onto Codex's built-in OpenAI provider without requiring a reinstall. Profiles
|
|
1694
1698
|
// using API/proxy/OpenRouter funding retain the custom HTTP provider for now. Only a
|
|
1695
1699
|
// gateway that explicitly identifies itself as private may replace Codex's built-in catalog.
|
|
1696
1700
|
const catalog = await resolveCodexLaunchCatalog(config);
|
|
1697
|
-
await writeCodexLaunchProfile({
|
|
1701
|
+
codexProfile = await writeCodexLaunchProfile({
|
|
1698
1702
|
gatewayBaseUrl: config.gateway_base_url,
|
|
1699
1703
|
subscriptionAuth: resolveSubscriptionAuth(config, "codex"),
|
|
1700
1704
|
profile: config.profile,
|
|
@@ -1732,6 +1736,7 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
1732
1736
|
}
|
|
1733
1737
|
if (harness === "opencode") models = await resolveOpenCodeLaunchModels(config);
|
|
1734
1738
|
const invocation = buildPennyInvocation(harness, argv.slice(1), config, models);
|
|
1739
|
+
const launchedAtMs = Date.now();
|
|
1735
1740
|
const child = spawn(invocation.command, invocation.args, {
|
|
1736
1741
|
cwd: process.cwd(),
|
|
1737
1742
|
env: invocation.env,
|
|
@@ -1756,10 +1761,32 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
1756
1761
|
if (config._pilotSettingsPath) await rm(config._pilotSettingsPath, { force: true });
|
|
1757
1762
|
if (config._quickPromptPath) await rm(config._quickPromptPath, { force: true });
|
|
1758
1763
|
}
|
|
1764
|
+
if (harness === "codex") await printCodexResumeHint(
|
|
1765
|
+
launchedAtMs,
|
|
1766
|
+
config.profile === activePennyProfile(config) ? null : config.profile,
|
|
1767
|
+
codexProfile.modelProvider,
|
|
1768
|
+
);
|
|
1759
1769
|
if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
|
|
1760
1770
|
return result.code ?? 1;
|
|
1761
1771
|
}
|
|
1762
1772
|
|
|
1773
|
+
async function printCodexResumeHint(launchedAtMs, profile, modelProvider) {
|
|
1774
|
+
const codexHome = process.env.CODEX_HOME || join(homeDir(), ".codex");
|
|
1775
|
+
let sessions;
|
|
1776
|
+
try {
|
|
1777
|
+
sessions = (await findCodexSessionsSince(codexHome, launchedAtMs, process.cwd()))
|
|
1778
|
+
.filter((session) => session.modelProvider === modelProvider);
|
|
1779
|
+
} catch {
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
// Two Penny Codex windows in the same directory can both write during this launch; a
|
|
1783
|
+
// guessed id could resume the wrong conversation, so only an unambiguous match is named.
|
|
1784
|
+
if (sessions.length === 1) console.log(`\n${renderCodexResumeHint(sessions[0], profile)}`);
|
|
1785
|
+
else if (sessions.length > 1) {
|
|
1786
|
+
console.log(`\n${renderCodexResumeHint({ ...sessions[0], id: "<session-id>" }, profile)}`);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1763
1790
|
async function runClaudeDesktop(config, { logs = false, mode = "gateway" } = {}) {
|
|
1764
1791
|
if (isClaudeDesktopRunning()) {
|
|
1765
1792
|
throw new Error(
|
package/src/session.js
CHANGED
|
@@ -66,11 +66,34 @@ export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider
|
|
|
66
66
|
} catch {
|
|
67
67
|
// non-JSON body; fall through to status-only message
|
|
68
68
|
}
|
|
69
|
+
if (response.status === 401 || response.status === 403) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`The gateway at ${gatewayBaseUrl} rejected your PennyRouter key (HTTP ${response.status}), `
|
|
72
|
+
+ `so the ${provider} credential was not stored. Re-run with \`--private-key pr-...\`, `
|
|
73
|
+
+ "or remove the saved key to have a new one issued.",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
69
76
|
throw new Error(`Storing ${provider} credential failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`);
|
|
70
77
|
}
|
|
71
78
|
return response.json();
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
// Whether the gateway still honors a saved key: "valid", "rejected" (the gateway answered 401
|
|
82
|
+
// or 403), or "unknown" when it could not be asked. Only "rejected" is worth acting on; an
|
|
83
|
+
// unreachable gateway fails loudly later with its own message.
|
|
84
|
+
export async function checkGatewayKey({ gatewayBaseUrl, apiKey, timeoutMs = 8000 }) {
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(`${gatewayBaseUrl.replace(/\/+$/, "")}/v1/auth/capabilities`, {
|
|
87
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
88
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
89
|
+
});
|
|
90
|
+
if (response.ok) return "valid";
|
|
91
|
+
return response.status === 401 || response.status === 403 ? "rejected" : "unknown";
|
|
92
|
+
} catch {
|
|
93
|
+
return "unknown";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
export async function fetchGatewayCapabilities({ gatewayBaseUrl, apiKey, timeoutMs = 5000 }) {
|
|
75
98
|
try {
|
|
76
99
|
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/auth/capabilities`;
|
package/src/session.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { checkGatewayKey, createCliSession, probeClaudeCodeGateway, storeUpstreamCredential } from "./session.js";
|
|
3
4
|
|
|
4
5
|
const originalFetch = globalThis.fetch;
|
|
5
6
|
|
|
@@ -42,3 +43,24 @@ try {
|
|
|
42
43
|
} finally {
|
|
43
44
|
globalThis.fetch = originalFetch;
|
|
44
45
|
}
|
|
46
|
+
|
|
47
|
+
async function withStubGateway(status, run) {
|
|
48
|
+
const server = createServer((_, res) => { res.statusCode = status; res.end("{}"); });
|
|
49
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
50
|
+
try {
|
|
51
|
+
return await run(`http://127.0.0.1:${server.address().port}`);
|
|
52
|
+
} finally {
|
|
53
|
+
server.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A rejected key is distinguishable from a gateway that could not be asked, and a 401 while
|
|
58
|
+
// storing a credential blames the PennyRouter key rather than the credential being stored.
|
|
59
|
+
assert.equal(await withStubGateway(200, (u) => checkGatewayKey({ gatewayBaseUrl: u, apiKey: "pr-x" })), "valid");
|
|
60
|
+
assert.equal(await withStubGateway(401, (u) => checkGatewayKey({ gatewayBaseUrl: u, apiKey: "pr-x" })), "rejected");
|
|
61
|
+
assert.equal(await withStubGateway(500, (u) => checkGatewayKey({ gatewayBaseUrl: u, apiKey: "pr-x" })), "unknown");
|
|
62
|
+
assert.equal(await checkGatewayKey({ gatewayBaseUrl: "http://127.0.0.1:1", apiKey: "pr-x" }), "unknown");
|
|
63
|
+
await withStubGateway(401, (u) => assert.rejects(
|
|
64
|
+
storeUpstreamCredential({ gatewayBaseUrl: u, apiKey: "pr-x", provider: "anthropic_oauth", token: "t" }),
|
|
65
|
+
/rejected your PennyRouter key/,
|
|
66
|
+
));
|
|
@@ -267,7 +267,7 @@
|
|
|
267
267
|
<label>Right label <input id="right-custom-label" placeholder="PENNY"></label>
|
|
268
268
|
</div>
|
|
269
269
|
<label>Model (both panes)
|
|
270
|
-
<input id="model" placeholder="
|
|
270
|
+
<input id="model" placeholder="Loading models…" autocomplete="off" spellcheck="false"></label>
|
|
271
271
|
<details id="environment">
|
|
272
272
|
<summary>Environment <span class="choice" id="environment-choice"></span></summary>
|
|
273
273
|
<label>Provider
|
|
@@ -1534,15 +1534,9 @@
|
|
|
1534
1534
|
select.append(option);
|
|
1535
1535
|
}
|
|
1536
1536
|
}
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
claude: [['opus', 'Claude Opus 5.5'], ['sonnet', 'Claude Sonnet 5.5'], ['haiku', 'Claude Haiku 4.5'],
|
|
1541
|
-
['fable', 'Claude Fable 5.1']],
|
|
1542
|
-
codex: [['gpt-6.1-sol', 'GPT-6.1 Sol'], ['gpt-6-astra', 'GPT-6 Astra'], ['gpt-6-sol', 'GPT-6 Sol'],
|
|
1543
|
-
['gpt-6-luna', 'GPT-6 Luna'], ['gpt-5.6-sol', 'GPT-5.6 Sol'], ['gpt-5.6-terra', 'GPT-5.6 Terra'],
|
|
1544
|
-
['gpt-5.6-luna', 'GPT-5.6 Luna'], ['gpt-5.5', 'GPT-5.5']],
|
|
1545
|
-
};
|
|
1537
|
+
// Filled from the server's /models, which lists Codex models from Codex's configured proxy.
|
|
1538
|
+
let modelChoices = { claude: [], codex: [] };
|
|
1539
|
+
let modelsLoaded = false;
|
|
1546
1540
|
$('left-variant').value = config.defaultHarness;
|
|
1547
1541
|
$('right-variant').value = `penny-${config.defaultHarness}`;
|
|
1548
1542
|
$('left-cwd').value = config.folders.left;
|
|
@@ -1603,17 +1597,26 @@
|
|
|
1603
1597
|
const model = $('model');
|
|
1604
1598
|
model.disabled = leftCodex !== rightCodex;
|
|
1605
1599
|
const family = leftCodex && rightCodex ? 'codex' : !leftCodex && !rightCodex ? 'claude' : null;
|
|
1606
|
-
|
|
1600
|
+
modelSuggestions = family ? modelChoices[family].map(([value, label]) => ({ value, label })) : [];
|
|
1601
|
+
if (family !== modelFamily || !model.value) model.value = modelSuggestions[0]?.label || '';
|
|
1607
1602
|
modelFamily = family;
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
model.placeholder = family ? 'Harness default' : 'Pick panes from the same harness to choose a model';
|
|
1603
|
+
model.placeholder = !family ? 'Pick panes from the same harness to choose a model'
|
|
1604
|
+
: !modelsLoaded ? 'Loading models…' : 'No models available';
|
|
1611
1605
|
refreshOptions();
|
|
1612
1606
|
refreshPickers();
|
|
1613
1607
|
};
|
|
1614
1608
|
$('left-variant').onchange = refreshVariants;
|
|
1615
1609
|
$('right-variant').onchange = refreshVariants;
|
|
1616
1610
|
refreshVariants();
|
|
1611
|
+
fetch('/models', { cache: 'no-store' }).then((response) => response.json()).then((choices) => {
|
|
1612
|
+
modelChoices = { claude: choices.claude, codex: choices.codex };
|
|
1613
|
+
if (choices.codexError) $('local-error').textContent = choices.codexError;
|
|
1614
|
+
}).catch(() => {
|
|
1615
|
+
$('local-error').textContent = 'Could not load the model list. Reload to try again.';
|
|
1616
|
+
}).finally(() => {
|
|
1617
|
+
modelsLoaded = true;
|
|
1618
|
+
refreshVariants();
|
|
1619
|
+
});
|
|
1617
1620
|
if (config.compare) {
|
|
1618
1621
|
$('setup').hidden = false;
|
|
1619
1622
|
$('local-setup').hidden = false;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Models a demo pane may be launched with, as [alias, label]. Claude Code resolves the Claude
|
|
2
|
+
// aliases (and the penny wrapper maps them to the gateway's ids); the label names the current model.
|
|
3
|
+
export const modelChoices = {
|
|
4
|
+
claude: [['opus', 'Claude Opus 5.5'], ['sonnet', 'Claude Sonnet 5.5'], ['haiku', 'Claude Haiku 4.5'],
|
|
5
|
+
['fable', 'Claude Fable 5.1']],
|
|
6
|
+
codex: [['gpt-6.1-sol', 'GPT-6.1 Sol'], ['gpt-6-astra', 'GPT-6 Astra'], ['gpt-6-sol', 'GPT-6 Sol'],
|
|
7
|
+
['gpt-6-luna', 'GPT-6 Luna'], ['gpt-5.6-sol', 'GPT-5.6 Sol'], ['gpt-5.6-terra', 'GPT-5.6 Terra'],
|
|
8
|
+
['gpt-5.6-luna', 'GPT-5.6 Luna'], ['gpt-5.5', 'GPT-5.5']],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const isModelChoice = (harness, model) =>
|
|
12
|
+
model === '' || Boolean(modelChoices[harness]?.some(([alias]) => alias === model));
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Codex model choices from the proxy Codex is configured to use, as [id, label].
|
|
2
|
+
//
|
|
3
|
+
// When ~/.codex/config.toml selects a custom provider (codex-proxy.sh writes one), the proxy's
|
|
4
|
+
// own /models list is the set both panes can actually run, so the picker offers that list
|
|
5
|
+
// instead of a fixed one. Codex's prompts and tools target recent GPT releases, so only GPT
|
|
6
|
+
// 5.4 and newer are offered, matching what the PennyRouter gateway lists to Codex.
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const minimumGptVersion = [5, 4];
|
|
12
|
+
const gptVersionId = /^(?:.+\/)?gpt-(\d+)(?:\.(\d+))?(?:-|$)/i;
|
|
13
|
+
|
|
14
|
+
export const codexConfigPath = (env = process.env) =>
|
|
15
|
+
join(env.CODEX_HOME || join(homedir(), '.codex'), 'config.toml');
|
|
16
|
+
|
|
17
|
+
// A TOML basic ("...") or literal ('...') string value, or null.
|
|
18
|
+
function tomlString(raw) {
|
|
19
|
+
const value = raw.trim().replace(/\s+#.*$/, '');
|
|
20
|
+
if (value.startsWith('"')) {
|
|
21
|
+
try { return JSON.parse(value); } catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
const literal = value.match(/^'([^']*)'$/);
|
|
24
|
+
return literal ? literal[1] : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The custom provider Codex's config selects, as { baseUrl, token }, or null when Codex talks
|
|
28
|
+
// to a built-in provider. Only the keys a provider table needs for an authenticated GET are read.
|
|
29
|
+
export function codexProxy(configText, env = process.env) {
|
|
30
|
+
let table = '';
|
|
31
|
+
let providerId = null;
|
|
32
|
+
const tables = new Map();
|
|
33
|
+
for (const line of configText.split(/\r?\n/)) {
|
|
34
|
+
const header = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
|
|
35
|
+
if (header) { table = header[1].trim(); continue; }
|
|
36
|
+
const pair = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
|
|
37
|
+
if (!pair) continue;
|
|
38
|
+
const value = tomlString(pair[2]);
|
|
39
|
+
if (value === null) continue;
|
|
40
|
+
if (!table && pair[1] === 'model_provider') providerId = value;
|
|
41
|
+
else if (table) tables.set(table, { ...tables.get(table), [pair[1]]: value });
|
|
42
|
+
}
|
|
43
|
+
if (!providerId) return null;
|
|
44
|
+
const provider = tables.get(`model_providers.${providerId}`)
|
|
45
|
+
|| tables.get(`model_providers."${providerId}"`);
|
|
46
|
+
if (!provider?.base_url) return null;
|
|
47
|
+
const token = provider.experimental_bearer_token || (provider.env_key ? env[provider.env_key] : '') || '';
|
|
48
|
+
return { baseUrl: provider.base_url.replace(/\/+$/, ''), token };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const versionOf = (id) => {
|
|
52
|
+
const match = id.match(gptVersionId);
|
|
53
|
+
return match ? [Number(match[1]), Number(match[2] || 0)] : null;
|
|
54
|
+
};
|
|
55
|
+
const newer = (a, b) => a[0] - b[0] || a[1] - b[1];
|
|
56
|
+
|
|
57
|
+
// "gpt-5.6-luna" -> "GPT-5.6 Luna"; a provider prefix such as "openai/" is left off the label.
|
|
58
|
+
export function modelLabel(id) {
|
|
59
|
+
const [, version, ...rest] = id.split('/').pop().split('-');
|
|
60
|
+
return [`GPT-${version}`, ...rest.map((word) => word.charAt(0).toUpperCase() + word.slice(1))].join(' ');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The proxy catalog's GPT 5.4+ models, newest release first, one entry per id.
|
|
64
|
+
export function codexModelChoices(catalog) {
|
|
65
|
+
const records = Array.isArray(catalog?.data) ? catalog.data : [];
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
const models = [];
|
|
68
|
+
for (const record of records) {
|
|
69
|
+
const id = record?.id;
|
|
70
|
+
if (typeof id !== 'string' || seen.has(id)) continue;
|
|
71
|
+
const version = versionOf(id);
|
|
72
|
+
if (!version || newer(version, minimumGptVersion) < 0) continue;
|
|
73
|
+
seen.add(id);
|
|
74
|
+
models.push({ id, version });
|
|
75
|
+
}
|
|
76
|
+
return models.sort((a, b) => newer(b.version, a.version)).map(({ id }) => [id, modelLabel(id)]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The configured proxy's Codex choices, or null when Codex is not configured to use a proxy.
|
|
80
|
+
// Throws when a proxy is configured but its catalog cannot be read.
|
|
81
|
+
export async function fetchCodexModelChoices({ env = process.env, fetchImpl = fetch } = {}) {
|
|
82
|
+
const configText = await readFile(codexConfigPath(env), 'utf8').catch(() => null);
|
|
83
|
+
const proxy = configText && codexProxy(configText, env);
|
|
84
|
+
if (!proxy) return null;
|
|
85
|
+
const response = await fetchImpl(`${proxy.baseUrl}/models`, {
|
|
86
|
+
headers: proxy.token ? { authorization: `Bearer ${proxy.token}` } : {},
|
|
87
|
+
signal: AbortSignal.timeout(10_000),
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok) throw new Error(`the Codex proxy's model list returned HTTP ${response.status}`);
|
|
90
|
+
const choices = codexModelChoices(await response.json());
|
|
91
|
+
if (!choices.length) throw new Error("the Codex proxy lists no GPT 5.4 or newer model");
|
|
92
|
+
return choices;
|
|
93
|
+
}
|
|
@@ -15,6 +15,8 @@ import { listArtifacts, readArtifact } from './artifacts.mjs';
|
|
|
15
15
|
import { readTelemetry, telemetryEnv, telemetryRoute } from './telemetry.mjs';
|
|
16
16
|
import { Readable } from 'node:stream';
|
|
17
17
|
import { bundleTranscripts, maxTranscriptBytes, transcriptBytes } from './session-transcripts.mjs';
|
|
18
|
+
import { modelChoices as builtInModelChoices } from './model-choices.mjs';
|
|
19
|
+
import { fetchCodexModelChoices } from './proxy-models.mjs';
|
|
18
20
|
|
|
19
21
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
22
|
const compareDir = dirname(here);
|
|
@@ -74,6 +76,12 @@ const server = createServer(async (request, response) => {
|
|
|
74
76
|
await submitFeedback(request, response);
|
|
75
77
|
return;
|
|
76
78
|
}
|
|
79
|
+
if (url.pathname === '/models' && request.method === 'GET') {
|
|
80
|
+
const choices = await modelChoices();
|
|
81
|
+
response.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
|
82
|
+
.end(JSON.stringify(choices));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
77
85
|
const telemetryMeter = telemetryMeters.get(url.pathname);
|
|
78
86
|
if (telemetryMeter && request.method === 'POST') {
|
|
79
87
|
try {
|
|
@@ -218,10 +226,30 @@ function validateEnvironment(environment = {}) {
|
|
|
218
226
|
}
|
|
219
227
|
const safeModel = /^[A-Za-z0-9._/-]{1,160}$/;
|
|
220
228
|
|
|
229
|
+
// The models the page offers, as { claude, codex } lists of [id, label]. Codex's come from the
|
|
230
|
+
// proxy Codex is configured to use, when there is one; codexError says why they are missing.
|
|
231
|
+
let proxyChoices = { expires: 0, codex: null, codexError: null };
|
|
232
|
+
async function modelChoices() {
|
|
233
|
+
if (Date.now() >= proxyChoices.expires) {
|
|
234
|
+
try {
|
|
235
|
+
proxyChoices = { expires: Date.now() + 60_000, codex: await fetchCodexModelChoices(), codexError: null };
|
|
236
|
+
} catch (error) {
|
|
237
|
+
console.warn(`Codex proxy models unavailable: ${error.message}`);
|
|
238
|
+
proxyChoices = { expires: Date.now() + 10_000, codex: [],
|
|
239
|
+
codexError: `Could not list Codex models: ${error.message}.` };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const { codex, codexError } = proxyChoices;
|
|
243
|
+
return { claude: builtInModelChoices.claude, codex: codex ?? builtInModelChoices.codex, codexError };
|
|
244
|
+
}
|
|
245
|
+
|
|
221
246
|
async function validateConfig(config) {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
const claudeModels = [
|
|
247
|
+
const choices = await modelChoices();
|
|
248
|
+
const codexModels = choices.codex.map(([id]) => id);
|
|
249
|
+
const claudeModels = choices.claude.map(([id]) => id);
|
|
250
|
+
if (choices.codexError && config?.model && variants.has(config.variant) && config.variant.includes('codex')) {
|
|
251
|
+
throw new Error(choices.codexError);
|
|
252
|
+
}
|
|
225
253
|
if (!config || !variants.has(config.variant) || typeof config.cwd !== 'string' ||
|
|
226
254
|
config.cwd.length > 4096 || typeof config.label !== 'string' || config.label.length > 80 ||
|
|
227
255
|
typeof config.model !== 'string' || (config.model && !safeModel.test(config.model)) ||
|