pennyrouter 0.3.17 → 0.3.18
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 +22 -3
- package/src/harnesses/codex.js +14 -0
- package/src/harnesses/codex.test.js +24 -2
- package/src/launch.js +10 -0
- package/src/launch.test.js +4 -4
- package/src/session.js +11 -0
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,
|
|
@@ -832,7 +841,13 @@ export async function install(flags = {}) {
|
|
|
832
841
|
|
|
833
842
|
const harnessNeededAuth = needsAuth.includes(harness);
|
|
834
843
|
const codexSubscriptionAuth = harness.id === "codex"
|
|
835
|
-
?
|
|
844
|
+
? await codexNativeAuthAvailable({
|
|
845
|
+
gatewayBaseUrl: gatewayBaseUrl,
|
|
846
|
+
apiKey: claim.api_key,
|
|
847
|
+
localAuth: harnessNeededAuth
|
|
848
|
+
? funding.codexSubscriptionAuth
|
|
849
|
+
: Boolean(existingRecord?.openai_subscription_auth),
|
|
850
|
+
})
|
|
836
851
|
: false;
|
|
837
852
|
const claudeSubscriptionAuth = harness.id === "claude-code"
|
|
838
853
|
&& Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
|
|
@@ -1269,7 +1284,11 @@ async function update(flags) {
|
|
|
1269
1284
|
const claudeSubscriptionAuth = record.harness === "claude-code"
|
|
1270
1285
|
&& Boolean(record.anthropic_token_stored);
|
|
1271
1286
|
const codexSubscriptionAuth = record.harness === "codex"
|
|
1272
|
-
? await
|
|
1287
|
+
? await codexNativeAuthAvailable({
|
|
1288
|
+
gatewayBaseUrl,
|
|
1289
|
+
apiKey,
|
|
1290
|
+
localAuth: await detectCodexChatgptAuth(),
|
|
1291
|
+
})
|
|
1273
1292
|
: false;
|
|
1274
1293
|
|
|
1275
1294
|
if (supportsPennyCommand) {
|
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();
|
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
|