pennyrouter 0.3.28 → 0.3.29

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/README.md CHANGED
@@ -147,6 +147,17 @@ the user explicitly changes it. Close running Claude Code or Codex sessions only
147
147
  removing global bare-command routing. A Penny-only install does not require closing native sessions.
148
148
 
149
149
  The launcher uses a private config, a Claude settings overlay, and a secret-free Codex profile.
150
+ For `penny claude`, it refreshes the gateway's `/v1/models` catalog before launch. A private
151
+ gateway explicitly marks that catalog as its complete allowlist, so Penny uses it as Claude
152
+ Code's curated `modelPicker` lineup and hides named built-in Anthropic rows while preserving the
153
+ gateway's order and labels; Claude Code's own `Default` row remains. Hosted gateways omit the
154
+ marker and retain Claude Code's normal built-in lineup. The last successful catalog and its
155
+ private-gateway marker are cached per Penny profile for transient discovery failures.
156
+ For `penny codex`, that same private marker makes the launcher write the gateway's rich Codex
157
+ catalog to a per-profile file and select it with Codex's `model_catalog_json` setting. The picker
158
+ and startup model are therefore limited to models the private upstream advertised. Hosted
159
+ gateways do not set the marker, so Codex keeps its normal built-in catalog and automatically
160
+ receives new OpenAI models. A cached private catalog is used only for the same gateway URL.
150
161
  The installer adds marked PATH blocks to the interactive and login startup files for zsh or bash
151
162
  on macOS/Linux, or a single user-PATH entry on Windows, when needed; `--no-path-update` skips that
152
163
  edit. Open a new terminal after a PATH update.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.28",
3
+ "version": "0.3.29",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -53,6 +53,63 @@ export function writeClaudeCodeModelPresentation(env) {
53
53
  }
54
54
  }
55
55
 
56
+ /**
57
+ * Fetch the Anthropic-shaped catalog that Claude Code would discover at startup.
58
+ *
59
+ * `modelPicker.replaceBuiltInOptions` also suppresses discovered rows, so the launcher
60
+ * has to repeat those rows as curated options if it wants a gateway-only picker. Keep
61
+ * the same id filter and display-name behavior as Claude Code: ids must contain
62
+ * "claude" or "anthropic", and a display name identical to the id is not an override.
63
+ */
64
+ export async function fetchClaudeCodeModels(gatewayBaseUrl, apiKey) {
65
+ const headers = {
66
+ "x-pennyrouter-harness": "claude-code",
67
+ "anthropic-version": "2023-06-01",
68
+ };
69
+ if (apiKey) {
70
+ headers.Authorization = `Bearer ${apiKey}`;
71
+ headers["x-api-key"] = apiKey;
72
+ }
73
+ const response = await fetch(
74
+ `${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1/models?limit=1000`,
75
+ {
76
+ headers,
77
+ redirect: "error",
78
+ signal: AbortSignal.timeout(3000),
79
+ },
80
+ );
81
+ if (!response.ok) throw new Error(`model catalog returned ${response.status}`);
82
+ const payload = await response.json();
83
+ const source = Array.isArray(payload?.data)
84
+ ? payload.data
85
+ : (Array.isArray(payload?.models) ? payload.models : []);
86
+ const seen = new Set();
87
+ const models = [];
88
+ for (const entry of source) {
89
+ const id = typeof entry?.id === "string" ? entry.id.trim() : "";
90
+ if (!id || !/(claude|anthropic)/i.test(id) || seen.has(id)) continue;
91
+ seen.add(id);
92
+ const displayName = typeof entry.display_name === "string"
93
+ ? entry.display_name.trim()
94
+ : (typeof entry.name === "string" ? entry.name.trim() : "");
95
+ const description = typeof entry.description === "string"
96
+ ? entry.description.replace(/\s+/g, " ").trim()
97
+ : "";
98
+ models.push({
99
+ model: id,
100
+ ...(displayName && displayName !== id ? { label: displayName } : {}),
101
+ ...(description ? { description } : {}),
102
+ });
103
+ }
104
+ if (models.length === 0) {
105
+ throw new Error("gateway returned no Claude-compatible models");
106
+ }
107
+ return {
108
+ models,
109
+ privateGateway: response.headers?.get?.("x-pennyrouter-private-gateway") === "1",
110
+ };
111
+ }
112
+
56
113
  const CLAUDE_CODE_MODEL_PRESENTATION_KEYS = Object.keys(CLAUDE_CODE_MODEL_PRESENTATION)
