pennyrouter 0.3.0 → 0.3.2

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.
@@ -0,0 +1,196 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { harnessFundingChanged, main } from "./cli.js";
7
+
8
+ // --config lets a GUI installer (the macOS DMG) answer install questions up front instead of
9
+ // over a TTY. These tests drive it through `main()` in --dry-run, which prints its plan and
10
+ // returns before any network call or filesystem write, so no mocking is needed.
11
+
12
+ function withConfigFile(config, run) {
13
+ const dir = mkdtempSync(join(tmpdir(), "pennyrouter-config-"));
14
+ const path = join(dir, "penny_install.json");
15
+ writeFileSync(path, JSON.stringify(config));
16
+ try {
17
+ return run(path);
18
+ } finally {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ }
21
+ }
22
+
23
+ function captureLogs(run) {
24
+ const original = console.log;
25
+ const lines = [];
26
+ console.log = (...args) => lines.push(args.join(" "));
27
+ return run().finally(() => { console.log = original; }).then(() => lines);
28
+ }
29
+
30
+ test("--config selects harnesses without prompting", async () => {
31
+ await withConfigFile({ harnesses: ["claude-code"] }, async (path) => {
32
+ const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
33
+ assert.ok(lines.some((line) => line.includes("Claude Code")));
34
+ assert.ok(!lines.some((line) => line.includes("Codex")));
35
+ });
36
+ });
37
+
38
+ test("--config with claudeFunding subscription sets --anthropic-auth", async () => {
39
+ await withConfigFile(
40
+ { harnesses: ["claude-code"], claudeFunding: ["subscription"] },
41
+ async (path) => {
42
+ const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
43
+ assert.ok(lines.some((line) => line.includes("Claude Code provider picker")));
44
+ },
45
+ );
46
+ });
47
+
48
+ test("--config rejects an unreadable file", async () => {
49
+ await assert.rejects(
50
+ () => main(["install", "--config", "/nonexistent/penny_install.json", "--dry-run"]),
51
+ /Could not read --config file/,
52
+ );
53
+ });
54
+
55
+ test("explicit --harness overrides --config harnesses", async () => {
56
+ await withConfigFile({ harnesses: ["codex"] }, async (path) => {
57
+ const lines = await captureLogs(
58
+ () => main(["install", "--harness", "claude-code", "--config", path, "--dry-run"]),
59
+ );
60
+ assert.ok(lines.some((line) => line.includes("Claude Code")));
61
+ assert.ok(!lines.some((line) => line.includes("Codex")));
62
+ });
63
+ });
64
+
65
+ // harnessFundingChanged decides whether a --config re-run needs to re-open the account-claim
66
+ // and subscription-auth popups for a harness, or can treat it like `update` (skip both). This
67
+ // only kicks in on --config re-runs against an existing manifest; a live end-to-end run against
68
+ // a real account confirmed the same three cases these unit tests cover.
69
+
70
+ const claudeHarness = { id: "claude-code" };
71
+ const codexHarness = { id: "codex" };
72
+
73
+ test("harnessFundingChanged: brand new harness always needs auth", () => {
74
+ const manifest = { installations: [] };
75
+ assert.equal(harnessFundingChanged({}, claudeHarness, manifest), true);
76
+ });
77
+
78
+ test("harnessFundingChanged: claude subscription unchanged", () => {
79
+ const manifest = {
80
+ installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
81
+ };
82
+ const flags = { configClaudeFunding: new Set(["subscription"]) };
83
+ assert.equal(harnessFundingChanged(flags, claudeHarness, manifest), false);
84
+ });
85
+
86
+ test("harnessFundingChanged: claude subscription to anthropic-api is a change", () => {
87
+ const manifest = {
88
+ installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
89
+ };
90
+ const flags = { configClaudeFunding: new Set(["anthropic-api"]) };
91
+ assert.equal(harnessFundingChanged(flags, claudeHarness, manifest), true);
92
+ });
93
+
94
+ test("harnessFundingChanged: codex subscription unchanged (openai_subscription_auth flag)", () => {
95
+ const manifest = {
96
+ installations: [{
97
+ harness: "codex",
98
+ user_funded_provider: "chatgpt-subscription",
99
+ openai_subscription_auth: true,
100
+ }],
101
+ };
102
+ const flags = { configCodexFunding: new Set(["subscription"]) };
103
+ assert.equal(harnessFundingChanged(flags, codexHarness, manifest), false);
104
+ });
105
+
106
+ test("harnessFundingChanged: codex openrouter unchanged", () => {
107
+ const manifest = {
108
+ installations: [{
109
+ harness: "codex",
110
+ user_funded_provider: "openrouter",
111
+ openai_subscription_auth: false,
112
+ }],
113
+ };
114
+ const flags = { configCodexFunding: new Set(["openrouter"]) };
115
+ assert.equal(harnessFundingChanged(flags, codexHarness, manifest), false);
116
+ });
117
+
118
+ test("harnessFundingChanged: no requested funding for an existing harness is not a change", () => {
119
+ const manifest = {
120
+ installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
121
+ };
122
+ assert.equal(harnessFundingChanged({}, claudeHarness, manifest), false);
123
+ });
124
+
125
+ // --- Other proxy (user-supplied Anthropic-compatible base URL) ----------------
126
+
127
+ test("--proxy-base-url and --proxy-api-key parse in both forms", async () => {
128
+ for (const args of [
129
+ ["--proxy-base-url", "https://proxy.example.com", "--proxy-api-key", "k"],
130
+ ["--proxy-base-url=https://proxy.example.com", "--proxy-api-key=k"],
131
+ ]) {
132
+ const lines = await captureLogs(() => main([
133
+ "install", "--harness", "claude-code", "--dry-run", ...args,
134
+ ]));
135
+ assert.ok(lines.length > 0);
136
+ }
137
+ });
138
+
139
+ test("--config maps anthropic-proxy funding to the proxy flags", async () => {
140
+ await withConfigFile(
141
+ {
142
+ harnesses: ["claude-code"],
143
+ claudeFunding: ["anthropic-proxy"],
144
+ proxyBaseUrl: "https://proxy.example.com",
145
+ proxyApiKey: "secret-key",
146
+ },
147
+ async (path) => {
148
+ const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
149
+ assert.ok(lines.some((line) => line.includes("Claude Code")));
150
+ // The key must never be echoed to the terminal.
151
+ assert.ok(!lines.some((line) => line.includes("secret-key")));
152
+ },
153
+ );
154
+ });
155
+
156
+ test("harnessFundingChanged sees a switch to the proxy as a change", () => {
157
+ const manifest = {
158
+ installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
159
+ };
160
+ const changed = harnessFundingChanged(
161
+ { configClaudeFunding: new Set(["anthropic-proxy"]) },
162
+ { id: "claude-code" },
163
+ manifest,
164
+ );
165
+ assert.equal(changed, true);
166
+ });
167
+
168
+ test("harnessFundingChanged sees an unchanged proxy install as unchanged", () => {
169
+ const manifest = {
170
+ installations: [{ harness: "claude-code", user_funded_provider: "anthropic-proxy" }],
171
+ };
172
+ const changed = harnessFundingChanged(
173
+ { configClaudeFunding: new Set(["anthropic-proxy"]) },
174
+ { id: "claude-code" },
175
+ manifest,
176
+ );
177
+ assert.equal(changed, false);
178
+ });
179
+
180
+ test("auth anthropic --forget targets the installed gateway, not prod", async () => {
181
+ // Regression: this defaulted to https://api.pennyrouter.com even when the machine was
182
+ // installed against a local gateway, silently deleting the wrong account's token.
183
+ const lines = await captureLogs(() => main([
184
+ "auth", "anthropic", "--forget", "--dry-run",
185
+ "--penny-key", "pr-test", "--gateway-base-url", "http://localhost:8400",
186
+ ]));
187
+ assert.ok(lines.some((line) => line.includes("http://localhost:8400")));
188
+ assert.ok(!lines.some((line) => line.includes("api.pennyrouter.com")));
189
+ });
190
+
191
+ test("auth anthropic --forget honors --local", async () => {
192
+ const lines = await captureLogs(() => main([
193
+ "auth", "anthropic", "--forget", "--dry-run", "--local", "--penny-key", "pr-test",
194
+ ]));
195
+ assert.ok(lines.some((line) => line.includes("localhost:8400")));
196
+ });
@@ -4,25 +4,37 @@ import { configureClaudeCodeStatusline, restoreClaudeCodeStatusline } from "../s
4
4
  const CONFIG_PATH = "~/.claude/settings.json";
