sprag-cli 3.64.0 → 3.65.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.64.0",
3
+ "version": "3.65.0",
4
4
  "description": "Lower your Claude Code bill without changing how you work: simple tasks go to cheaper models, documents go in as compact Markdown, and the statusline shows spend and savings live. Works with Codex too.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -396,10 +396,16 @@ function buildRulesSeg(stats, c, g) {
396
396
  }
397
397
 
398
398
  /**
399
- * This session's subagent runs: `3 (haiku 2 · sonnet 1)`. Counts every
400
- * subagent the session started, not only the ones a routing rule sent, so it
401
- * answers "how much did this session fan out" rather than "how many rules
402
- * fired". Null when the session ran none, which is the common case.
399
+ * This session's subagent runs: `3 (haiku 2 · sonnet 1) spent $0.42`. Counts
400
+ * every subagent the session started, not only the ones a routing rule sent,
401
+ * so it answers "how much did this session fan out" rather than "how many
402
+ * rules fired", and what that fan-out cost. Null when the session ran none,
403
+ * which is the common case.
404
+ *
405
+ * The cost uses the routing headline's word, `spent`, and its shape: runs a
406
+ * gateway could not price are counted (`+1 unpriced`) rather than read as
407
+ * free, and a session whose every run went unpriced reads `n/a`. A summary
408
+ * without `usd` (an older caller) draws no cost at all.
403
409
  */
