pennyrouter 0.3.0 → 0.3.1
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 +14 -9
- package/package.json +2 -2
- package/src/cli.js +563 -132
- package/src/cli.test.js +196 -0
- package/src/harnesses/claude-code.js +81 -32
- package/src/harnesses/opencode.js +82 -8
- package/src/harnesses/opencode.test.js +75 -0
- package/src/launch.js +260 -28
- package/src/launch.test.js +47 -1
- package/src/mcp-register.js +212 -0
- package/src/mcp-register.test.js +200 -0
- package/src/mcp.js +375 -0
- package/src/mcp.test.js +259 -0
- package/src/session.js +9 -5
- package/src/statusline.js +73 -5
- package/src/thread-session-codex.js +218 -0
- package/src/thread-session-codex.test.js +151 -0
- package/src/thread-session.js +215 -0
- package/src/thread-session.test.js +151 -0
package/src/cli.test.js
ADDED
|
@@ -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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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: "
|
|
21
|
-
sonnet: "
|
|
22
|
-
opus: "
|
|
23
|
-
fable: "
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
current.env.
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
current.env.
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
current.env.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|