5
5
  const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
6
6
  const CONNECTOR_SETTING = "disableClaudeAiConnectors";
7
- // Values written into Claude Code's four model slots. CC displays a slot's VALUE verbatim in
8
- // its /model menu AND sends it as the request `model`, so each value is both the menu label
9
- // and the gateway's routing key. The three concrete Claude slots carry friendly PINNED labels
10
- // ("Opus 5/Sonnet 5/Haiku 4.5/Fable 5 (PennyRouter)") that the gateway maps to that exact
11
- // model — the user's pick runs on the model they chose, no dynamic tier switching on the
12
- // trunk (background/utility calls still route cheap, which is where the savings come from).
13
- // Named "<Model> (PennyRouter)" rather than "Penny <Model>" so CC's own generated "Custom ...
14
- // model" subtext still reads as a real Claude model routed through Penny, not a distinct
15
- // custom model. The separate Fable slot is pinned to Fable; the DYNAMIC "Penny Custom" option
16
- // lets PennyRouter cost-route. Legacy "Penny Speed/Core/Power/Opus/Sonnet/Haiku/Fable"
17
- // installs (dynamic bundles, or the pre-rename pinned labels) are still recognized for
18
- // uninstall (see isPennyRouterModelValue).
7
+ // The slot values remain genuine Anthropic ids so Claude Code recognizes them and sends the
8
+ // native id on the wire. Claude Code separately supports *_MODEL_NAME and *_MODEL_DESCRIPTION
9
+ // for the picker, so Penny can present a friendly alias without sacrificing model recognition.
10
+ //
11
+ // The 1M-context variant is a `[1m]` suffix on the id — Claude Code does NOT infer a 1M window
12
+ // from a bare id, so a bare `claude-sonnet-5` gets 200K and auto-compacts around 150K. The
13
+ // suffix rides the wire; the gateway strips it for the upstream model and turns it into the
14
+ // `context-1m` beta header. Haiku has no 1M variant, so it stays bare.
19
15
  export const CLAUDE_CODE_MODELS = {
20
- haiku: "Haiku 4.5 (PennyRouter)", // -> pinned anthropic/claude-haiku-4-5-20251001
21
- sonnet: "Sonnet 5 (PennyRouter)", // -> pinned anthropic/claude-sonnet-5
22
- opus: "Opus 5 (PennyRouter)", // -> pinned anthropic/claude-opus-5
23
- fable: "Fable 5 (PennyRouter)", // -> pinned anthropic/claude-fable-5
24
- custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
16
+ haiku: "claude-haiku-4-5-20251001",
17
+ sonnet: "claude-sonnet-5[1m]",
18
+ opus: "claude-opus-5[1m]",
19
+ fable: "claude-fable-5[1m]",
25
20
  };
