pennyrouter 0.3.8 → 0.3.10
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 +5 -2
- 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 {
|
|
@@ -756,12 +756,15 @@ export async function install(flags = {}) {
|
|
|
756
756
|
|
|
757
757
|
let record;
|
|
758
758
|
if (!supportsPennyCommand || makeDefault) {
|
|
759
|
+
const opencodeModels = harness.id === "opencode" && flags.privateGatewayFlow
|
|
760
|
+
? await fetchOpenCodeModels(gatewayBaseUrl, claim.api_key, { requireFresh: true })
|
|
761
|
+
: null;
|
|
759
762
|
record = harness.id === "claude-code" && claudeSubscriptionAuth
|
|
760
763
|
? await harness.installStoredToken({
|
|
761
764
|
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
|
|
762
765
|
})
|
|
763
766
|
: await harness.install({
|
|
764
|
-
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
|
|
767
|
+
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord, models: opencodeModels,
|
|
765
768
|
});
|
|
766
769
|
} else {
|
|
767
770
|
if (hadGlobalRouting) {
|
|
@@ -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
|
|