pennyrouter 0.3.31 → 0.3.32

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.31",
3
+ "version": "0.3.32",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -99,6 +99,8 @@ export async function fetchClaudeCodeModels(gatewayBaseUrl, apiKey) {
99
99
  model: id,
100
100
  ...(displayName && displayName !== id ? { label: displayName } : {}),
101
101
  ...(description ? { description } : {}),
102
+ ...(Number.isFinite(entry.max_input_tokens)
103
+ ? { max_input_tokens: entry.max_input_tokens } : {}),
102
104
  });
103
105
  }
104
106
  if (models.length === 0) {
@@ -107,6 +109,7 @@ export async function fetchClaudeCodeModels(gatewayBaseUrl, apiKey) {
107
109
  return {
108
110
  models,
109
111
  privateGateway: response.headers?.get?.("x-pennyrouter-private-gateway") === "1",
112
+ ...(payload?.anthropic_catalog_live === true ? { nativeCatalogLive: true } : {}),
110
113
  };
111
114
  }
112
115
 
@@ -156,13 +159,13 @@ export const claudeCodeHarness = {
156
159
  delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_DESCRIPTION`];
157
160
  }
158
161
  } else {
159
- // Claude Code displays these env values directly in its model picker; the
160
- // gateway decodes the friendly labels into bundle/profile overrides.
161
- current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
162
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
163
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
164
- current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
165
- writeClaudeCodeModelPresentation(current.env);
162
+ // Hosted discovery follows Anthropic's live catalog. Fixed slot overrides in
163
+ // shared settings would keep plain `claude` installs pinned to old releases.
164
+ for (const slot of ["OPUS", "SONNET", "HAIKU", "FABLE"]) {
165
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL`];
166
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_NAME`];
167
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_DESCRIPTION`];
168
+ }
166
169
  }
167
170
  delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
168
171
  delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
@@ -220,11 +223,11 @@ export const claudeCodeHarness = {
220
223
  delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_DESCRIPTION`];
221
224
  }
222
225
  } else {
223
- current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
224
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
225
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
226
- current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
227
- writeClaudeCodeModelPresentation(current.env);
226
+ for (const slot of ["OPUS", "SONNET", "HAIKU", "FABLE"]) {
227
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL`];
228
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_NAME`];
229
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_DESCRIPTION`];
230
+ }
228
231
  }
229
232
  delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
230
233
  delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
@@ -271,11 +274,11 @@ export const claudeCodeHarness = {
271
274
  current.env.ANTHROPIC_API_KEY = pennyKey;
272
275
  current.env.ANTHROPIC_AUTH_TOKEN = anthropicAuthToken;
273
276
  current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
274
- current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
275
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
276
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
277
- current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
278
- writeClaudeCodeModelPresentation(current.env);
277
+ for (const slot of ["OPUS", "SONNET", "HAIKU", "FABLE"]) {
278
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL`];
279
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_NAME`];
280
+ delete current.env[`ANTHROPIC_DEFAULT_${slot}_MODEL_DESCRIPTION`];
281
+ }
279
282
  delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
280
283
  delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
281
284
  delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
package/src/launch.js CHANGED
@@ -23,7 +23,6 @@ import { configureClaudeCodeStatusline } from "./statusline.js";
23
23
  import {
24
24
  fetchClaudeCodeModels,
25
25
  upgradeClaudeCodeModelValue,
26
- writeClaudeCodeModelPresentation,
27
26
  } from "./harnesses/claude-code.js";