21
+ const CLAUDE_CODE_MODEL_PRESENTATION = {
22
+ haiku: { name: "Haiku 4.5", description: "Fast, economical Claude model · 200K context" },
23
+ sonnet: { name: "Sonnet 5", description: "Balanced Claude model · 1M context" },
24
+ opus: { name: "Opus 5", description: "Most capable Claude model · 1M context" },
25
+ fable: { name: "Fable 5", description: "Claude model · 1M context" },
26
+ };
27
+ const LEGACY_PINNED_MODEL_IDS = {
28
+ "Haiku 4.5": CLAUDE_CODE_MODELS.haiku,
29
+ "Sonnet 5": CLAUDE_CODE_MODELS.sonnet,
30
+ "Opus 5": CLAUDE_CODE_MODELS.opus,
31
+ "Fable 5": CLAUDE_CODE_MODELS.fable,
32
+ "Haiku 4.5 (PennyRouter)": CLAUDE_CODE_MODELS.haiku,
33
+ "Sonnet 5 (PennyRouter)": CLAUDE_CODE_MODELS.sonnet,
34
+ "Opus 5 (PennyRouter)": CLAUDE_CODE_MODELS.opus,
35
+ "Fable 5 (PennyRouter)": CLAUDE_CODE_MODELS.fable,
36
+ };
37
+ const LEGACY_PINNED_MODEL_LABELS = Object.keys(LEGACY_PINNED_MODEL_IDS);
26
38
  // Retired dynamic-bundle labels (pre-pinned-trunk) and the pre-rename pinned labels. Not
