pennyrouter 0.3.8 → 0.3.11
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 +103 -41
- package/src/harnesses/opencode.js +50 -22
- package/src/harnesses/opencode.test.js +35 -1
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
import { clineHarness } from "./harnesses/cline.js";
|
|
21
21
|
import { codeGptHarness } from "./harnesses/codegpt.js";
|
|
22
22
|
import { codexHarness, detectCodexChatgptAuth } from "./harnesses/codex.js";
|
|
23
|
-
import { opencodeHarness } from "./harnesses/opencode.js";
|
|
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 {
|
|
@@ -642,7 +642,16 @@ export async function install(flags = {}) {
|
|
|
642
642
|
// key against a store this install never talks to. Ask for the gateway, mint there, and
|
|
643
643
|
// skip the claim entirely.
|
|
644
644
|
gatewayBaseUrl = await resolvePrivateGatewayUrl(flags);
|
|
645
|
-
|
|
645
|
+
const launchConfig = await loadPennyConfig();
|
|
646
|
+
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
647
|
+
const storedGateway = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
|
|
648
|
+
const storedKey = storedGateway && sameGatewayBaseUrl(gatewayBaseUrl, storedGateway)
|
|
649
|
+
? (launchConfig.api_key || globalAuth.apiKey)
|
|
650
|
+
: null;
|
|
651
|
+
claim = {
|
|
652
|
+
api_key: await obtainPrivateGatewayKey(flags, gatewayBaseUrl, storedKey),
|
|
653
|
+
gateway_base_url: gatewayBaseUrl,
|
|
654
|
+
};
|
|
646
655
|
|
|
647
656
|
const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
|
|
648
657
|
const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
|
|
@@ -661,48 +670,86 @@ export async function install(flags = {}) {
|
|
|
661
670
|
}));
|
|
662
671
|
}
|
|
663
672
|
} else {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
673
|
+
// Re-running the ordinary installer should refresh local harness configuration, not mint
|
|
674
|
+
// another machine key. Reuse a key already saved for this same gateway; an explicit gateway
|
|
675
|
+
// URL still wins, and a URL change deliberately falls through to fresh authorization.
|
|
676
|
+
const launchConfig = await loadPennyConfig();
|
|
677
|
+
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
678
|
+
const existingApiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
|
|
679
|
+
const existingGateway = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl
|
|
680
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
681
|
+
const requestedGateway = flags.gatewayBaseUrl || existingGateway;
|
|
682
|
+
const reusableKey = existingApiKey
|
|
683
|
+
&& sameGatewayBaseUrl(requestedGateway, existingGateway)
|
|
684
|
+
? existingApiKey
|
|
685
|
+
: null;
|
|
686
|
+
|
|
687
|
+
if (reusableKey) {
|
|
688
|
+
gatewayBaseUrl = existingGateway;
|
|
689
|
+
claim = { api_key: reusableKey, gateway_base_url: gatewayBaseUrl };
|
|
690
|
+
console.log(`Using existing PennyRouter key for ${gatewayBaseUrl}.`);
|
|
691
|
+
console.log("");
|
|
669
692
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
693
|
+
const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
|
|
694
|
+
const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
|
|
695
|
+
claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
|
|
696
|
+
flags, needsAuth, claudeProviders.has("subscription"));
|
|
697
|
+
funding = await configureUserFunding(
|
|
698
|
+
flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
|
|
675
699
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
700
|
+
for (const credential of funding.credentials) {
|
|
701
|
+
await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
|
|
702
|
+
gatewayBaseUrl,
|
|
703
|
+
apiKey: claim.api_key,
|
|
704
|
+
provider: credential.provider,
|
|
705
|
+
token: credential.token,
|
|
706
|
+
metadata: credential.metadata,
|
|
707
|
+
}));
|
|
708
|
+
}
|
|
709
|
+
} else {
|
|
710
|
+
const session = await createCliSession({
|
|
711
|
+
appUrl: flags.appUrl || APP_URL,
|
|
712
|
+
harnesses: selected.map((harness) => harness.id),
|
|
713
|
+
accountMode: "existing",
|
|
714
|
+
});
|
|
679
715
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
);
|
|
716
|
+
console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
|
|
717
|
+
console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
|
|
718
|
+
if (!flags.noBrowser) openBrowser(session.authorize_url);
|
|
719
|
+
console.log("");
|
|
720
|
+
console.log("Waiting for approval...");
|
|
721
|
+
|
|
722
|
+
claim = await pollForKey(session);
|
|
723
|
+
console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
|
|
724
|
+
console.log("");
|
|
725
|
+
|
|
726
|
+
const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
|
|
727
|
+
const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
|
|
728
|
+
claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
|
|
729
|
+
flags, needsAuth, claudeProviders.has("subscription"));
|
|
730
|
+
funding = await configureUserFunding(
|
|
731
|
+
flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
|
|
732
|
+
gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
|
|
733
|
+
if (flags.gatewayBaseUrl) {
|
|
734
|
+
const normalized = normalizeGatewayBaseUrl(flags.gatewayBaseUrl);
|
|
735
|
+
if (!normalized) {
|
|
736
|
+
throw new Error(
|
|
737
|
+
`--gateway-base-url "${flags.gatewayBaseUrl}" is not a usable URL. Use a host like `
|
|
738
|
+
+ "localhost:8000 or https://gateway.example.com.",
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
gatewayBaseUrl = normalized;
|
|
694
742
|
}
|
|
695
|
-
gatewayBaseUrl = normalized;
|
|
696
|
-
}
|
|
697
743
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
744
|
+
for (const credential of funding.credentials) {
|
|
745
|
+
await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
|
|
746
|
+
gatewayBaseUrl,
|
|
747
|
+
apiKey: claim.api_key,
|
|
748
|
+
provider: credential.provider,
|
|
749
|
+
token: credential.token,
|
|
750
|
+
metadata: credential.metadata,
|
|
751
|
+
}));
|
|
752
|
+
}
|
|
706
753
|
}
|
|
707
754
|
}
|
|
708
755
|
|
|
@@ -756,12 +803,15 @@ export async function install(flags = {}) {
|
|
|
756
803
|
|
|
757
804
|
let record;
|
|
758
805
|
if (!supportsPennyCommand || makeDefault) {
|
|
806
|
+
const opencodeModels = harness.id === "opencode" && flags.privateGatewayFlow
|
|
807
|
+
? await fetchOpenCodeModels(gatewayBaseUrl, claim.api_key, { requireFresh: true })
|
|
808
|
+
: null;
|
|
759
809
|
record = harness.id === "claude-code" && claudeSubscriptionAuth
|
|
760
810
|
? await harness.installStoredToken({
|
|
761
811
|
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
|
|
762
812
|
})
|
|
763
813
|
: await harness.install({
|
|
764
|
-
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
|
|
814
|
+
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord, models: opencodeModels,
|
|
765
815
|
});
|
|
766
816
|
} else {
|
|
767
817
|
if (hadGlobalRouting) {
|
|
@@ -923,7 +973,7 @@ async function resolvePrivateGatewayUrl(flags) {
|
|
|
923
973
|
/* The key for a private gateway: an admin-issued one if the user has it, otherwise minted
|
|
924
974
|
from the gateway itself against their email. --private-key wins so an administrator can
|
|
925
975
|
still hand out keys on a gateway that also allows self-service. */
|
|
926
|
-
async function obtainPrivateGatewayKey(flags, gatewayBaseUrl) {
|
|
976
|
+
async function obtainPrivateGatewayKey(flags, gatewayBaseUrl, existingApiKey = null) {
|
|
927
977
|
const issued = (flags.privateKey || "").trim();
|
|
928
978
|
if (issued) {
|
|
929
979
|
if (!issued.startsWith("pr-")) {
|
|
@@ -934,6 +984,12 @@ async function obtainPrivateGatewayKey(flags, gatewayBaseUrl) {
|
|
|
934
984
|
return issued;
|
|
935
985
|
}
|
|
936
986
|
|
|
987
|
+
if (existingApiKey) {
|
|
988
|
+
console.log(`Using existing PennyRouter key for ${gatewayBaseUrl}.`);
|
|
989
|
+
console.log("");
|
|
990
|
+
return existingApiKey;
|
|
991
|
+
}
|
|
992
|
+
|
|
937
993
|
let email = (flags.email || "").trim();
|
|
938
994
|
if (!email) {
|
|
939
995
|
// A shared --config file names the gateway but not who is installing, so the email is
|
|
@@ -982,6 +1038,12 @@ function normalizeGatewayBaseUrl(raw) {
|
|
|
982
1038
|
return withScheme.replace(/\/+$/, "");
|
|
983
1039
|
}
|
|
984
1040
|
|
|
1041
|
+
function sameGatewayBaseUrl(left, right) {
|
|
1042
|
+
const normalizedLeft = normalizeGatewayBaseUrl(left);
|
|
1043
|
+
const normalizedRight = normalizeGatewayBaseUrl(right);
|
|
1044
|
+
return Boolean(normalizedLeft && normalizedRight && normalizedLeft === normalizedRight);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
985
1047
|
/* A gateway that is unreachable (wrong host, wrong port, not running) surfaces from fetch() as
|
|
986
1048
|
an opaque "fetch failed" with no status. Name the gateway we could not reach so the user knows
|
|
987
1049
|
which URL to fix. Real HTTP errors carry their own message and pass through untouched. */
|
|
@@ -8,14 +8,17 @@ import {
|
|
|
8
8
|
readText,
|
|
9
9
|
writeJsonFile,
|
|
10
10
|
} from "../state.js";
|
|
11
|
+
import { unlink } from "node:fs/promises";
|
|
11
12
|
const JSONC_PATH = "~/.config/opencode/opencode.jsonc";
|
|
12
13
|
const JSON_PATH = "~/.config/opencode/opencode.json";
|
|
13
14
|
const MODEL_STATE_PATH = "~/.local/state/opencode/model.json";
|
|
15
|
+
const MODEL_CACHE_PATH = "~/.cache/opencode/models.json";
|
|
14
16
|
export const OPENCODE_MODEL_CHOICES = [
|
|
15
|
-
{ id: "pennyrouter/penny-core:premium", name: "Penny Core" },
|
|
16
|
-
{ id: "pennyrouter/penny-speed:standard", name: "Penny Speed" },
|
|
17
|
-
{ id: "pennyrouter/penny-power:premium", name: "Penny Power" },
|
|
18
17
|
{ id: "pennyrouter/auto", name: "PennyRouter Auto" },
|
|
18
|
+
{ id: "claude-haiku-4-5-20251001", name: "Haiku 4.5" },
|
|
19
|
+
{ id: "claude-sonnet-5", name: "Sonnet 5" },
|
|
20
|
+
{ id: "claude-opus-5", name: "Opus 5" },
|
|
21
|
+
{ id: "claude-fable-5", name: "Fable 5" },
|
|
19
22
|
{ id: "claude-penny-deepseek-v4-flash", name: "DeepSeek V4 Flash" },
|
|
20
23
|
{ id: "claude-penny-glm-5-3", name: "GLM 5.3" },
|
|
21
24
|
{ id: "claude-penny-gpt-5-6-luna", name: "GPT-5.6 Luna" },
|
|
@@ -25,6 +28,16 @@ export const OPENCODE_MODEL_CHOICES = [
|
|
|
25
28
|
{ id: "claude-penny-minimax-m3", name: "MiniMax M3" },
|
|
26
29
|
{ id: "claude-penny-nemotron-ultra-free", name: "Nemotron 3 Ultra (free)" },
|
|
27
30
|
{ id: "claude-penny-solar-pro4", name: "Solar Pro 4" },
|
|
31
|
+
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol (PennyRouter)" },
|
|
32
|
+
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra (PennyRouter)" },
|
|
33
|
+
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna (PennyRouter)" },
|
|
34
|
+
{ id: "gpt-5.5", name: "GPT 5.5 (PennyRouter)" },
|
|
35
|
+
{ id: "gpt-5.4", name: "GPT 5.4 (PennyRouter)" },
|
|
36
|
+
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini (PennyRouter)" },
|
|
37
|
+
{ id: "gpt-penny-auto", name: "PennyRouter Auto (GPT)" },
|
|
38
|
+
{ id: "gpt-penny-deepseek", name: "DeepSeek V4 Flash (PennyRouter)" },
|
|
39
|
+
{ id: "gpt-penny-glm", name: "GLM 5.3 (PennyRouter)" },
|
|
40
|
+
{ id: "gpt-penny-grok", name: "Grok 4.6 (PennyRouter)" },
|
|
28
41
|
];
|
|
29
42
|
|
|
30
43
|
export const opencodeHarness = {
|
|
@@ -42,8 +55,8 @@ export const opencodeHarness = {
|
|
|
42
55
|
summary: [
|
|
43
56
|
`write ${configPath}`,
|
|
44
57
|
"merge PennyRouter provider without replacing existing providers",
|
|
45
|
-
"publish
|
|
46
|
-
`reset ${MODEL_STATE_PATH} so opencode rediscovers configured models`,
|
|
58
|
+
"publish PennyRouter Auto and the available Claude/GPT model pins",
|
|
59
|
+
`reset ${MODEL_STATE_PATH} and ${MODEL_CACHE_PATH} so opencode rediscovers configured models`,
|
|
47
60
|
],
|
|
48
61
|
};
|
|
49
62
|
},
|
|
@@ -90,6 +103,10 @@ export const opencodeHarness = {
|
|
|
90
103
|
}
|
|
91
104
|
}
|
|
92
105
|
await writeJsonFile(MODEL_STATE_PATH, model);
|
|
106
|
+
// OpenCode keeps a separate generated registry cache. Clearing it is necessary when a
|
|
107
|
+
// provider's model map changes; otherwise the picker can continue showing retired models
|
|
108
|
+
// even though opencode.json has been rewritten correctly.
|
|
109
|
+
await removeGeneratedModelCache();
|
|
93
110
|
|
|
94
111
|
return {
|
|
95
112
|
harness: "opencode",
|
|
@@ -129,6 +146,14 @@ export const opencodeHarness = {
|
|
|
129
146
|
},
|
|
130
147
|
};
|
|
131
148
|
|
|
149
|
+
async function removeGeneratedModelCache() {
|
|
150
|
+
try {
|
|
151
|
+
await unlink(expandHome(MODEL_CACHE_PATH));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error.code !== "ENOENT") throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
132
157
|
async function readConfig(path) {
|
|
133
158
|
const text = await readText(path, "");
|
|
134
159
|
if (!text.trim()) return {};
|
|
@@ -183,27 +208,30 @@ export function opencodeModelId(modelId) {
|
|
|
183
208
|
return modelId;
|
|
184
209
|
}
|
|
185
210
|
|
|
186
|
-
export async function fetchOpenCodeModels(gatewayBaseUrl) {
|
|
211
|
+
export async function fetchOpenCodeModels(gatewayBaseUrl, apiKey, { requireFresh = false } = {}) {
|
|
187
212
|
try {
|
|
188
|
-
const
|
|
213
|
+
const headers = apiKey
|
|
214
|
+
? { Authorization: `Bearer ${apiKey}`, "x-api-key": apiKey }
|
|
215
|
+
: undefined;
|
|
216
|
+
const response = await fetch(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1/models`, { headers });
|
|
189
217
|
if (!response.ok) throw new Error(`model catalog returned ${response.status}`);
|
|
190
218
|
const payload = await response.json();
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
...(model?.description ? { description: model.description } : {}),
|
|
203
|
-
};
|
|
204
|
-
});
|
|
219
|
+
const models = Array.isArray(payload?.data)
|
|
220
|
+
? payload.data
|
|
221
|
+
.filter((model) => typeof model?.id === "string")
|
|
222
|
+
// Bundles are a legacy OpenCode surface. Auto is the one intentional exception.
|
|
223
|
+
.filter((model) => !model.id.startsWith("pennyrouter/") || model.id === "pennyrouter/auto")
|
|
224
|
+
.map((model) => ({
|
|
225
|
+
id: model.id,
|
|
226
|
+
name: model.display_name || model.name || openCodeModelName(model.id),
|
|
227
|
+
...(model.description ? { description: model.description } : {}),
|
|
228
|
+
}))
|
|
229
|
+
: [];
|
|
205
230
|
if (models.length > 0) return models;
|
|
206
|
-
} catch {
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (requireFresh) throw new Error(`Could not fetch the private gateway model catalog: ${error.message}`);
|
|
233
|
+
}
|
|
234
|
+
if (requireFresh) throw new Error("The private gateway returned no usable OpenCode models.");
|
|
207
235
|
return OPENCODE_MODEL_CHOICES;
|
|
208
236
|
}
|
|
209
237
|
|
|
@@ -3,13 +3,19 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
fetchOpenCodeModels,
|
|
8
|
+
opencodeHarness,
|
|
9
|
+
opencodeModelId,
|
|
10
|
+
OPENCODE_MODEL_CHOICES,
|
|
11
|
+
} from "./opencode.js";
|
|
7
12
|
|
|
8
13
|
const ENV_KEYS = ["HOME", "XDG_STATE_HOME"];
|
|
9
14
|
|
|
10
15
|
async function main() {
|
|
11
16
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pr-opencode-test-"));
|
|
12
17
|
const previous = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
|
|
18
|
+
const previousFetch = globalThis.fetch;
|
|
13
19
|
const home = path.join(root, "home");
|
|
14
20
|
const configPath = path.join(home, ".config", "opencode", "opencode.jsonc");
|
|
15
21
|
const modelStatePath = path.join(home, ".local", "state", "opencode", "model.json");
|
|
@@ -35,6 +41,33 @@ async function main() {
|
|
|
35
41
|
try {
|
|
36
42
|
assert.equal(opencodeModelId("pennyrouter/auto"), "pennyrouter/auto");
|
|
37
43
|
assert.equal(opencodeModelId("custom/model-a"), "custom/model-a");
|
|
44
|
+
|
|
45
|
+
let request;
|
|
46
|
+
globalThis.fetch = async (url, options) => {
|
|
47
|
+
request = { url, options };
|
|
48
|
+
return {
|
|
49
|
+
ok: true,
|
|
50
|
+
async json() {
|
|
51
|
+
return { data: [
|
|
52
|
+
{ id: "pennyrouter/auto", display_name: "Auto from private gateway" },
|
|
53
|
+
{ id: "pennyrouter/penny-core:premium", display_name: "Legacy bundle" },
|
|
54
|
+
{ id: "claude-sonnet-5", display_name: "Private Sonnet", description: "Fresh" },
|
|
55
|
+
] };
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
assert.deepEqual(await fetchOpenCodeModels(
|
|
60
|
+
"https://private.example/", "pr-test", { requireFresh: true },
|
|
61
|
+
), [
|
|
62
|
+
{ id: "pennyrouter/auto", name: "Auto from private gateway" },
|
|
63
|
+
{ id: "claude-sonnet-5", name: "Private Sonnet", description: "Fresh" },
|
|
64
|
+
]);
|
|
65
|
+
assert.equal(request.url, "https://private.example/v1/models");
|
|
66
|
+
assert.deepEqual(request.options.headers, {
|
|
67
|
+
Authorization: "Bearer pr-test",
|
|
68
|
+
"x-api-key": "pr-test",
|
|
69
|
+
});
|
|
70
|
+
|
|
38
71
|
const plan = await opencodeHarness.planInstall();
|
|
39
72
|
const record = await opencodeHarness.install({
|
|
40
73
|
apiKey: "pr-test",
|
|
@@ -63,6 +96,7 @@ async function main() {
|
|
|
63
96
|
if (previous[key] === undefined) delete process.env[key];
|
|
64
97
|
else process.env[key] = previous[key];
|
|
65
98
|
}
|
|
99
|
+
globalThis.fetch = previousFetch;
|
|
66
100
|
fs.rmSync(root, { recursive: true, force: true });
|
|
67
101
|
}
|
|
68
102
|
|