pennyrouter 0.1.4 → 0.1.6
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 +10 -2
- package/package.json +1 -1
- package/src/cli.js +163 -39
- package/src/harnesses/aider.js +1 -1
- package/src/harnesses/claude-code.js +59 -9
- package/src/harnesses/cline.js +2 -1
- package/src/harnesses/codegpt.js +2 -1
- package/src/harnesses/opencode.js +6 -12
- package/src/harnesses/shared.js +23 -10
- package/src/harnesses/cursor.js +0 -83
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ status checks, and uninstall metadata.
|
|
|
11
11
|
```bash
|
|
12
12
|
npx pennyrouter install
|
|
13
13
|
npx pennyrouter install --existing-account
|
|
14
|
-
npx pennyrouter install --harness claude-code,cline,opencode,codex,
|
|
14
|
+
npx pennyrouter install --harness claude-code,cline,opencode,codex,windsurf,zed,codegpt,aider
|
|
15
15
|
npx pennyrouter disable
|
|
16
16
|
npx pennyrouter enable
|
|
17
17
|
npx pennyrouter uninstall
|
|
@@ -27,12 +27,20 @@ detected configs start checked; press Enter to accept or type tool IDs to toggle
|
|
|
27
27
|
- Cline (manual setup instructions; no VS Code settings mutation)
|
|
28
28
|
- Codex
|
|
29
29
|
- opencode
|
|
30
|
-
- Cursor
|
|
31
30
|
- Windsurf
|
|
32
31
|
- Zed
|
|
33
32
|
- JetBrains CodeGPT (manual setup instructions; no XML mutation)
|
|
34
33
|
- Aider
|
|
35
34
|
|
|
35
|
+
## Penny Models
|
|
36
|
+
|
|
37
|
+
Tools that expose a model picker get four curated choices:
|
|
38
|
+
|
|
39
|
+
- Penny Core: `pennyrouter/penny-core:premium`
|
|
40
|
+
- Penny Speed: `pennyrouter/penny-speed:standard`
|
|
41
|
+
- Penny Power: `pennyrouter/penny-power:premium`
|
|
42
|
+
- Penny Custom: `pennyrouter/auto`, using the bundle/profile saved in the user's PennyRouter account
|
|
43
|
+
|
|
36
44
|
## Agent Flow
|
|
37
45
|
|
|
38
46
|
Users can ask a coding agent to follow:
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -7,7 +7,6 @@ import { claudeCodeHarness } from "./harnesses/claude-code.js";
|
|
|
7
7
|
import { clineHarness } from "./harnesses/cline.js";
|
|
8
8
|
import { codeGptHarness } from "./harnesses/codegpt.js";
|
|
9
9
|
import { codexHarness } from "./harnesses/codex.js";
|
|
10
|
-
import { cursorHarness } from "./harnesses/cursor.js";
|
|
11
10
|
import { opencodeHarness } from "./harnesses/opencode.js";
|
|
12
11
|
import { windsurfHarness } from "./harnesses/windsurf.js";
|
|
13
12
|
import { zedHarness } from "./harnesses/zed.js";
|
|
@@ -20,6 +19,7 @@ import {
|
|
|
20
19
|
} from "./session.js";
|
|
21
20
|
import {
|
|
22
21
|
backupFile,
|
|
22
|
+
compactHome,
|
|
23
23
|
loadManifest,
|
|
24
24
|
restoreFile,
|
|
25
25
|
saveManifest,
|
|
@@ -31,7 +31,6 @@ const HARNESS_BY_ID = {
|
|
|
31
31
|
cline: clineHarness,
|
|
32
32
|
opencode: opencodeHarness,
|
|
33
33
|
codex: codexHarness,
|
|
34
|
-
cursor: cursorHarness,
|
|
35
34
|
windsurf: windsurfHarness,
|
|
36
35
|
zed: zedHarness,
|
|
37
36
|
codegpt: codeGptHarness,
|
|
@@ -139,15 +138,19 @@ async function install(flags) {
|
|
|
139
138
|
|
|
140
139
|
const manifest = await loadManifest();
|
|
141
140
|
const records = [];
|
|
141
|
+
const failures = [];
|
|
142
142
|
for (const harness of selected) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
await runHarnessJob(failures, harness.id, "install", async () => {
|
|
144
|
+
const plan = await harness.planInstall();
|
|
145
|
+
const record = await harness.install({
|
|
146
|
+
apiKey: claim.api_key,
|
|
147
|
+
gatewayBaseUrl: claim.gateway_base_url,
|
|
148
|
+
plan,
|
|
149
|
+
});
|
|
150
|
+
records.push(record);
|
|
151
|
+
upsertManifestRecord(manifest, record);
|
|
152
|
+
await saveManifest(manifest);
|
|
148
153
|
});
|
|
149
|
-
records.push(record);
|
|
150
|
-
upsertManifestRecord(manifest, record);
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
await saveManifest(manifest);
|
|
@@ -158,13 +161,14 @@ async function install(flags) {
|
|
|
158
161
|
console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
|
|
159
162
|
}
|
|
160
163
|
console.log(`Manifest: ${statePath("installations.json")}`);
|
|
164
|
+
reportFailures(failures);
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
async function disable(flags) {
|
|
164
168
|
const manifest = await loadManifest();
|
|
169
|
+
if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
|
|
165
170
|
const records = manifest.installations || [];
|
|
166
|
-
const selectedIds =
|
|
167
|
-
const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
|
|
171
|
+
const { selectedIds, selectedRecords } = await resolveLifecycleTargets(flags, records);
|
|
168
172
|
|
|
169
173
|
if (selectedRecords.length === 0) {
|
|
170
174
|
console.log("No matching PennyRouter installations found.");
|
|
@@ -183,6 +187,7 @@ async function disable(flags) {
|
|
|
183
187
|
if (!ok) return;
|
|
184
188
|
}
|
|
185
189
|
|
|
190
|
+
const failures = [];
|
|
186
191
|
for (const record of selectedRecords) {
|
|
187
192
|
if (record.disabled) {
|
|
188
193
|
console.log(`${record.harness} is already disabled.`);
|
|
@@ -195,24 +200,30 @@ async function disable(flags) {
|
|
|
195
200
|
continue;
|
|
196
201
|
}
|
|
197
202
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
203
|
+
await runHarnessJob(failures, record.harness, "disable", async () => {
|
|
204
|
+
if (record.config_path) {
|
|
205
|
+
const snapshotPath = await backupFile(record.config_path, `${record.harness}-disabled`);
|
|
206
|
+
record.disabled_snapshot_path = snapshotPath;
|
|
207
|
+
}
|
|
202
208
|
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
if (typeof harness.disable === "function") await harness.disable(record);
|
|
210
|
+
else await harness.uninstall(record);
|
|
205
211
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
212
|
+
record.disabled = true;
|
|
213
|
+
record.disabled_at = new Date().toISOString();
|
|
214
|
+
upsertManifestRecord(manifest, record);
|
|
215
|
+
await saveManifest(manifest);
|
|
216
|
+
console.log(`Disabled ${record.harness}.`);
|
|
217
|
+
});
|
|
209
218
|
}
|
|
210
219
|
|
|
211
220
|
await saveManifest(manifest);
|
|
221
|
+
reportFailures(failures);
|
|
212
222
|
}
|
|
213
223
|
|
|
214
224
|
async function enable(flags) {
|
|
215
225
|
const manifest = await loadManifest();
|
|
226
|
+
if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
|
|
216
227
|
const records = manifest.installations || [];
|
|
217
228
|
const selectedIds = resolveRequestedHarnessIds(flags, records.map((record) => record.harness));
|
|
218
229
|
const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
|
|
@@ -234,6 +245,7 @@ async function enable(flags) {
|
|
|
234
245
|
if (!ok) return;
|
|
235
246
|
}
|
|
236
247
|
|
|
248
|
+
const failures = [];
|
|
237
249
|
for (const record of selectedRecords) {
|
|
238
250
|
if (!record.disabled) {
|
|
239
251
|
console.log(`${record.harness} is already enabled.`);
|
|
@@ -246,29 +258,33 @@ async function enable(flags) {
|
|
|
246
258
|
continue;
|
|
247
259
|
}
|
|
248
260
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
261
|
+
await runHarnessJob(failures, record.harness, "enable", async () => {
|
|
262
|
+
if (typeof harness.enable === "function") await harness.enable(record);
|
|
263
|
+
else if (record.config_path && record.disabled_snapshot_path) {
|
|
264
|
+
await restoreFile(record.disabled_snapshot_path, record.config_path);
|
|
265
|
+
} else if (record.manual_required) {
|
|
266
|
+
console.log(`${record.harness}: re-enable manually in the tool UI using the status details.`);
|
|
267
|
+
} else {
|
|
268
|
+
throw new Error("no disabled snapshot found; run install again");
|
|
269
|
+
}
|
|
258
270
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
271
|
+
delete record.disabled;
|
|
272
|
+
delete record.disabled_at;
|
|
273
|
+
upsertManifestRecord(manifest, record);
|
|
274
|
+
await saveManifest(manifest);
|
|
275
|
+
console.log(`Enabled ${record.harness}.`);
|
|
276
|
+
});
|
|
262
277
|
}
|
|
263
278
|
|
|
264
279
|
await saveManifest(manifest);
|
|
280
|
+
reportFailures(failures);
|
|
265
281
|
}
|
|
266
282
|
|
|
267
283
|
async function uninstall(flags) {
|
|
268
284
|
const manifest = await loadManifest();
|
|
285
|
+
if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
|
|
269
286
|
const records = manifest.installations || [];
|
|
270
|
-
const selectedIds =
|
|
271
|
-
const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
|
|
287
|
+
const { selectedIds, selectedRecords } = await resolveLifecycleTargets(flags, records);
|
|
272
288
|
|
|
273
289
|
if (selectedRecords.length === 0) {
|
|
274
290
|
console.log("No matching PennyRouter installations found.");
|
|
@@ -288,6 +304,7 @@ async function uninstall(flags) {
|
|
|
288
304
|
}
|
|
289
305
|
|
|
290
306
|
const kept = [];
|
|
307
|
+
const failures = [];
|
|
291
308
|
for (const record of records) {
|
|
292
309
|
if (!selectedIds.includes(record.harness)) {
|
|
293
310
|
kept.push(record);
|
|
@@ -301,16 +318,81 @@ async function uninstall(flags) {
|
|
|
301
318
|
continue;
|
|
302
319
|
}
|
|
303
320
|
|
|
304
|
-
await harness
|
|
305
|
-
|
|
321
|
+
const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
322
|
+
await harness.uninstall(record);
|
|
323
|
+
console.log(`Removed ${record.harness}.`);
|
|
324
|
+
});
|
|
325
|
+
if (!ok) kept.push(record);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
for (const record of selectedRecords) {
|
|
329
|
+
if (records.some((existing) => existing.harness === record.harness)) continue;
|
|
330
|
+
const harness = HARNESS_BY_ID[record.harness];
|
|
331
|
+
if (!harness) {
|
|
332
|
+
console.log(`Skipping ${record.harness}: this CLI does not know how to uninstall it.`);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
336
|
+
await harness.uninstall(record);
|
|
337
|
+
console.log(`Removed ${record.harness}.`);
|
|
338
|
+
});
|
|
306
339
|
}
|
|
307
340
|
|
|
308
341
|
manifest.installations = kept;
|
|
309
342
|
await saveManifest(manifest);
|
|
343
|
+
reportFailures(failures);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function resolveLifecycleTargets(flags, records) {
|
|
347
|
+
const requested = parseHarnessList(flags.harness);
|
|
348
|
+
const ids = flags.all
|
|
349
|
+
? Object.keys(HARNESS_BY_ID)
|
|
350
|
+
: requested.length > 0
|
|
351
|
+
? requested
|
|
352
|
+
: records.length > 0
|
|
353
|
+
? records.map((record) => record.harness)
|
|
354
|
+
: await detectHarnessIds();
|
|
355
|
+
|
|
356
|
+
const selectedIds = [...new Set(ids)];
|
|
357
|
+
const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
|
|
358
|
+
const manifestIds = new Set(selectedRecords.map((record) => record.harness));
|
|
359
|
+
|
|
360
|
+
for (const id of selectedIds) {
|
|
361
|
+
if (manifestIds.has(id)) continue;
|
|
362
|
+
const harness = HARNESS_BY_ID[id];
|
|
363
|
+
if (!harness) continue;
|
|
364
|
+
if (!(await safeDetect(harness))) continue;
|
|
365
|
+
let plan;
|
|
366
|
+
try {
|
|
367
|
+
plan = await harness.planInstall();
|
|
368
|
+
} catch (error) {
|
|
369
|
+
console.log(`Skipping ${id}: ${formatError(error)}`);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
selectedRecords.push({
|
|
373
|
+
harness: id,
|
|
374
|
+
config_path: plan.configPath ? compactHome(plan.configPath) : null,
|
|
375
|
+
backup_path: null,
|
|
376
|
+
installed_at: null,
|
|
377
|
+
recovered_from_detected_config: true,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return { selectedIds, selectedRecords };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function detectHarnessIds() {
|
|
385
|
+
const ids = [];
|
|
386
|
+
for (const harness of Object.values(HARNESS_BY_ID)) {
|
|
387
|
+
if (await safeDetect(harness)) ids.push(harness.id);
|
|
388
|
+
}
|
|
389
|
+
return ids;
|
|
310
390
|
}
|
|
311
391
|
|
|
312
392
|
async function status() {
|
|
313
393
|
const manifest = await loadManifest();
|
|
394
|
+
const pruned = pruneUnsupportedManifestRecords(manifest);
|
|
395
|
+
if (pruned.length > 0) await saveManifest(manifest);
|
|
314
396
|
const records = manifest.installations || [];
|
|
315
397
|
console.log("PennyRouter local setup");
|
|
316
398
|
console.log("");
|
|
@@ -336,7 +418,7 @@ async function selectHarnesses(flags) {
|
|
|
336
418
|
const requested = flags.all ? Object.keys(HARNESS_BY_ID) : parseHarnessList(flags.harness);
|
|
337
419
|
const detected = [];
|
|
338
420
|
for (const harness of Object.values(HARNESS_BY_ID)) {
|
|
339
|
-
if (await harness
|
|
421
|
+
if (await safeDetect(harness)) detected.push(harness);
|
|
340
422
|
}
|
|
341
423
|
|
|
342
424
|
if (requested.length > 0) {
|
|
@@ -519,6 +601,48 @@ function upsertManifestRecord(manifest, record) {
|
|
|
519
601
|
manifest.installations.push(record);
|
|
520
602
|
}
|
|
521
603
|
|
|
604
|
+
function pruneUnsupportedManifestRecords(manifest) {
|
|
605
|
+
manifest.installations ||= [];
|
|
606
|
+
const kept = manifest.installations.filter((record) => HARNESS_BY_ID[record.harness]);
|
|
607
|
+
const pruned = manifest.installations.filter((record) => !HARNESS_BY_ID[record.harness]);
|
|
608
|
+
manifest.installations = kept;
|
|
609
|
+
return pruned;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function safeDetect(harness) {
|
|
613
|
+
try {
|
|
614
|
+
return await harness.detect();
|
|
615
|
+
} catch (error) {
|
|
616
|
+
console.log(`Skipping ${harness.id}: detect failed: ${formatError(error)}`);
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function runHarnessJob(failures, harnessId, action, job) {
|
|
622
|
+
try {
|
|
623
|
+
await job();
|
|
624
|
+
return true;
|
|
625
|
+
} catch (error) {
|
|
626
|
+
failures.push({ harness: harnessId, action, error });
|
|
627
|
+
console.error(`${harnessId} ${action} failed: ${formatError(error)}`);
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function reportFailures(failures) {
|
|
633
|
+
if (failures.length === 0) return;
|
|
634
|
+
console.error("");
|
|
635
|
+
console.error("Some PennyRouter integrations failed:");
|
|
636
|
+
for (const failure of failures) {
|
|
637
|
+
console.error(` - ${failure.harness} ${failure.action}: ${formatError(failure.error)}`);
|
|
638
|
+
}
|
|
639
|
+
process.exitCode = 1;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function formatError(error) {
|
|
643
|
+
return error?.message || String(error);
|
|
644
|
+
}
|
|
645
|
+
|
|
522
646
|
async function confirm(question) {
|
|
523
647
|
const rl = createInterface({ input, output });
|
|
524
648
|
const answer = await rl.question(`${question} [y/N] `);
|
|
@@ -533,7 +657,7 @@ function installNoteForRecord(record) {
|
|
|
533
657
|
|
|
534
658
|
function installNoteForHarness(harnessId) {
|
|
535
659
|
if (harnessId === "cline" || harnessId === "codegpt") return " (manual setup)";
|
|
536
|
-
if (harnessId === "opencode") return " (select
|
|
660
|
+
if (harnessId === "opencode") return " (select a Penny model from /model)";
|
|
537
661
|
return " (auto-selected)";
|
|
538
662
|
}
|
|
539
663
|
|
|
@@ -559,7 +683,7 @@ Examples:
|
|
|
559
683
|
npx pennyrouter install
|
|
560
684
|
npx pennyrouter install --existing-account
|
|
561
685
|
npx pennyrouter install --all
|
|
562
|
-
npx pennyrouter install --harness
|
|
686
|
+
npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
|
|
563
687
|
npx pennyrouter disable
|
|
564
688
|
npx pennyrouter enable
|
|
565
689
|
npx pennyrouter uninstall
|
package/src/harnesses/aider.js
CHANGED
|
@@ -17,7 +17,7 @@ export const aiderHarness = {
|
|
|
17
17
|
summary: [
|
|
18
18
|
`write ${CONFIG_PATH}`,
|
|
19
19
|
"set OpenAI-compatible base URL and API key",
|
|
20
|
-
"set default model to
|
|
20
|
+
"set default model to Penny Custom through LiteLLM naming",
|
|
21
21
|
],
|
|
22
22
|
};
|
|
23
23
|
},
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile } from "../state.js";
|
|
2
2
|
|
|
3
3
|
const CONFIG_PATH = "~/.claude/settings.json";
|
|
4
|
+
const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
|
|
5
|
+
const CLAUDE_CODE_MODELS = {
|
|
6
|
+
speed: "Penny Speed",
|
|
7
|
+
core: "Penny Core",
|
|
8
|
+
power: "Penny Power",
|
|
9
|
+
custom: "Penny Custom",
|
|
10
|
+
};
|
|
4
11
|
|
|
5
12
|
export const claudeCodeHarness = {
|
|
6
13
|
id: "claude-code",
|
|
@@ -17,7 +24,7 @@ export const claudeCodeHarness = {
|
|
|
17
24
|
`write ${CONFIG_PATH}`,
|
|
18
25
|
"set Anthropic-compatible base URL to PennyRouter",
|
|
19
26
|
"store the PennyRouter API key without printing it",
|
|
20
|
-
|
|
27
|
+
"map Claude Code models to Penny model choices",
|
|
21
28
|
],
|
|
22
29
|
};
|
|
23
30
|
},
|
|
@@ -31,12 +38,12 @@ export const claudeCodeHarness = {
|
|
|
31
38
|
// Anthropic API key. Clear any stale key so it doesn't take precedence.
|
|
32
39
|
current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
|
33
40
|
delete current.env.ANTHROPIC_API_KEY;
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
current.env.
|
|
38
|
-
current.env.
|
|
39
|
-
current.env.
|
|
41
|
+
// Claude Code displays these env values directly in its model picker; the
|
|
42
|
+
// gateway decodes the friendly labels into bundle/profile overrides.
|
|
43
|
+
current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.power;
|
|
44
|
+
current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.core;
|
|
45
|
+
current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.speed;
|
|
46
|
+
current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.custom;
|
|
40
47
|
|
|
41
48
|
await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
|
|
42
49
|
|
|
@@ -53,6 +60,7 @@ export const claudeCodeHarness = {
|
|
|
53
60
|
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
54
61
|
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
55
62
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
63
|
+
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
56
64
|
],
|
|
57
65
|
},
|
|
58
66
|
};
|
|
@@ -60,7 +68,7 @@ export const claudeCodeHarness = {
|
|
|
60
68
|
|
|
61
69
|
async uninstall(record) {
|
|
62
70
|
const current = (await readJsonFile(record.config_path, null)) || {};
|
|
63
|
-
if (current.env
|
|
71
|
+
if (hasPennyRouterEnv(current.env)) {
|
|
64
72
|
delete current.env.ANTHROPIC_BASE_URL;
|
|
65
73
|
const tok = current.env.ANTHROPIC_AUTH_TOKEN;
|
|
66
74
|
if (typeof tok === "string" && tok.startsWith("pr-")) {
|
|
@@ -74,11 +82,53 @@ export const claudeCodeHarness = {
|
|
|
74
82
|
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
75
83
|
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
76
84
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
85
|
+
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
77
86
|
]) {
|
|
78
|
-
if (current.env[k] ===
|
|
87
|
+
if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
|
|
79
88
|
}
|
|
80
89
|
}
|
|
90
|
+
if (current["anthropic.baseURL"] && isPennyRouterValue(current["anthropic.baseURL"])) {
|
|
91
|
+
delete current["anthropic.baseURL"];
|
|
92
|
+
}
|
|
93
|
+
if (typeof current["anthropic.apiKey"] === "string" && current["anthropic.apiKey"].startsWith("pr-")) {
|
|
94
|
+
delete current["anthropic.apiKey"];
|
|
95
|
+
}
|
|
81
96
|
if (current.env && Object.keys(current.env).length === 0) delete current.env;
|
|
82
97
|
await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
|
|
83
98
|
},
|
|
84
99
|
};
|
|
100
|
+
|
|
101
|
+
function hasPennyRouterEnv(env) {
|
|
102
|
+
if (!env || typeof env !== "object") return false;
|
|
103
|
+
return (
|
|
104
|
+
isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ||
|
|
105
|
+
isPennyRouterValue(env.ANTHROPIC_AUTH_TOKEN) ||
|
|
106
|
+
isPennyRouterValue(env.ANTHROPIC_API_KEY) ||
|
|
107
|
+
env.ANTHROPIC_DEFAULT_OPUS_MODEL === LEGACY_MODEL_LABEL ||
|
|
108
|
+
env.ANTHROPIC_DEFAULT_SONNET_MODEL === LEGACY_MODEL_LABEL ||
|
|
109
|
+
env.ANTHROPIC_DEFAULT_HAIKU_MODEL === LEGACY_MODEL_LABEL ||
|
|
110
|
+
env.ANTHROPIC_DEFAULT_FABLE_MODEL === LEGACY_MODEL_LABEL ||
|
|
111
|
+
isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_OPUS_MODEL) ||
|
|
112
|
+
isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_SONNET_MODEL) ||
|
|
113
|
+
isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_HAIKU_MODEL) ||
|
|
114
|
+
isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_FABLE_MODEL)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isPennyRouterValue(value) {
|
|
119
|
+
return typeof value === "string" && (value.startsWith("pr-") || value.includes("pennyrouter"));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isPennyRouterBaseUrl(value) {
|
|
123
|
+
return typeof value === "string" && value.includes("pennyrouter");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isPennyRouterModelValue(value) {
|
|
127
|
+
return (
|
|
128
|
+
isPennyRouterValue(value) ||
|
|
129
|
+
value === CLAUDE_CODE_MODELS.speed ||
|
|
130
|
+
value === CLAUDE_CODE_MODELS.core ||
|
|
131
|
+
value === CLAUDE_CODE_MODELS.power ||
|
|
132
|
+
value === CLAUDE_CODE_MODELS.custom
|
|
133
|
+
);
|
|
134
|
+
}
|
package/src/harnesses/cline.js
CHANGED
|
@@ -28,7 +28,7 @@ export const clineHarness = {
|
|
|
28
28
|
console.log(" - API Provider: OpenAI Compatible");
|
|
29
29
|
console.log(` - Base URL: ${gatewayBaseUrl}/v1`);
|
|
30
30
|
console.log(` - API Key: ${copied ? "copied to clipboard" : "copy from your PennyRouter dashboard"}`);
|
|
31
|
-
console.log(" - Model ID: pennyrouter/auto");
|
|
31
|
+
console.log(" - Model ID: pennyrouter/auto (Penny Custom)");
|
|
32
32
|
console.log("");
|
|
33
33
|
console.log("The CLI did not write VS Code settings because Cline stores provider config differently across versions.");
|
|
34
34
|
if (!copied) {
|
|
@@ -46,6 +46,7 @@ export const clineHarness = {
|
|
|
46
46
|
provider: "OpenAI Compatible",
|
|
47
47
|
base_url: `${gatewayBaseUrl}/v1`,
|
|
48
48
|
model: "pennyrouter/auto",
|
|
49
|
+
model_name: "Penny Custom",
|
|
49
50
|
},
|
|
50
51
|
};
|
|
51
52
|
},
|
package/src/harnesses/codegpt.js
CHANGED
|
@@ -27,7 +27,7 @@ export const codeGptHarness = {
|
|
|
27
27
|
console.log(" Open CodeGPT settings in your JetBrains IDE and set:");
|
|
28
28
|
console.log(` - Base URL: ${gatewayBaseUrl}/v1`);
|
|
29
29
|
console.log(` - API Key: ${copied ? "copied to clipboard" : "copy from your PennyRouter dashboard"}`);
|
|
30
|
-
console.log(" - Model: pennyrouter/auto");
|
|
30
|
+
console.log(" - Model: pennyrouter/auto (Penny Custom)");
|
|
31
31
|
console.log("");
|
|
32
32
|
console.log("The CLI did not write JetBrains XML settings because CodeGPT stores options under IDE/version-specific paths.");
|
|
33
33
|
if (!copied) {
|
|
@@ -45,6 +45,7 @@ export const codeGptHarness = {
|
|
|
45
45
|
provider: "OpenAI Compatible",
|
|
46
46
|
base_url: `${gatewayBaseUrl}/v1`,
|
|
47
47
|
model: "pennyrouter/auto",
|
|
48
|
+
model_name: "Penny Custom",
|
|
48
49
|
},
|
|
49
50
|
};
|
|
50
51
|
},
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
readText,
|
|
9
9
|
writeJsonFile,
|
|
10
10
|
} from "../state.js";
|
|
11
|
+
import { PENNYROUTER_MODEL_CHOICES } from "./shared.js";
|
|
11
12
|
|
|
12
13
|
const JSONC_PATH = "~/.config/opencode/opencode.jsonc";
|
|
13
14
|
const JSON_PATH = "~/.config/opencode/opencode.json";
|
|
@@ -28,6 +29,7 @@ export const opencodeHarness = {
|
|
|
28
29
|
summary: [
|
|
29
30
|
`write ${configPath}`,
|
|
30
31
|
"merge PennyRouter provider without replacing existing providers",
|
|
32
|
+
"publish the four Penny model choices",
|
|
31
33
|
`reset ${MODEL_STATE_PATH} so opencode rediscovers configured models`,
|
|
32
34
|
],
|
|
33
35
|
};
|
|
@@ -45,17 +47,9 @@ export const opencodeHarness = {
|
|
|
45
47
|
baseURL: `${gatewayBaseUrl}/v1`,
|
|
46
48
|
apiKey,
|
|
47
49
|
},
|
|
48
|
-
models:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"pennyrouter/openai": { name: "PennyRouter OpenAI" },
|
|
52
|
-
"pennyrouter/gemini": { name: "PennyRouter Gemini" },
|
|
53
|
-
"pennyrouter/grok": { name: "PennyRouter Grok" },
|
|
54
|
-
"pennyrouter/deepseek": { name: "PennyRouter DeepSeek" },
|
|
55
|
-
"pennyrouter/zai": { name: "PennyRouter Z.ai" },
|
|
56
|
-
"pennyrouter/qwen": { name: "PennyRouter Qwen" },
|
|
57
|
-
"pennyrouter/penny": { name: "PennyRouter Penny" },
|
|
58
|
-
},
|
|
50
|
+
models: Object.fromEntries(
|
|
51
|
+
PENNYROUTER_MODEL_CHOICES.map((model) => [model.id, { name: model.name }]),
|
|
52
|
+
),
|
|
59
53
|
};
|
|
60
54
|
|
|
61
55
|
await atomicWrite(configPath, `${JSON.stringify(current, null, 2)}\n`);
|
|
@@ -68,7 +62,7 @@ export const opencodeHarness = {
|
|
|
68
62
|
config_path: compactHome(plan.configPath),
|
|
69
63
|
backup_path: backupPath ? compactHome(backupPath) : null,
|
|
70
64
|
installed_at: new Date().toISOString(),
|
|
71
|
-
restart: "type /exit, run opencode again, then pick
|
|
65
|
+
restart: "type /exit, run opencode again, then pick a Penny model",
|
|
72
66
|
changes: {
|
|
73
67
|
provider_id: "pennyrouter",
|
|
74
68
|
model_state_path: MODEL_STATE_PATH,
|
package/src/harnesses/shared.js
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
|
-
export const
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
export const PENNYROUTER_MODEL_CHOICES = [
|
|
2
|
+
{
|
|
3
|
+
id: "pennyrouter/penny-core:premium",
|
|
4
|
+
name: "Penny Core",
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
id: "pennyrouter/penny-speed:standard",
|
|
8
|
+
name: "Penny Speed",
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: "pennyrouter/penny-power:premium",
|
|
12
|
+
name: "Penny Power",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: "pennyrouter/auto",
|
|
16
|
+
name: "Penny Custom",
|
|
17
|
+
},
|
|
11
18
|
];
|
|
12
19
|
|
|
20
|
+
export const PENNYROUTER_MODELS = PENNYROUTER_MODEL_CHOICES.map((model) => model.id);
|
|
21
|
+
|
|
22
|
+
export const PENNYROUTER_MODEL_NAMES = Object.fromEntries(
|
|
23
|
+
PENNYROUTER_MODEL_CHOICES.map((model) => [model.id, model.name]),
|
|
24
|
+
);
|
|
25
|
+
|
|
13
26
|
export function removeJsonKeys(object, keys) {
|
|
14
27
|
if (!object || typeof object !== "object") return;
|
|
15
28
|
for (const key of keys) delete object[key];
|
package/src/harnesses/cursor.js
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { backupFile, compactHome, exists, expandHome } from "../state.js";
|
|
3
|
-
import { PENNYROUTER_MODELS } from "./shared.js";
|
|
4
|
-
|
|
5
|
-
const CONFIG_PATH = process.platform === "darwin"
|
|
6
|
-
? "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb"
|
|
7
|
-
: process.platform === "win32"
|
|
8
|
-
? "~/AppData/Roaming/Cursor/User/globalStorage/state.vscdb"
|
|
9
|
-
: "~/.config/Cursor/User/globalStorage/state.vscdb";
|
|
10
|
-
|
|
11
|
-
export const cursorHarness = {
|
|
12
|
-
id: "cursor",
|
|
13
|
-
name: "Cursor",
|
|
14
|
-
|
|
15
|
-
async detect() {
|
|
16
|
-
return exists(CONFIG_PATH);
|
|
17
|
-
},
|
|
18
|
-
|
|
19
|
-
async planInstall() {
|
|
20
|
-
return {
|
|
21
|
-
configPath: expandHome(CONFIG_PATH),
|
|
22
|
-
summary: [
|
|
23
|
-
`write ${CONFIG_PATH}`,
|
|
24
|
-
"set Cursor backend OpenAI base URL and API key",
|
|
25
|
-
"set Cursor custom model list to PennyRouter aliases",
|
|
26
|
-
],
|
|
27
|
-
};
|
|
28
|
-
},
|
|
29
|
-
|
|
30
|
-
async install({ apiKey, gatewayBaseUrl, plan }) {
|
|
31
|
-
const backupPath = await backupFile(CONFIG_PATH, "cursor");
|
|
32
|
-
runSqlite(plan.configPath, [
|
|
33
|
-
["backend.openaiBaseUrl", JSON.stringify(`${gatewayBaseUrl}/v1`)],
|
|
34
|
-
["backend.openaiApiKey", JSON.stringify(apiKey)],
|
|
35
|
-
["backend.customModels", JSON.stringify(PENNYROUTER_MODELS)],
|
|
36
|
-
]);
|
|
37
|
-
|
|
38
|
-
return {
|
|
39
|
-
harness: "cursor",
|
|
40
|
-
config_path: compactHome(plan.configPath),
|
|
41
|
-
backup_path: backupPath ? compactHome(backupPath) : null,
|
|
42
|
-
installed_at: new Date().toISOString(),
|
|
43
|
-
restart: "quit and restart Cursor",
|
|
44
|
-
changes: {
|
|
45
|
-
table: "kvptr",
|
|
46
|
-
keys: ["backend.openaiBaseUrl", "backend.openaiApiKey", "backend.customModels"],
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
},
|
|
50
|
-
|
|
51
|
-
async uninstall(record) {
|
|
52
|
-
runSqlite(expandHome(record.config_path), null, [
|
|
53
|
-
"backend.openaiBaseUrl",
|
|
54
|
-
"backend.openaiApiKey",
|
|
55
|
-
"backend.customModels",
|
|
56
|
-
]);
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
function runSqlite(dbPath, upserts = [], deletes = []) {
|
|
61
|
-
const statements = [];
|
|
62
|
-
for (const [key, value] of upserts || []) {
|
|
63
|
-
statements.push(`INSERT OR REPLACE INTO kvptr (key, value) VALUES (${sqlString(key)}, ${sqlString(value)});`);
|
|
64
|
-
}
|
|
65
|
-
for (const key of deletes || []) {
|
|
66
|
-
statements.push(`DELETE FROM kvptr WHERE key = ${sqlString(key)};`);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const result = spawnSync("sqlite3", [dbPath], {
|
|
70
|
-
input: `${statements.join("\n")}\n`,
|
|
71
|
-
encoding: "utf8",
|
|
72
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
if (result.status !== 0) {
|
|
76
|
-
const detail = result.stderr?.trim() || "sqlite3 command failed";
|
|
77
|
-
throw new Error(`Cursor config update failed: ${detail}. Quit Cursor and ensure sqlite3 is installed.`);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function sqlString(value) {
|
|
82
|
-
return `'${String(value).replace(/'/g, "''")}'`;
|
|
83
|
-
}
|