27
39
  // written by a fresh install; matched only so uninstall/detect still recognize and clean up
28
40
  // an older install.
@@ -31,6 +43,22 @@ const LEGACY_PENNY_LABELS = [
31
43
  "Penny Opus", "Penny Sonnet", "Penny Haiku", "Penny Fable",
32
44
  ];
33
45
 
46
+ // Used both for globally-installed Claude Code and `penny claude`, whose isolated
47
+ // launch environment otherwise bypasses ~/.claude/settings.json entirely.
48
+ export function writeClaudeCodeModelPresentation(env) {
49
+ for (const [slot, { name, description }] of Object.entries(CLAUDE_CODE_MODEL_PRESENTATION)) {
50
+ const prefix = `ANTHROPIC_DEFAULT_${slot.toUpperCase()}_MODEL`;
51
+ env[`${prefix}_NAME`] = name;
52
+ env[`${prefix}_DESCRIPTION`] = description;
53
+ }
54
+ }
55
+
56
+ const CLAUDE_CODE_MODEL_PRESENTATION_KEYS = Object.keys(CLAUDE_CODE_MODEL_PRESENTATION)
57
+ .flatMap((slot) => {
58
+ const prefix = `ANTHROPIC_DEFAULT_${slot.toUpperCase()}_MODEL`;
59
+ return [`${prefix}_NAME`, `${prefix}_DESCRIPTION`];
60
+ });
61
+
34
62
  export const claudeCodeHarness = {
35
63
  id: "claude-code",
36
64
  name: "Claude Code",
@@ -61,16 +89,20 @@ export const claudeCodeHarness = {
61
89
  // Anthropic API key. Clear any stale key so it doesn't take precedence.
62
90
  current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
63
91
  delete current.env.ANTHROPIC_API_KEY;
92
+ current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
64
93
  // Claude Code displays these env values directly in its model picker; the
65
94
  // gateway decodes the friendly labels into bundle/profile overrides.
66
95
  current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
67
96
  current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
68
97
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
69
98
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
70
- // The pinned labels above aren't real Anthropic model ids, so CC can't infer their
71
- // context window from the name and falls back to a conservative 200K default. Tell it
72
- // directly: every pinned slot is a Claude model with the 1M-context beta enabled upstream.
73
- current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = "1000000";
99
+ writeClaudeCodeModelPresentation(current.env);
100
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
101
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
102
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
103
+ // This is global, not per-model. Leave it unset so Claude Code uses each native id's
104
+ // real context limit instead of incorrectly giving Haiku a 1M window.
105
+ delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
74
106
 
75
107
  const statusline = await configureClaudeCodeStatusline(current);
76
108
 
@@ -93,7 +125,7 @@ export const claudeCodeHarness = {
93
125
  "ANTHROPIC_DEFAULT_SONNET_MODEL",
94
126
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
95
127
  "ANTHROPIC_DEFAULT_FABLE_MODEL",
96
- "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
128
+ ...CLAUDE_CODE_MODEL_PRESENTATION_KEYS,
97
129
  ],
98
130
  },
99
131
  };
