pennyrouter 0.3.26 → 0.3.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.js +24 -6
- package/src/harnesses/claude-code.js +32 -14
- package/src/harnesses/codex.js +5 -4
- package/src/harnesses/codex.test.js +1 -1
- package/src/launch.js +6 -4
- package/src/launch.test.js +28 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -44,7 +44,8 @@ import {
|
|
|
44
44
|
storeUpstreamCredential,
|
|
45
45
|
} from "./session.js";
|
|
46
46
|
|
|
47
|
-
async function codexNativeAuthAvailable({ gatewayBaseUrl, apiKey, localAuth }) {
|
|
47
|
+
async function codexNativeAuthAvailable({ gatewayBaseUrl, apiKey, localAuth, privateGateway = false }) {
|
|
48
|
+
if (privateGateway) return false;
|
|
48
49
|
if (!isCodexSubscriptionAuthGateway(gatewayBaseUrl)) return false;
|
|
49
50
|
// A local gateway should use Codex's detected login as the opt-in. Do not infer
|
|
50
51
|
// subscription auth from some other upstream credential stored on the local gateway.
|
|
@@ -544,7 +545,7 @@ async function authProvider(flags) {
|
|
|
544
545
|
apiKey: pennyKey,
|
|
545
546
|
gatewayBaseUrl,
|
|
546
547
|
subscriptionAuth: true,
|
|
547
|
-
models: CLAUDE_CODE_MODELS,
|
|
548
|
+
models: flags.profile === "private" ? null : CLAUDE_CODE_MODELS,
|
|
548
549
|
profile: flags.profile,
|
|
549
550
|
});
|
|
550
551
|
let record = existingRecord || {
|
|
@@ -561,6 +562,7 @@ async function authProvider(flags) {
|
|
|
561
562
|
...record,
|
|
562
563
|
...(await claudeCodeHarness.installStoredToken({
|
|
563
564
|
apiKey: pennyKey, gatewayBaseUrl, plan, existingRecord,
|
|
565
|
+
privateGateway: flags.profile === "private",
|
|
564
566
|
})),
|
|
565
567
|
};
|
|
566
568
|
}
|
|
@@ -892,6 +894,7 @@ export async function install(flags = {}) {
|
|
|
892
894
|
? await codexNativeAuthAvailable({
|
|
893
895
|
gatewayBaseUrl: gatewayBaseUrl,
|
|
894
896
|
apiKey: claim.api_key,
|
|
897
|
+
privateGateway: flags.privateGatewayFlow,
|
|
895
898
|
localAuth: harnessNeededAuth
|
|
896
899
|
? funding.codexSubscriptionAuth
|
|
897
900
|
: Boolean(existingRecord?.openai_subscription_auth),
|
|
@@ -907,7 +910,11 @@ export async function install(flags = {}) {
|
|
|
907
910
|
subscriptionAuth: harness.id === "claude-code"
|
|
908
911
|
? claudeSubscriptionAuth
|
|
909
912
|
: codexSubscriptionAuth,
|
|
910
|
-
|
|
913
|
+
privateGateway: flags.privateGatewayFlow,
|
|
914
|
+
// Private gateways advertise their complete upstream catalog. Leave the
|
|
915
|
+
// Claude defaults unset there so the picker does not merge hosted-only ids.
|
|
916
|
+
models: harness.id === "claude-code" && !flags.privateGatewayFlow
|
|
917
|
+
? CLAUDE_CODE_MODELS : null,
|
|
911
918
|
profile: flags.profile,
|
|
912
919
|
});
|
|
913
920
|
}
|
|
@@ -920,9 +927,11 @@ export async function install(flags = {}) {
|
|
|
920
927
|
record = harness.id === "claude-code" && claudeSubscriptionAuth
|
|
921
928
|
? await harness.installStoredToken({
|
|
922
929
|
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
|
|
930
|
+
privateGateway: flags.privateGatewayFlow,
|
|
923
931
|
})
|
|
924
932
|
: await harness.install({
|
|
925
933
|
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord, models: opencodeModels,
|
|
934
|
+
privateGateway: flags.privateGatewayFlow,
|
|
926
935
|
});
|
|
927
936
|
} else {
|
|
928
937
|
if (hadGlobalRouting) {
|
|
@@ -1373,6 +1382,7 @@ async function update(flags) {
|
|
|
1373
1382
|
? await codexNativeAuthAvailable({
|
|
1374
1383
|
gatewayBaseUrl,
|
|
1375
1384
|
apiKey,
|
|
1385
|
+
privateGateway: profile === "private",
|
|
1376
1386
|
localAuth: await detectCodexChatgptAuth(),
|
|
1377
1387
|
})
|
|
1378
1388
|
: false;
|
|
@@ -1384,7 +1394,9 @@ async function update(flags) {
|
|
|
1384
1394
|
subscriptionAuth: record.harness === "claude-code"
|
|
1385
1395
|
? claudeSubscriptionAuth
|
|
1386
1396
|
: codexSubscriptionAuth,
|
|
1387
|
-
|
|
1397
|
+
privateGateway: profile === "private",
|
|
1398
|
+
models: record.harness === "claude-code" && profile !== "private"
|
|
1399
|
+
? CLAUDE_CODE_MODELS : null,
|
|
1388
1400
|
profile,
|
|
1389
1401
|
});
|
|
1390
1402
|
}
|
|
@@ -1396,8 +1408,14 @@ async function update(flags) {
|
|
|
1396
1408
|
// (previous_model_provider, claude_ai_connectors_before) survives the rewrite
|
|
1397
1409
|
// and a later uninstall still restores the user's original values.
|
|
1398
1410
|
const installed = record.harness === "claude-code" && claudeSubscriptionAuth
|
|
1399
|
-
? await harness.installStoredToken({
|
|
1400
|
-
|
|
1411
|
+
? await harness.installStoredToken({
|
|
1412
|
+
apiKey, gatewayBaseUrl, plan, existingRecord: record,
|
|
1413
|
+
privateGateway: profile === "private",
|
|
1414
|
+
})
|
|
1415
|
+
: await harness.install({
|
|
1416
|
+
apiKey, gatewayBaseUrl, plan, existingRecord: record,
|
|
1417
|
+
privateGateway: profile === "private",
|
|
1418
|
+
});
|
|
1401
1419
|
next = { ...record, ...installed, installed_at: record.installed_at };
|
|
1402
1420
|
}
|
|
1403
1421
|
|
|
@@ -79,7 +79,7 @@ export const claudeCodeHarness = {
|
|
|
79
79
|
};
|
|
80
80
|
},
|
|
81
81
|
|
|
82
|
-
async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
|
|
82
|
+
async install({ apiKey, gatewayBaseUrl, plan, existingRecord, privateGateway = false }) {
|
|
83
83
|
const backupPath = await backupFile(CONFIG_PATH, "claude-code");
|
|
84
84
|
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
85
85
|
const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
|
|
@@ -90,13 +90,23 @@ export const claudeCodeHarness = {
|
|
|
90
90
|
current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
|
91
91
|
delete current.env.ANTHROPIC_API_KEY;
|
|
92
92
|
current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
if (privateGateway) {
|
|
94
|
+
// A private gateway's /v1/models response is the complete model catalog. Do not
|
|
95
|
+
// merge the hosted Claude defaults into that picker: they may not exist upstream.
|
|
96
|
+
for (const slot of ["OPUS", "SONNET", "HAIKU", "FABLE"]) {
|
|
97
|
+
delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL`];
|
|
98
|
+
delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_NAME`];
|
|
99
|
+
delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_DESCRIPTION`];
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
// Claude Code displays these env values directly in its model picker; the
|
|
103
|
+
// gateway decodes the friendly labels into bundle/profile overrides.
|
|
104
|
+
current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
|
|
105
|
+
current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
|
|
106
|
+
current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
|
|
107
|
+
current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
|
|
108
|
+
writeClaudeCodeModelPresentation(current.env);
|
|
109
|
+
}
|
|
100
110
|
delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
|
101
111
|
delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
|
|
102
112
|
delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
|
|
@@ -136,7 +146,7 @@ export const claudeCodeHarness = {
|
|
|
136
146
|
// config therefore carries only the pr- bearer + base URL, not the Anthropic token.
|
|
137
147
|
// The gateway pulls the stored token per request. This avoids putting the Anthropic
|
|
138
148
|
// credential on disk and avoids a dual-credential configuration.
|
|
139
|
-
async installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
|
|
149
|
+
async installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord, privateGateway = false }) {
|
|
140
150
|
const backupPath = await backupFile(CONFIG_PATH, "claude-code");
|
|
141
151
|
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
142
152
|
const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
|
|
@@ -146,11 +156,19 @@ export const claudeCodeHarness = {
|
|
|
146
156
|
// Single credential only: no stale api key, no separate Anthropic bearer.
|
|
147
157
|
delete current.env.ANTHROPIC_API_KEY;
|
|
148
158
|
current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
159
|
+
if (privateGateway) {
|
|
160
|
+
for (const slot of ["OPUS", "SONNET", "HAIKU", "FABLE"]) {
|
|
161
|
+
delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL`];
|
|
162
|
+
delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_NAME`];
|
|
163
|
+
delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_DESCRIPTION`];
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
|
|
167
|
+
current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
|
|
168
|
+
current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
|
|
169
|
+
current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
|
|
170
|
+
writeClaudeCodeModelPresentation(current.env);
|
|
171
|
+
}
|
|
154
172
|
delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
|
155
173
|
delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
|
|
156
174
|
delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
|
package/src/harnesses/codex.js
CHANGED
|
@@ -27,9 +27,9 @@ export const codexHarness = {
|
|
|
27
27
|
};
|
|
28
28
|
},
|
|
29
29
|
|
|
30
|
-
async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
|
|
30
|
+
async install({ apiKey, gatewayBaseUrl, plan, existingRecord, privateGateway = false }) {
|
|
31
31
|
const current = await readText(CONFIG_PATH, "");
|
|
32
|
-
const subscriptionAuth = await detectCodexChatgptAuth();
|
|
32
|
+
const subscriptionAuth = !privateGateway && await detectCodexChatgptAuth();
|
|
33
33
|
const previousModelProvider = existingRecord?.previous_model_provider
|
|
34
34
|
|| rootAssignment(removeManagedCodexConfig(current), "model_provider");
|
|
35
35
|
const backupPath = await backupFile(CONFIG_PATH, "codex");
|
|
@@ -37,6 +37,7 @@ export const codexHarness = {
|
|
|
37
37
|
apiKey,
|
|
38
38
|
gatewayBaseUrl,
|
|
39
39
|
subscriptionAuth,
|
|
40
|
+
privateGateway,
|
|
40
41
|
surface: "codex-terminal",
|
|
41
42
|
});
|
|
42
43
|
|
|
@@ -101,14 +102,14 @@ export async function detectCodexChatgptAuth() {
|
|
|
101
102
|
*/
|
|
102
103
|
export function renderCodexConfig(
|
|
103
104
|
text,
|
|
104
|
-
{ apiKey, gatewayBaseUrl, subscriptionAuth, surface = "codex-terminal" },
|
|
105
|
+
{ apiKey, gatewayBaseUrl, subscriptionAuth, privateGateway = false, surface = "codex-terminal" },
|
|
105
106
|
) {
|
|
106
107
|
// Native Codex ChatGPT subscription auth is allowed through the hosted gateway and the
|
|
107
108
|
// explicitly supported local development gateway. Other custom gateways use Penny auth.
|
|
108
109
|
// Keep this guard at the final renderer so every install/update/desktop path gets the
|
|
109
110
|
// same safety rule regardless of which profile selected it.
|
|
110
111
|
subscriptionAuth = Boolean(
|
|
111
|
-
subscriptionAuth && isCodexSubscriptionAuthGateway(gatewayBaseUrl),
|
|
112
|
+
subscriptionAuth && !privateGateway && isCodexSubscriptionAuthGateway(gatewayBaseUrl),
|
|
112
113
|
);
|
|
113
114
|
const clean = removeManagedCodexConfig(text, { removeRootProvider: true }).trimEnd();
|
|
114
115
|
if (new RegExp(`^\\s*\\[model_providers\\.${escapeRegex(PROVIDER_ID)}\\]\\s*$`, "m").test(clean)) {
|
|
@@ -40,7 +40,6 @@ function testSubscriptionProviderIsRootSafe() {
|
|
|
40
40
|
function testPrivateAndProxyGatewaysNeverEnableNativeSubscription() {
|
|
41
41
|
assert.equal(isHostedPennyRouterGateway("https://api.pennyrouter.com"), true);
|
|
42
42
|
for (const gateway of [
|
|
43
|
-
"http://localhost:8400",
|
|
44
43
|
"https://private.example.test",
|
|
45
44
|
"https://openrouter.example.test",
|
|
46
45
|
]) {
|
|
@@ -49,6 +48,7 @@ function testPrivateAndProxyGatewaysNeverEnableNativeSubscription() {
|
|
|
49
48
|
apiKey: "pr-private",
|
|
50
49
|
gatewayBaseUrl: gateway,
|
|
51
50
|
subscriptionAuth: true,
|
|
51
|
+
privateGateway: true,
|
|
52
52
|
});
|
|
53
53
|
assert.ok(next.includes('model_provider = "pennyrouter"'));
|
|
54
54
|
assert.ok(next.includes('experimental_bearer_token = "pr-private"'));
|
package/src/launch.js
CHANGED
|
@@ -442,12 +442,12 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
|
|
|
442
442
|
return { supported: true, ...path, shim_path: pennyShimPath(), compare };
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
-
export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
445
|
+
export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, privateGateway = false }) {
|
|
446
446
|
// Native ChatGPT subscription auth is allowed for the hosted gateway and the explicitly
|
|
447
447
|
// supported local development gateway. Apply the same rule as the config renderer at this
|
|
448
448
|
// boundary, since this path is what bare `penny codex` selects at runtime.
|
|
449
449
|
subscriptionAuth = Boolean(
|
|
450
|
-
subscriptionAuth && isCodexSubscriptionAuthGateway(gatewayBaseUrl),
|
|
450
|
+
subscriptionAuth && !privateGateway && isCodexSubscriptionAuthGateway(gatewayBaseUrl),
|
|
451
451
|
);
|
|
452
452
|
if (subscriptionAuth) {
|
|
453
453
|
return [
|
|
@@ -879,7 +879,7 @@ async function writeClaudeUserSkill() {
|
|
|
879
879
|
}
|
|
880
880
|
}
|
|
881
881
|
|
|
882
|
-
async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profile }) {
|
|
882
|
+
async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profile, privateGateway = false }) {
|
|
883
883
|
const path = codexLaunchProfilePath(profile);
|
|
884
884
|
const existing = await readText(path, "");
|
|
885
885
|
if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
|
|
@@ -887,13 +887,14 @@ async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profi
|
|
|
887
887
|
`${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
|
|
888
888
|
);
|
|
889
889
|
}
|
|
890
|
-
await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }));
|
|
890
|
+
await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, privateGateway }));
|
|
891
891
|
}
|
|
892
892
|
|
|
893
893
|
export async function configurePennyHarness(harness, {
|
|
894
894
|
apiKey,
|
|
895
895
|
gatewayBaseUrl,
|
|
896
896
|
subscriptionAuth = false,
|
|
897
|
+
privateGateway = false,
|
|
897
898
|
models = null,
|
|
898
899
|
profile = null,
|
|
899
900
|
}) {
|
|
@@ -919,6 +920,7 @@ export async function configurePennyHarness(harness, {
|
|
|
919
920
|
await writeCodexLaunchProfile({
|
|
920
921
|
gatewayBaseUrl,
|
|
921
922
|
subscriptionAuth,
|
|
923
|
+
privateGateway,
|
|
922
924
|
profile: config.active_profile,
|
|
923
925
|
});
|
|
924
926
|
config.harnesses.codex = {
|
package/src/launch.test.js
CHANGED
|
@@ -266,6 +266,25 @@ async function main() {
|
|
|
266
266
|
assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "claude-sonnet-5[1m]");
|
|
267
267
|
assert.equal("CLAUDE_CODE_MAX_CONTEXT_TOKENS" in persistedClaude.env, false);
|
|
268
268
|
|
|
269
|
+
// Private gateways own the model catalog. Their picker must not merge the
|
|
270
|
+
// hosted Claude defaults, which may not exist in the configured proxy.
|
|
271
|
+
await claudeCodeHarness.install({
|
|
272
|
+
apiKey: "pr-super-secret",
|
|
273
|
+
gatewayBaseUrl: "http://localhost:8400",
|
|
274
|
+
plan: { configPath: path.join(home, ".claude", "settings.json") },
|
|
275
|
+
privateGateway: true,
|
|
276
|
+
});
|
|
277
|
+
const persistedPrivateClaude = JSON.parse(
|
|
278
|
+
fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"),
|
|
279
|
+
);
|
|
280
|
+
for (const slot of ["OPUS", "SONNET", "HAIKU", "FABLE"]) {
|
|
281
|
+
assert.equal(persistedPrivateClaude.env[`ANTHROPIC_DEFAULT_${slot}_MODEL`], undefined);
|
|
282
|
+
assert.equal(persistedPrivateClaude.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_NAME`], undefined);
|
|
283
|
+
assert.equal(
|
|
284
|
+
persistedPrivateClaude.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_DESCRIPTION`], undefined,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
269
288
|
await removePennyHarness("claude-code");
|
|
270
289
|
assert.ok(!fs.existsSync(claudeUserSkillPath()));
|
|
271
290
|
fs.mkdirSync(path.dirname(claudeUserSkillPath()), { recursive: true });
|
|
@@ -300,6 +319,15 @@ async function main() {
|
|
|
300
319
|
});
|
|
301
320
|
assert.ok(paid.includes('env_key = "PENNYROUTER_API_KEY"'));
|
|
302
321
|
assert.ok(!paid.includes("requires_openai_auth"));
|
|
322
|
+
const localSubscription = renderCodexLaunchProfile({
|
|
323
|
+
gatewayBaseUrl: "http://localhost:8400", subscriptionAuth: true,
|
|
324
|
+
});
|
|
325
|
+
assert.ok(localSubscription.includes('model_provider = "openai"'));
|
|
326
|
+
const localPrivate = renderCodexLaunchProfile({
|
|
327
|
+
gatewayBaseUrl: "http://localhost:8400", subscriptionAuth: true, privateGateway: true,
|
|
328
|
+
});
|
|
329
|
+
assert.ok(localPrivate.includes('model_provider = "pennyrouter"'));
|
|
330
|
+
assert.ok(localPrivate.includes('env_key = "PENNYROUTER_API_KEY"'));
|
|
303
331
|
|
|
304
332
|
// Inside the fixture: testProfiles persists a config, which must land in the
|
|
305
333
|
// temporary HOME and never in the developer's real ~/.config/pennyrouter.
|