404
410
  function buildSessionDelegationSeg(sd, c, isIcon, verbose, g) {
405
411
  const total = Number(sd && sd.total) || 0;
@@ -411,7 +417,14 @@ function buildSessionDelegationSeg(sd, c, isIcon, verbose, g) {
411
417
  ? `${g.routing} ${verbose ? 'Subagents this session' : 'this session'}`
412
418
  : 'Subagents this session';
413
419
  const split = families.length ? ` ${c(GRAY)}(${families.join(' · ')})${c(RESET)}` : '';
414
- return `${c(CYAN)}${label} ${total}${c(RESET)}${split}`;
420
+ const usd = Number(sd.usd);
421
+ const unpriced = Number(sd.unpriced) || 0;
422
+ let spent = '';
423
+ if (sd.usd != null && Number.isFinite(usd)) {
424
+ const amt = usd > 0 || unpriced === 0 ? formatMoney(usd) : 'n/a';
425
+ spent = ` ${c(GRAY)}spent ${amt}${unpriced > 0 ? ` +${unpriced} unpriced` : ''}${c(RESET)}`;
426
+ }
427
+ return `${c(CYAN)}${label} ${total}${c(RESET)}${split}${spent}`;
415
428
  }
416
429
 
417
430
  /**
@@ -634,9 +647,23 @@ export function formatReport(data, { color = true, verbose = false, timer = true
634
647
  ? ` ${c(GRAY)}${verbose ? `last scan ${scanAge} ago` : `scan ${scanAge}`}${c(RESET)}`
635
648
  : '';
636
649
 
650
+ // What the same delegated runs cost on the models they ran on, read beside
651
+ // the saving so the reader sees what it was bought with: "saved $14.7 ·
652
+ // spent $5.2". Gray, since it is context for the saving rather than a result.
653
+ // Shown once any run carries a cost; runs whose event has none (recorded
654
+ // before costs were kept and not rescanned since, or unpriced) are counted
655
+ // instead of being read as free.
656
+ // text: "Routing saved $14.7 · spent $5.2 since 10/9"
657
+ // icon: "🔀 $14.7 · spent $5.2 (10/9~)"
658
+ const spentUsd = Number(totals && totals.spent) || 0;
659
+ const spentMissing = Number(totals && totals.spentMissing) || 0;
660
+ const spentTag = spentUsd > 0
661
+ ? ` ${c(GRAY)}· spent ${formatMoney(spentUsd)}${spentMissing > 0 ? ` +${spentMissing} unpriced` : ''}${c(RESET)}`
662
+ : '';
663
+
637
664
  const savedTone = delegationSaved < 0 ? RED : delegationSaved > 0 ? GREEN : GRAY;
638
665
  const savingsChip = delegationSaved !== 0 || routingSince
639
- ? `${c(savedTone)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}${sinceInline(routingSince)}${scanTag}`
666
+ ? `${c(savedTone)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}${spentTag}${sinceInline(routingSince)}${scanTag}`
640
667
  : null;
641
668
  // The unresolved count stands in only while there are no savings to show, so
642
669
  // a dated zero keeps both: the reset and the dropped runs are separate facts.
@@ -707,8 +734,8 @@ export function formatReport(data, { color = true, verbose = false, timer = true
707
734
  // a row of rolling windows it read as a breakdown of whichever one it
708
735
  // touched. A single lifetime total makes the whole line one timeframe with
709
736
  // nothing to mismatch.
710
- // icon: "🔀 Routing saved $9.8 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
711
- // text: "Routing saved $9.8 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
737
+ // icon: "🔀 Routing saved $9.8 · spent $3.1 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
738
+ // text: "Routing saved $9.8 · spent $3.1 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
712
739
  let totalsLine = null;
713
740
  const totalsNum = Number(totals && totals.total);
714
741
  // A zero total gets its line only when a reset mark explains it; see the
@@ -737,7 +764,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
737
764
  totalsLine =
738
765
  `${c(tone)}${c(BOLD)}${head}${c(RESET)} ` +
739
766
  `${c(tone)}${formatMoney(totalsNum)}${c(RESET)}` +
740
- sinceWord(routingSince) + scanTag +
767
+ spentTag + sinceWord(routingSince) + scanTag +
741
768
  (pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '');
742
769
  }
743
770
 
package/src/route-scan.js CHANGED
@@ -374,7 +374,26 @@ export function worthDelegating(tier, rank) {
374
374
  */
375
375
  export function runSaving(run, mainModel) {
376
376
  if (!run.model || !mainModel) return null;
377
- const totals = {
377
+ const totals = runTotals(run);
378
+ const actual = sessionCost(totals, run.model);
379
+ const counterfactual = sessionCost(totals, mainModel);
380
+ if (!actual || !counterfactual) return null;
381
+ return counterfactual.actual - actual.actual;
382
+ }
383
+
384
+ /**
385
+ * USD a delegated run cost on the model it ran on: the price side of
386
+ * runSaving, recorded beside the saving so the statusline can show what the
387
+ * saving was bought with. null when the model has no price.
388
+ */
389
+ export function runCost(run) {
390
+ if (!run.model) return null;
391
+ const actual = sessionCost(runTotals(run), run.model);
392
+ return actual ? actual.actual : null;
393
+ }
394
+
395
+ function runTotals(run) {
396
+ return {
378
397
  input: run.input,
379
398
  cacheCreation: run.cacheCreation,
380
399
  cacheRead: run.cacheRead,
@@ -383,10 +402,6 @@ export function runSaving(run, mainModel) {
383
402
  output: run.out,
384
403
  ...(run.long ? { long: run.long } : {}),
385
404
  };
386
- const actual = sessionCost(totals, run.model);
387
- const counterfactual = sessionCost(totals, mainModel);
388
- if (!actual || !counterfactual) return null;
389
- return counterfactual.actual - actual.actual;
390
405
  }
391
406
 
392
407
  const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v));
@@ -865,6 +880,7 @@ export async function runRouteScan({ days = 14 } = {}) {
865
880
  key: e.run.path,
866
881
  ts: e.run.endedAt ?? e.run.startedAt ?? Date.now(),
867
882
  usd,
883
+ cost: runCost(e.run),
868
884
  rule: rule.signature,
869
885
  from: baseline,
870
886
  to: e.run.model,
@@ -10,10 +10,13 @@
10
10
  *
11
11
  * File: <userDataDir>/delegation-ledger.json
12
12
  * { "version": 2,
13
- * "events": { "<run path>": { "ts", "usd", "rule", "from", "to" } } }
13
+ * "events": { "<run path>": { "ts", "usd", "cost", "rule", "from", "to" } } }
14
14
  *
15
15
  * `from`/`to` are the models the routing decision moved between, and `usd` is
16
- * the price difference between them for this run's tokens. Version 1 priced
16
+ * the price difference between them for this run's tokens. `cost` is what the
17
+ * run itself cost on the model it ran on, so the statusline can show what the
18
+ * saving was bought with. Events written before `cost` existed lack it until a
19
+ * scan that still covers the run fills it in. Version 1 priced
17
20
  * every downgraded subagent run against the session's priciest model, which
18
21
  * credited the tool for runs no rule of its own had routed; those events are
19
22
  * discarded rather than migrated, since the number they carry cannot be
@@ -81,11 +84,12 @@ export function loadLedger() {
81
84
 
82
85
  /**
83
86
  * Upsert saving events. `events` is an array of
84
- * { key, ts, usd, rule, from, to } where `key` is the run's transcript path
85
- * (unique per subagent run) and `from`/`to` name the models the routing
87
+ * { key, ts, usd, cost, rule, from, to } where `key` is the run's transcript
88
+ * path (unique per subagent run) and `from`/`to` name the models the routing
86
89
  * decision moved between. `usd` is signed: a run on a pricier model is a loss
87
90
  * and is recorded as a negative amount so it reduces the totals. Only
88
- * non-finite amounts (unpriced runs) are skipped.
91
+ * non-finite amounts (unpriced runs) are skipped. A non-finite `cost` is left
92
+ * out of the event while its saving is still recorded.
89
93
  */
90
94
  export function recordDelegationEvents(events) {
91
95
  if (!Array.isArray(events) || events.length === 0) return;
@@ -101,12 +105,16 @@ export function recordDelegationEvents(events) {
101
105
  const prevKey = keyOf.get(id) ?? e.key;
102
106
  const prev = data.events[prevKey];
103
107
  const usd = roundUsd(e.usd);
104
- if (prev && prev.ts === e.ts && prev.usd === usd) continue;
108
+ const cost = Number.isFinite(e.cost) ? roundUsd(e.cost) : undefined;
109
+ // An event recorded before `cost` existed compares unequal here, so the
110
+ // first scan that still covers the run fills its cost in.
111
+ if (prev && prev.ts === e.ts && prev.usd === usd && prev.cost === cost) continue;
105
112
  if (prevKey !== e.key) delete data.events[prevKey];
106
113
  keyOf.set(id, e.key);
107
114
  data.events[e.key] = {
108
115
  ts: e.ts,
109
116
  usd,
117
+ ...(cost !== undefined ? { cost } : {}),
110
118
  ...(e.rule ? { rule: e.rule } : {}),
111
119
  ...(e.from ? { from: e.from } : {}),
112
120
  ...(e.to ? { to: e.to } : {}),
@@ -143,13 +151,17 @@ export function modelFamily(model) {
143
151
  * lifetime rollup by family-level model change, priciest first. `now` is
144
152
  * injectable for tests. Never throws — an unreadable ledger yields zeros.
145
153
  *
154
+ * `spent` is the lifetime sum of what the same runs cost on the models they
155
+ * ran on, and `spentMissing` counts the runs whose event carries no `cost`
156
+ * (written before the field existed, or unpriced), which `spent` leaves out.
157
+ *
146
158
  * Events before the `routing` reset mark (`sprag saved reset`) are skipped
147
159
  * everywhere, and `since` reports the mark (null when never reset). `dir` is
148
160
  * the user data directory the mark is read from.
149
161
  */
150
162
  export function delegationSavedTotals(now = Date.now(), dir = userDataDir()) {
151
163
  const since = readResetMarks(dir).routing;
152
- const empty = () => ({ week: 0, month: 0, total: 0, pairs: [], since });
164
+ const empty = () => ({ week: 0, month: 0, total: 0, spent: 0, spentMissing: 0, pairs: [], since });
153
165
  const totals = empty();
154
166
  const byPair = new Map();
155
167
  try {
@@ -158,6 +170,8 @@ export function delegationSavedTotals(now = Date.now(), dir = userDataDir()) {
158
170
  const usd = Number(e.usd);
159
171
  if (!Number.isFinite(usd)) continue;
160
172
  totals.total += usd;
173
+ if (Number.isFinite(e.cost)) totals.spent += e.cost;
174
+ else totals.spentMissing += 1;
161
175
  if (Number.isFinite(e.ts)) {
162
176
  if (now - e.ts <= WEEK_MS) totals.week += usd;
163
177
  if (now - e.ts <= MONTH_MS) totals.month += usd;
@@ -10,6 +10,7 @@
10
10
  import { resolve } from 'node:path';
11
11
  import { modelFamily } from './savings-ledger.js';
12
12
  import { loadModelRules } from './model-rules.js';
13
+ import { sessionCostAcross } from './claude-price.js';
13
14
 
14
15
  /** Longest family name shown before it is cut. Keeps an opaque gateway id from taking the line. */
15
16
  const FAMILY_MAX = 14;
@@ -32,12 +33,17 @@ const shortFamily = (name) => (name.length > FAMILY_MAX ? `${name.slice(0, FAMIL
32
33
  * is rather than folding it into a neighbour; the name is shortened here so an
33
34
  * ARN cannot flood the line.
34
35
  *
36
+ * `usd` is what the runs cost, each priced at its own model by the same path
37
+ * the session's own cost takes (a gateway session from the gateway's table),
38
+ * and `unpriced` counts the priced parts that had no rate and were left out.
39
+ *
35
40
  * @param {Array} sessions parseAllSessions result
36
- * @param {{ transcriptPath?: string|null, sessionId?: string|null }} [ids]
37
- * @returns {{ total: number, families: Array<{ family: string, runs: number }> }|null}
41
+ * @param {{ transcriptPath?: string|null, sessionId?: string|null, env?: object }} [ids]
42
+ * `env` decides the pricing path, as in sessionCost
43
+ * @returns {{ total: number, families: Array<{ family: string, runs: number }>, usd: number, unpriced: number }|null}
38
44
  * null when the session cannot be found or ran no subagents
39
45
  */
40
- export function sessionSubagentSummary(sessions, { transcriptPath = null, sessionId = null } = {}) {
46
+ export function sessionSubagentSummary(sessions, { transcriptPath = null, sessionId = null, env = process.env } = {}) {
41
47
  if (!Array.isArray(sessions) || (!transcriptPath && !sessionId)) return null;
42
48
  let hit = null;
43
49
  if (transcriptPath) {
@@ -62,7 +68,10 @@ export function sessionSubagentSummary(sessions, { transcriptPath = null, sessio
62
68
  const families = [...byFamily.entries()]
63
69
  .map(([family, n]) => ({ family, runs: n }))
64
70
  .sort((a, b) => b.runs - a.runs || a.family.localeCompare(b.family));
65
- return { total: runs.length, families };
71
+ // A run is a session-shaped record (totals, model, modelTotals when it
72
+ // switched model), so it is priced the way a session is.
73
+ const cost = sessionCostAcross(runs, { env });
74
+ return { total: runs.length, families, usd: cost.actual, unpriced: cost.unpriced };
66
75
  }
67
76
 
68
77
  /**