@@ -113,14 +145,18 @@ export const claudeCodeHarness = {
113
145
  current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
114
146
  // Single credential only: no stale api key, no separate Anthropic bearer.
115
147
  delete current.env.ANTHROPIC_API_KEY;
148
+ current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
116
149
  current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
117
150
  current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
118
151
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
119
152
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
120
- // The pinned labels above aren't real Anthropic model ids, so CC can't infer their
121
- // context window from the name and falls back to a conservative 200K default. Tell it
122
- // directly: every pinned slot is a Claude model with the 1M-context beta enabled upstream.
123
- current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = "1000000";
153
+ writeClaudeCodeModelPresentation(current.env);
154
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
155
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
156
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
157
+ // This is global, not per-model. Leave it unset so Claude Code uses each native id's
158
+ // real context limit instead of incorrectly giving Haiku a 1M window.
159
+ delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
124
160
 
125
161
  const statusline = await configureClaudeCodeStatusline(current);
126
162
 
@@ -143,7 +179,7 @@ export const claudeCodeHarness = {
143
179
  "ANTHROPIC_DEFAULT_SONNET_MODEL",
144
180
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
145
181
  "ANTHROPIC_DEFAULT_FABLE_MODEL",
146
- "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
182
+ ...CLAUDE_CODE_MODEL_PRESENTATION_KEYS,
147
183
  ],
148
184
  },
149
185
  };
@@ -159,14 +195,18 @@ export const claudeCodeHarness = {
159
195
  // Authorization bearer token is forwarded by the gateway to Anthropic.
160
196
  current.env.ANTHROPIC_API_KEY = pennyKey;
161
197
  current.env.ANTHROPIC_AUTH_TOKEN = anthropicAuthToken;
198
+ current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
162
199
  current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
163
200
  current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
164
201
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
165
202
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
166
- // The pinned labels above aren't real Anthropic model ids, so CC can't infer their
167
- // context window from the name and falls back to a conservative 200K default. Tell it
168
- // directly: every pinned slot is a Claude model with the 1M-context beta enabled upstream.
169
- current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = "1000000";
203
+ writeClaudeCodeModelPresentation(current.env);
204
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
205
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
206
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
207
+ // This is global, not per-model. Leave it unset so Claude Code uses each native id's
208
+ // real context limit instead of incorrectly giving Haiku a 1M window.
209
+ delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
170
210
 
171
211
  const statusline = await configureClaudeCodeStatusline(current);
172
212
 
@@ -189,7 +229,7 @@ export const claudeCodeHarness = {
189
229
  "ANTHROPIC_DEFAULT_SONNET_MODEL",
190
230
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
191
231
  "ANTHROPIC_DEFAULT_FABLE_MODEL",
192
- "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
232
+ ...CLAUDE_CODE_MODEL_PRESENTATION_KEYS,
193
233
  ],
194
234
  },
195
235
  };
@@ -224,6 +264,7 @@ export const claudeCodeHarness = {
224
264
  ]) {
225
265
  if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
226
266
  }
267
+ for (const k of CLAUDE_CODE_MODEL_PRESENTATION_KEYS) delete current.env[k];
227
268
  delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
228
269
  }
229
270
  restoreClaudeAiConnectors(current, record);
