pennyrouter 0.2.7 → 0.2.8
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/LICENSE +21 -21
- package/README.md +225 -222
- package/bin/penny.js +9 -9
- package/bin/pennyrouter.js +7 -7
- package/package.json +30 -30
- package/src/cli.js +1727 -1733
- package/src/harnesses/aider.js +48 -48
- package/src/harnesses/claude-code.js +318 -318
- package/src/harnesses/claude-code.test.js +57 -57
- package/src/harnesses/cline.js +77 -77
- package/src/harnesses/codegpt.js +76 -76
- package/src/harnesses/codex.js +180 -180
- package/src/harnesses/codex.test.js +119 -119
- package/src/harnesses/opencode.js +151 -151
- package/src/harnesses/shared.js +51 -51
- package/src/harnesses/windsurf.js +57 -57
- package/src/harnesses/zed.js +61 -61
- package/src/launch.js +467 -393
- package/src/launch.test.js +153 -153
- package/src/session.js +157 -157
- package/src/session.test.js +44 -44
- package/src/state.js +103 -103
- package/src/statusline.js +202 -202
- package/src/statusline.test.js +72 -72
|
@@ -1,318 +1,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
|
-
// 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
|
+
// 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
|
+
}
|