57
114
  .flatMap((slot) => {
58
115
  const prefix = `ANTHROPIC_DEFAULT_${slot.toUpperCase()}_MODEL`;
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
 
3
3
  import {
4
4
  disableClaudeAiConnectors,
5
+ fetchClaudeCodeModels,
5
6
  restoreClaudeAiConnectors,
6
7
  } from "./claude-code.js";
7
8
 
@@ -54,4 +55,52 @@ testAbsentSettingIsRemovedOnUninstall();
54
55
  testExistingSettingIsRestored();
55
56
  testReinstallKeepsOriginalValue();
56
57
  testUninstallPreservesLaterUserEdit();
58
+
59
+ async function testGatewayModelPickerRows() {
60
+ const previousFetch = globalThis.fetch;
61
+ let request;
62
+ try {
63
+ globalThis.fetch = async (url, options) => {
64
+ request = { url, options };
65
+ return {
66
+ ok: true,
67
+ headers: { get: (name) => name === "x-pennyrouter-private-gateway" ? "1" : null },
68
+ async json() {
69
+ return {
70
+ data: [
71
+ { id: "claude-fable-5-1", display_name: "Fable 5.1", description: "Fast\nmodel" },
72
+ { id: "claude-opus-4.8", display_name: "claude-opus-4.8" },
73
+ { id: "gpt-5", display_name: "GPT 5" },
74
+ { id: "vertex_ai/anthropic.claude-sonnet-5", name: "Vertex Sonnet" },
75
+ { id: "claude-fable-5-1", display_name: "Duplicate" },
76
+ ],
77
+ };
78
+ },
79
+ };
80
+ };
81
+ assert.deepEqual(
82
+ await fetchClaudeCodeModels("https://gateway.example/", "pr-test"),
83
+ {
84
+ models: [
85
+ { model: "claude-fable-5-1", label: "Fable 5.1", description: "Fast model" },
86
+ { model: "claude-opus-4.8" },
87
+ { model: "vertex_ai/anthropic.claude-sonnet-5", label: "Vertex Sonnet" },
88
+ ],
89
+ privateGateway: true,
90
+ },
91
+ );
92
+ assert.equal(request.url, "https://gateway.example/v1/models?limit=1000");
93
+ assert.deepEqual(request.options.headers, {
94
+ "x-pennyrouter-harness": "claude-code",
95
+ "anthropic-version": "2023-06-01",
96
+ Authorization: "Bearer pr-test",
97
+ "x-api-key": "pr-test",
98
+ });
99
+ assert.equal(request.options.redirect, "error");
100
+ } finally {
101
+ globalThis.fetch = previousFetch;
102
+ }
103
+ }
104
+
105
+ await testGatewayModelPickerRows();
57
106
  console.log("claude-code.test.js: all checks passed");
@@ -90,6 +90,41 @@ export async function detectCodexChatgptAuth() {
90
90
  return status.status === 0 && /logged in using chatgpt/i.test(`${status.stdout}\n${status.stderr}`);
91
91
  }
92
92
 
93
+ /** Fetch the rich Codex catalog advertised by a PennyRouter gateway. */
94
+ export async function fetchCodexModels(gatewayBaseUrl, apiKey) {
95
+ const controller = new AbortController();
96
+ const timeout = setTimeout(() => controller.abort(), 3000);
97
+ let response;
98
+ try {
99
+ response = await fetch(
100
+ `${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1/models?limit=1000`,
101
+ {
102
+ headers: {
103
+ authorization: `Bearer ${apiKey}`,
104
+ "x-api-key": apiKey,
105
+ "x-pennyrouter-harness": "codex",
106
+ },
107
+ redirect: "error",
108
+ signal: controller.signal,
109
+ },
110
+ );
111
+ } finally {
112
+ clearTimeout(timeout);
113
+ }
114
+ if (!response.ok) {
115
+ throw new Error(`model discovery returned HTTP ${response.status}`);
116
+ }
117
+ const payload = await response.json();
118
+ const models = Array.isArray(payload?.models)
119
+ ? payload.models.filter((model) => model && typeof model.slug === "string" && model.slug.trim())
120
+ : [];
121
+ if (!models.length) throw new Error("model discovery returned no Codex models");
122
+ return {
123
+ models,
124
+ privateGateway: response.headers.get("x-pennyrouter-private-gateway") === "1",
125
+ };
126
+ }
127
+
93
128
  /**
94
129
  * Install one marker-fenced provider block immediately before the first TOML table.
95
130
  * Root keys appended after a table are scoped into that table by TOML, so model_provider
@@ -6,11 +6,45 @@ import path from "node:path";
6
6
  import {
7
7
  codexHarness,
8
8
  codexUsesChatgptAuth,
9
+ fetchCodexModels,
9
10
  removeManagedCodexConfig,
10
11
  renderCodexConfig,
11
12
  isHostedPennyRouterGateway,
12
13
  } from "./codex.js";
13
14
 
15
+ async function testGatewayCatalogDiscovery() {
16
+ const originalFetch = globalThis.fetch;
17
+ let request;
18
+ globalThis.fetch = async (url, options) => {
19
+ request = { url, options };
20
+ return {
21
+ ok: true,
22
+ status: 200,
23
+ headers: { get: (name) => name === "x-pennyrouter-private-gateway" ? "1" : null },
24
+ async json() {
25
+ return {
26
+ models: [
27
+ { slug: "gpt-5.6-terra", display_name: "GPT-5.6-Terra" },
28
+ { id: "not-a-codex-entry" },
29
+ ],
30
+ };
31
+ },
32
+ };
33
+ };
34
+ try {
35
+ const catalog = await fetchCodexModels("https://private.example/", "pr-secret");
36
+ assert.equal(request.url, "https://private.example/v1/models?limit=1000");
37
+ assert.equal(request.options.redirect, "error");
38
+ assert.equal(request.options.headers.authorization, "Bearer pr-secret");
39
+ assert.equal(request.options.headers["x-api-key"], "pr-secret");
40
+ assert.equal(request.options.headers["x-pennyrouter-harness"], "codex");
41
+ assert.equal(catalog.privateGateway, true);
42
+ assert.deepEqual(catalog.models.map((model) => model.slug), ["gpt-5.6-terra"]);
43
+ } finally {
44
+ globalThis.fetch = originalFetch;
45
+ }
46
+ }
47
+
14
48
  function testAuthDetection() {
15
49
  assert.equal(codexUsesChatgptAuth(null), false);
16
50
  assert.equal(codexUsesChatgptAuth({ auth_mode: "apikey" }), false);
@@ -140,5 +174,6 @@ testPrivateAndProxyGatewaysNeverEnableNativeSubscription();
140
174
  testLegacyMigrationAndIdempotence();
141
175
  testProviderCollisionFailsClosed();
142
176
  testUninstallLeavesUserConfig();
177
+ await testGatewayCatalogDiscovery();
143
178
  await testReinstallPreservesRecordedProvider();
144
179
  console.log("codex.test.js: all checks passed");
package/src/launch.js CHANGED
@@ -21,10 +21,12 @@ import { randomUUID } from "node:crypto";
21
21
 
22
22
  import { configureClaudeCodeStatusline } from "./statusline.js";
23
23
  import {
24
+ fetchClaudeCodeModels,
24
25
  upgradeClaudeCodeModelValue,
25
26
  writeClaudeCodeModelPresentation,
26
27
  } from "./harnesses/claude-code.js";
27
28
  import {
29
+ fetchCodexModels,
28
30
  isCodexSubscriptionAuthGateway,
29
31
  renderCodexConfig,
30
32
  } from "./harnesses/codex.js";
@@ -125,6 +127,14 @@ export function codexProfileName(profile = DEFAULT_PROFILE) {
125
127
  return `pennyrouter-${profile}`;
126
128
  }
127
129
 
130
+ export function codexModelCatalogPath(profile = DEFAULT_PROFILE) {
131
+ return join(pennyDataDir(), `codex-models-${profile}.json`);
132
+ }
133
+
134
+ function codexModelCatalogStatePath(profile = DEFAULT_PROFILE) {
135
+ return join(pennyDataDir(), `codex-models-${profile}.state.json`);
136
+ }
137
+
128
138
  export function claudeUserSkillPath() {
129
139
  return join(homeDir(), ".claude", "skills", "penny", "SKILL.md");
130
140
  }
@@ -442,7 +452,13 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
442
452
  return { supported: true, ...path, shim_path: pennyShimPath(), compare };
443
453
  }
444
454
 
445
- export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, privateGateway = false }) {
455
+ export function renderCodexLaunchProfile({
456
+ gatewayBaseUrl,
457
+ subscriptionAuth,
458
+ privateGateway = false,
459
+ modelCatalogPath = null,
460
+ model = null,
461
+ }) {
446
462
  // Native ChatGPT subscription auth is allowed for the hosted gateway and the explicitly
447
463
  // supported local development gateway. Apply the same rule as the config renderer at this
448
464
  // boundary, since this path is what bare `penny codex` selects at runtime.
@@ -466,7 +482,11 @@ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, pri
466
482
  ];
467
483
  return [
468
484
  CODEX_PROFILE_MARKER,
485
+ ...(model ? [`model = ${JSON.stringify(model)}`] : []),
469
486
  'model_provider = "pennyrouter"',
487
+ ...(privateGateway && modelCatalogPath
488
+ ? [`model_catalog_json = ${JSON.stringify(modelCatalogPath)}`]
489
+ : []),
470
490
  "",
471
491
  "[model_providers.pennyrouter]",
472
492
  'name = "PennyRouter"',
@@ -824,7 +844,12 @@ export function startPennyTerminalTitle({ gatewayBaseUrl, apiKey, titleSessionId
824
844
  };
825
845
  }
826
846
 
827
- async function writeClaudeLaunchSettings({ gatewayBaseUrl, apiKey } = {}) {
847
+ export async function writeClaudeLaunchSettings({
848
+ gatewayBaseUrl,
849
+ apiKey,
850
+ models = [],
851
+ privateGateway = false,
852
+ } = {}) {
828
853
  const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
829
854
  // Claude Code gives values from --settings precedence over the parent process
830
855
  // environment. Keep this overlay authoritative so a separately-managed
@@ -832,6 +857,17 @@ async function writeClaudeLaunchSettings({ gatewayBaseUrl, apiKey } = {}) {
832
857
  // redirect a `penny claude` session away from the selected Penny profile.
833
858
  const overlay = {
834
859
  disableClaudeAiConnectors: true,
860
+ ...(privateGateway && models.length
861
+ ? {
862
+ // Replacement hides both Claude's built-in rows and its automatically
863
+ // discovered rows. Repeat the gateway catalog here so the curated picker
864
+ // contains exactly the models that gateway advertised, in gateway order.
865
+ modelPicker: {
866
+ options: models,
867
+ replaceBuiltInOptions: true,
868
+ },
869
+ }
870
+ : {}),
835
871
  ...(gatewayBaseUrl && apiKey
836
872
  ? {
837
873
  env: {
@@ -849,6 +885,82 @@ async function writeClaudeLaunchSettings({ gatewayBaseUrl, apiKey } = {}) {
849
885
  await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
850
886
  }
851
887
 
888
+ function claudeCodeModelCachePath(profile) {
889
+ return join(pennyDataDir(), `claude-models-${profile || DEFAULT_PROFILE}.json`);
890
+ }
891
+
892
+ /** Resolve a gateway-only Claude picker without making gateway availability fatal. */
893
+ export async function resolveClaudeCodeLaunchModels(config) {
894
+ const cachePath = claudeCodeModelCachePath(config.profile);
895
+ try {
896
+ const catalog = await fetchClaudeCodeModels(config.gateway_base_url, config.api_key);
897
+ await writeJsonFile(cachePath, { ...catalog, fetched_at: new Date().toISOString() });
898
+ return catalog;
899
+ } catch (error) {
900
+ const cached = await readJsonFile(cachePath, null);
901
+ if (Array.isArray(cached?.models) && cached.models.length) {
902
+ console.error(
903
+ `PennyRouter: could not refresh Claude models from ${config.gateway_base_url} `
904
+ + `(${error.message}). Using the catalog cached ${cached.fetched_at}.`,
905
+ );
906
+ return {
907
+ models: cached.models,
908
+ // Old cache files predate the explicit server marker. Treating them as hosted
909
+ // is the safe rollout behavior: never hide built-ins based on a guess.
910
+ privateGateway: cached.privateGateway === true,
911
+ };
912
+ }
913
+ console.error(
914
+ `PennyRouter: could not load Claude models from ${config.gateway_base_url} `
915
+ + `(${error.message}). Claude Code will use its normal discovery fallback.`,
916
+ );
917
+ return { models: [], privateGateway: false };
918
+ }
919
+ }
920
+
921
+ function normalizedGatewayBaseUrl(value) {
922
+ return String(value || "").replace(/\/+$/, "");
923
+ }
924
+
925
+ /** Resolve the private Codex allowlist without ever applying it to the hosted gateway. */
926
+ export async function resolveCodexLaunchCatalog(config) {
927
+ const catalogPath = codexModelCatalogPath(config.profile);
928
+ const statePath = codexModelCatalogStatePath(config.profile);
929
+ const gatewayBaseUrl = normalizedGatewayBaseUrl(config.gateway_base_url);
930
+ try {
931
+ const catalog = await fetchCodexModels(config.gateway_base_url, config.api_key);
932
+ const state = {
933
+ gateway_base_url: gatewayBaseUrl,
934
+ privateGateway: catalog.privateGateway,
935
+ models: catalog.models,
936
+ fetched_at: new Date().toISOString(),
937
+ };
938
+ await privateWrite(statePath, `${JSON.stringify(state, null, 2)}\n`);
939
+ if (!catalog.privateGateway) {
940
+ return { models: [], privateGateway: false, modelCatalogPath: null };
941
+ }
942
+ await privateWrite(catalogPath, `${JSON.stringify({ models: catalog.models }, null, 2)}\n`);
943
+ return { ...catalog, modelCatalogPath: catalogPath };
944
+ } catch (error) {
945
+ const cached = await readJsonFile(statePath, null);
946
+ if (cached?.privateGateway === true
947
+ && normalizedGatewayBaseUrl(cached.gateway_base_url) === gatewayBaseUrl
948
+ && Array.isArray(cached.models) && cached.models.length) {
949
+ await privateWrite(catalogPath, `${JSON.stringify({ models: cached.models }, null, 2)}\n`);
950
+ console.error(
951
+ `PennyRouter: could not refresh Codex models from ${config.gateway_base_url} `
952
+ + `(${error.message}). Using the private catalog cached ${cached.fetched_at}.`,
953
+ );
954
+ return { models: cached.models, privateGateway: true, modelCatalogPath: catalogPath };
955
+ }
956
+ console.error(
957
+ `PennyRouter: could not load Codex models from ${config.gateway_base_url} `
958
+ + `(${error.message}). Codex will use its normal model catalog.`,
959
+ );
960
+ return { models: [], privateGateway: false, modelCatalogPath: null };
961
+ }
962
+ }
963
+
852
964
  async function writeClaudeUserSkill() {
853
965
  const path = claudeUserSkillPath();
854
966
  const existing = await readText(path, "");
@@ -879,7 +991,31 @@ async function writeClaudeUserSkill() {
879
991
  }
880
992
  }
881
993
 
882
- async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profile, privateGateway = false }) {
994
+ function rootTomlString(text, key) {
995
+ const root = String(text || "").split(/^\s*\[/m, 1)[0];
996
+ const match = root.match(new RegExp(`^\\s*${key}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")`, "m"));
997
+ if (!match) return null;
998
+ try {
999
+ return JSON.parse(match[1]);
1000
+ } catch {
1001
+ return null;
1002
+ }
1003
+ }
1004
+
1005
+ export function selectCodexLaunchModel(models, ...candidates) {
1006
+ const slugs = models.map((item) => item?.slug).filter(Boolean);
1007
+ const allowed = new Set(slugs);
1008
+ return candidates.find((candidate) => allowed.has(candidate)) || slugs[0] || null;
1009
+ }
1010
+
1011
+ async function writeCodexLaunchProfile({
1012
+ gatewayBaseUrl,
1013
+ subscriptionAuth,
1014
+ profile,
1015
+ privateGateway = false,
1016
+ models = [],
1017
+ modelCatalogPath = null,
1018
+ }) {
883
1019
  const path = codexLaunchProfilePath(profile);
884
1020
  const existing = await readText(path, "");
885
1021
  if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
@@ -887,7 +1023,21 @@ async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profi
887
1023
  `${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
888
1024
  );
889
1025
  }
890
- await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, privateGateway }));
1026
+ const native = await readText(join(dirname(path), "config.toml"), "");
1027
+ const model = privateGateway
1028
+ ? selectCodexLaunchModel(
1029
+ models,
1030
+ rootTomlString(existing, "model"),
1031
+ rootTomlString(native, "model"),
1032
+ )
1033
+ : null;
1034
+ await privateWrite(path, renderCodexLaunchProfile({
1035
+ gatewayBaseUrl,
1036
+ subscriptionAuth,
1037
+ privateGateway,
1038
+ modelCatalogPath,
1039
+ model,
1040
+ }));
891
1041
  }
892
1042
 
893
1043
  export async function configurePennyHarness(harness, {
@@ -1298,22 +1448,31 @@ export async function runPenny(argv = process.argv.slice(2)) {
1298
1448
  if (harness === "codex") {
1299
1449
  // Re-render on every launch so an upgraded Penny command moves existing subscription
1300
1450
  // profiles onto Codex's built-in OpenAI provider without requiring a reinstall. Profiles
1301
- // using API/proxy/OpenRouter funding retain the custom HTTP provider for now.
1451
+ // using API/proxy/OpenRouter funding retain the custom HTTP provider for now. Only a
1452
+ // gateway that explicitly identifies itself as private may replace Codex's built-in catalog.
1453
+ const catalog = await resolveCodexLaunchCatalog(config);
1302
1454
  await writeCodexLaunchProfile({
1303
1455
  gatewayBaseUrl: config.gateway_base_url,
1304
1456
  subscriptionAuth: resolveSubscriptionAuth(config, "codex"),
1305
1457
  profile: config.profile,
1458
+ privateGateway: catalog.privateGateway,
1459
+ models: catalog.models,
1460
+ modelCatalogPath: catalog.modelCatalogPath,
1306
1461
  });
1307
1462
  }
1463
+ let models = null;
1308
1464
  if (harness === "claude") {
1309
1465
  // Refresh on every launch so profile selection is honored immediately,
1310
1466
  // including --private, --cloud, and named profiles.
1467
+ const catalog = await resolveClaudeCodeLaunchModels(config);
1311
1468
  await writeClaudeLaunchSettings({
1312
1469
  gatewayBaseUrl: config.gateway_base_url,
1313
1470
  apiKey: config.api_key,
1471
+ models: catalog.models,
1472
+ privateGateway: catalog.privateGateway,
1314
1473
  });
1315
1474
  }
1316
- const models = harness === "opencode" ? await resolveOpenCodeLaunchModels(config) : null;
1475
+ if (harness === "opencode") models = await resolveOpenCodeLaunchModels(config);
1317
1476
  const invocation = buildPennyInvocation(harness, argv.slice(1), config, models);
1318
1477
  const child = spawn(invocation.command, invocation.args, {
1319
1478
  cwd: process.cwd(),
@@ -11,6 +11,7 @@ import {
11
11
  claudeLaunchSettingsPath,
12
12
  claudeUserSkillPath,
13
13
  codexLaunchProfilePath,
14
+ codexModelCatalogPath,
14
15
  configureClaudeDesktopProfile,
15
16
  createCodexDesktopHome,
16
17
  configurePennyHarness,
@@ -31,8 +32,11 @@ import {
31
32
  restoreClaudeDesktopProfile,
32
33
  selectClaudeDesktopNativeMode,
33
34
  renderCodexLaunchProfile,
35
+ selectCodexLaunchModel,
34
36
  extractProfileArgs,
37
+ resolveCodexLaunchCatalog,
35
38
  resolveOpenCodeLaunchModels,
39
+ writeClaudeLaunchSettings,
36
40
  buildOpenCodeSessionConfig,
37
41
  resolvePennyProfile,
38
42
  setPennyProfile,
@@ -137,6 +141,37 @@ async function main() {
137
141
  ANTHROPIC_AUTH_TOKEN: "pr-super-secret",
138
142
  ANTHROPIC_API_KEY: "",
139
143
  });
144
+ await writeClaudeLaunchSettings({
145
+ gatewayBaseUrl: "https://gateway.example/",
146
+ apiKey: "pr-super-secret",
147
+ models: [
148
+ { model: "claude-fable-5-1", label: "Fable 5.1" },
149
+ { model: "claude-opus-4.8" },
150
+ ],
151
+ });
152
+ const hostedClaudeSettings = JSON.parse(
153
+ fs.readFileSync(claudeLaunchSettingsPath(), "utf8"),
154
+ );
155
+ assert.equal(hostedClaudeSettings.modelPicker, undefined);
156
+ await writeClaudeLaunchSettings({
157
+ gatewayBaseUrl: "https://gateway.example/",
158
+ apiKey: "pr-super-secret",
159
+ privateGateway: true,
160
+ models: [
161
+ { model: "claude-fable-5-1", label: "Fable 5.1" },
162
+ { model: "claude-opus-4.8" },
163
+ ],
164
+ });
165
+ const curatedClaudeSettings = JSON.parse(
166
+ fs.readFileSync(claudeLaunchSettingsPath(), "utf8"),
167
+ );
168
+ assert.deepEqual(curatedClaudeSettings.modelPicker, {
169
+ options: [
170
+ { model: "claude-fable-5-1", label: "Fable 5.1" },
171
+ { model: "claude-opus-4.8" },
172
+ ],
173
+ replaceBuiltInOptions: true,
174
+ });
140
175
  assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
141
176
  assert.equal(await fileMode(pennyConfigPath()), 0o600);
142
177
  assert.equal(await fileMode(claudeLaunchSettingsPath()), 0o600);
@@ -324,10 +359,86 @@ async function main() {
324
359
  });
325
360
  assert.ok(localSubscription.includes('model_provider = "openai"'));
326
361
  const localPrivate = renderCodexLaunchProfile({
327
- gatewayBaseUrl: "http://localhost:8400", subscriptionAuth: true, privateGateway: true,
362
+ gatewayBaseUrl: "http://localhost:8400",
363
+ subscriptionAuth: true,
364
+ privateGateway: true,
365
+ modelCatalogPath: "/tmp/private-models.json",
366
+ model: "gpt-5.6-terra",
328
367
  });
329
368
  assert.ok(localPrivate.includes('model_provider = "pennyrouter"'));
330
369
  assert.ok(localPrivate.includes('env_key = "PENNYROUTER_API_KEY"'));
370
+ assert.ok(localPrivate.includes('model = "gpt-5.6-terra"'));
371
+ assert.ok(localPrivate.includes('model_catalog_json = "/tmp/private-models.json"'));
372
+ assert.ok(!paid.includes("model_catalog_json"));
373
+ assert.equal(
374
+ selectCodexLaunchModel(
375
+ [{ slug: "gpt-5.6-sol" }, { slug: "gpt-5.6-terra" }],
376
+ "gpt-6-luna",
377
+ "gpt-5.6-terra",
378
+ ),
379
+ "gpt-5.6-terra",
380
+ );
381
+ assert.ok(codexModelCatalogPath("private").endsWith("codex-models-private.json"));
382
+
383
+ const originalFetch = globalThis.fetch;
384
+ try {
385
+ globalThis.fetch = async () => ({
386
+ ok: true,
387
+ status: 200,
388
+ headers: { get: (name) => name === "x-pennyrouter-private-gateway" ? "1" : null },
389
+ json: async () => ({ models: [{ slug: "gpt-5.6-terra", display_name: "Terra" }] }),
390
+ });
391
+ const privateCatalog = await resolveCodexLaunchCatalog({
392
+ gateway_base_url: "https://private.example",
393
+ api_key: "pr-private",
394
+ profile: "private",
395
+ });
396
+ assert.equal(privateCatalog.privateGateway, true);
397
+ assert.deepEqual(
398
+ JSON.parse(fs.readFileSync(privateCatalog.modelCatalogPath, "utf8")),
399
+ { models: [{ slug: "gpt-5.6-terra", display_name: "Terra" }] },
400
+ );
401
+
402
+ globalThis.fetch = async () => { throw new Error("offline"); };
403
+ const cachedCatalog = await resolveCodexLaunchCatalog({
404
+ gateway_base_url: "https://private.example/",
405
+ api_key: "pr-private",
406
+ profile: "private",
407
+ });
408
+ assert.equal(cachedCatalog.privateGateway, true);
409
+
410
+ globalThis.fetch = async () => ({
411
+ ok: true,
412
+ status: 200,
413
+ headers: { get: () => null },
414
+ json: async () => ({ models: [{ slug: "gpt-6-luna" }] }),
415
+ });
416
+ const unmarkedCatalog = await resolveCodexLaunchCatalog({
417
+ gateway_base_url: "https://private.example",
418
+ api_key: "pr-private",
419
+ profile: "private",
420
+ });
421
+ assert.equal(unmarkedCatalog.privateGateway, false);
422
+ assert.equal(unmarkedCatalog.modelCatalogPath, null);
423
+
424
+ globalThis.fetch = async () => { throw new Error("offline"); };
425
+ const invalidatedCache = await resolveCodexLaunchCatalog({
426
+ gateway_base_url: "https://private.example",
427
+ api_key: "pr-private",
428
+ profile: "private",
429
+ });
430
+ assert.equal(invalidatedCache.privateGateway, false);
431
+
432
+ const wrongGateway = await resolveCodexLaunchCatalog({
433
+ gateway_base_url: "https://other.example",
434
+ api_key: "pr-private",
435
+ profile: "private",
436
+ });
437
+ assert.equal(wrongGateway.privateGateway, false);
438
+ assert.equal(wrongGateway.modelCatalogPath, null);
439
+ } finally {
440
+ globalThis.fetch = originalFetch;
441
+ }
331
442
 
332
443
  // Inside the fixture: testProfiles persists a config, which must land in the
333
444
  // temporary HOME and never in the developer's real ~/.config/pennyrouter.