@@ -293,10 +334,18 @@ function isPennyRouterModelValue(value) {
293
334
  value === CLAUDE_CODE_MODELS.opus ||
294
335
  value === CLAUDE_CODE_MODELS.fable ||
295
336
  value === CLAUDE_CODE_MODELS.custom ||
337
+ LEGACY_PINNED_MODEL_LABELS.includes(value) ||
296
338
  LEGACY_PENNY_LABELS.includes(value)
297
339
  );
298
340
  }
299
341
 
342
+ // `penny claude` can launch using a local Penny config written by an earlier CLI
343
+ // version. Translate only our previous friendly labels; a user-supplied model id
344
+ // remains untouched.
345
+ export function upgradeClaudeCodeModelValue(value) {
346
+ return LEGACY_PINNED_MODEL_IDS[value] || value;
347
+ }
348
+
300
349
  export async function readClaudeCodePennyRouterKey() {
301
350
  const current = (await readJsonFile(CONFIG_PATH, null)) || {};
302
351
  const env = current.env || {};
@@ -8,11 +8,24 @@ import {
8
8
  readText,
9
9
  writeJsonFile,
10
10
  } from "../state.js";
11
- import { PENNYROUTER_MODEL_CHOICES } from "./shared.js";
12
-
13
11
  const JSONC_PATH = "~/.config/opencode/opencode.jsonc";
14
12
  const JSON_PATH = "~/.config/opencode/opencode.json";
15
13
  const MODEL_STATE_PATH = "~/.local/state/opencode/model.json";
14
+ 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
+ { id: "pennyrouter/auto", name: "PennyRouter Auto" },
19
+ { id: "claude-penny-deepseek-v4-flash", name: "DeepSeek V4 Flash" },
20
+ { id: "claude-penny-glm-5-3", name: "GLM 5.3" },
21
+ { id: "claude-penny-gpt-5-6-luna", name: "GPT-5.6 Luna" },
22
+ { id: "claude-penny-grok-4-6", name: "Grok 4.6" },
23
+ { id: "claude-penny-kimi-k3", name: "Kimi K3" },
24
+ { id: "claude-penny-ling-3-flash", name: "Ling-3.0 Flash" },
25
+ { id: "claude-penny-minimax-m3", name: "MiniMax M3" },
26
+ { id: "claude-penny-nemotron-ultra-free", name: "Nemotron 3 Ultra (free)" },
27
+ { id: "claude-penny-solar-pro4", name: "Solar Pro 4" },
28
+ ];
16
29
 
17
30
  export const opencodeHarness = {
18
31
  id: "opencode",
@@ -29,33 +42,50 @@ export const opencodeHarness = {
29
42
  summary: [
30
43
  `write ${configPath}`,
31
44
  "merge PennyRouter provider without replacing existing providers",
32
- "publish the four Penny model choices",
45
+ "publish the curated Penny bundles and individual model pins",
33
46
  `reset ${MODEL_STATE_PATH} so opencode rediscovers configured models`,
34
47
  ],
35
48
  };
36
49
  },
37
50
 
