pennyrouter 0.2.9 → 0.2.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.
@@ -1,318 +1,346 @@
1
- import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile } from "../state.js";
2
- import { configureClaudeCodeStatusline, restoreClaudeCodeStatusline } from "../statusline.js";
3
-
4
- const CONFIG_PATH = "~/.claude/settings.json";
5
- const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
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).
19
- 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)
25
- };
26
- // Retired dynamic-bundle labels (pre-pinned-trunk) and the pre-rename pinned labels. Not
27
- // written by a fresh install; matched only so uninstall/detect still recognize and clean up
28
- // an older install.
29
- const LEGACY_PENNY_LABELS = [
30
- "Penny Speed", "Penny Core", "Penny Power",
31
- "Penny Opus", "Penny Sonnet", "Penny Haiku", "Penny Fable",
32
- ];
33
-
34
- export const claudeCodeHarness = {
35
- id: "claude-code",
36
- name: "Claude Code",
37
-
38
- async detect() {
39
- return exists(CONFIG_PATH);
40
- },
41
-
42
- async planInstall() {
43
- return {
44
- configPath: expandHome(CONFIG_PATH),
45
- summary: [
46
- `write ${CONFIG_PATH}`,
47
- "set Anthropic-compatible base URL to PennyRouter",
48
- "store the PennyRouter API key without printing it",
49
- "map Claude Code models to Penny model choices",
50
- ],
51
- };
52
- },
53
-
54
- async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
55
- const backupPath = await backupFile(CONFIG_PATH, "claude-code");
56
- const current = (await readJsonFile(CONFIG_PATH, null)) || {};
57
- const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
58
- current.env ||= {};
59
- current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
60
- // Custom base URL: Claude Code authenticates with the auth token, not an
61
- // Anthropic API key. Clear any stale key so it doesn't take precedence.
62
- current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
63
- delete current.env.ANTHROPIC_API_KEY;
64
- // Claude Code displays these env values directly in its model picker; the
65
- // gateway decodes the friendly labels into bundle/profile overrides.
66
- current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
67
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
68
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
69
- 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";
74
-
75
- const statusline = await configureClaudeCodeStatusline(current);
76
-
77
- await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
78
-
79
- return {
80
- harness: "claude-code",
81
- config_path: compactHome(plan.configPath),
82
- backup_path: backupPath ? compactHome(backupPath) : null,
83
- installed_at: new Date().toISOString(),
84
- restart: "restart Claude Code",
85
- statusline: statusline.wrapped ? "wrapped-existing" : "installed",
86
- claude_ai_connectors_before: claudeAiConnectorsBefore,
87
- changes: {
88
- settings_keys: [CONNECTOR_SETTING],
89
- env_keys: [
90
- "ANTHROPIC_BASE_URL",
91
- "ANTHROPIC_AUTH_TOKEN",
92
- "ANTHROPIC_DEFAULT_OPUS_MODEL",
93
- "ANTHROPIC_DEFAULT_SONNET_MODEL",
94
- "ANTHROPIC_DEFAULT_HAIKU_MODEL",
95
- "ANTHROPIC_DEFAULT_FABLE_MODEL",
96
- "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
97
- ],
98
- },
99
- };
100
- },
101
-
102
- // Stored-token mode: the Anthropic setup-token was already POSTed to the gateway
103
- // and encrypted against the account (see cli.js storeAnthropicToken). Claude Code's
104
- // config therefore carries only the pr- bearer + base URL, not the Anthropic token.
105
- // The gateway pulls the stored token per request. This avoids putting the Anthropic
106
- // credential on disk and avoids a dual-credential configuration.
107
- async installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
108
- const backupPath = await backupFile(CONFIG_PATH, "claude-code");
109
- const current = (await readJsonFile(CONFIG_PATH, null)) || {};
110
- const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
111
- current.env ||= {};
112
- current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
113
- current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
114
- // Single credential only: no stale api key, no separate Anthropic bearer.
115
- delete current.env.ANTHROPIC_API_KEY;
116
- current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
117
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
118
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
119
- 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";
124
-
125
- const statusline = await configureClaudeCodeStatusline(current);
126
-
127
- await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
128
-
129
- return {
130
- harness: "claude-code",
131
- config_path: compactHome(plan.configPath),
132
- backup_path: backupPath ? compactHome(backupPath) : null,
133
- installed_at: new Date().toISOString(),
134
- restart: "restart Claude Code",
135
- statusline: statusline.wrapped ? "wrapped-existing" : "installed",
136
- claude_ai_connectors_before: claudeAiConnectorsBefore,
137
- changes: {
138
- settings_keys: [CONNECTOR_SETTING],
139
- env_keys: [
140
- "ANTHROPIC_BASE_URL",
141
- "ANTHROPIC_AUTH_TOKEN",
142
- "ANTHROPIC_DEFAULT_OPUS_MODEL",
143
- "ANTHROPIC_DEFAULT_SONNET_MODEL",
144
- "ANTHROPIC_DEFAULT_HAIKU_MODEL",
145
- "ANTHROPIC_DEFAULT_FABLE_MODEL",
146
- "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
147
- ],
148
- },
149
- };
150
- },
151
-
152
- async installAnthropicOAuth({ pennyKey, anthropicAuthToken, gatewayBaseUrl, existingRecord }) {
153
- const backupPath = await backupFile(CONFIG_PATH, "claude-code-anthropic-auth");
154
- const current = (await readJsonFile(CONFIG_PATH, null)) || {};
155
- const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
156
- current.env ||= {};
157
- current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
158
- // Dual-auth mode: x-api-key authenticates the local user to PennyRouter; the
159
- // Authorization bearer token is forwarded by the gateway to Anthropic.
160
- current.env.ANTHROPIC_API_KEY = pennyKey;
161
- current.env.ANTHROPIC_AUTH_TOKEN = anthropicAuthToken;
162
- current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
163
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
164
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
165
- 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";
170
-
171
- const statusline = await configureClaudeCodeStatusline(current);
172
-
173
- await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
174
- return {
175
- harness: "claude-code",
176
- config_path: compactHome(expandHome(CONFIG_PATH)),
177
- backup_path: backupPath ? compactHome(backupPath) : null,
178
- installed_at: new Date().toISOString(),
179
- restart: "restart Claude Code",
180
- statusline: statusline.wrapped ? "wrapped-existing" : "installed",
181
- claude_ai_connectors_before: claudeAiConnectorsBefore,
182
- changes: {
183
- settings_keys: [CONNECTOR_SETTING],
184
- env_keys: [
185
- "ANTHROPIC_BASE_URL",
186
- "ANTHROPIC_API_KEY",
187
- "ANTHROPIC_AUTH_TOKEN",
188
- "ANTHROPIC_DEFAULT_OPUS_MODEL",
189
- "ANTHROPIC_DEFAULT_SONNET_MODEL",
190
- "ANTHROPIC_DEFAULT_HAIKU_MODEL",
191
- "ANTHROPIC_DEFAULT_FABLE_MODEL",
192
- "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
193
- ],
194
- },
195
- };
196
- },
197
-
198
- async uninstall(record) {
199
- const current = (await readJsonFile(record.config_path, null)) || {};
200
- if (hasPennyRouterEnv(current.env)) {
201
- // If the base URL points at PennyRouter, every auth value in env was written
202
- // by us for our gateway (a Penny pr- key and/or an Anthropic OAuth bearer we
203
- // forward upstream) — clear them all. We can't rely on the token's prefix:
204
- // in OAuth mode ANTHROPIC_AUTH_TOKEN holds an Anthropic sk-ant-oat... token,
205
- // not a pr- value, and the manifest's anthropic_oauth flag may be absent on a
206
- // record synthesized from a detected config. Keying off our base URL is what
207
- // makes cleanup reliable regardless of which auth mode was installed.
208
- const ourGateway = isPennyRouterBaseUrl(current.env.ANTHROPIC_BASE_URL);
209
- delete current.env.ANTHROPIC_BASE_URL;
210
- const tok = current.env.ANTHROPIC_AUTH_TOKEN;
211
- if (typeof tok === "string" && (ourGateway || tok.startsWith("pr-") || record.anthropic_oauth)) {
212
- delete current.env.ANTHROPIC_AUTH_TOKEN;
213
- }
214
- // ANTHROPIC_API_KEY carries the Penny key in OAuth dual-auth mode, or a pr-
215
- // key in legacy installs. Clear it when it's ours by base URL or by prefix.
216
- if (typeof current.env.ANTHROPIC_API_KEY === "string" && (ourGateway || current.env.ANTHROPIC_API_KEY.startsWith("pr-"))) {
217
- delete current.env.ANTHROPIC_API_KEY;
218
- }
219
- for (const k of [
220
- "ANTHROPIC_DEFAULT_OPUS_MODEL",
221
- "ANTHROPIC_DEFAULT_SONNET_MODEL",
222
- "ANTHROPIC_DEFAULT_HAIKU_MODEL",
223
- "ANTHROPIC_DEFAULT_FABLE_MODEL",
224
- ]) {
225
- if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
226
- }
227
- delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
228
- }
229
- restoreClaudeAiConnectors(current, record);
230
- if (current["anthropic.baseURL"] && isPennyRouterValue(current["anthropic.baseURL"])) {
231
- delete current["anthropic.baseURL"];
232
- }
233
- if (typeof current["anthropic.apiKey"] === "string" && current["anthropic.apiKey"].startsWith("pr-")) {
234
- delete current["anthropic.apiKey"];
235
- }
236
- if (current.env && Object.keys(current.env).length === 0) delete current.env;
237
- // Statusline: restore the user's original command if we wrapped one, else remove ours.
238
- await restoreClaudeCodeStatusline(current);
239
- await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
240
- },
241
- };
242
-
243
- export function disableClaudeAiConnectors(settings, existingRecord = null) {
244
- const saved = existingRecord?.claude_ai_connectors_before;
245
- const before = saved && typeof saved.present === "boolean"
246
- ? { ...saved }
247
- : Object.prototype.hasOwnProperty.call(settings, CONNECTOR_SETTING)
248
- ? { present: true, value: settings[CONNECTOR_SETTING] }
249
- : { present: false };
250
- settings[CONNECTOR_SETTING] = true;
251
- return before;
252
- }
253
-
254
- export function restoreClaudeAiConnectors(settings, record) {
255
- const before = record?.claude_ai_connectors_before;
256
- if (!before || typeof before.present !== "boolean") return;
257
- // Respect a user edit made after installation. We only undo the value we manage.
258
- if (settings[CONNECTOR_SETTING] !== true) return;
259
- if (before.present) settings[CONNECTOR_SETTING] = before.value;
260
- else delete settings[CONNECTOR_SETTING];
261
- }
262
-
263
- function hasPennyRouterEnv(env) {
264
- if (!env || typeof env !== "object") return false;
265
- return (
266
- isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ||
267
- isPennyRouterValue(env.ANTHROPIC_AUTH_TOKEN) ||
268
- isPennyRouterValue(env.ANTHROPIC_API_KEY) ||
269
- env.ANTHROPIC_DEFAULT_OPUS_MODEL === LEGACY_MODEL_LABEL ||
270
- env.ANTHROPIC_DEFAULT_SONNET_MODEL === LEGACY_MODEL_LABEL ||
271
- env.ANTHROPIC_DEFAULT_HAIKU_MODEL === LEGACY_MODEL_LABEL ||
272
- env.ANTHROPIC_DEFAULT_FABLE_MODEL === LEGACY_MODEL_LABEL ||
273
- isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_OPUS_MODEL) ||
274
- isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_SONNET_MODEL) ||
275
- isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_HAIKU_MODEL) ||
276
- isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_FABLE_MODEL)
277
- );
278
- }
279
-
280
- function isPennyRouterValue(value) {
281
- return typeof value === "string" && (value.startsWith("pr-") || value.includes("pennyrouter"));
282
- }
283
-
284
- function isPennyRouterBaseUrl(value) {
285
- return typeof value === "string" && value.includes("pennyrouter");
286
- }
287
-
288
- function isPennyRouterModelValue(value) {
289
- return (
290
- isPennyRouterValue(value) ||
291
- value === CLAUDE_CODE_MODELS.haiku ||
292
- value === CLAUDE_CODE_MODELS.sonnet ||
293
- value === CLAUDE_CODE_MODELS.opus ||
294
- value === CLAUDE_CODE_MODELS.fable ||
295
- value === CLAUDE_CODE_MODELS.custom ||
296
- LEGACY_PENNY_LABELS.includes(value)
297
- );
298
- }
299
-
300
- export async function readClaudeCodePennyRouterKey() {
301
- const current = (await readJsonFile(CONFIG_PATH, null)) || {};
302
- const env = current.env || {};
303
- for (const value of [env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN, current["anthropic.apiKey"]]) {
304
- if (typeof value === "string" && value.startsWith("pr-")) return value;
305
- }
306
- return null;
307
- }
308
-
309
- // The PennyRouter gateway URL + pr- key currently in Claude Code's config, used to
310
- // call the gateway's "forget my Claude token" endpoint on uninstall. Read before
311
- // uninstall strips these keys. Either field may be null if not present.
312
- export async function readClaudeCodeGatewayAuth() {
313
- const current = (await readJsonFile(CONFIG_PATH, null)) || {};
314
- const env = current.env || {};
315
- const gatewayBaseUrl = isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ? env.ANTHROPIC_BASE_URL : null;
316
- const apiKey = await readClaudeCodePennyRouterKey();
317
- return { gatewayBaseUrl, apiKey };
318
- }
1
+ import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile } from "../state.js";
2
+ import { configureClaudeCodeStatusline, restoreClaudeCodeStatusline } from "../statusline.js";
3
+
4
+ const CONFIG_PATH = "~/.claude/settings.json";
5
+ const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
6
+ const CONNECTOR_SETTING = "disableClaudeAiConnectors";
7
+ // The slot values remain genuine Anthropic ids so Claude Code recognizes their actual context
8
+ // windows (Haiku 200K; the 1M-capable models 1M) and sends the native id on the wire. Claude
9
+ // Code separately supports *_MODEL_NAME and *_MODEL_DESCRIPTION for the picker, so Penny can
10
+ // present a friendly alias without sacrificing model recognition.
11
+ export const CLAUDE_CODE_MODELS = {
12
+ haiku: "claude-haiku-4-5-20251001",
13
+ sonnet: "claude-sonnet-5",
14
+ opus: "claude-opus-5",
15
+ fable: "claude-fable-5",
16
+ custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
17
+ };
18
+ const CLAUDE_CODE_MODEL_PRESENTATION = {
19
+ haiku: { name: "Haiku 4.5 (PennyRouter)", description: "Fast, economical Claude model · 200K context" },
20
+ sonnet: { name: "Sonnet 5 (PennyRouter)", description: "Balanced Claude model · 1M context" },
21
+ opus: { name: "Opus 5 (PennyRouter)", description: "Most capable Claude model · 1M context" },
22
+ fable: { name: "Fable 5 (PennyRouter)", description: "Claude model · 1M context" },
23
+ };
24
+ const LEGACY_PINNED_MODEL_IDS = {
25
+ "Haiku 4.5 (PennyRouter)": CLAUDE_CODE_MODELS.haiku,
26
+ "Sonnet 5 (PennyRouter)": CLAUDE_CODE_MODELS.sonnet,
27
+ "Opus 5 (PennyRouter)": CLAUDE_CODE_MODELS.opus,
28
+ "Fable 5 (PennyRouter)": CLAUDE_CODE_MODELS.fable,
29
+ };
30
+ const LEGACY_PINNED_MODEL_LABELS = Object.keys(LEGACY_PINNED_MODEL_IDS);
31
+ // Retired dynamic-bundle labels (pre-pinned-trunk) and the pre-rename pinned labels. Not
32
+ // written by a fresh install; matched only so uninstall/detect still recognize and clean up
33
+ // an older install.
34
+ const LEGACY_PENNY_LABELS = [
35
+ "Penny Speed", "Penny Core", "Penny Power",
36
+ "Penny Opus", "Penny Sonnet", "Penny Haiku", "Penny Fable",
37
+ ];
38
+
39
+ function writeClaudeCodeModelPresentation(env) {
40
+ for (const [slot, { name, description }] of Object.entries(CLAUDE_CODE_MODEL_PRESENTATION)) {
41
+ const prefix = `ANTHROPIC_DEFAULT_${slot.toUpperCase()}_MODEL`;
42
+ env[`${prefix}_NAME`] = name;
43
+ env[`${prefix}_DESCRIPTION`] = description;
44
+ }
45
+ }
46
+
47
+ const CLAUDE_CODE_MODEL_PRESENTATION_KEYS = Object.keys(CLAUDE_CODE_MODEL_PRESENTATION)
48
+ .flatMap((slot) => {
49
+ const prefix = `ANTHROPIC_DEFAULT_${slot.toUpperCase()}_MODEL`;
50
+ return [`${prefix}_NAME`, `${prefix}_DESCRIPTION`];
51
+ });
52
+
53
+ export const claudeCodeHarness = {
54
+ id: "claude-code",
55
+ name: "Claude Code",
56
+
57
+ async detect() {
58
+ return exists(CONFIG_PATH);
59
+ },
60
+
61
+ async planInstall() {
62
+ return {
63
+ configPath: expandHome(CONFIG_PATH),
64
+ summary: [
65
+ `write ${CONFIG_PATH}`,
66
+ "set Anthropic-compatible base URL to PennyRouter",
67
+ "store the PennyRouter API key without printing it",
68
+ "map Claude Code models to Penny model choices",
69
+ ],
70
+ };
71
+ },
72
+
73
+ async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
74
+ const backupPath = await backupFile(CONFIG_PATH, "claude-code");
75
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
76
+ const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
77
+ current.env ||= {};
78
+ current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
79
+ // Custom base URL: Claude Code authenticates with the auth token, not an
80
+ // Anthropic API key. Clear any stale key so it doesn't take precedence.
81
+ current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
82
+ delete current.env.ANTHROPIC_API_KEY;
83
+ // Claude Code displays these env values directly in its model picker; the
84
+ // gateway decodes the friendly labels into bundle/profile overrides.
85
+ current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
86
+ current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
87
+ current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
88
+ current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
89
+ writeClaudeCodeModelPresentation(current.env);
90
+ // This is global, not per-model. Leave it unset so Claude Code uses each native id's
91
+ // real context limit instead of incorrectly giving Haiku a 1M window.
92
+ delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
93
+
94
+ const statusline = await configureClaudeCodeStatusline(current);
95
+
96
+ await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
97
+
98
+ return {
99
+ harness: "claude-code",
100
+ config_path: compactHome(plan.configPath),
101
+ backup_path: backupPath ? compactHome(backupPath) : null,
102
+ installed_at: new Date().toISOString(),
103
+ restart: "restart Claude Code",
104
+ statusline: statusline.wrapped ? "wrapped-existing" : "installed",
105
+ claude_ai_connectors_before: claudeAiConnectorsBefore,
106
+ changes: {
107
+ settings_keys: [CONNECTOR_SETTING],
108
+ env_keys: [
109
+ "ANTHROPIC_BASE_URL",
110
+ "ANTHROPIC_AUTH_TOKEN",
111
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
112
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
113
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
114
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
115
+ ...CLAUDE_CODE_MODEL_PRESENTATION_KEYS,
116
+ ],
117
+ },
118
+ };
119
+ },
120
+
121
+ // Stored-token mode: the Anthropic setup-token was already POSTed to the gateway
122
+ // and encrypted against the account (see cli.js storeAnthropicToken). Claude Code's
123
+ // config therefore carries only the pr- bearer + base URL, not the Anthropic token.
124
+ // The gateway pulls the stored token per request. This avoids putting the Anthropic
125
+ // credential on disk and avoids a dual-credential configuration.
126
+ async installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
127
+ const backupPath = await backupFile(CONFIG_PATH, "claude-code");
128
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
129
+ const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
130
+ current.env ||= {};
131
+ current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
132
+ current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
133
+ // Single credential only: no stale api key, no separate Anthropic bearer.
134
+ delete current.env.ANTHROPIC_API_KEY;
135
+ current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
136
+ current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
137
+ current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
138
+ current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
139
+ writeClaudeCodeModelPresentation(current.env);
140
+ // This is global, not per-model. Leave it unset so Claude Code uses each native id's
141
+ // real context limit instead of incorrectly giving Haiku a 1M window.
142
+ delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
143
+
144
+ const statusline = await configureClaudeCodeStatusline(current);
145
+
146
+ await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
147
+
148
+ return {
149
+ harness: "claude-code",
150
+ config_path: compactHome(plan.configPath),
151
+ backup_path: backupPath ? compactHome(backupPath) : null,
152
+ installed_at: new Date().toISOString(),
153
+ restart: "restart Claude Code",
154
+ statusline: statusline.wrapped ? "wrapped-existing" : "installed",
155
+ claude_ai_connectors_before: claudeAiConnectorsBefore,
156
+ changes: {
157
+ settings_keys: [CONNECTOR_SETTING],
158
+ env_keys: [
159
+ "ANTHROPIC_BASE_URL",
160
+ "ANTHROPIC_AUTH_TOKEN",
161
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
162
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
163
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
164
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
165
+ ...CLAUDE_CODE_MODEL_PRESENTATION_KEYS,
166
+ ],
167
+ },
168
+ };
169
+ },
170
+
171
+ async installAnthropicOAuth({ pennyKey, anthropicAuthToken, gatewayBaseUrl, existingRecord }) {
172
+ const backupPath = await backupFile(CONFIG_PATH, "claude-code-anthropic-auth");
173
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
174
+ const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
175
+ current.env ||= {};
176
+ current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
177
+ // Dual-auth mode: x-api-key authenticates the local user to PennyRouter; the
178
+ // Authorization bearer token is forwarded by the gateway to Anthropic.
179
+ current.env.ANTHROPIC_API_KEY = pennyKey;
180
+ current.env.ANTHROPIC_AUTH_TOKEN = anthropicAuthToken;
181
+ current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
182
+ current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
183
+ current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
184
+ current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
185
+ writeClaudeCodeModelPresentation(current.env);
186
+ // This is global, not per-model. Leave it unset so Claude Code uses each native id's
187
+ // real context limit instead of incorrectly giving Haiku a 1M window.
188
+ delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
189
+
190
+ const statusline = await configureClaudeCodeStatusline(current);
191
+
192
+ await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
193
+ return {
194
+ harness: "claude-code",
195
+ config_path: compactHome(expandHome(CONFIG_PATH)),
196
+ backup_path: backupPath ? compactHome(backupPath) : null,
197
+ installed_at: new Date().toISOString(),
198
+ restart: "restart Claude Code",
199
+ statusline: statusline.wrapped ? "wrapped-existing" : "installed",
200
+ claude_ai_connectors_before: claudeAiConnectorsBefore,
201
+ changes: {
202
+ settings_keys: [CONNECTOR_SETTING],
203
+ env_keys: [
204
+ "ANTHROPIC_BASE_URL",
205
+ "ANTHROPIC_API_KEY",
206
+ "ANTHROPIC_AUTH_TOKEN",
207
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
208
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
209
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
210
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
211
+ ...CLAUDE_CODE_MODEL_PRESENTATION_KEYS,
212
+ ],
213
+ },
214
+ };
215
+ },
216
+
217
+ async uninstall(record) {
218
+ const current = (await readJsonFile(record.config_path, null)) || {};
219
+ if (hasPennyRouterEnv(current.env)) {
220
+ // If the base URL points at PennyRouter, every auth value in env was written
221
+ // by us for our gateway (a Penny pr- key and/or an Anthropic OAuth bearer we
222
+ // forward upstream) — clear them all. We can't rely on the token's prefix:
223
+ // in OAuth mode ANTHROPIC_AUTH_TOKEN holds an Anthropic sk-ant-oat... token,
224
+ // not a pr- value, and the manifest's anthropic_oauth flag may be absent on a
225
+ // record synthesized from a detected config. Keying off our base URL is what
226
+ // makes cleanup reliable regardless of which auth mode was installed.
227
+ const ourGateway = isPennyRouterBaseUrl(current.env.ANTHROPIC_BASE_URL);
228
+ delete current.env.ANTHROPIC_BASE_URL;
229
+ const tok = current.env.ANTHROPIC_AUTH_TOKEN;
230
+ if (typeof tok === "string" && (ourGateway || tok.startsWith("pr-") || record.anthropic_oauth)) {
231
+ delete current.env.ANTHROPIC_AUTH_TOKEN;
232
+ }
233
+ // ANTHROPIC_API_KEY carries the Penny key in OAuth dual-auth mode, or a pr-
234
+ // key in legacy installs. Clear it when it's ours by base URL or by prefix.
235
+ if (typeof current.env.ANTHROPIC_API_KEY === "string" && (ourGateway || current.env.ANTHROPIC_API_KEY.startsWith("pr-"))) {
236
+ delete current.env.ANTHROPIC_API_KEY;
237
+ }
238
+ for (const k of [
239
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
240
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
241
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
242
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
243
+ ]) {
244
+ if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
245
+ }
246
+ for (const k of CLAUDE_CODE_MODEL_PRESENTATION_KEYS) delete current.env[k];
247
+ delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
248
+ }
249
+ restoreClaudeAiConnectors(current, record);
250
+ if (current["anthropic.baseURL"] && isPennyRouterValue(current["anthropic.baseURL"])) {
251
+ delete current["anthropic.baseURL"];
252
+ }
253
+ if (typeof current["anthropic.apiKey"] === "string" && current["anthropic.apiKey"].startsWith("pr-")) {
254
+ delete current["anthropic.apiKey"];
255
+ }
256
+ if (current.env && Object.keys(current.env).length === 0) delete current.env;
257
+ // Statusline: restore the user's original command if we wrapped one, else remove ours.
258
+ await restoreClaudeCodeStatusline(current);
259
+ await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
260
+ },
261
+ };
262
+
263
+ export function disableClaudeAiConnectors(settings, existingRecord = null) {
264
+ const saved = existingRecord?.claude_ai_connectors_before;
265
+ const before = saved && typeof saved.present === "boolean"
266
+ ? { ...saved }
267
+ : Object.prototype.hasOwnProperty.call(settings, CONNECTOR_SETTING)
268
+ ? { present: true, value: settings[CONNECTOR_SETTING] }
269
+ : { present: false };
270
+ settings[CONNECTOR_SETTING] = true;
271
+ return before;
272
+ }
273
+
274
+ export function restoreClaudeAiConnectors(settings, record) {
275
+ const before = record?.claude_ai_connectors_before;
276
+ if (!before || typeof before.present !== "boolean") return;
277
+ // Respect a user edit made after installation. We only undo the value we manage.
278
+ if (settings[CONNECTOR_SETTING] !== true) return;
279
+ if (before.present) settings[CONNECTOR_SETTING] = before.value;
280
+ else delete settings[CONNECTOR_SETTING];
281
+ }
282
+
283
+ function hasPennyRouterEnv(env) {
284
+ if (!env || typeof env !== "object") return false;
285
+ return (
286
+ isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ||
287
+ isPennyRouterValue(env.ANTHROPIC_AUTH_TOKEN) ||
288
+ isPennyRouterValue(env.ANTHROPIC_API_KEY) ||
289
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL === LEGACY_MODEL_LABEL ||
290
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL === LEGACY_MODEL_LABEL ||
291
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL === LEGACY_MODEL_LABEL ||
292
+ env.ANTHROPIC_DEFAULT_FABLE_MODEL === LEGACY_MODEL_LABEL ||
293
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_OPUS_MODEL) ||
294
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_SONNET_MODEL) ||
295
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_HAIKU_MODEL) ||
296
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_FABLE_MODEL)
297
+ );
298
+ }
299
+
300
+ function isPennyRouterValue(value) {
301
+ return typeof value === "string" && (value.startsWith("pr-") || value.includes("pennyrouter"));
302
+ }
303
+
304
+ function isPennyRouterBaseUrl(value) {
305
+ return typeof value === "string" && value.includes("pennyrouter");
306
+ }
307
+
308
+ function isPennyRouterModelValue(value) {
309
+ return (
310
+ isPennyRouterValue(value) ||
311
+ value === CLAUDE_CODE_MODELS.haiku ||
312
+ value === CLAUDE_CODE_MODELS.sonnet ||
313
+ value === CLAUDE_CODE_MODELS.opus ||
314
+ value === CLAUDE_CODE_MODELS.fable ||
315
+ value === CLAUDE_CODE_MODELS.custom ||
316
+ LEGACY_PINNED_MODEL_LABELS.includes(value) ||
317
+ LEGACY_PENNY_LABELS.includes(value)
318
+ );
319
+ }
320
+
321
+ // `penny claude` can launch using a local Penny config written by an earlier CLI
322
+ // version. Translate only our previous friendly labels; a user-supplied model id
323
+ // remains untouched.
324
+ export function upgradeClaudeCodeModelValue(value) {
325
+ return LEGACY_PINNED_MODEL_IDS[value] || value;
326
+ }
327
+
328
+ export async function readClaudeCodePennyRouterKey() {
329
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
330
+ const env = current.env || {};
331
+ for (const value of [env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN, current["anthropic.apiKey"]]) {
332
+ if (typeof value === "string" && value.startsWith("pr-")) return value;
333
+ }
334
+ return null;
335
+ }
336
+
337
+ // The PennyRouter gateway URL + pr- key currently in Claude Code's config, used to
338
+ // call the gateway's "forget my Claude token" endpoint on uninstall. Read before
339
+ // uninstall strips these keys. Either field may be null if not present.
340
+ export async function readClaudeCodeGatewayAuth() {
341
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
342
+ const env = current.env || {};
343
+ const gatewayBaseUrl = isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ? env.ANTHROPIC_BASE_URL : null;
344
+ const apiKey = await readClaudeCodePennyRouterKey();
345
+ return { gatewayBaseUrl, apiKey };
346
+ }