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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.55",
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
- console.log(`Using existing PennyRouter key for ${gatewayBaseUrl}.`);
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
- await privateWrite(path, renderCodexLaunchProfile({
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`;
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
- import { createCliSession, probeClaudeCodeGateway } from "./session.js";
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="Harness default" autocomplete="off" spellcheck="false"></label>
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
- const modelChoices = {
1538
- // Claude Code resolves these aliases (and the penny wrapper maps them to the gateway's
1539
- // ids), so the alias is what is sent; the label names the model it currently resolves to.
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
- if (family !== modelFamily) model.value = '';
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
- modelSuggestions = family ? [{ value: '', label: 'Harness default' },
1609
- ...modelChoices[family].map(([value, label]) => ({ value, label }))] : [];
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 codexModels = ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra',
223
- 'gpt-5.6-luna', 'gpt-5.5'];
224
- const claudeModels = ['opus', 'sonnet', 'haiku', 'fable'];
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)) ||