pennyrouter 0.3.27 → 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 +11 -0
- package/package.json +1 -1
- package/src/harnesses/claude-code.js +57 -0
- package/src/harnesses/claude-code.test.js +49 -0
- package/src/harnesses/codex.js +35 -0
- package/src/harnesses/codex.test.js +35 -0
- package/src/launch.js +165 -6
- package/src/launch.test.js +112 -1
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
|
@@ -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");
|
package/src/harnesses/codex.js
CHANGED
|
@@ -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({
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(),
|
package/src/launch.test.js
CHANGED
|
@@ -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",
|
|
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.
|