pennyrouter 0.3.8 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.8",
3
+ "version": "0.3.11",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
package/src/cli.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  import { clineHarness } from "./harnesses/cline.js";
21
21
  import { codeGptHarness } from "./harnesses/codegpt.js";
22
22
  import { codexHarness, detectCodexChatgptAuth } from "./harnesses/codex.js";
23
- import { opencodeHarness } from "./harnesses/opencode.js";
23
+ import { fetchOpenCodeModels, opencodeHarness } from "./harnesses/opencode.js";
24
24
  import { windsurfHarness } from "./harnesses/windsurf.js";
25
25
  import { zedHarness } from "./harnesses/zed.js";
26
26
  import {
@@ -642,7 +642,16 @@ export async function install(flags = {}) {
642
642
  // key against a store this install never talks to. Ask for the gateway, mint there, and
643
643
  // skip the claim entirely.
644
644
  gatewayBaseUrl = await resolvePrivateGatewayUrl(flags);
645
- claim = { api_key: await obtainPrivateGatewayKey(flags, gatewayBaseUrl), gateway_base_url: gatewayBaseUrl };
645
+ const launchConfig = await loadPennyConfig();
646
+ const globalAuth = await readClaudeCodeGatewayAuth();
647
+ const storedGateway = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
648
+ const storedKey = storedGateway && sameGatewayBaseUrl(gatewayBaseUrl, storedGateway)
649
+ ? (launchConfig.api_key || globalAuth.apiKey)
650
+ : null;
651
+ claim = {
652
+ api_key: await obtainPrivateGatewayKey(flags, gatewayBaseUrl, storedKey),
653
+ gateway_base_url: gatewayBaseUrl,
654
+ };
646
655
 
647
656
  const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
648
657
  const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
@@ -661,48 +670,86 @@ export async function install(flags = {}) {
661
670
  }));
662
671
  }
663
672
  } else {
664
- const session = await createCliSession({
665
- appUrl: flags.appUrl || APP_URL,
666
- harnesses: selected.map((harness) => harness.id),
667
- accountMode: "existing",
668
- });
673
+ // Re-running the ordinary installer should refresh local harness configuration, not mint
674
+ // another machine key. Reuse a key already saved for this same gateway; an explicit gateway
675
+ // URL still wins, and a URL change deliberately falls through to fresh authorization.
676
+ const launchConfig = await loadPennyConfig();
677
+ const globalAuth = await readClaudeCodeGatewayAuth();
678
+ const existingApiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
679
+ const existingGateway = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl
680
+ || DEFAULT_GATEWAY_BASE_URL;
681
+ const requestedGateway = flags.gatewayBaseUrl || existingGateway;
682
+ const reusableKey = existingApiKey
683
+ && sameGatewayBaseUrl(requestedGateway, existingGateway)
684
+ ? existingApiKey
685
+ : null;
686
+
687
+ if (reusableKey) {
688
+ gatewayBaseUrl = existingGateway;
689
+ claim = { api_key: reusableKey, gateway_base_url: gatewayBaseUrl };
690
+ console.log(`Using existing PennyRouter key for ${gatewayBaseUrl}.`);
691
+ console.log("");
669
692
 
670
- console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
671
- console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
672
- if (!flags.noBrowser) openBrowser(session.authorize_url);
673
- console.log("");
674
- console.log("Waiting for approval...");
693
+ const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
694
+ const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
695
+ claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
696
+ flags, needsAuth, claudeProviders.has("subscription"));
697
+ funding = await configureUserFunding(
698
+ flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
675
699
 
676
- claim = await pollForKey(session);
677
- console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
678
- console.log("");
700
+ for (const credential of funding.credentials) {
701
+ await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
702
+ gatewayBaseUrl,
703
+ apiKey: claim.api_key,
704
+ provider: credential.provider,
705
+ token: credential.token,
706
+ metadata: credential.metadata,
707
+ }));
708
+ }
709
+ } else {
710
+ const session = await createCliSession({
711
+ appUrl: flags.appUrl || APP_URL,
712
+ harnesses: selected.map((harness) => harness.id),
713
+ accountMode: "existing",
714
+ });
679
715
 
680
- const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
681
- const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
682
- claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
683
- flags, needsAuth, claudeProviders.has("subscription"));
684
- funding = await configureUserFunding(
685
- flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
686
- gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
687
- if (flags.gatewayBaseUrl) {
688
- const normalized = normalizeGatewayBaseUrl(flags.gatewayBaseUrl);
689
- if (!normalized) {
690
- throw new Error(
691
- `--gateway-base-url "${flags.gatewayBaseUrl}" is not a usable URL. Use a host like `
692
- + "localhost:8400 or https://gateway.example.com.",
693
- );
716
+ console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
717
+ console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
718
+ if (!flags.noBrowser) openBrowser(session.authorize_url);
719
+ console.log("");
720
+ console.log("Waiting for approval...");
721
+
722
+ claim = await pollForKey(session);
723
+ console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
724
+ console.log("");
725
+
726
+ const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
727
+ const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
728
+ claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
729
+ flags, needsAuth, claudeProviders.has("subscription"));
730
+ funding = await configureUserFunding(
731
+ flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
732
+ gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
733
+ if (flags.gatewayBaseUrl) {
734
+ const normalized = normalizeGatewayBaseUrl(flags.gatewayBaseUrl);
735
+ if (!normalized) {
736
+ throw new Error(
737
+ `--gateway-base-url "${flags.gatewayBaseUrl}" is not a usable URL. Use a host like `
738
+ + "localhost:8000 or https://gateway.example.com.",
739
+ );
740
+ }
741
+ gatewayBaseUrl = normalized;
694
742
  }
695
- gatewayBaseUrl = normalized;
696
- }
697
743
 
698
- for (const credential of funding.credentials) {
699
- await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
700
- gatewayBaseUrl,
701
- apiKey: claim.api_key,
702
- provider: credential.provider,
703
- token: credential.token,
704
- metadata: credential.metadata,
705
- }));
744
+ for (const credential of funding.credentials) {
745
+ await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
746
+ gatewayBaseUrl,
747
+ apiKey: claim.api_key,
748
+ provider: credential.provider,
749
+ token: credential.token,
750
+ metadata: credential.metadata,
751
+ }));
752
+ }
706
753
  }
