pennyrouter 0.1.5 → 0.1.7

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 CHANGED
@@ -11,6 +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 auth anthropic
14
15
  npx pennyrouter install --harness claude-code,cline,opencode,codex,windsurf,zed,codegpt,aider
15
16
  npx pennyrouter disable
16
17
  npx pennyrouter enable
@@ -34,12 +35,33 @@ detected configs start checked; press Enter to accept or type tool IDs to toggle
34
35
 
35
36
  ## Penny Models
36
37
 
37
- Tools that expose a model picker get four curated choices:
38
+ Tools that expose a model picker (e.g. Claude Code `/model`) get four curated choices. The three
39
+ named-model choices PIN the trunk to that exact model — you pick the model, PennyRouter still cuts
40
+ the bill via its side machinery (cache/compaction/swallowed background calls) without switching the
41
+ model you chose. "Penny Custom" is the dynamic option: PennyRouter cost-routes each turn.
38
42
 
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
+ - Penny Opus: pins `anthropic/claude-opus-4-8`
44
+ - Penny Sonnet: pins `anthropic/claude-sonnet-4-6`
45
+ - Penny Haiku: pins `anthropic/claude-haiku-4-5-20251001`
46
+ - Penny Custom: `pennyrouter/auto` dynamic routing on the bundle/profile saved in the user's account
47
+
48
+ ## Claude Pro/Max Subscription Auth
49
+
50
+ After installing PennyRouter for Claude Code, users with an Anthropic OAuth login can route
51
+ Anthropic-native Claude turns through their own Claude subscription token:
52
+
53
+ ```bash
54
+ ant auth login
55
+ npx pennyrouter auth anthropic
56
+ ```
57
+
58
+ The command updates Claude Code so `x-api-key` carries the PennyRouter key and
59
+ `Authorization: Bearer ...` carries the Anthropic OAuth token. The gateway forwards that
60
+ bearer token to Anthropic with the required OAuth beta header and does not deduct
61
+ PennyRouter credit for those Anthropic-native upstream calls.
62
+
63
+ `ant auth print-credentials` refreshes the access token before the CLI stores it. If the
64
+ stored token expires during a long-running session, rerun `npx pennyrouter auth anthropic`.
43
65
 
44
66
  ## Agent Flow
45
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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
@@ -3,7 +3,7 @@ import { emitKeypressEvents } from "node:readline";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { stdin as input, stdout as output } from "node:process";
5
5
  import { aiderHarness } from "./harnesses/aider.js";
6
- import { claudeCodeHarness } from "./harnesses/claude-code.js";
6
+ import { claudeCodeHarness, readClaudeCodePennyRouterKey } 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";
@@ -52,6 +52,11 @@ export async function main(argv = process.argv.slice(2)) {
52
52
  return;
53
53
  }
54
54
 
