claude-token-saver 3.12.1 → 3.13.0

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.en.md CHANGED
@@ -214,11 +214,15 @@ For environments the learner cannot reach, write the mapping yourself in `<userD
214
214
  ```jsonc
215
215
  {
216
216
  "modelAliases": {
217
- "arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5"
217
+ "arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5",
218
+ "prod-large": "claude-opus-5", // house aliases map the same way
219
+ "team-*": "claude-haiku-4-5"
218
220
  }
219
221
  }
220
222
  ```
221
223
 
224
+ **Map house aliases that carry no family name** (`prod-large`, `team-fast`) here too. Shapes that keep the family name are recognized as-is — Bedrock (`anthropic.claude-opus-4-5-v1:0`), Vertex (`claude-opus-4-5@20251101`), and the 1M suffix (`claude-sonnet-4-5[1m]`) — but an alias without one cannot be priced. Rather than report a wrong figure, routing-savings **drops those runs from the aggregate** (both sides of the comparison must be recognizable); one line in the table above brings them back.
225
+
222
226
  That file holds internal identifiers in plain text — do not commit it. On a direct-API machine it is never created and behaviour is unchanged.
223
227
 
224
228
  ## Spike issue codes
@@ -301,6 +305,13 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
301
305
 
302
306
  ## Release notes
303
307
 
308
+ ### v3.13.0 (2026-08-22)
309
+ - **Routing savings are now priced "model before the rule → model that ran it"** — previously the counterfactual was the session's priciest model, which booked a saving even when that model had never handled work of this shape. Each rule now remembers the **model that handled its category before promotion** (its baseline) and prices only against that. The baseline is sticky once set: as a rule takes effect fewer episodes stay on the expensive model, so a recomputed baseline would drift down and shrink the very saving the rule produces.
310
+ - **Delegations no rule covers are excluded** — `Explore`, hand-written agents, and plugin subagents were all being counted, crediting this tool for routing it never did. Ledger events now carry `rule` / `from` / `to`, so every dollar is traceable to the rule and model change behind it.
311
+ - **Ledger schema version 2** — v1 events used the old counterfactual and are discarded rather than migrated; the next `route-scan` refills whatever is genuinely attributable.
312
+ - **House alias model ids no longer price as Sonnet by accident** — when a gateway reports an alias carrying no family name (`prod-large`), the pricing table's default made it Sonnet, fabricating or erasing savings. Both sides of a comparison must now be recognizable ids. Bedrock (`anthropic.claude-opus-4-5-v1:0`), Vertex (`claude-opus-4-5@20251101`), and the `[1m]` suffix are recognized as-is; house aliases come back into the aggregate with one line in `modelAliases` (wildcards supported).
313
+ - `harness check` also reports CLAUDE.md size and whether a `.claudeignore` exists (advisory; the 🅷 score is unaffected).
314
+
304
315
  ### v3.12.1 (2026-08-22)
305
316
  - **Routing-savings headline reads more clearly** — the amounts are now in the savings green (the one unambiguously good number on the line), `wk`/`mo`/`all` are spelled out as `weekly`/`monthly`/`total`, and the period now leads the amount. Three bare amounts in a row read as a single number until the eye found the trailing marker.
306
317
 
package/README.md CHANGED
@@ -194,11 +194,15 @@ v3.10.0부터는 프로파일 ID를 역할(main·opus·sonnet·haiku)로 되돌
194
194
  ```jsonc
195
195
  {
196
196
  "modelAliases": {
197
- "arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5"
197
+ "arn:aws:bedrock:*:*:application-inference-profile/<PROFILE_ID>": "claude-opus-5",
198
+ "prod-large": "claude-opus-5", // 사내 별칭도 같은 방식으로 매핑됩니다
199
+ "team-*": "claude-haiku-4-5"
198
200
  }
199
201
  }