707
754
  }
708
755
 
@@ -756,12 +803,15 @@ export async function install(flags = {}) {
756
803
 
757
804
  let record;
758
805
  if (!supportsPennyCommand || makeDefault) {
806
+ const opencodeModels = harness.id === "opencode" && flags.privateGatewayFlow
807
+ ? await fetchOpenCodeModels(gatewayBaseUrl, claim.api_key, { requireFresh: true })
808
+ : null;
759
809
  record = harness.id === "claude-code" && claudeSubscriptionAuth
760
810
  ? await harness.installStoredToken({
761
811
  apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
762
812
  })
763
813
  : await harness.install({
764
- apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
814
+ apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord, models: opencodeModels,
765
815
  });
766
816
  } else {
767
817
  if (hadGlobalRouting) {
@@ -923,7 +973,7 @@ async function resolvePrivateGatewayUrl(flags) {
923
973
  /* The key for a private gateway: an admin-issued one if the user has it, otherwise minted
924
974
  from the gateway itself against their email. --private-key wins so an administrator can
925
975
  still hand out keys on a gateway that also allows self-service. */
926
- async function obtainPrivateGatewayKey(flags, gatewayBaseUrl) {
976
+ async function obtainPrivateGatewayKey(flags, gatewayBaseUrl, existingApiKey = null) {
927
977
  const issued = (flags.privateKey || "").trim();
928
978
  if (issued) {
929
979
  if (!issued.startsWith("pr-")) {
@@ -934,6 +984,12 @@ async function obtainPrivateGatewayKey(flags, gatewayBaseUrl) {
934
984
  return issued;
935
985
  }
936
986
 
987
+ if (existingApiKey) {
988
+ console.log(`Using existing PennyRouter key for ${gatewayBaseUrl}.`);
989
+ console.log("");
990
+ return existingApiKey;
991
+ }
992
+
937
993
  let email = (flags.email || "").trim();
938
994
  if (!email) {
939
995
  // A shared --config file names the gateway but not who is installing, so the email is
@@ -982,6 +1038,12 @@ function normalizeGatewayBaseUrl(raw) {
982
1038
  return withScheme.replace(/\/+$/, "");
983
1039
  }
984
1040
 
1041
+ function sameGatewayBaseUrl(left, right) {
1042
+ const normalizedLeft = normalizeGatewayBaseUrl(left);
1043
+ const normalizedRight = normalizeGatewayBaseUrl(right);
1044
+ return Boolean(normalizedLeft && normalizedRight && normalizedLeft === normalizedRight);
1045
+ }
1046
+
985
1047
  /* A gateway that is unreachable (wrong host, wrong port, not running) surfaces from fetch() as
986
1048
  an opaque "fetch failed" with no status. Name the gateway we could not reach so the user knows
987
1049
  which URL to fix. Real HTTP errors carry their own message and pass through untouched. */
@@ -8,14 +8,17 @@ import {
8
8
  readText,
9
9
  writeJsonFile,
10
10
  } from "../state.js";
11
+ import { unlink } from "node:fs/promises";
11
12
  const JSONC_PATH = "~/.config/opencode/opencode.jsonc";
12
13
  const JSON_PATH = "~/.config/opencode/opencode.json";
13
14
  const MODEL_STATE_PATH = "~/.local/state/opencode/model.json";
15
+ const MODEL_CACHE_PATH = "~/.cache/opencode/models.json";
14
16
  export const OPENCODE_MODEL_CHOICES = [
15
- { id: "pennyrouter/penny-core:premium", name: "Penny Core" },
16
- { id: "pennyrouter/penny-speed:standard", name: "Penny Speed" },
17
- { id: "pennyrouter/penny-power:premium", name: "Penny Power" },
18
17
  { id: "pennyrouter/auto", name: "PennyRouter Auto" },
18
+ { id: "claude-haiku-4-5-20251001", name: "Haiku 4.5" },
19
+ { id: "claude-sonnet-5", name: "Sonnet 5" },
20
+ { id: "claude-opus-5", name: "Opus 5" },
21
+ { id: "claude-fable-5", name: "Fable 5" },
19
22
  { id: "claude-penny-deepseek-v4-flash", name: "DeepSeek V4 Flash" },
20
23
  { id: "claude-penny-glm-5-3", name: "GLM 5.3" },
21
24
  { id: "claude-penny-gpt-5-6-luna", name: "GPT-5.6 Luna" },
@@ -25,6 +28,16 @@ export const OPENCODE_MODEL_CHOICES = [
25
28
  { id: "claude-penny-minimax-m3", name: "MiniMax M3" },
26
29
  { id: "claude-penny-nemotron-ultra-free", name: "Nemotron 3 Ultra (free)" },
27
30
  { id: "claude-penny-solar-pro4", name: "Solar Pro 4" },
31
+ { id: "gpt-5.6-sol", name: "GPT 5.6 Sol (PennyRouter)" },
32
+ { id: "gpt-5.6-terra", name: "GPT 5.6 Terra (PennyRouter)" },
33
+ { id: "gpt-5.6-luna", name: "GPT 5.6 Luna (PennyRouter)" },
34
+ { id: "gpt-5.5", name: "GPT 5.5 (PennyRouter)" },
35
+ { id: "gpt-5.4", name: "GPT 5.4 (PennyRouter)" },
36
+ { id: "gpt-5.4-mini", name: "GPT 5.4 Mini (PennyRouter)" },
37
+ { id: "gpt-penny-auto", name: "PennyRouter Auto (GPT)" },
38
+ { id: "gpt-penny-deepseek", name: "DeepSeek V4 Flash (PennyRouter)" },
39
+ { id: "gpt-penny-glm", name: "GLM 5.3 (PennyRouter)" },
40
+ { id: "gpt-penny-grok", name: "Grok 4.6 (PennyRouter)" },
28
41
  ];
29
42
 
30
43
  export const opencodeHarness = {
@@ -42,8 +55,8 @@ export const opencodeHarness = {
42
55
  summary: [
43
56
  `write ${configPath}`,
44
57
  "merge PennyRouter provider without replacing existing providers",
45
- "publish the curated Penny bundles and individual model pins",
46
- `reset ${MODEL_STATE_PATH} so opencode rediscovers configured models`,
58
+ "publish PennyRouter Auto and the available Claude/GPT model pins",
59
+ `reset ${MODEL_STATE_PATH} and ${MODEL_CACHE_PATH} so opencode rediscovers configured models`,
47
60
  ],
48
61
  };
49
62
  },
@@ -90,6 +103,10 @@ export const opencodeHarness = {
90
103
  }
91
104
  }
92
105
  await writeJsonFile(MODEL_STATE_PATH, model);
106
+ // OpenCode keeps a separate generated registry cache. Clearing it is necessary when a
107
+ // provider's model map changes; otherwise the picker can continue showing retired models
108
+ // even though opencode.json has been rewritten correctly.
109
+ await removeGeneratedModelCache();
93
110
 
94
111
  return {
95
112
  harness: "opencode",
@@ -129,6 +146,14 @@ export const opencodeHarness = {
129
146
  },
130
147
  };
131
148
 
149
+ async function removeGeneratedModelCache() {
150
+ try {
151
+ await unlink(expandHome(MODEL_CACHE_PATH));
152
+ } catch (error) {
153
+ if (error.code !== "ENOENT") throw error;
154
+ }
155
+ }
156
+
132
157
  async function readConfig(path) {
133
158
  const text = await readText(path, "");
134
159
  if (!text.trim()) return {};
@@ -183,27 +208,30 @@ export function opencodeModelId(modelId) {
183
208
  return modelId;
184
209
  }
185
210
 
186
- export async function fetchOpenCodeModels(gatewayBaseUrl) {
211
+ export async function fetchOpenCodeModels(gatewayBaseUrl, apiKey, { requireFresh = false } = {}) {
187
212
  try {
188
- const response = await fetch(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1/models`);
213
+ const headers = apiKey
214
+ ? { Authorization: `Bearer ${apiKey}`, "x-api-key": apiKey }
215
+ : undefined;
216
+ const response = await fetch(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1/models`, { headers });
189
217
  if (!response.ok) throw new Error(`model catalog returned ${response.status}`);
190
218
  const payload = await response.json();
191
- const catalog = new Map(
192
- Array.isArray(payload?.data)
193
- ? payload.data
194
- .filter((model) => typeof model?.id === "string")
195
- .map((model) => [model.id, model])
196
- : [],
197
- );
198
- const models = OPENCODE_MODEL_CHOICES.map((choice) => {
199
- const model = catalog.get(choice.id);
200
- return {
201
- ...choice,
202
- ...(model?.description ? { description: model.description } : {}),
203
- };
204
- });
219
+ const models = Array.isArray(payload?.data)
220
+ ? payload.data
221
+ .filter((model) => typeof model?.id === "string")
222
+ // Bundles are a legacy OpenCode surface. Auto is the one intentional exception.
223
+ .filter((model) => !model.id.startsWith("pennyrouter/") || model.id === "pennyrouter/auto")
224
+ .map((model) => ({
225
+ id: model.id,
226
+ name: model.display_name || model.name || openCodeModelName(model.id),
227
+ ...(model.description ? { description: model.description } : {}),
228
+ }))
229
+ : [];
205
230
  if (models.length > 0) return models;
206
- } catch {}
231
+ } catch (error) {
232
+ if (requireFresh) throw new Error(`Could not fetch the private gateway model catalog: ${error.message}`);
233
+ }
234
+ if (requireFresh) throw new Error("The private gateway returned no usable OpenCode models.");
207
235
  return OPENCODE_MODEL_CHOICES;
208
236
  }
209
237
 
@@ -3,13 +3,19 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
- import { opencodeHarness, opencodeModelId, OPENCODE_MODEL_CHOICES } from "./opencode.js";
6
+ import {
7
+ fetchOpenCodeModels,
8
+ opencodeHarness,
9
+ opencodeModelId,
10
+ OPENCODE_MODEL_CHOICES,
11
+ } from "./opencode.js";
7
12
 
8
13
  const ENV_KEYS = ["HOME", "XDG_STATE_HOME"];
9
14
 
10
15
  async function main() {
11
16
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "pr-opencode-test-"));
12
17
  const previous = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
18
+ const previousFetch = globalThis.fetch;
13
19
  const home = path.join(root, "home");
14
20
  const configPath = path.join(home, ".config", "opencode", "opencode.jsonc");
15
21
  const modelStatePath = path.join(home, ".local", "state", "opencode", "model.json");
@@ -35,6 +41,33 @@ async function main() {
35
41
  try {
36
42
  assert.equal(opencodeModelId("pennyrouter/auto"), "pennyrouter/auto");
37
43
  assert.equal(opencodeModelId("custom/model-a"), "custom/model-a");
44
+
45
+ let request;
46
+ globalThis.fetch = async (url, options) => {
47
+ request = { url, options };
48
+ return {
49
+ ok: true,
50
+ async json() {
51
+ return { data: [
52
+ { id: "pennyrouter/auto", display_name: "Auto from private gateway" },
53
+ { id: "pennyrouter/penny-core:premium", display_name: "Legacy bundle" },
54
+ { id: "claude-sonnet-5", display_name: "Private Sonnet", description: "Fresh" },
55
+ ] };
56
+ },
57
+ };
58
+ };
59
+ assert.deepEqual(await fetchOpenCodeModels(
60
+ "https://private.example/", "pr-test", { requireFresh: true },
61
+ ), [
62
+ { id: "pennyrouter/auto", name: "Auto from private gateway" },
63
+ { id: "claude-sonnet-5", name: "Private Sonnet", description: "Fresh" },
64
+ ]);
65
+ assert.equal(request.url, "https://private.example/v1/models");
66
+ assert.deepEqual(request.options.headers, {
67
+ Authorization: "Bearer pr-test",
68
+ "x-api-key": "pr-test",
69
+ });
70
+
38
71
  const plan = await opencodeHarness.planInstall();
39
72
  const record = await opencodeHarness.install({
40
73
  apiKey: "pr-test",
@@ -63,6 +96,7 @@ async function main() {
63
96
  if (previous[key] === undefined) delete process.env[key];
64
97
  else process.env[key] = previous[key];
65
98
  }
99
+ globalThis.fetch = previousFetch;
66
100
  fs.rmSync(root, { recursive: true, force: true });
67
101
  }
68
102