38
- async install({ apiKey, gatewayBaseUrl, plan }) {
51
+ async install({ apiKey, gatewayBaseUrl, plan, models }) {
39
52
  const configPath = compactHome(plan.configPath);
40
53
  const backupPath = await backupFile(configPath, "opencode");
41
54
  const current = await readConfig(configPath);
42
55
  current.provider ||= {};
56
+ current.enabled_providers = Array.isArray(current.enabled_providers)
57
+ ? current.enabled_providers
58
+ : [];
59
+ if (!current.enabled_providers.includes("pennyrouter")) {
60
+ current.enabled_providers.push("pennyrouter");
61
+ }
43
62
  current.provider.pennyrouter = {
44
63
  npm: "@ai-sdk/openai-compatible",
45
64
  name: "PennyRouter",
46
65
  options: {
47
- baseURL: `${gatewayBaseUrl}/v1`,
66
+ baseURL: `${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`,
48
67
  apiKey,
49
68
  },
50
69
  models: Object.fromEntries(
51
- PENNYROUTER_MODEL_CHOICES.map((model) => [model.id, { name: model.name }]),
70
+ (models || OPENCODE_MODEL_CHOICES).map((model) => [
71
+ opencodeModelId(model.id),
72
+ { name: model.name, ...(model.description ? { description: model.description } : {}) },
73
+ ]),
52
74
  ),
53
75
  };
54
76
 
55
77
  await atomicWrite(configPath, `${JSON.stringify(current, null, 2)}\n`);
56
78
 
57
79
  const modelBackupPath = await backupFile(MODEL_STATE_PATH, "opencode-model");
58
- await writeJsonFile(MODEL_STATE_PATH, { recent: [], favorite: [], variant: {} });
80
+ const model = (await readJsonFile(MODEL_STATE_PATH, null)) || {};
81
+ model.recent = removePennyRouterModels(model.recent);
82
+ model.favorite = removePennyRouterModels(model.favorite);
83
+ if (model.variant && typeof model.variant === "object") {
84
+ for (const [key, value] of Object.entries(model.variant)) {
85
+ if (isPennyRouterModel(key) || isPennyRouterModel(value)) delete model.variant[key];
86
+ }
87
+ }
88
+ await writeJsonFile(MODEL_STATE_PATH, model);
59
89
 
60
90
  return {
61
91
  harness: "opencode",
@@ -73,6 +103,12 @@ export const opencodeHarness = {
73
103
 
74
104
  async uninstall(record) {
75
105
  const current = await readConfig(record.config_path);
106
+ if (Array.isArray(current.enabled_providers)) {
107
+ current.enabled_providers = current.enabled_providers.filter(
108
+ (provider) => provider !== "pennyrouter",
109
+ );
110
+ if (current.enabled_providers.length === 0) delete current.enabled_providers;
111
+ }
76
112
  if (current.provider?.pennyrouter) delete current.provider.pennyrouter;
77
113
  if (current.provider && Object.keys(current.provider).length === 0) delete current.provider;
78
114
  await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
@@ -136,7 +172,45 @@ function stripJsonComments(text) {
136
172
  result += char;
137
173
  }
138
174
 
139
- return result;
175
+ return result.replace(/,\s*([}\]])/g, "$1");
176
+ }
177
+
178
+ export function opencodeModelId(modelId) {
179
+ return modelId;
180
+ }
181
+
182
+ export async function fetchOpenCodeModels(gatewayBaseUrl) {
183
+ try {
184
+ const response = await fetch(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1/models`);
185
+ if (!response.ok) throw new Error(`model catalog returned ${response.status}`);
186
+ const payload = await response.json();
187
+ const catalog = new Map(
188
+ Array.isArray(payload?.data)
189
+ ? payload.data
190
+ .filter((model) => typeof model?.id === "string")
191
+ .map((model) => [model.id, model])
192
+ : [],
193
+ );
194
+ const models = OPENCODE_MODEL_CHOICES.map((choice) => {
195
+ const model = catalog.get(choice.id);
196
+ return {
197
+ ...choice,
198
+ ...(model?.description ? { description: model.description } : {}),
199
+ };
200
+ });
201
+ if (models.length > 0) return models;
202
+ } catch {}
203
+ return OPENCODE_MODEL_CHOICES;
204
+ }
205
+
206
+ function openCodeModelName(modelId) {
207
+ const suffix = opencodeModelId(modelId);
208
+ if (suffix === "auto") return "PennyRouter Auto";
209
+ return `PennyRouter ${suffix
210
+ .split(/[-:]/)
211
+ .filter(Boolean)
212
+ .map((part) => part[0].toUpperCase() + part.slice(1))
213
+ .join(" ")}`;
140
214
  }
141
215
 
142
216
  function removePennyRouterModels(value) {
@@ -0,0 +1,75 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { opencodeHarness, opencodeModelId, OPENCODE_MODEL_CHOICES } from "./opencode.js";
7
+
8
+ const ENV_KEYS = ["HOME", "XDG_STATE_HOME"];
9
+
10
+ async function main() {
11
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pr-opencode-test-"));
12
+ const previous = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
13
+ const home = path.join(root, "home");
14
+ const configPath = path.join(home, ".config", "opencode", "opencode.jsonc");
15
+ const modelStatePath = path.join(home, ".local", "state", "opencode", "model.json");
16
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
17
+ fs.mkdirSync(path.dirname(modelStatePath), { recursive: true });
18
+ fs.writeFileSync(configPath, [
19
+ "{",
20
+ ' "$schema": "https://opencode.ai/config.json",',
21
+ ' "enabled_providers": ["custom"],',
22
+ ' "provider": {',
23
+ ' "custom": { "models": { "model-a": {} } },',
24
+ " },",
25
+ "}",
26
+ "",
27
+ ].join("\n"));
28
+ fs.writeFileSync(modelStatePath, JSON.stringify({
29
+ recent: ["pennyrouter/old", "custom/model-a"],
30
+ favorite: ["pennyrouter/old"],
31
+ variant: { "pennyrouter/old": { value: "pennyrouter/old" }, "custom/model-a": {} },
32
+ }));
33
+ Object.assign(process.env, { HOME: home, XDG_STATE_HOME: path.join(home, ".local", "state") });
34
+
35
+ try {
36
+ assert.equal(opencodeModelId("pennyrouter/auto"), "pennyrouter/auto");
37
+ assert.equal(opencodeModelId("custom/model-a"), "custom/model-a");
38
+ const plan = await opencodeHarness.planInstall();
39
+ const record = await opencodeHarness.install({
40
+ apiKey: "pr-test",
41
+ gatewayBaseUrl: "https://api.pennyrouter.com/",
42
+ plan,
43
+ models: OPENCODE_MODEL_CHOICES,
44
+ });
45
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
46
+ assert.equal(config.provider.custom.models["model-a"] !== undefined, true);
47
+ assert.deepEqual(config.enabled_providers, ["custom", "pennyrouter"]);
48
+ assert.deepEqual(Object.keys(config.provider.pennyrouter.models), OPENCODE_MODEL_CHOICES.map((model) => model.id));
49
+ assert.equal(config.provider.pennyrouter.options.baseURL, "https://api.pennyrouter.com/v1");
50
+ assert.deepEqual(JSON.parse(fs.readFileSync(modelStatePath, "utf8")).recent, ["custom/model-a"]);
51
+
52
+ await opencodeHarness.uninstall(record);
53
+ const restored = JSON.parse(fs.readFileSync(configPath, "utf8"));
54
+ assert.equal(restored.provider.pennyrouter, undefined);
55
+ assert.deepEqual(restored.enabled_providers, ["custom"]);
56
+ assert.deepEqual(restored.provider.custom.models, { "model-a": {} });
57
+ const model = JSON.parse(fs.readFileSync(modelStatePath, "utf8"));
58
+ assert.deepEqual(model.recent, ["custom/model-a"]);
59
+ assert.deepEqual(model.favorite, []);
60
+ assert.deepEqual(model.variant, { "custom/model-a": {} });
61
+ } finally {
62
+ for (const key of ENV_KEYS) {
63
+ if (previous[key] === undefined) delete process.env[key];
64
+ else process.env[key] = previous[key];
65
+ }
66
+ fs.rmSync(root, { recursive: true, force: true });
67
+ }
68
+
69
+ console.log("opencode.test.js: all checks passed");
70
+ }
71
+
72
+ main().catch((error) => {
73
+ console.error(error);
74
+ process.exit(1);
75
+ });