claude-token-saver 3.13.0 → 3.14.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 +7 -0
- package/README.md +17 -0
- package/package.json +1 -1
- package/src/commands/route-scan.js +52 -0
- package/src/formatters/statusline.js +15 -1
- package/src/model-rules.js +8 -1
- package/src/route-scan.js +67 -31
- package/src/savings-ledger.js +31 -4
package/README.en.md
CHANGED
|
@@ -199,6 +199,7 @@ claude-token-saver route-scan # scan (24h cache) + tiered can
|
|
|
199
199
|
claude-token-saver harness promote R1 --project # promote candidate R1 to a model-fitting rule
|
|
200
200
|
claude-token-saver route-scan dismiss 1 # not interested — won't resurface
|
|
201
201
|
claude-token-saver route-scan rules # list model-fitting rules (rm <N> to remove)
|
|
202
|
+
claude-token-saver route-scan savings # the savings ledger — which rule moved work off which model, onto which
|
|
202
203
|
```
|
|
203
204
|
|
|
204
205
|
Dig deeper: **tier criteria & research evidence** → [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) (Korean) · **rule-file mechanics, scan triggers, subagent setup** → [docs/ROUTE_SCAN.md](./docs/ROUTE_SCAN.md) (Korean + English)
|
|
@@ -305,6 +306,12 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
|
|
|
305
306
|
|
|
306
307
|
## Release notes
|
|
307
308
|
|
|
309
|
+
### v3.14.0 (2026-08-22)
|
|
310
|
+
- **The statusline headline names the model changes behind the total** — `🔀 Routing saved weekly $1.4 · monthly $2.1 · total $2.1 | fable→sonnet 1× $0.72 · opus→haiku 1× $0.57 …`. Versions are dropped (`claude-opus-4-5-20251101-v1:0` → `opus`) since they bump constantly and add nothing here. **Every** pair is listed rather than a top-N: the amounts sit next to a total, so a truncated list misstates what that total is made of. Families collapse the list on their own, so it stays short without being cut.
|
|
311
|
+
- **New `route-scan savings`** — the evidence behind the headline: a per-model-change rollup (from, to, runs, dollars) and the per-run log (date, amount, model change, the rule that caused it).
|
|
312
|
+
- **Baselines are the model that handled a category most, not the priciest seen** — transcripts routinely carry more than one model (a mid-session switch), and taking the priciest let a single Fable record set the baseline for a category Opus had handled thirty times, inflating every later saving. Ties break toward the pricier model. Baselines written under the old definition are recomputed once.
|
|
313
|
+
- **The ledger is written after the rule refresh** — writing it first made a changed baseline take two scans to settle: the first stored the new baseline but billed against the old one.
|
|
314
|
+
|
|
308
315
|
### v3.13.0 (2026-08-22)
|
|
309
316
|
- **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
317
|
- **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.
|
package/README.md
CHANGED
|
@@ -179,6 +179,17 @@ claude-token-saver route-scan # 스캔 (24h 캐시) + 티어
|
|
|
179
179
|
claude-token-saver harness promote R1 --project # 후보 R1을 모델 피팅 룰로 등록
|
|
180
180
|
claude-token-saver route-scan dismiss 1 # 관심 없으면 무시 (재스캔에도 안 뜸)
|
|
181
181
|
claude-token-saver route-scan rules # 등록된 모델 피팅 룰 목록 (rm <N>으로 제거)
|
|
182
|
+
claude-token-saver route-scan savings # 절감 원장 — 어느 룰이 어떤 모델에서 어떤 모델로 옮겼는지
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`route-scan savings`는 statusline의 `🔀 Routing saved` 한 줄 뒤에 있는 근거를 그대로 보여줍니다. 모델 이동별 합계와 실행별 내역이 함께 나오므로, 금액이 어디서 나왔는지 추적할 수 있습니다.
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
🔀 라우팅 절감 — 주간 $1.40 · 월간 $2.09 · 누적 $2.09
|
|
189
|
+
|
|
190
|
+
모델 이동별:
|
|
191
|
+
claude-fable-5 → claude-sonnet-5 — 1회, $0.72
|
|
192
|
+
claude-opus-5 → claude-haiku-4-5 — 1회, $0.57
|
|
182
193
|
```
|
|
183
194
|
|
|
184
195
|
더 알아보기: **티어 기준·리서치 근거** → [docs/TIER_CRITERIA.md](./docs/TIER_CRITERIA.md) · **룰 파일 구조·스캔 트리거·서브에이전트 준비** → [docs/ROUTE_SCAN.md](./docs/ROUTE_SCAN.md)
|
|
@@ -261,6 +272,12 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
|
|
|
261
272
|
|
|
262
273
|
## 릴리스 노트
|
|
263
274
|
|
|
275
|
+
### v3.14.0 (2026-08-22)
|
|
276
|
+
- **statusline 헤드라인이 모델 이동을 함께 보여줍니다** — `🔀 Routing saved weekly $1.4 · monthly $2.1 · total $2.1 | fable→sonnet 1× $0.72 · opus→haiku 1× $0.57 …`. 버전 숫자는 계속 올라가고 statusline에서는 잡음이라 계열명만 남깁니다(`claude-opus-4-5-20251101-v1:0` → `opus`). 이동 목록은 **자르지 않고 전부** 표시합니다 — 금액이 `total` 옆에 붙어 있어서 일부만 보이면 합계를 잘못 말하게 됩니다. 계열 단위로 접히면 조합 수가 원래 많지 않아 줄은 짧게 유지됩니다.
|
|
277
|
+
- **`route-scan savings` 추가** — 헤드라인 뒤에 있는 근거를 그대로 조회합니다. 모델 이동별 합계(어느 모델에서 어느 모델로 몇 회, 얼마)와 실행별 내역(날짜·금액·모델 이동·해당 룰)이 함께 나옵니다.
|
|
278
|
+
- **기준 모델을 최고가가 아니라 '가장 많이 처리한 모델'로 정합니다** — 한 트랜스크립트에 모델이 섞이는 일이 흔한데(세션 중 모델 전환 등) 최고가를 고르면 Fable 기록 한 건이 Opus가 서른 번 처리한 카테고리의 기준을 차지해 이후 절감액을 전부 부풀렸습니다. 동수일 때만 비싼 쪽으로 기웁니다. 옛 정의로 굳은 기준은 1회 재계산됩니다.
|
|
279
|
+
- **원장 기록을 룰 갱신 뒤로 옮겼습니다** — 앞서 기록하면 기준이 바뀐 스캔이 새 기준을 저장하면서 청구는 옛 기준으로 해, 합계가 두 번째 스캔에야 맞았습니다.
|
|
280
|
+
|
|
264
281
|
### v3.13.0 (2026-08-22)
|
|
265
282
|
- **라우팅 절감액의 기준을 '승격 전 모델 → 위임 모델' 차액으로 바꿨습니다** — 이전에는 세션의 최상위 모델을 반사실로 잡아, 그 모델이 해당 유형을 실제로 처리한 적이 없어도 차액을 절감으로 기록했습니다. 이제 각 룰이 **승격 전 그 유형을 직접 처리하던 모델**(baseline)을 기억하고, 그 기준 대비로만 계산합니다. baseline은 한 번 정해지면 고정됩니다 — 룰이 효력을 낼수록 직접 처리 사례가 줄어 기준이 흘러내리고, 그러면 룰이 만든 절감이 스스로 작아지기 때문입니다.
|
|
266
283
|
- **룰이 커버하지 않는 위임은 집계에서 뺐습니다** — `Explore`, 직접 만든 에이전트, 플러그인 에이전트처럼 이 도구와 무관하게 돌던 서브에이전트 실행까지 절감으로 잡히고 있었습니다. 도구가 라우팅하지 않은 작업의 절감을 도구 성과로 표시하면 안 됩니다. 원장 이벤트에 `rule`/`from`/`to`를 남겨 어느 룰이 어떤 모델 차이를 만들었는지 추적할 수 있습니다.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-token-saver",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.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": {
|
|
@@ -10,6 +10,58 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
10
10
|
const { userLanguage } = await import('../config.js');
|
|
11
11
|
const lang = userLanguage();
|
|
12
12
|
|
|
13
|
+
// route-scan savings — the delegation ledger behind the statusline's
|
|
14
|
+
// "Routing saved" headline. The headline is one number; this is the
|
|
15
|
+
// evidence for it: which rule fired, which model the work moved off, and
|
|
16
|
+
// which model actually ran it.
|
|
17
|
+
if (args[1] === 'savings') {
|
|
18
|
+
const { loadLedger, delegationSavedTotals } = await import('../savings-ledger.js');
|
|
19
|
+
const events = Object.entries(loadLedger().events)
|
|
20
|
+
.map(([key, e]) => ({ key, ...e }))
|
|
21
|
+
.sort((a, b) => b.ts - a.ts);
|
|
22
|
+
if (events.length === 0) {
|
|
23
|
+
console.log(lang === 'ko'
|
|
24
|
+
? '기록된 라우팅 절감 없음. 승격된 룰이 실제로 위임을 일으킨 뒤 `route-scan --refresh` 를 돌리면 채워집니다.'
|
|
25
|
+
: 'No routing savings recorded yet. Promote a rule, let it delegate, then run `route-scan --refresh`.');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const t = delegationSavedTotals();
|
|
29
|
+
const money = (v) => `$${v.toFixed(2)}`;
|
|
30
|
+
console.log(lang === 'ko'
|
|
31
|
+
? `🔀 라우팅 절감 — 주간 ${money(t.week)} · 월간 ${money(t.month)} · 누적 ${money(t.total)}`
|
|
32
|
+
: `🔀 Routing saved — weekly ${money(t.week)} · monthly ${money(t.month)} · total ${money(t.total)}`);
|
|
33
|
+
|
|
34
|
+
// Per model-pair rollup first: the "what moved where" question is what
|
|
35
|
+
// this view exists to answer, and it is easier to read than the log.
|
|
36
|
+
const pairs = new Map();
|
|
37
|
+
for (const e of events) {
|
|
38
|
+
const k = `${e.from || '?'} → ${e.to || '?'}`;
|
|
39
|
+
const p = pairs.get(k) || { runs: 0, usd: 0 };
|
|
40
|
+
p.runs += 1;
|
|
41
|
+
p.usd += Number(e.usd) || 0;
|
|
42
|
+
pairs.set(k, p);
|
|
43
|
+
}
|
|
44
|
+
console.log('');
|
|
45
|
+
console.log(lang === 'ko' ? '모델 이동별:' : 'By model change:');
|
|
46
|
+
for (const [k, p] of [...pairs].sort((a, b) => b[1].usd - a[1].usd)) {
|
|
47
|
+
const runs = lang === 'ko' ? `${p.runs}회` : `${p.runs} run${p.runs === 1 ? '' : 's'}`;
|
|
48
|
+
console.log(` ${k} — ${runs}, ${money(p.usd)}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log('');
|
|
52
|
+
console.log(lang === 'ko' ? '실행별 (최근순):' : 'By run (newest first):');
|
|
53
|
+
for (const e of events) {
|
|
54
|
+
const when = new Date(e.ts).toISOString().slice(0, 10);
|
|
55
|
+
console.log(` ${when} ${money(Number(e.usd) || 0).padStart(7)} ${e.from || '?'} → ${e.to || '?'}`);
|
|
56
|
+
console.log(` ${lang === 'ko' ? '룰' : 'rule'}: ${e.rule || '(unattributed)'}`);
|
|
57
|
+
}
|
|
58
|
+
console.log('');
|
|
59
|
+
console.log(lang === 'ko'
|
|
60
|
+
? '금액은 "룰 승격 전 그 유형을 처리하던 모델"과 실제 실행 모델의 가격 차이입니다 (토큰 수는 고정 가정).'
|
|
61
|
+
: 'Each amount is the price gap between the model that handled this category before the rule and the model that actually ran it (token counts held constant).');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
13
65
|
// route-scan rules [rm <N>] — the model-fitting rule registry (rules
|
|
14
66
|
// promoted from candidates; auto-refreshed from logs on every rescan).
|
|
15
67
|
if (args[1] === 'rules') {
|
|
@@ -291,11 +291,25 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
291
291
|
const part = (usd, label) =>
|
|
292
292
|
`${c(GRAY)}${label}${c(RESET)} ${c(GREEN)}${formatMoney(Number(usd) || 0)}${c(RESET)}`;
|
|
293
293
|
const head = isIcon ? '🔀 Routing saved' : 'Routing saved';
|
|
294
|
+
// Model changes behind the total, family-level and version-free: `opus →
|
|
295
|
+
// haiku 2× $0.6`. Versions bump constantly and add nothing here — the
|
|
296
|
+
// shape of the trade is the point.
|
|
297
|
+
//
|
|
298
|
+
// Every pair is listed, not a top-N: the amounts are shown next to a
|
|
299
|
+
// total, so a truncated list reads as "this is what the total is made of"
|
|
300
|
+
// and quietly misstates it. Families collapse the list on their own —
|
|
301
|
+
// there are only so many tier-to-tier moves — so it stays short without
|
|
302
|
+
// being cut.
|
|
303
|
+
const pairs = Array.isArray(totals.pairs) ? totals.pairs : [];
|
|
304
|
+
const pairText = pairs
|
|
305
|
+
.map((p) => `${c(GRAY)}${p.from}→${p.to}${c(RESET)} ${c(GRAY)}${p.runs}×${c(RESET)} ${c(GREEN)}${formatMoney(p.usd)}${c(RESET)}`)
|
|
306
|
+
.join(` ${c(GRAY)}·${c(RESET)} `);
|
|
294
307
|
totalsLine =
|
|
295
308
|
`${c(GREEN)}${c(BOLD)}${head}${c(RESET)} ` +
|
|
296
309
|
`${part(totals.week, 'weekly')} ${c(GRAY)}·${c(RESET)} ` +
|
|
297
310
|
`${part(totals.month, 'monthly')} ${c(GRAY)}·${c(RESET)} ` +
|
|
298
|
-
`${part(totals.total, 'total')}
|
|
311
|
+
`${part(totals.total, 'total')}` +
|
|
312
|
+
(pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '');
|
|
299
313
|
}
|
|
300
314
|
|
|
301
315
|
// Period label honors hour-precision configs (`mode 6h` → "6h", `mode 1d` → "1d").
|
package/src/model-rules.js
CHANGED
|
@@ -344,8 +344,15 @@ export function refreshModelRules(episodeStats, delegatedStats = new Map(), { no
|
|
|
344
344
|
// it. Sticky once set: as a rule takes effect, fewer episodes stay on the
|
|
345
345
|
// expensive model, so a recomputed baseline would drift downward and
|
|
346
346
|
// shrink the very savings the rule is producing.
|
|
347
|
-
|
|
347
|
+
//
|
|
348
|
+
// `baselineSource` marks which definition produced it. Baselines written
|
|
349
|
+
// before the definition changed from "priciest model seen" to "model that
|
|
350
|
+
// handled the most episodes" are recomputed once — a single stray record
|
|
351
|
+
// of a pricier model could otherwise hold the baseline above what the
|
|
352
|
+
// category was really running on, inflating every saving priced against it.
|
|
353
|
+
if (s.baselineModel && (!r.baselineModel || r.baselineSource !== 'dominant')) {
|
|
348
354
|
r.baselineModel = s.baselineModel;
|
|
355
|
+
r.baselineSource = 'dominant';
|
|
349
356
|
changed = true;
|
|
350
357
|
}
|
|
351
358
|
}
|
package/src/route-scan.js
CHANGED
|
@@ -262,6 +262,24 @@ export function episodeRank(ep) {
|
|
|
262
262
|
* net loss. (Replaces the old boolean "is it haiku?" test, which could not
|
|
263
263
|
* tell a Sonnet session from a Fable one.)
|
|
264
264
|
*/
|
|
265
|
+
/**
|
|
266
|
+
* The model a category was mostly handled by, from a { model → episodes } map.
|
|
267
|
+
* Ties break toward the pricier model: with no majority either way, the more
|
|
268
|
+
* expensive reading of "what this used to cost" is the one worth stating.
|
|
269
|
+
* Returns null for an empty map, which callers read as "no baseline yet".
|
|
270
|
+
*/
|
|
271
|
+
export function dominantModel(counts) {
|
|
272
|
+
let best = null;
|
|
273
|
+
let bestN = 0;
|
|
274
|
+
for (const [model, n] of Object.entries(counts || {})) {
|
|
275
|
+
if (n > bestN || (n === bestN && best && modelRank(model) > modelRank(best))) {
|
|
276
|
+
best = model;
|
|
277
|
+
bestN = n;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return best;
|
|
281
|
+
}
|
|
282
|
+
|
|
265
283
|
export function worthDelegating(tier, rank) {
|
|
266
284
|
const target = TIER_TARGET_RANK[tier];
|
|
267
285
|
return target !== undefined && rank > target;
|
|
@@ -379,24 +397,29 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
379
397
|
const episodeStats = new Map(); // "category|project" (+ "category|*") → outcome stats
|
|
380
398
|
let tieredEpisodes = 0;
|
|
381
399
|
const bumpStats = (key, ep) => {
|
|
382
|
-
const s = episodeStats.get(key) || { count: 0, errCount: 0, epCount: 0, baselineModel: null,
|
|
400
|
+
const s = episodeStats.get(key) || { count: 0, errCount: 0, epCount: 0, baselineModel: null, modelCounts: {} };
|
|
383
401
|
s.count += 1;
|
|
384
402
|
s.epCount += 1;
|
|
385
403
|
if (ep.errors > 0) s.errCount += 1;
|
|
386
404
|
// Baseline model: what actually handled this category BEFORE any rule sent
|
|
387
405
|
// it elsewhere. This is the only honest counterfactual for "routing saved
|
|
388
406
|
// money" — the session's priciest model is not, since it may never have
|
|
389
|
-
// touched work of this shape.
|
|
390
|
-
//
|
|
391
|
-
//
|
|
407
|
+
// touched work of this shape.
|
|
408
|
+
//
|
|
409
|
+
// Counted, not maxed. A transcript routinely carries more than one model
|
|
410
|
+
// (the user switches mid-session, a compaction pass runs elsewhere), and
|
|
411
|
+
// taking the priciest of them would let a single Fable record set the
|
|
412
|
+
// baseline for a category that Opus handled thirty times — inflating every
|
|
413
|
+
// later saving. The model that handled the most episodes is the one the
|
|
414
|
+
// rule actually replaced; price breaks a tie.
|
|
392
415
|
for (const m of ep.models) {
|
|
393
416
|
// Only ids the pricing table really recognizes may become a baseline —
|
|
394
417
|
// an unresolved gateway id or a house alias would be priced as Sonnet
|
|
395
418
|
// and quietly rewrite every saving computed against it.
|
|
396
419
|
if (!isRecognizedModelId(m)) continue;
|
|
397
|
-
|
|
398
|
-
if (r > s.baselineRank) { s.baselineRank = r; s.baselineModel = m; }
|
|
420
|
+
s.modelCounts[m] = (s.modelCounts[m] || 0) + 1;
|
|
399
421
|
}
|
|
422
|
+
s.baselineModel = dominantModel(s.modelCounts);
|
|
400
423
|
episodeStats.set(key, s);
|
|
401
424
|
};
|
|
402
425
|
|
|
@@ -522,34 +545,13 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
522
545
|
// not against whatever the session's priciest model happened to be.
|
|
523
546
|
const rule = ruleForRun(runTier, cat.id, projectDir);
|
|
524
547
|
if (!rule) continue; // no rule routed this run — not our saving to claim
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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) {
|
|
535
|
-
ledgerEvents.push({
|
|
536
|
-
key: run.path,
|
|
537
|
-
ts: run.endedAt ?? run.startedAt ?? Date.now(),
|
|
538
|
-
usd: routed,
|
|
539
|
-
rule: rule.signature,
|
|
540
|
-
from: baseline,
|
|
541
|
-
to: run.model,
|
|
542
|
-
});
|
|
543
|
-
}
|
|
548
|
+
// Priced below, after this scan's baselines have been written back to
|
|
549
|
+
// the registry — see the note at the ledger write.
|
|
550
|
+
if (!isRecognizedModelId(run.model)) continue;
|
|
551
|
+
ledgerEvents.push({ run, rule, tier: runTier, catId: cat.id, projectDir });
|
|
544
552
|
}
|
|
545
553
|
}
|
|
546
554
|
}
|
|
547
|
-
try {
|
|
548
|
-
const { recordDelegationEvents } = await import('./savings-ledger.js');
|
|
549
|
-
recordDelegationEvents(ledgerEvents);
|
|
550
|
-
} catch {
|
|
551
|
-
// ledger write failure only delays the statusline totals, never the scan
|
|
552
|
-
}
|
|
553
555
|
|
|
554
556
|
// Keep prior dismissed/promoted signatures across rescans.
|
|
555
557
|
const prev = readRouteScan();
|
|
@@ -656,6 +658,40 @@ export async function runRouteScan({ days = 14 } = {}) {
|
|
|
656
658
|
refreshModelRules(episodeStats, delegatedStats, { now: cache.scannedAt });
|
|
657
659
|
} catch { /* registry unwritable — scan result still valid */ }
|
|
658
660
|
|
|
661
|
+
// Ledger last, so savings are priced against the baselines this scan just
|
|
662
|
+
// wrote. Pricing before the refresh made a changed baseline take two scans
|
|
663
|
+
// to show up: the first wrote the new baseline but billed against the old
|
|
664
|
+
// one, and the totals only settled on the second.
|
|
665
|
+
try {
|
|
666
|
+
const { recordDelegationEvents } = await import('./savings-ledger.js');
|
|
667
|
+
const { loadModelRules } = await import('./model-rules.js');
|
|
668
|
+
const fresh = loadModelRules().rules;
|
|
669
|
+
const priced = [];
|
|
670
|
+
for (const e of ledgerEvents) {
|
|
671
|
+
const rule = fresh.find((r) => r.signature === e.rule.signature) || e.rule;
|
|
672
|
+
const baseline = baselineFor(rule, e.tier, e.catId, e.projectDir);
|
|
673
|
+
// Both sides of the comparison must be ids the pricing table really
|
|
674
|
+
// recognizes. A house alias from a company gateway prices as Sonnet by
|
|
675
|
+
// default, which would fabricate a saving against a cheap baseline or
|
|
676
|
+
// erase a real one — worse than showing nothing. Map such ids in
|
|
677
|
+
// profile-map.json's `modelAliases` to bring these runs back in.
|
|
678
|
+
if (!baseline || !isRecognizedModelId(baseline)) continue;
|
|
679
|
+
const usd = runSaving(e.run, baseline);
|
|
680
|
+
if (usd <= 0) continue;
|
|
681
|
+
priced.push({
|
|
682
|
+
key: e.run.path,
|
|
683
|
+
ts: e.run.endedAt ?? e.run.startedAt ?? Date.now(),
|
|
684
|
+
usd,
|
|
685
|
+
rule: rule.signature,
|
|
686
|
+
from: baseline,
|
|
687
|
+
to: e.run.model,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
recordDelegationEvents(priced);
|
|
691
|
+
} catch {
|
|
692
|
+
// ledger write failure only delays the statusline totals, never the scan
|
|
693
|
+
}
|
|
694
|
+
|
|
659
695
|
return cache;
|
|
660
696
|
}
|
|
661
697
|
|
package/src/savings-ledger.js
CHANGED
|
@@ -88,11 +88,31 @@ export function recordDelegationEvents(events) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
91
|
+
* Family name of a model id, with the version dropped: `claude-opus-5` and
|
|
92
|
+
* `claude-opus-4-5-20251101-v1:0` both read as `opus`.
|
|
93
|
+
*
|
|
94
|
+
* Versions move constantly, and on a statusline the digits are noise — what
|
|
95
|
+
* the reader wants is the shape of the trade ("opus work now runs on haiku").
|
|
96
|
+
* Falls back to the id itself so an unmapped name is visible rather than
|
|
97
|
+
* silently folded into another family.
|
|
98
|
+
*/
|
|
99
|
+
export function modelFamily(model) {
|
|
100
|
+
const m = String(model || '').toLowerCase();
|
|
101
|
+
for (const f of ['fable', 'mythos', 'opus', 'sonnet', 'haiku']) {
|
|
102
|
+
if (m.includes(f)) return f;
|
|
103
|
+
}
|
|
104
|
+
return String(model || '?');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Rolling totals: last 7 days, last 30 days, and lifetime, plus `pairs` — the
|
|
109
|
+
* lifetime rollup by family-level model change, priciest first. `now` is
|
|
110
|
+
* injectable for tests. Never throws — an unreadable ledger yields zeros.
|
|
93
111
|
*/
|
|
94
112
|
export function delegationSavedTotals(now = Date.now()) {
|
|
95
|
-
const
|
|
113
|
+
const empty = () => ({ week: 0, month: 0, total: 0, pairs: [] });
|
|
114
|
+
const totals = empty();
|
|
115
|
+
const byPair = new Map();
|
|
96
116
|
try {
|
|
97
117
|
for (const e of Object.values(loadLedger().events)) {
|
|
98
118
|
const usd = Number(e.usd) || 0;
|
|
@@ -102,9 +122,16 @@ export function delegationSavedTotals(now = Date.now()) {
|
|
|
102
122
|
if (now - e.ts <= WEEK_MS) totals.week += usd;
|
|
103
123
|
if (now - e.ts <= MONTH_MS) totals.month += usd;
|
|
104
124
|
}
|
|
125
|
+
if (!e.from || !e.to) continue;
|
|
126
|
+
const key = `${modelFamily(e.from)}→${modelFamily(e.to)}`;
|
|
127
|
+
const p = byPair.get(key) || { from: modelFamily(e.from), to: modelFamily(e.to), runs: 0, usd: 0 };
|
|
128
|
+
p.runs += 1;
|
|
129
|
+
p.usd += usd;
|
|
130
|
+
byPair.set(key, p);
|
|
105
131
|
}
|
|
106
132
|
} catch {
|
|
107
|
-
return
|
|
133
|
+
return empty();
|
|
108
134
|
}
|
|
135
|
+
totals.pairs = [...byPair.values()].sort((a, b) => b.usd - a.usd);
|
|
109
136
|
return totals;
|
|
110
137
|
}
|