pennyrouter 0.3.17 → 0.3.19
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 -4
- package/package.json +1 -1
- package/src/cli.js +58 -11
- package/src/harnesses/codex.js +14 -0
- package/src/harnesses/codex.test.js +24 -2
- package/src/install-manifest.json +3 -3
- package/src/launch.js +10 -0
- package/src/launch.test.js +4 -4
- package/src/session.js +11 -0
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ Installing against a self-hosted gateway is its own flow, with no browser login:
|
|
|
29
29
|
keeps its own accounts, so the key is minted there.
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
npx pennyrouter install --private-gateway https://
|
|
32
|
+
npx pennyrouter install --private-gateway https://penny-router.apps-dev.gtri.org
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
It asks for the gateway URL (unless given) and your email, and mints a key against that
|
|
@@ -44,11 +44,11 @@ ones:
|
|
|
44
44
|
```json
|
|
45
45
|
{
|
|
46
46
|
"harnesses": ["claude-code", "codex"],
|
|
47
|
-
"gatewayBaseUrl": "https://
|
|
47
|
+
"gatewayBaseUrl": "https://penny-router.apps-dev.gtri.org",
|
|
48
48
|
"claudeFunding": ["anthropic-proxy"],
|
|
49
|
-
"
|
|
49
|
+
"anthropicProxyBaseUrl": "https://litellm.core.gtri.org/v1",
|
|
50
50
|
"codexFunding": ["openai-proxy"],
|
|
51
|
-
"openaiProxyBaseUrl": "https://litellm.
|
|
51
|
+
"openaiProxyBaseUrl": "https://litellm.core.gtri.org/v1"
|
|
52
52
|
}
|
|
53
53
|
```
|
|
54
54
|
|
|
@@ -57,6 +57,13 @@ npx pennyrouter install --config team-install.json
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
Each person is prompted for their email and their proxy key; everything else is prefilled.
|
|
60
|
+
|
|
61
|
+
For clarity, `proxyBaseUrl`/`proxyApiKey` are the legacy names for the Claude/Anthropic
|
|
62
|
+
wire lane. New files should use `anthropicProxyBaseUrl`/`anthropicProxyApiKey`; Codex uses
|
|
63
|
+
`openaiProxyBaseUrl`/`openaiProxyApiKey`. The `--private-server` spelling is a compatibility
|
|
64
|
+
alias for `--private-gateway`.
|
|
65
|
+
|
|
66
|
+
|
|
60
67
|
Any field left out of the file is prompted for, and an unattended run that cannot prompt
|
|
61
68
|
refuses by name rather than installing a half-configured harness. Add `email`, `proxyApiKey`
|
|
62
69
|
and `openaiProxyApiKey` to the file to skip the prompts entirely.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -19,13 +19,14 @@ import {
|
|
|
19
19
|
} from "./harnesses/claude-code.js";
|
|
20
20
|
import { clineHarness } from "./harnesses/cline.js";
|
|
21
21
|
import { codeGptHarness } from "./harnesses/codegpt.js";
|
|
22
|
-
import { codexHarness, detectCodexChatgptAuth } from "./harnesses/codex.js";
|
|
22
|
+
import { codexHarness, detectCodexChatgptAuth, isHostedPennyRouterGateway } from "./harnesses/codex.js";
|
|
23
23
|
import { fetchOpenCodeModels, opencodeHarness } from "./harnesses/opencode.js";
|
|
24
24
|
import { windsurfHarness } from "./harnesses/windsurf.js";
|
|
25
25
|
import { zedHarness } from "./harnesses/zed.js";
|
|
26
26
|
import {
|
|
27
27
|
APP_URL,
|
|
28
28
|
createCliSession,
|
|
29
|
+
fetchGatewayCapabilities,
|
|
29
30
|
forgetAnthropicToken,
|
|
30
31
|
forgetUpstreamCredential,
|
|
31
32
|
maskKey,
|
|
@@ -36,6 +37,14 @@ import {
|
|
|
36
37
|
storeAnthropicToken,
|
|
37
38
|
storeUpstreamCredential,
|
|
38
39
|
} from "./session.js";
|
|
40
|
+
|
|
41
|
+
async function codexNativeAuthAvailable({ gatewayBaseUrl, apiKey, localAuth }) {
|
|
42
|
+
if (!isHostedPennyRouterGateway(gatewayBaseUrl)) return false;
|
|
43
|
+
const capabilities = await fetchGatewayCapabilities({ gatewayBaseUrl, apiKey });
|
|
44
|
+
const hasOpenAiCredential = Array.isArray(capabilities?.upstream_credentials)
|
|
45
|
+
&& capabilities.upstream_credentials.includes("openai_api");
|
|
46
|
+
return Boolean(localAuth || hasOpenAiCredential);
|
|
47
|
+
}
|
|
39
48
|
import {
|
|
40
49
|
backupFile,
|
|
41
50
|
compactHome,
|
|
@@ -225,6 +234,10 @@ function parseArgs(argv) {
|
|
|
225
234
|
else if (arg.startsWith("--openai-proxy-base-url=")) flags.openaiProxyBaseUrl = arg.slice("--openai-proxy-base-url=".length);
|
|
226
235
|
else if (arg === "--openai-proxy-api-key") flags.openaiProxyApiKey = rest[++i] || "";
|
|
227
236
|
else if (arg.startsWith("--openai-proxy-api-key=")) flags.openaiProxyApiKey = arg.slice("--openai-proxy-api-key=".length);
|
|
237
|
+
else if (arg === "--anthropic-proxy-base-url") flags.proxyBaseUrl = rest[++i] || "";
|
|
238
|
+
else if (arg.startsWith("--anthropic-proxy-base-url=")) flags.proxyBaseUrl = arg.slice("--anthropic-proxy-base-url=".length);
|
|
239
|
+
else if (arg === "--anthropic-proxy-api-key") flags.proxyApiKey = rest[++i] || "";
|
|
240
|
+
else if (arg.startsWith("--anthropic-proxy-api-key=")) flags.proxyApiKey = arg.slice("--anthropic-proxy-api-key=".length);
|
|
228
241
|
else if (arg === "--proxy-base-url") flags.proxyBaseUrl = rest[++i] || "";
|
|
229
242
|
else if (arg.startsWith("--proxy-base-url=")) flags.proxyBaseUrl = arg.slice("--proxy-base-url=".length);
|
|
230
243
|
else if (arg === "--proxy-api-key") flags.proxyApiKey = rest[++i] || "";
|
|
@@ -241,17 +254,17 @@ function parseArgs(argv) {
|
|
|
241
254
|
// --gateway-base-url, which expects the URL and an admin-issued key up front, this asks
|
|
242
255
|
// for what it needs (gateway URL, then email) and mints the key from the gateway itself,
|
|
243
256
|
// so a user on a VPN can install without an administrator handing them a key first.
|
|
244
|
-
else if (arg === "--private-gateway") {
|
|
257
|
+
else if (arg === "--private-gateway" || arg === "--private-server") {
|
|
245
258
|
flags.privateGatewayFlow = true;
|
|
246
259
|
flags.privateGateway = true;
|
|
247
260
|
// Optional inline URL: `--private-gateway https://...` rather than a prompt. Only
|
|
248
261
|
// consume the next argument when it is not itself a flag.
|
|
249
262
|
if (rest[i + 1] && !rest[i + 1].startsWith("-")) flags.gatewayBaseUrl = rest[++i];
|
|
250
263
|
}
|
|
251
|
-
else if (arg.startsWith("--private-gateway=")) {
|
|
264
|
+
else if (arg.startsWith("--private-gateway=") || arg.startsWith("--private-server=")) {
|
|
252
265
|
flags.privateGatewayFlow = true;
|
|
253
266
|
flags.privateGateway = true;
|
|
254
|
-
flags.gatewayBaseUrl = arg.slice("
|
|
267
|
+
flags.gatewayBaseUrl = arg.slice(arg.indexOf("=") + 1);
|
|
255
268
|
}
|
|
256
269
|
// Email the private gateway mints the key against; prompted for when absent.
|
|
257
270
|
else if (arg === "--email") flags.email = rest[++i] || "";
|
|
@@ -319,6 +332,15 @@ export function applyConfigFile(flags) {
|
|
|
319
332
|
if (config.harnesses && flags.harness === undefined) {
|
|
320
333
|
flags.harness = config.harnesses.join(",");
|
|
321
334
|
}
|
|
335
|
+
// Migrate the ambiguous Claude proxy keys in-memory. Existing shared install files remain
|
|
336
|
+
// valid while new files can say explicitly which wire lane they configure.
|
|
337
|
+
if (config.anthropicProxyBaseUrl === undefined && config.proxyBaseUrl !== undefined) {
|
|
338
|
+
config.anthropicProxyBaseUrl = config.proxyBaseUrl;
|
|
339
|
+
}
|
|
340
|
+
if (config.anthropicProxyApiKey === undefined && config.proxyApiKey !== undefined) {
|
|
341
|
+
config.anthropicProxyApiKey = config.proxyApiKey;
|
|
342
|
+
}
|
|
343
|
+
|
|
322
344
|
const claudeFunding = new Set(config.claudeFunding || []);
|
|
323
345
|
const codexFunding = new Set(config.codexFunding || []);
|
|
324
346
|
if (claudeFunding.has("subscription") && flags.anthropicAuth === undefined) {
|
|
@@ -349,6 +371,15 @@ export function applyConfigFile(flags) {
|
|
|
349
371
|
const value = String(config[field.config_key] || "").trim();
|
|
350
372
|
if (value && flags[field.config_key] === undefined) flags[field.config_key] = value;
|
|
351
373
|
}
|
|
374
|
+
// ``proxy*`` was the original Claude/Anthropic naming. Keep it as an input alias, but
|
|
375
|
+
// normalize the explicit JSON names back to the internal flags used by the installer.
|
|
376
|
+
if (flags.proxyBaseUrl === undefined && flags.anthropicProxyBaseUrl !== undefined) {
|
|
377
|
+
flags.proxyBaseUrl = flags.anthropicProxyBaseUrl;
|
|
378
|
+
}
|
|
379
|
+
if (flags.proxyApiKey === undefined && flags.anthropicProxyApiKey !== undefined) {
|
|
380
|
+
flags.proxyApiKey = flags.anthropicProxyApiKey;
|
|
381
|
+
}
|
|
382
|
+
|
|
352
383
|
if (flags.gatewayBaseUrl) {
|
|
353
384
|
flags.privateGateway = true;
|
|
354
385
|
// Take the self-hosted install path rather than the hosted browser claim, whose key is
|
|
@@ -832,7 +863,13 @@ export async function install(flags = {}) {
|
|
|
832
863
|
|
|
833
864
|
const harnessNeededAuth = needsAuth.includes(harness);
|
|
834
865
|
const codexSubscriptionAuth = harness.id === "codex"
|
|
835
|
-
?
|
|
866
|
+
? await codexNativeAuthAvailable({
|
|
867
|
+
gatewayBaseUrl: gatewayBaseUrl,
|
|
868
|
+
apiKey: claim.api_key,
|
|
869
|
+
localAuth: harnessNeededAuth
|
|
870
|
+
? funding.codexSubscriptionAuth
|
|
871
|
+
: Boolean(existingRecord?.openai_subscription_auth),
|
|
872
|
+
})
|
|
836
873
|
: false;
|
|
837
874
|
const claudeSubscriptionAuth = harness.id === "claude-code"
|
|
838
875
|
&& Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
|
|
@@ -1269,7 +1306,11 @@ async function update(flags) {
|
|
|
1269
1306
|
const claudeSubscriptionAuth = record.harness === "claude-code"
|
|
1270
1307
|
&& Boolean(record.anthropic_token_stored);
|
|
1271
1308
|
const codexSubscriptionAuth = record.harness === "codex"
|
|
1272
|
-
? await
|
|
1309
|
+
? await codexNativeAuthAvailable({
|
|
1310
|
+
gatewayBaseUrl,
|
|
1311
|
+
apiKey,
|
|
1312
|
+
localAuth: await detectCodexChatgptAuth(),
|
|
1313
|
+
})
|
|
1273
1314
|
: false;
|
|
1274
1315
|
|
|
1275
1316
|
if (supportsPennyCommand) {
|
|
@@ -1404,8 +1445,10 @@ async function selectClaudeFundingProviders(flags, selected) {
|
|
|
1404
1445
|
|| process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
|
|
1405
1446
|
if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
|
|
1406
1447
|
if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
|
|
1407
|
-
if ((flags.proxyBaseUrl || process.env.
|
|
1408
|
-
|
|
1448
|
+
if ((flags.proxyBaseUrl || process.env.PENNYROUTER_ANTHROPIC_PROXY_BASE_URL
|
|
1449
|
+
|| process.env.PENNYROUTER_PROXY_BASE_URL)
|
|
1450
|
+
&& (flags.proxyApiKey || process.env.PENNYROUTER_ANTHROPIC_PROXY_API_KEY
|
|
1451
|
+
|| process.env.PENNYROUTER_PROXY_API_KEY)) {
|
|
1409
1452
|
supplied.add("anthropic-proxy");
|
|
1410
1453
|
}
|
|
1411
1454
|
// A --config file states its providers outright, and that choice stands whether or not the
|
|
@@ -1540,10 +1583,14 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1540
1583
|
flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY || "",
|
|
1541
1584
|
).trim();
|
|
1542
1585
|
let proxyBaseUrl = String(
|
|
1543
|
-
flags.proxyBaseUrl ||
|
|
1586
|
+
flags.proxyBaseUrl || flags.anthropicProxyBaseUrl
|
|
1587
|
+
|| process.env.PENNYROUTER_ANTHROPIC_PROXY_BASE_URL
|
|
1588
|
+
|| process.env.PENNYROUTER_PROXY_BASE_URL || "",
|
|
1544
1589
|
).trim();
|
|
1545
1590
|
let proxyApiKey = String(
|
|
1546
|
-
flags.proxyApiKey ||
|
|
1591
|
+
flags.proxyApiKey || flags.anthropicProxyApiKey
|
|
1592
|
+
|| process.env.PENNYROUTER_ANTHROPIC_PROXY_API_KEY
|
|
1593
|
+
|| process.env.PENNYROUTER_PROXY_API_KEY || "",
|
|
1547
1594
|
).trim();
|
|
1548
1595
|
const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
|
|
1549
1596
|
? await detectCodexChatgptAuth()
|
|
@@ -2382,7 +2429,7 @@ function printHelp() {
|
|
|
2382
2429
|
console.log(`PennyRouter CLI
|
|
2383
2430
|
|
|
2384
2431
|
Usage:
|
|
2385
|
-
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openai-proxy-base-url URL --openai-proxy-api-key KEY] [--openrouter-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--private-gateway[=URL] [--email you@example.com]] [--gateway-base-url URL] [--private-key pr-...] [--local[=URL]|--prod] [--config PATH]
|
|
2432
|
+
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openai-proxy-base-url URL --openai-proxy-api-key KEY] [--openrouter-api-key KEY] [--anthropic-proxy-base-url URL --anthropic-proxy-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--private-gateway|--private-server[=URL] [--email you@example.com]] [--gateway-base-url URL] [--private-key pr-...] [--local[=URL]|--prod] [--config PATH]
|
|
2386
2433
|
pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp]
|
|
2387
2434
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
2388
2435
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
package/src/harnesses/codex.js
CHANGED
|
@@ -103,6 +103,12 @@ export function renderCodexConfig(
|
|
|
103
103
|
text,
|
|
104
104
|
{ apiKey, gatewayBaseUrl, subscriptionAuth, surface = "codex-terminal" },
|
|
105
105
|
) {
|
|
106
|
+
// Native Codex subscription/WebSocket auth is valid only through PennyRouter's hosted
|
|
107
|
+
// cloud gateway. Local, private, proxy, and other custom gateways must always use the
|
|
108
|
+
// ordinary PennyRouter bearer provider, even when Codex itself is logged into ChatGPT.
|
|
109
|
+
// Keep this guard at the final renderer so every install/update/desktop path gets the
|
|
110
|
+
// same safety rule regardless of which profile selected it.
|
|
111
|
+
subscriptionAuth = Boolean(subscriptionAuth && isHostedPennyRouterGateway(gatewayBaseUrl));
|
|
106
112
|
const clean = removeManagedCodexConfig(text, { removeRootProvider: true }).trimEnd();
|
|
107
113
|
if (new RegExp(`^\\s*\\[model_providers\\.${escapeRegex(PROVIDER_ID)}\\]\\s*$`, "m").test(clean)) {
|
|
108
114
|
throw new Error(
|
|
@@ -142,6 +148,14 @@ export function renderCodexConfig(
|
|
|
142
148
|
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
|
|
143
149
|
}
|
|
144
150
|
|
|
151
|
+
export function isHostedPennyRouterGateway(gatewayBaseUrl) {
|
|
152
|
+
try {
|
|
153
|
+
return new URL(String(gatewayBaseUrl)).hostname.toLowerCase() === "api.pennyrouter.com";
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
145
159
|
export function removeManagedCodexConfig(text, { removeRootProvider = false } = {}) {
|
|
146
160
|
let out = String(text || "").replace(
|
|
147
161
|
new RegExp(`${escapeRegex(MANAGED_START)}[\\s\\S]*?${escapeRegex(MANAGED_END)}\\n?`, "g"),
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
codexUsesChatgptAuth,
|
|
9
9
|
removeManagedCodexConfig,
|
|
10
10
|
renderCodexConfig,
|
|
11
|
+
isHostedPennyRouterGateway,
|
|
11
12
|
} from "./codex.js";
|
|
12
13
|
|
|
13
14
|
function testAuthDetection() {
|
|
@@ -21,13 +22,13 @@ function testSubscriptionProviderIsRootSafe() {
|
|
|
21
22
|
const original = `model = "gpt-test"\n\n[features]\nplugins = false\n`;
|
|
22
23
|
const next = renderCodexConfig(original, {
|
|
23
24
|
apiKey: "pr-test-secret",
|
|
24
|
-
gatewayBaseUrl: "
|
|
25
|
+
gatewayBaseUrl: "https://api.pennyrouter.com/",
|
|
25
26
|
subscriptionAuth: true,
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
assert.ok(next.indexOf('model_provider = "pennyrouter"') < next.indexOf("[features]"));
|
|
29
30
|
assert.ok(next.includes('[model_providers.pennyrouter]'));
|
|
30
|
-
assert.ok(next.includes('base_url = "
|
|
31
|
+
assert.ok(next.includes('base_url = "https://api.pennyrouter.com/v1"'));
|
|
31
32
|
assert.ok(next.includes("requires_openai_auth = true"));
|
|
32
33
|
assert.ok(next.includes('"x-pennyrouter-key" = "pr-test-secret"'));
|
|
33
34
|
assert.ok(next.includes('"x-pennyrouter-harness" = "codex"'));
|
|
@@ -36,6 +37,26 @@ function testSubscriptionProviderIsRootSafe() {
|
|
|
36
37
|
assert.equal((next.match(/# --- PennyRouter managed Codex provider ---/g) || []).length, 1);
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
function testPrivateAndProxyGatewaysNeverEnableNativeSubscription() {
|
|
41
|
+
assert.equal(isHostedPennyRouterGateway("https://api.pennyrouter.com"), true);
|
|
42
|
+
for (const gateway of [
|
|
43
|
+
"http://localhost:8400",
|
|
44
|
+
"https://private.example.test",
|
|
45
|
+
"https://openrouter.example.test",
|
|
46
|
+
]) {
|
|
47
|
+
assert.equal(isHostedPennyRouterGateway(gateway), false);
|
|
48
|
+
const next = renderCodexConfig("", {
|
|
49
|
+
apiKey: "pr-private",
|
|
50
|
+
gatewayBaseUrl: gateway,
|
|
51
|
+
subscriptionAuth: true,
|
|
52
|
+
});
|
|
53
|
+
assert.ok(next.includes('model_provider = "pennyrouter"'));
|
|
54
|
+
assert.ok(next.includes('experimental_bearer_token = "pr-private"'));
|
|
55
|
+
assert.ok(!next.includes("requires_openai_auth"));
|
|
56
|
+
assert.ok(next.includes('supports_websockets = false'));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
function testLegacyMigrationAndIdempotence() {
|
|
40
61
|
const legacy = `[features]\nplugins = false\nopenai_base_url = "http://old/v1"\nopenai_api_key = "pr-old"\n`;
|
|
41
62
|
const once = renderCodexConfig(legacy, {
|
|
@@ -115,6 +136,7 @@ async function testReinstallPreservesRecordedProvider() {
|
|
|
115
136
|
|
|
116
137
|
testAuthDetection();
|
|
117
138
|
testSubscriptionProviderIsRootSafe();
|
|
139
|
+
testPrivateAndProxyGatewaysNeverEnableNativeSubscription();
|
|
118
140
|
testLegacyMigrationAndIdempotence();
|
|
119
141
|
testProviderCollisionFailsClosed();
|
|
120
142
|
testUninstallLeavesUserConfig();
|
|
@@ -56,12 +56,12 @@
|
|
|
56
56
|
"name": "Proxy (e.g., Bedrock, Azure, private server)",
|
|
57
57
|
"fields": [
|
|
58
58
|
{
|
|
59
|
-
"config_key": "
|
|
60
|
-
"label": "
|
|
59
|
+
"config_key": "anthropicProxyBaseUrl",
|
|
60
|
+
"label": "Anthropic proxy base URL",
|
|
61
61
|
"secret": false,
|
|
62
62
|
"placeholder": "https://proxy.example.com"
|
|
63
63
|
},
|
|
64
|
-
{ "config_key": "
|
|
64
|
+
{ "config_key": "anthropicProxyApiKey", "label": "Anthropic proxy API key", "secret": true }
|
|
65
65
|
]
|
|
66
66
|
}
|
|
67
67
|
]
|
package/src/launch.js
CHANGED
|
@@ -390,6 +390,16 @@ export async function installPennyRuntime({ updatePath = true } = {}) {
|
|
|
390
390
|
}
|
|
391
391
|
|
|
392
392
|
export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
393
|
+
// Private/proxy/custom gateways never use Codex's native subscription/WebSocket lane.
|
|
394
|
+
// Apply the same hosted-only rule as the Codex config renderer at this launch-profile
|
|
395
|
+
// boundary, since this path is what bare `penny codex` selects at runtime.
|
|
396
|
+
try {
|
|
397
|
+
subscriptionAuth = Boolean(
|
|
398
|
+
subscriptionAuth && new URL(String(gatewayBaseUrl)).hostname.toLowerCase() === "api.pennyrouter.com",
|
|
399
|
+
);
|
|
400
|
+
} catch {
|
|
401
|
+
subscriptionAuth = false;
|
|
402
|
+
}
|
|
393
403
|
if (subscriptionAuth) {
|
|
394
404
|
return [
|
|
395
405
|
CODEX_PROFILE_MARKER,
|
package/src/launch.test.js
CHANGED
|
@@ -187,14 +187,14 @@ async function main() {
|
|
|
187
187
|
const desktopConfig = fs.readFileSync(path.join(desktopHome, "config.toml"), "utf8");
|
|
188
188
|
assert.match(desktopConfig, /^model_provider = "pennyrouter"/);
|
|
189
189
|
assert.match(desktopConfig, /\[model_providers\.pennyrouter\]/);
|
|
190
|
-
assert.match(desktopConfig, /
|
|
190
|
+
assert.match(desktopConfig, /experimental_bearer_token = "pr-super-secret"/);
|
|
191
191
|
assert.match(desktopConfig, /"x-pennyrouter-surface" = "codex-desktop"/);
|
|
192
192
|
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
193
193
|
fs.rmSync(desktopHome, { recursive: true, force: true });
|
|
194
194
|
const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
|
|
195
|
-
assert.match(codexProfile, /model_provider = "
|
|
196
|
-
assert.match(codexProfile, /
|
|
197
|
-
assert.ok(
|
|
195
|
+
assert.match(codexProfile, /model_provider = "pennyrouter"/);
|
|
196
|
+
assert.match(codexProfile, /base_url = "https:\/\/gateway\.example\/v1"/);
|
|
197
|
+
assert.ok(codexProfile.includes("[model_providers.pennyrouter]"));
|
|
198
198
|
assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
|
|
199
199
|
assert.ok(!codexProfile.includes("pr-super-secret"));
|
|
200
200
|
assert.ok(!rc.includes("pr-super-secret"));
|
package/src/session.js
CHANGED
|
@@ -71,6 +71,17 @@ export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider
|
|
|
71
71
|
return response.json();
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
export async function fetchGatewayCapabilities({ gatewayBaseUrl, apiKey }) {
|
|
75
|
+
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/auth/capabilities`;
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(url, { headers: { authorization: `Bearer ${apiKey}` } });
|
|
78
|
+
if (!response.ok) return null;
|
|
79
|
+
return await response.json();
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
// Mint a key on a private gateway that has self-service provisioning enabled. The hosted
|
|
75
86
|
// browser claim mints against pennyrouter.com's key store, which a private gateway does not
|
|
76
87
|
// share, so this is the equivalent step for a self-hosted install. A gateway that has not
|