55
+ if (command === "auth") {
56
+ await authProvider(flags);
57
+ return;
58
+ }
59
+
55
60
  if (command === "uninstall") {
56
61
  await uninstall(flags);
57
62
  return;
@@ -79,6 +84,7 @@ function parseArgs(argv) {
79
84
  const command = argv[0] && !argv[0].startsWith("-") ? argv[0] : "install";
80
85
  const rest = command === argv[0] ? argv.slice(1) : argv;
81
86
  const flags = {};
87
+ flags.args = [];
82
88
 
83
89
  for (let i = 0; i < rest.length; i += 1) {
84
90
  const arg = rest[i];
@@ -88,16 +94,86 @@ function parseArgs(argv) {
88
94
  else if (arg === "--dry-run") flags.dryRun = true;
89
95
  else if (arg === "--no-browser") flags.noBrowser = true;
90
96
  else if (arg === "--existing-account") flags.existingAccount = true;
97
+ else if (arg === "--penny-key") flags.pennyKey = rest[++i] || "";
98
+ else if (arg.startsWith("--penny-key=")) flags.pennyKey = arg.slice("--penny-key=".length);
99
+ else if (arg === "--token") flags.token = rest[++i] || "";
100
+ else if (arg.startsWith("--token=")) flags.token = arg.slice("--token=".length);
91
101
  else if (arg === "--harness") flags.harness = rest[++i] || "";
92
102
  else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
93
103
  else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
94
104
  else if (arg.startsWith("--app-url=")) flags.appUrl = arg.slice("--app-url=".length);
105
+ else if (arg === "--gateway-base-url") flags.gatewayBaseUrl = rest[++i] || "";
106
+ else if (arg.startsWith("--gateway-base-url=")) flags.gatewayBaseUrl = arg.slice("--gateway-base-url=".length);
107
+ // --local: point the installed harness at a locally-running gateway (default
108
+ // http://localhost:8400), for developing/testing against your own gateway. Auth still goes
109
+ // to prod (pennyrouter.com) so you get a real key; only the harness's serving URL is local.
110
+ // `--local=<url>` overrides the port/host.
111
+ else if (arg === "--local") flags.gatewayBaseUrl = "http://localhost:8400";
112
+ else if (arg.startsWith("--local=")) flags.gatewayBaseUrl = arg.slice("--local=".length);
113
+ else if (!arg.startsWith("-")) flags.args.push(arg);
95
114
  else throw new Error(`Unknown option "${arg}". Run "pennyrouter help".`);
96
115
  }
97
116
 
98
117
  return { command, flags };
99
118
  }
100
119
 
120
+ async function authProvider(flags) {
121
+ const provider = (flags.args[0] || "anthropic").toLowerCase();
122
+ if (provider !== "anthropic" && provider !== "claude") {
123
+ throw new Error(`Unknown auth provider "${provider}". Try "pennyrouter auth anthropic".`);
124
+ }
125
+
126
+ if (flags.dryRun) {
127
+ console.log("Will configure Claude Code for PennyRouter + Anthropic OAuth dual auth.");
128
+ console.log("Dry run only. No files changed.");
129
+ return;
130
+ }
131
+
132
+ const pennyKey = flags.pennyKey || await readClaudeCodePennyRouterKey();
133
+ if (!pennyKey) {
134
+ throw new Error(
135
+ "No PennyRouter key found in Claude Code settings. Run `pennyrouter install --harness claude-code` first, or pass `--penny-key pr-...`.",
136
+ );
137
+ }
138
+
139
+ const token = flags.token || readAnthropicOAuthToken();
140
+ if (!token) {
141
+ throw new Error(
142
+ "No Anthropic OAuth token found. Run `ant auth login`, then retry `pennyrouter auth anthropic`.",
143
+ );
144
+ }
145
+
146
+ const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
147
+ const record = await claudeCodeHarness.installAnthropicOAuth({
148
+ pennyKey,
149
+ anthropicAuthToken: token,
150
+ gatewayBaseUrl,
151
+ });
152
+
153
+ const manifest = await loadManifest();
154
+ upsertManifestRecord(manifest, {
155
+ ...record,
156
+ anthropic_oauth: true,
157
+ token_source: flags.token ? "flag" : "ant",
158
+ });
159
+ await saveManifest(manifest);
160
+
161
+ console.log("Configured Claude Code for PennyRouter + Anthropic OAuth.");
162
+ console.log("Anthropic-native Claude turns will use your Claude subscription token upstream.");
163
+ console.log("If Claude auth expires, run `pennyrouter auth anthropic` again.");
164
+ console.log(`Config: ${record.config_path}`);
165
+ }
166
+
167
+ function readAnthropicOAuthToken() {
168
+ if (process.env.ANTHROPIC_AUTH_TOKEN) return process.env.ANTHROPIC_AUTH_TOKEN.trim();
169
+ const result = spawnSync("ant", ["auth", "print-credentials", "--access-token"], {
170
+ encoding: "utf8",
171
+ stdio: ["ignore", "pipe", "pipe"],
172
+ });
173
+ if (result.status !== 0) return "";
174
+ return String(result.stdout || "").trim();
175
+ }
176
+
101
177
  async function install(flags) {
102
178
  const selected = await selectHarnesses(flags);
103
179
  if (selected.length === 0) {
@@ -112,6 +188,7 @@ async function install(flags) {
112
188
  for (const harness of selected) {
113
189
  console.log(`Will install on ${harness.name}${installNoteForHarness(harness.id)}.`);
114
190
  }
191
+ if (flags.gatewayBaseUrl) console.log(`Serving gateway: ${flags.gatewayBaseUrl}`);
115
192
  console.log("Dry run only. No files changed.");
116
193
  return;
117
194
  }
@@ -144,7 +221,9 @@ async function install(flags) {
144
221
  const plan = await harness.planInstall();
145
222
  const record = await harness.install({
146
223
  apiKey: claim.api_key,
147
- gatewayBaseUrl: claim.gateway_base_url,
224
+ // An explicit --local / --gateway-base-url overrides the URL the server hands back
225
+ // (which points at prod) — so a --local install serves through the local gateway.
226
+ gatewayBaseUrl: flags.gatewayBaseUrl || claim.gateway_base_url,
148
227
  plan,
149
228
  });
150
229
  records.push(record);
@@ -673,7 +752,8 @@ function printHelp() {
673
752
  console.log(`PennyRouter CLI
674
753
 
675
754
  Usage:
676
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account]
755
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account] [--local[=URL]]
756
+ pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--gateway-base-url URL]
677
757
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
678
758
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
679
759
  pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
@@ -682,6 +762,7 @@ Usage:
682
762
  Examples:
683
763
  npx pennyrouter install
684
764
  npx pennyrouter install --existing-account
765
+ npx pennyrouter auth anthropic
685
766
  npx pennyrouter install --all
686
767
  npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
687
768
  npx pennyrouter disable
@@ -2,20 +2,23 @@ import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile
2
2
 
3
3
  const CONFIG_PATH = "~/.claude/settings.json";
4
4
  const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
5
+ // Values written into Claude Code's four model slots. CC displays a slot's VALUE verbatim in
6
+ // its /model menu AND sends it as the request `model`, so each value is both the menu label
7
+ // and the gateway's routing key. The three concrete Claude slots carry friendly PINNED labels
8
+ // ("Penny Opus/Sonnet/Haiku") that the gateway maps to that exact model — the user's pick
9
+ // runs on the model they chose, no dynamic tier switching on the trunk (background/utility
10
+ // calls still route cheap, which is where the savings come from). The Fable slot is the
11
+ // DYNAMIC "Penny Custom" option: let PennyRouter cost-route. Legacy "Penny Speed/Core/Power"
12
+ // installs (dynamic bundles) are still recognized for uninstall (see isPennyRouterModelValue).
5
13
  const CLAUDE_CODE_MODELS = {
6
- speed: "Penny Speed",
7
- core: "Penny Core",
8
- coreMax: "Penny Core Max",
9
- power: "Penny Power",
10
- custom: "Penny Custom",
11
- };
12
- const MODEL_OVERRIDE_KEYS = {
13
- "claude-sonnet-4-6": CLAUDE_CODE_MODELS.core,
14
- "claude-sonnet-4-6-1m": CLAUDE_CODE_MODELS.coreMax,
15
- "claude-opus-4-8": CLAUDE_CODE_MODELS.power,
16
- "claude-haiku-4-5": CLAUDE_CODE_MODELS.speed,
17
- "claude-fable-5": CLAUDE_CODE_MODELS.custom,
14
+ haiku: "Penny Haiku", // -> pinned anthropic/claude-haiku-4-5-20251001
15
+ sonnet: "Penny Sonnet", // -> pinned anthropic/claude-sonnet-4-6
16
+ opus: "Penny Opus", // -> pinned anthropic/claude-opus-4-8
17
+ custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
18
18
  };
19
+ // Retired dynamic-bundle labels (pre-pinned-trunk). Not written by a fresh install; matched
20
+ // only so uninstall/detect still recognize and clean up an older install.
21
+ const LEGACY_PENNY_LABELS = ["Penny Speed", "Penny Core", "Penny Power"];
19
22
 
20
23
  export const claudeCodeHarness = {
21
24
  id: "claude-code",
@@ -48,12 +51,10 @@ export const claudeCodeHarness = {
48
51
  delete current.env.ANTHROPIC_API_KEY;
49
52
  // Claude Code displays these env values directly in its model picker; the
50
53
  // gateway decodes the friendly labels into bundle/profile overrides.
51
- current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.power;
52
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.speed;
53
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.core;
54
+ current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
55
+ current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
56
+ current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
54
57
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.custom;
55
- current.modelOverrides ||= {};
56
- Object.assign(current.modelOverrides, MODEL_OVERRIDE_KEYS);
57
58
 
58
59
  await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
59
60
 
@@ -72,7 +73,41 @@ export const claudeCodeHarness = {
72
73
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
73
74
  "ANTHROPIC_DEFAULT_FABLE_MODEL",
74
75
  ],
75
- model_overrides: Object.keys(MODEL_OVERRIDE_KEYS),
76
+ },
77
+ };
78
+ },
79
+
80
+ async installAnthropicOAuth({ pennyKey, anthropicAuthToken, gatewayBaseUrl }) {
81
+ const backupPath = await backupFile(CONFIG_PATH, "claude-code-anthropic-auth");
82
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
83
+ current.env ||= {};
84
+ current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
85
+ // Dual-auth mode: x-api-key authenticates the local user to PennyRouter; the
86
+ // Authorization bearer token is forwarded by the gateway to Anthropic.
87
+ current.env.ANTHROPIC_API_KEY = pennyKey;
88
+ current.env.ANTHROPIC_AUTH_TOKEN = anthropicAuthToken;
89
+ current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
90
+ current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
91
+ current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
92
+ current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.custom;
93
+
94
+ await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
95
+ return {
96
+ harness: "claude-code",
97
+ config_path: compactHome(expandHome(CONFIG_PATH)),
98
+ backup_path: backupPath ? compactHome(backupPath) : null,
99
+ installed_at: new Date().toISOString(),
100
+ restart: "restart Claude Code",
101
+ changes: {
102
+ env_keys: [
103
+ "ANTHROPIC_BASE_URL",
104
+ "ANTHROPIC_API_KEY",
105
+ "ANTHROPIC_AUTH_TOKEN",
106
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
107
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
108
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
109
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
110
+ ],
76
111
  },
77
112
  };
78
113
  },
@@ -82,7 +117,7 @@ export const claudeCodeHarness = {
82
117
  if (hasPennyRouterEnv(current.env)) {
83
118
  delete current.env.ANTHROPIC_BASE_URL;
84
119
  const tok = current.env.ANTHROPIC_AUTH_TOKEN;
85
- if (typeof tok === "string" && tok.startsWith("pr-")) {
120
+ if (typeof tok === "string" && (tok.startsWith("pr-") || record.anthropic_oauth)) {
86
121
  delete current.env.ANTHROPIC_AUTH_TOKEN;
87
122
  }
88
123
  // Legacy installs stored the key under ANTHROPIC_API_KEY.
@@ -98,12 +133,6 @@ export const claudeCodeHarness = {
98
133
  if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
99
134
  }
100
135
  }
101
- if (current.modelOverrides && typeof current.modelOverrides === "object") {
102
- for (const [key, value] of Object.entries(MODEL_OVERRIDE_KEYS)) {
103
- if (current.modelOverrides[key] === value) delete current.modelOverrides[key];
104
- }
105
- if (Object.keys(current.modelOverrides).length === 0) delete current.modelOverrides;
106
- }
107
136
  if (current["anthropic.baseURL"] && isPennyRouterValue(current["anthropic.baseURL"])) {
108
137
  delete current["anthropic.baseURL"];
109
138
  }
@@ -143,10 +172,19 @@ function isPennyRouterBaseUrl(value) {
143
172
  function isPennyRouterModelValue(value) {
144
173
  return (
145
174
  isPennyRouterValue(value) ||
146
- value === CLAUDE_CODE_MODELS.speed ||
147
- value === CLAUDE_CODE_MODELS.core ||
148
- value === CLAUDE_CODE_MODELS.coreMax ||
149
- value === CLAUDE_CODE_MODELS.power ||
150
- value === CLAUDE_CODE_MODELS.custom
175
+ value === CLAUDE_CODE_MODELS.haiku ||
176
+ value === CLAUDE_CODE_MODELS.sonnet ||
177
+ value === CLAUDE_CODE_MODELS.opus ||
178
+ value === CLAUDE_CODE_MODELS.custom ||
179
+ LEGACY_PENNY_LABELS.includes(value)
151
180
  );
152
181
  }
182
+
183
+ export async function readClaudeCodePennyRouterKey() {
184
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
185
+ const env = current.env || {};
186
+ for (const value of [env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN, current["anthropic.apiKey"]]) {
187
+ if (typeof value === "string" && value.startsWith("pr-")) return value;
188
+ }
189
+ return null;
190
+ }