28
27
  import {
29
28
  fetchCodexModels,
@@ -856,6 +855,7 @@ export async function writeClaudeLaunchSettings({
856
855
  apiKey,
857
856
  models = [],
858
857
  privateGateway = false,
858
+ slotModels = {},
859
859
  } = {}) {
860
860
  const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
861
861
  // Claude Code gives values from --settings precedence over the parent process
@@ -883,6 +883,22 @@ export async function writeClaudeLaunchSettings({
883
883
  // An empty value is treated as unset by Claude Code, and prevents a
884
884
  // stale API key in the shared settings from creating dual auth.
885
885
  ANTHROPIC_API_KEY: "",
886
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: "",
887
+ ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME: "",
888
+ ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION: "",
889
+ ANTHROPIC_DEFAULT_SONNET_MODEL: "",
890
+ ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: "",
891
+ ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION: "",
892
+ ANTHROPIC_DEFAULT_OPUS_MODEL: "",
893
+ ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: "",
894
+ ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION: "",
895
+ ANTHROPIC_DEFAULT_FABLE_MODEL: "",
896
+ ANTHROPIC_DEFAULT_FABLE_MODEL_NAME: "",
897
+ ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION: "",
898
+ ...Object.fromEntries(
899
+ Object.entries(slotModels).filter(([slot]) => ["haiku", "sonnet", "opus", "fable"].includes(slot))
900
+ .map(([slot, value]) => [`ANTHROPIC_DEFAULT_${slot.toUpperCase()}_MODEL`, value]),
901
+ ),
886
902
  },
887
903
  }
888
904
  : {}),
@@ -892,6 +908,17 @@ export async function writeClaudeLaunchSettings({
892
908
  await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
893
909
  }
894
910
 
911
+ function hostedClaudeSlots(models) {
912
+ const slots = {};
913
+ for (const entry of models) {
914
+ const match = /^claude-(haiku|sonnet|opus|fable)-/.exec(entry.model || "");
915
+ if (!match || slots[match[1]]) continue;
916
+ const oneMillion = entry.max_input_tokens >= 1000000;
917
+ slots[match[1]] = `${entry.model}${oneMillion ? "[1m]" : ""}`;
918
+ }
919
+ return slots;
920
+ }
921
+
895
922
  function claudeCodeModelCachePath(profile) {
896
923
  return join(pennyDataDir(), `claude-models-${profile || DEFAULT_PROFILE}.json`);
897
924
  }
@@ -915,6 +942,7 @@ export async function resolveClaudeCodeLaunchModels(config) {
915
942
  // Old cache files predate the explicit server marker. Treating them as hosted
916
943
  // is the safe rollout behavior: never hide built-ins based on a guess.
917
944
  privateGateway: cached.privateGateway === true,
945
+ nativeCatalogLive: cached.nativeCatalogLive === true,
918
946
  };
919
947
  }
920
948
  console.error(
@@ -1326,21 +1354,23 @@ export function buildPennyInvocation(harness, args, config, models = null) {
1326
1354
  const env = { ...process.env, PENNYROUTER_API_KEY: config.api_key };
1327
1355
  if (harness === "claude") {
1328
1356
  env.PENNYROUTER_TITLE_SESSION_FILE = join(pennyDataDir(), `title-session-${randomUUID()}`);
1329
- const models = item.models || {};
1357
+ const claudeModels = models ?? item.models ?? {};
1330
1358
  env.ANTHROPIC_BASE_URL = config.gateway_base_url;
1331
1359
  env.ANTHROPIC_AUTH_TOKEN = config.api_key;
1332
1360
  // Ask Claude Code to discover the gateway's model catalog at startup. The gateway
1333
1361
  // advertises Penny-facing ids in /v1/models; this is separate from inference routing.
1334
1362
  env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
1335
1363
  delete env.ANTHROPIC_API_KEY;
1336
- if (models.haiku) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = upgradeClaudeCodeModelValue(models.haiku);
1337
- if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = upgradeClaudeCodeModelValue(models.sonnet);
1338
- if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = upgradeClaudeCodeModelValue(models.opus);
1339
- if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = upgradeClaudeCodeModelValue(models.fable);
1364
+ for (const slot of ["haiku", "sonnet", "opus", "fable"]) {
1365
+ const name = `ANTHROPIC_DEFAULT_${slot.toUpperCase()}_MODEL`;
1366
+ if (claudeModels[slot]) env[name] = upgradeClaudeCodeModelValue(claudeModels[slot]);
1367
+ else delete env[name];
1368
+ delete env[`${name}_NAME`];
1369
+ delete env[`${name}_DESCRIPTION`];
1370
+ }
1340
1371
  delete env.ANTHROPIC_CUSTOM_MODEL_OPTION;
1341
1372
  delete env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
1342
1373
  delete env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
1343
- writeClaudeCodeModelPresentation(env);
1344
1374
  // Per-model limits must come from Claude Code's recognized native model ids. This
1345
1375
  // global override would incorrectly give Haiku the 1M window.
1346
1376
  delete env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
@@ -1472,11 +1502,14 @@ export async function runPenny(argv = process.argv.slice(2)) {
1472
1502
  // Refresh on every launch so profile selection is honored immediately,
1473
1503
  // including --private, --cloud, and named profiles.
1474
1504
  const catalog = await resolveClaudeCodeLaunchModels(config);
1505
+ models = catalog.privateGateway || !catalog.nativeCatalogLive
1506
+ ? {} : hostedClaudeSlots(catalog.models);
1475
1507
  await writeClaudeLaunchSettings({
1476
1508
  gatewayBaseUrl: config.gateway_base_url,
1477
1509
  apiKey: config.api_key,
1478
1510
  models: catalog.models,
1479
1511
  privateGateway: catalog.privateGateway,
1512
+ slotModels: models,
1480
1513
  });
1481
1514
  }
1482
1515
  if (harness === "opencode") models = await resolveOpenCodeLaunchModels(config);
@@ -136,11 +136,11 @@ async function main() {
136
136
  const claudeLaunchSettings = JSON.parse(
137
137
  fs.readFileSync(claudeLaunchSettingsPath(), "utf8"),
138
138
  );
139
- assert.deepEqual(claudeLaunchSettings.env, {
140
- ANTHROPIC_BASE_URL: "https://gateway.example/",
141
- ANTHROPIC_AUTH_TOKEN: "pr-super-secret",
142
- ANTHROPIC_API_KEY: "",
143
- });
139
+ assert.equal(claudeLaunchSettings.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
140
+ assert.equal(claudeLaunchSettings.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
141
+ assert.equal(claudeLaunchSettings.env.ANTHROPIC_API_KEY, "");
142
+ assert.equal(claudeLaunchSettings.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "");
143
+ assert.equal(claudeLaunchSettings.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME, "");
144
144
  await writeClaudeLaunchSettings({
145
145
  gatewayBaseUrl: "https://gateway.example/",
146
146
  apiKey: "pr-super-secret",
@@ -153,6 +153,15 @@ async function main() {
153
153
  fs.readFileSync(claudeLaunchSettingsPath(), "utf8"),
154
154
  );
155
155
  assert.equal(hostedClaudeSettings.modelPicker, undefined);
156
+ await writeClaudeLaunchSettings({
157
+ gatewayBaseUrl: "https://gateway.example/",
158
+ apiKey: "pr-super-secret",
159
+ slotModels: { opus: "claude-opus-5-5[1m]" },
160
+ });
161
+ const refreshedClaudeSettings = JSON.parse(
162
+ fs.readFileSync(claudeLaunchSettingsPath(), "utf8"),
163
+ );
164
+ assert.equal(refreshedClaudeSettings.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "claude-opus-5-5[1m]");
156
165
  await writeClaudeLaunchSettings({
157
166
  gatewayBaseUrl: "https://gateway.example/",
158
167
  apiKey: "pr-super-secret",
@@ -260,9 +269,13 @@ async function main() {
260
269
  // would wrongly hand Haiku a 1M window too.
261
270
  assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "claude-opus-5[1m]");
262
271
  assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
263
- assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME, "Haiku 4.5");
264
- assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION, "Fast, economical Claude model · 200K context");
272
+ assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME, undefined);
273
+ assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION, undefined);
265
274
  assert.equal(claude.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, undefined);
275
+ const refreshedClaude = buildPennyInvocation("claude", [], config, {
276
+ haiku: "claude-haiku-4-5-20251001", opus: "claude-opus-5-5[1m]",
277
+ });
278
+ assert.equal(refreshedClaude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "claude-opus-5-5[1m]");
266
279
 
267
280
  const proxyStartup = buildPennyInvocation("claude", ["proxy"], config);
268
281
  assert.deepEqual(proxyStartup.args.slice(-1), ["@penny auth proxy"]);
@@ -289,16 +302,20 @@ async function main() {
289
302
  assert.ok(command.includes("@penny $ARGUMENTS"));
290
303
  assert.ok(command.includes("name: penny"));
291
304
 
292
- // Persistent direct-Claude installs use the same native ids and must not
293
- // leave the former global 1M override behind.
305
+ // A hosted reinstall clears old fixed slots so Claude Code can use its live
306
+ // gateway catalog and its own current built-in models.
307
+ fs.writeFileSync(path.join(home, ".claude", "settings.json"), JSON.stringify({
308
+ env: { ANTHROPIC_DEFAULT_OPUS_MODEL: "claude-opus-5[1m]",
309
+ ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: "Opus 5" },
310
+ }));
294
311
  await claudeCodeHarness.install({
295
312
  apiKey: "pr-super-secret",
296
313
  gatewayBaseUrl: "https://gateway.example/",
297
314
  plan: { configPath: path.join(home, ".claude", "settings.json") },
298
315
  });
299
316
  const persistedClaude = JSON.parse(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"));
300
- assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
301
- assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "claude-sonnet-5[1m]");
317
+ assert.equal("ANTHROPIC_DEFAULT_OPUS_MODEL" in persistedClaude.env, false);
318
+ assert.equal("ANTHROPIC_DEFAULT_OPUS_MODEL_NAME" in persistedClaude.env, false);
302
319
  assert.equal("CLAUDE_CODE_MAX_CONTEXT_TOKENS" in persistedClaude.env, false);
303
320
 
304
321
  // Private gateways own the model catalog. Their picker must not merge the