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 +1 -1
- package/src/formatters/statusline.js +36 -9
- package/src/route-scan.js +21 -5
- package/src/savings-ledger.js +21 -7
- package/src/statusline-data.js +13 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
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
|
|
400
|
-
* subagent the session started, not only the ones a routing rule sent,
|
|
401
|
-
* answers "how much did this session fan out" rather than "how many
|
|
402
|
-
* fired". Null when the session ran none,
|
|
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
|
-
|
|
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,
|
package/src/savings-ledger.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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;
|
package/src/statusline-data.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
/**
|