200
202
  ```
201
203
 
204
+ **모델명에 `opus`·`sonnet`·`haiku`·`fable` 이 들어 있지 않은 사내 별칭**(`prod-large`, `team-fast` 등)도 이 표로 매핑하십시오. Bedrock(`anthropic.claude-opus-4-5-v1:0`)·Vertex(`claude-opus-4-5@20251101`)·1M 접미사(`claude-sonnet-4-5[1m]`) 같이 계열명이 남아 있는 형태는 그대로 인식되지만, 계열명이 사라진 별칭은 가격표가 알아볼 수 없습니다. 이 경우 라우팅 절감 계산은 **틀린 금액을 내놓는 대신 그 실행을 집계에서 제외**하며(비교 양쪽 모두 인식 가능한 이름이어야 합니다), 위 표에 한 줄 추가하면 다시 집계에 들어옵니다.
205
+
202
206
  이 파일에는 사내 식별자가 평문으로 남으므로 저장소에 커밋하지 마십시오. 게이트웨이를 쓰지 않는 환경에서는 파일이 아예 만들어지지 않고 기존 동작이 그대로 유지됩니다.
203
207
 
204
208
  ## 토큰 급증 원인 코드
@@ -257,6 +261,13 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
257
261
 
258
262
  ## 릴리스 노트
259
263
 
264
+ ### v3.13.0 (2026-08-22)
265
+ - **라우팅 절감액의 기준을 '승격 전 모델 → 위임 모델' 차액으로 바꿨습니다** — 이전에는 세션의 최상위 모델을 반사실로 잡아, 그 모델이 해당 유형을 실제로 처리한 적이 없어도 차액을 절감으로 기록했습니다. 이제 각 룰이 **승격 전 그 유형을 직접 처리하던 모델**(baseline)을 기억하고, 그 기준 대비로만 계산합니다. baseline은 한 번 정해지면 고정됩니다 — 룰이 효력을 낼수록 직접 처리 사례가 줄어 기준이 흘러내리고, 그러면 룰이 만든 절감이 스스로 작아지기 때문입니다.
266
+ - **룰이 커버하지 않는 위임은 집계에서 뺐습니다** — `Explore`, 직접 만든 에이전트, 플러그인 에이전트처럼 이 도구와 무관하게 돌던 서브에이전트 실행까지 절감으로 잡히고 있었습니다. 도구가 라우팅하지 않은 작업의 절감을 도구 성과로 표시하면 안 됩니다. 원장 이벤트에 `rule`/`from`/`to`를 남겨 어느 룰이 어떤 모델 차이를 만들었는지 추적할 수 있습니다.
267
+ - **원장 스키마 version 2** — 반사실 기준이 달라진 만큼 v1 항목은 마이그레이션 없이 폐기합니다(두 의미를 한 합계에 섞을 수 없습니다). 다음 `route-scan`이 귀속 가능한 절감만 다시 채웁니다.
268
+ - **사내 별칭 모델명이 조용히 Sonnet으로 계산되지 않습니다** — 게이트웨이가 계열명 없는 별칭(`prod-large` 등)을 모델명으로 기록하면 가격표 기본값이 걸려 Sonnet으로 계산됐고, 그 결과 없는 절감이 생기거나 있는 절감이 지워졌습니다. 이제 비교 양쪽 모두 계열명이 남아 있는 id일 때만 집계합니다. Bedrock(`anthropic.claude-opus-4-5-v1:0`)·Vertex(`claude-opus-4-5@20251101`)·1M 접미사(`claude-sonnet-4-5[1m]`)는 그대로 인식되고, 사내 별칭은 `profile-map.json`의 `modelAliases`에 한 줄 추가하면 집계에 복귀합니다(와일드카드 가능).
269
+ - `harness check`가 CLAUDE.md 크기와 `.claudeignore` 유무를 함께 보고합니다(자문 정보, 🅷 점수에는 미반영).
270
+
260
271
  ### v3.12.1 (2026-08-22)
261
272
  - **라우팅 절감 헤드라인의 가독성 정리** — 금액을 절감 녹색으로 칠하고(그 줄에서 유일하게 명확한 호재입니다), `wk`·`mo`·`all` 축약을 `weekly`·`monthly`·`total`로 풀었으며, 금액이 앞서던 순서를 뒤집어 기간이 먼저 오게 했습니다. 금액 셋이 연달아 나오면 뒤따르는 기간 표시를 찾기 전까지 한 덩어리로 읽혔습니다.
262
273
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.12.1",
3
+ "version": "3.13.0",
4
4
  "description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet — post-hoc session analysis, no realtime router, no extra LLM calls.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cost.js CHANGED
@@ -128,6 +128,29 @@ export function isUnknownModel(model) {
128
128
  return !model || String(model).toLowerCase() === 'unknown';
129
129
  }
130
130
 
131
+ /**
132
+ * True when the id actually names a Claude family this table can price, as
133
+ * opposed to falling through to the Sonnet default.
134
+ *
135
+ * `detectPricingTier` must keep defaulting — a plain cost estimate is better
136
+ * off guessing Sonnet than refusing to answer. But anything that compares two
137
+ * models must not: behind a company gateway an id can be a house alias
138
+ * (`prod-large`, `team-fast`) carrying no family name, and pricing that as
139
+ * Sonnet silently invents or erases a delegation saving. Callers that need a
140
+ * real comparison gate on this and skip when it is false.
141
+ *
142
+ * Covers the shapes gateways actually emit — Bedrock
143
+ * (`anthropic.claude-opus-4-5-v1:0`, `us.anthropic.…`), Vertex
144
+ * (`claude-opus-4-5@20251101`), and the `[1m]` context suffix — because all of
145
+ * them keep the family name in the string. House aliases that do not are
146
+ * exactly what this returns false for; map those in profile-map.json's
147
+ * `modelAliases`.
148
+ */
149
+ export function isRecognizedModelId(model) {
150
+ if (isUnknownModel(model)) return false;
151
+ return /fable|mythos|opus|sonnet|haiku/i.test(String(model));
152
+ }
153
+
131
154
  export function modelRank(model) {
132
155
  // -1 sits below every real tier, so worthDelegating() rejects it and
133
156
  // tierForRank() attributes no saving to it: the run leaves the aggregate
@@ -134,8 +134,10 @@ function overrideAlias(model, map) {
134
134
  for (const [pattern, alias] of Object.entries(map.modelAliases || {})) {
135
135
  if (globMatch(pattern, model)) return alias;
136
136
  // Overrides are usually written without the LiteLLM `converse/` prefix.
137
- const bare = model.slice(model.indexOf('arn:aws:bedrock:'));
138
- if (globMatch(pattern, bare)) return alias;
137
+ // Only meaningful for ARNs — on a plain id indexOf returns -1 and the
138
+ // slice would hand the matcher a single trailing character.
139
+ const at = model.indexOf('arn:aws:bedrock:');
140
+ if (at > 0 && globMatch(pattern, model.slice(at))) return alias;
139
141
  }
140
142
  return null;
141
143
  }
@@ -150,13 +152,20 @@ function overrideAlias(model, map) {
150
152
  export function resolveModelAlias(rawModel, { env = process.env } = {}) {
151
153
  if (!rawModel) return UNKNOWN_MODEL;
152
154
  const model = String(rawModel);
153
- if (!isGatewayModelId(model)) return model;
154
155
 
155
156
  const map = loadProfileMap();
156
157
 
158
+ // Overrides are consulted for EVERY id, not just ARNs. A gateway can be
159
+ // configured to report a house alias (`prod-large`, `team-fast`) that names
160
+ // no Claude family at all; those never reach the ARN branch below, and left
161
+ // alone they price as Sonnet by default. One `modelAliases` entry maps them
162
+ // back. Ids that already name a family are left untouched — an override
163
+ // pattern has to match before anything changes.
157
164
  const override = overrideAlias(model, map);
158
165
  if (override) return override;
159
166
 
167
+ if (!isGatewayModelId(model)) return model;
168
+
160
169
  const pid = profileIdFrom(model);
161
170
  if (!pid) return UNKNOWN_MODEL;
162
171
 
@@ -340,6 +340,14 @@ export function refreshModelRules(episodeStats, delegatedStats = new Map(), { no
340
340
  if (s) {
341
341
  r.count = s.count;
342
342
  r.errRate = s.epCount > 0 ? s.errCount / s.epCount : 0;
343
+ // Baseline = the model that handled this category before the rule moved
344
+ // it. Sticky once set: as a rule takes effect, fewer episodes stay on the
345
+ // expensive model, so a recomputed baseline would drift downward and
346
+ // shrink the very savings the rule is producing.
347
+ if (!r.baselineModel && s.baselineModel) {
348
+ r.baselineModel = s.baselineModel;
349
+ changed = true;
350
+ }
343
351
  }
344
352
  // Window snapshot, not a running total: these describe the current scan
345
353
  // window so a rule that stopped firing decays to zero instead of coasting
package/src/route-scan.js CHANGED
@@ -23,7 +23,7 @@ import { userDataDir } from './paths.js';
23
23
  import { discoverSessionFiles } from './parser.js';
24
24
  import { collectSessionRecords } from './session-records.js';
25
25
  import { collectSubagentRuns, indexRuns, runsForEpisode } from './subagent-records.js';
26
- import { estimateCost, modelRank, TIER_TARGET_RANK, tierForRank } from './cost.js';
26
+ import { estimateCost, modelRank, isRecognizedModelId, TIER_TARGET_RANK, tierForRank } from './cost.js';
27
27
  import { learnProfileMapping, resetModelAliasCache } from './model-alias.js';
28
28
  import { agentPhrase, agentPhraseEn } from './agents.js';
29
29
 
@@ -379,10 +379,24 @@ export async function runRouteScan({ days = 14 } = {}) {
379
379
  const episodeStats = new Map(); // "category|project" (+ "category|*") → outcome stats
380
380
  let tieredEpisodes = 0;
381
381
  const bumpStats = (key, ep) => {
382
- const s = episodeStats.get(key) || { count: 0, errCount: 0, epCount: 0 };
382
+ const s = episodeStats.get(key) || { count: 0, errCount: 0, epCount: 0, baselineModel: null, baselineRank: -1 };
383
383
  s.count += 1;
384
384
  s.epCount += 1;
385
385
  if (ep.errors > 0) s.errCount += 1;
386
+ // Baseline model: what actually handled this category BEFORE any rule sent
387
+ // it elsewhere. This is the only honest counterfactual for "routing saved
388
+ // money" — the session's priciest model is not, since it may never have
389
+ // touched work of this shape. Episodes counted here are by definition ones
390
+ // an expensive model handled directly, so the priciest model seen on them
391
+ // is the before-picture the rule replaced.
392
+ for (const m of ep.models) {
393
+ // Only ids the pricing table really recognizes may become a baseline —
394
+ // an unresolved gateway id or a house alias would be priced as Sonnet
395
+ // and quietly rewrite every saving computed against it.
396
+ if (!isRecognizedModelId(m)) continue;
397
+ const r = modelRank(m);
398
+ if (r > s.baselineRank) { s.baselineRank = r; s.baselineModel = m; }
399
+ }
386
400
  episodeStats.set(key, s);
387
401
  };
388
402
 
@@ -454,7 +468,33 @@ export async function runRouteScan({ days = 14 } = {}) {
454
468
  // Ledger events feed the statusline's weekly/monthly "Routing saved"
455
469
  // totals. Keyed by run transcript path so overlapping re-scans upsert the
456
470
  // same event instead of double-counting it.
471
+ //
472
+ // What counts as a routing saving is narrower than what counts for
473
+ // rule-health above. A saving is the price difference a REGISTERED RULE
474
+ // caused: the model that used to handle this category (the rule's baseline,
475
+ // learned from episodes an expensive model handled directly) versus the
476
+ // model the run actually used. Subagent runs that no rule covers —
477
+ // Explore, a hand-written agent, a plugin's own subagent — would have gone
478
+ // to the same cheap model with or without this tool, so attributing their
479
+ // savings here would credit the tool for work it did not route.
457
480
  const ledgerEvents = [];
481
+ let ledgerRules = [];
482
+ try {
483
+ const { loadModelRules } = await import('./model-rules.js');
484
+ ledgerRules = loadModelRules().rules.filter((r) => r.status !== 'off');
485
+ } catch { /* no registry → no attributable savings, which is the honest zero */ }
486
+ // A rule's baseline: what it stored at promotion, else what this scan still
487
+ // observes handling the category directly (a rule promoted before baselines
488
+ // existed backfills on the next refresh).
489
+ const baselineFor = (rule, tier, catId, projectDir) => {
490
+ if (rule.baselineModel) return rule.baselineModel;
491
+ const s = episodeStats.get(`${tier}|${catId}|${projectDir}`)
492
+ || (rule.scope === 'global' ? episodeStats.get(`${tier}|${catId}|*`) : null);
493
+ return s?.baselineModel || null;
494
+ };
495
+ const ruleForRun = (tier, catId, projectDir) => ledgerRules.find((r) =>
496
+ r.tier === tier && r.category === catId &&
497
+ (r.scope === 'global' || r.project === projectDir));
458
498
  for (const [sessionPath, index] of runIndexBySession) {
459
499
  const used = new Set();
460
500
  for (const { ep, projectDir, sessionPath: epSession } of all) {
@@ -478,11 +518,27 @@ export async function runRouteScan({ days = 14 } = {}) {
478
518
  const saved = runSaving(run, mainModel);
479
519
  bumpDelegated(`${runTier}|${cat.id}|${projectDir}`, run, saved);
480
520
  bumpDelegated(`${runTier}|${cat.id}|*`, run, saved);
481
- if (saved > 0) {
521
+ // Routing saving: priced against the rule's baseline (before after),
522
+ // not against whatever the session's priciest model happened to be.
523
+ const rule = ruleForRun(runTier, cat.id, projectDir);
524
+ if (!rule) continue; // no rule routed this run — not our saving to claim
525
+ const baseline = baselineFor(rule, runTier, cat.id, projectDir);
526
+ if (!baseline) continue; // baseline unknown → no honest counterfactual
527
+ // Both sides of the comparison must be ids the pricing table really
528
+ // recognizes. A house alias from a company gateway prices as Sonnet by
529
+ // default, which would fabricate a saving against a cheap baseline or
530
+ // erase a real one — worse than showing nothing. Map such ids in
531
+ // profile-map.json's `modelAliases` to bring these runs back in.
532
+ if (!isRecognizedModelId(baseline) || !isRecognizedModelId(run.model)) continue;
533
+ const routed = runSaving(run, baseline);
534
+ if (routed > 0) {
482
535
  ledgerEvents.push({
483
536
  key: run.path,
484
537
  ts: run.endedAt ?? run.startedAt ?? Date.now(),
485
- usd: saved,
538
+ usd: routed,
539
+ rule: rule.signature,
540
+ from: baseline,
541
+ to: run.model,
486
542
  });
487
543
  }
488
544
  }
@@ -8,7 +8,15 @@
8
8
  * upsert instead of double-counting.
9
9
  *
10
10
  * File: <userDataDir>/delegation-ledger.json
11
- * { "events": { "<run path>": { "ts": <ms epoch>, "usd": <number> } } }
11
+ * { "version": 2,
12
+ * "events": { "<run path>": { "ts", "usd", "rule", "from", "to" } } }
13
+ *
14
+ * `from`/`to` are the models the routing decision moved between, and `usd` is
15
+ * the price difference between them for this run's tokens. Version 1 priced
16
+ * every downgraded subagent run against the session's priciest model, which
17
+ * credited the tool for runs no rule of its own had routed; those events are
18
+ * discarded rather than migrated, since the number they carry cannot be
19
+ * recomputed without a rescan (which route-scan does anyway).
12
20
  *
13
21
  * Best-effort like every other state file here: an unreadable ledger reads as
14
22
  * empty, and the statusline renders totals of 0 as "no chip".
@@ -25,32 +33,48 @@ export function ledgerPath() {
25
33
  return join(userDataDir(), 'delegation-ledger.json');
26
34
  }
27
35
 
36
+ export const LEDGER_VERSION = 2;
37
+
28
38
  export function loadLedger() {
29
39
  try {
30
40
  const data = JSON.parse(readFileSync(ledgerPath(), 'utf8'));
31
- return data && typeof data.events === 'object' && data.events !== null
32
- ? data
33
- : { events: {} };
41
+ if (!data || typeof data.events !== 'object' || data.events === null) {
42
+ return { version: LEDGER_VERSION, events: {} };
43
+ }
44
+ // Pre-v2 events were priced against a different counterfactual — drop them
45
+ // instead of mixing two meanings into one total. The next scan rebuilds
46
+ // whatever is still attributable.
47
+ if (data.version !== LEDGER_VERSION) return { version: LEDGER_VERSION, events: {} };
48
+ return data;
34
49
  } catch {
35
- return { events: {} };
50
+ return { version: LEDGER_VERSION, events: {} };
36
51
  }
37
52
  }
38
53
 
39
54
  /**
40
- * Upsert saving events. `events` is an array of { key, ts, usd } where `key`
41
- * is the run's transcript path (unique per subagent run). Zero-saving runs
42
- * are skipped they carry no information the totals care about.
55
+ * Upsert saving events. `events` is an array of
56
+ * { key, ts, usd, rule, from, to } where `key` is the run's transcript path
57
+ * (unique per subagent run) and `from`/`to` name the models the routing
58
+ * decision moved between. Zero-saving runs are skipped — they carry no
59
+ * information the totals care about.
43
60
  */
44
61
  export function recordDelegationEvents(events) {
45
62
  if (!Array.isArray(events) || events.length === 0) return;
46
63
  const data = loadLedger();
64
+ data.version = LEDGER_VERSION;
47
65
  let changed = false;
48
66
  for (const e of events) {
49
67
  if (!e || !e.key || !(Number(e.usd) > 0) || !Number.isFinite(e.ts)) continue;
50
68
  const prev = data.events[e.key];
51
69
  const usd = Math.round(Number(e.usd) * 10000) / 10000;
52
70
  if (prev && prev.ts === e.ts && prev.usd === usd) continue;
53
- data.events[e.key] = { ts: e.ts, usd };
71
+ data.events[e.key] = {
72
+ ts: e.ts,
73
+ usd,
74
+ ...(e.rule ? { rule: e.rule } : {}),
75
+ ...(e.from ? { from: e.from } : {}),
76
+ ...(e.to ? { to: e.to } : {}),
77
+ };
54
78
  changed = true;
55
79
  }
56
80
  if (!changed) return;