@vimoxshah/tokenflow 1.1.1 → 1.2.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/CHANGELOG.md +228 -0
- package/Dockerfile.team +20 -0
- package/README.md +30 -11
- package/bin/tokenflow.js +147 -12
- package/design/tokens.yaml +330 -0
- package/docs/architecture.md +5 -4
- package/docs/cli.md +204 -0
- package/docs/configuration.md +117 -2
- package/docs/design-system.md +187 -0
- package/docs/exports-and-budgets.md +85 -0
- package/docs/guard-codex.md +132 -0
- package/docs/ledger.md +144 -0
- package/docs/live-mode.md +40 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/receipts-aurora-dark.png +0 -0
- package/docs/providers-otel.md +179 -0
- package/docs/providers.md +54 -1
- package/docs/receipt-schema.md +74 -0
- package/docs/roadmap.md +182 -0
- package/docs/team-server.md +170 -0
- package/docs/ui-views.md +322 -0
- package/package.json +7 -2
- package/schemas/receipt.v0.json +160 -0
- package/scripts/build-dmg.sh +11 -2
- package/scripts/build-menubar-app.sh +58 -7
- package/scripts/design-build.js +475 -0
- package/src/analytics/anatomy.js +467 -0
- package/src/analytics/branch-compare.js +159 -0
- package/src/analytics/cache-health.js +141 -0
- package/src/analytics/live-view.js +266 -0
- package/src/analytics/receipt-schema.js +214 -0
- package/src/analytics/receipt.js +709 -0
- package/src/analytics/rhythm.js +184 -0
- package/src/analytics/whatif.js +263 -0
- package/src/commands/budget-scopes.js +133 -0
- package/src/commands/doctor-checks.js +400 -0
- package/src/commands/guard.js +531 -0
- package/src/commands/hooks.js +238 -0
- package/src/commands/pricing-diff.js +316 -0
- package/src/commands/receipt.js +226 -0
- package/src/commands/team-serve.js +407 -0
- package/src/commands/week.js +86 -0
- package/src/core/annotations.js +97 -0
- package/src/core/budget.js +33 -0
- package/src/core/bundle.js +45 -2
- package/src/core/ingest.js +33 -0
- package/src/core/live-status.js +227 -2
- package/src/core/policy.js +103 -0
- package/src/core/receipt-note.js +123 -0
- package/src/core/repo.js +64 -0
- package/src/core/sync.js +163 -26
- package/src/core/team.js +0 -0
- package/src/export/html-snapshot.js +28 -1
- package/src/export/menubar.js +21 -0
- package/src/export/receipt-card.js +210 -0
- package/src/export/week-card.js +185 -0
- package/src/providers/mock/index.js +383 -52
- package/src/providers/openai/index.js +31 -1
- package/src/providers/otel/index.js +656 -0
- package/src/server/routes/annotations.js +42 -0
- package/src/server/routes/cache-health.js +95 -0
- package/src/server/routes/index.js +54 -0
- package/src/server/routes/session.js +157 -0
- package/src/server/server.js +47 -1
- package/src/ui/app.js +541 -308
- package/src/ui/charts.js +95 -0
- package/src/ui/first-run.js +144 -0
- package/src/ui/index.html +4 -1
- package/src/ui/palette.js +335 -0
- package/src/ui/styles/anatomy.css +117 -0
- package/src/ui/styles/annotations.css +40 -0
- package/src/ui/styles/branches.css +99 -0
- package/src/ui/styles/cache.css +6 -0
- package/src/ui/styles/first-run.css +31 -0
- package/src/ui/styles/live.css +100 -0
- package/src/ui/styles/palette.css +85 -0
- package/src/ui/styles/rhythm.css +8 -0
- package/src/ui/styles/whatif.css +55 -0
- package/src/ui/styles.css +303 -196
- package/src/ui/views/anatomy.js +567 -0
- package/src/ui/views/annotations.js +121 -0
- package/src/ui/views/branches.js +304 -0
- package/src/ui/views/cache.js +232 -0
- package/src/ui/views/index.js +85 -0
- package/src/ui/views/live.js +683 -0
- package/src/ui/views/rhythm.js +206 -0
- package/src/ui/views/whatif.js +196 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rhythm: how the work happens — focus, switching, and when a turn costs
|
|
3
|
+
* most.
|
|
4
|
+
*
|
|
5
|
+
* Pure functions over two shapes only:
|
|
6
|
+
* - a session (src/core/store.js `sessionList()`): `{ d, pj, total,
|
|
7
|
+
* durationMs, req, ... }`.
|
|
8
|
+
* - a per-hour rollup (src/analytics/token-usage.js
|
|
9
|
+
* `calculateHourlyUsage()` bucket): `{ hour, total, req, cost, costReq,
|
|
10
|
+
* ... }`.
|
|
11
|
+
*
|
|
12
|
+
* No Node imports (besides the shared, dependency-free formatter module), so
|
|
13
|
+
* this runs the same in the browser, the CLI and the offline snapshot.
|
|
14
|
+
*/
|
|
15
|
+
import { pct, usd, compact, hourLabel, shortDate } from '../core/units.js';
|
|
16
|
+
|
|
17
|
+
/** A deep-work session runs this long or longer without a break. */
|
|
18
|
+
export const DEEP_WORK_MS = 45 * 60 * 1000;
|
|
19
|
+
/** ...or, when duration cannot be measured, spans at least this many turns. */
|
|
20
|
+
export const DEEP_WORK_TURNS = 40;
|
|
21
|
+
|
|
22
|
+
function hasKnownDuration(s) {
|
|
23
|
+
return Number.isFinite(s.durationMs);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isDeepWork(s) {
|
|
27
|
+
if (hasKnownDuration(s)) return s.durationMs >= DEEP_WORK_MS;
|
|
28
|
+
const turns = Number.isFinite(s.req) ? s.req : 0;
|
|
29
|
+
return turns >= DEEP_WORK_TURNS;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deep-work sessions: count, share of sessions, share of tokens, longest.
|
|
34
|
+
* A deep-work session runs 45 minutes or longer of continuous activity, or —
|
|
35
|
+
* when duration is unknown — spans 40 or more turns (requests).
|
|
36
|
+
* @param {any[]} sessions
|
|
37
|
+
* @returns {{count:number, total:number, shareOfSessions:number|null, shareOfTokens:number|null, longest:any|null}}
|
|
38
|
+
*/
|
|
39
|
+
export function deepWork(sessions) {
|
|
40
|
+
if (!sessions || !sessions.length) {
|
|
41
|
+
return { count: 0, total: 0, shareOfSessions: null, shareOfTokens: null, longest: null };
|
|
42
|
+
}
|
|
43
|
+
const flagged = sessions.filter(isDeepWork);
|
|
44
|
+
const totalTokens = sessions.reduce((a, s) => a + (s.total || 0), 0);
|
|
45
|
+
const deepTokens = flagged.reduce((a, s) => a + (s.total || 0), 0);
|
|
46
|
+
let longest = null;
|
|
47
|
+
for (const s of sessions) {
|
|
48
|
+
if (!hasKnownDuration(s)) continue;
|
|
49
|
+
if (!longest || s.durationMs > longest.durationMs) longest = s;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
count: flagged.length,
|
|
53
|
+
total: sessions.length,
|
|
54
|
+
shareOfSessions: sessions.length ? flagged.length / sessions.length : null,
|
|
55
|
+
shareOfTokens: totalTokens > 0 ? deepTokens / totalTokens : null,
|
|
56
|
+
longest,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Project-switching per day: distinct projects touched that day, minus one.
|
|
62
|
+
* Only days that actually have a session are counted — a day with no
|
|
63
|
+
* sessions has no measurable switching, which is not the same as zero
|
|
64
|
+
* switching, so it is left out rather than filled in as a fabricated 0.
|
|
65
|
+
* @param {any[]} sessions
|
|
66
|
+
* @returns {{days:{date:string, projects:number, switches:number}[], average:number|null, worst:{date:string, projects:number, switches:number}|null}}
|
|
67
|
+
*/
|
|
68
|
+
export function switching(sessions) {
|
|
69
|
+
if (!sessions || !sessions.length) return { days: [], average: null, worst: null };
|
|
70
|
+
const byDay = new Map();
|
|
71
|
+
for (const s of sessions) {
|
|
72
|
+
const day = s.d;
|
|
73
|
+
if (day === null || day === undefined) continue;
|
|
74
|
+
let set = byDay.get(day);
|
|
75
|
+
if (!set) { set = new Set(); byDay.set(day, set); }
|
|
76
|
+
set.add(s.pj ?? 'unknown');
|
|
77
|
+
}
|
|
78
|
+
const days = [...byDay.entries()]
|
|
79
|
+
.map(([date, projects]) => ({ date, projects: projects.size, switches: Math.max(0, projects.size - 1) }))
|
|
80
|
+
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
81
|
+
if (!days.length) return { days, average: null, worst: null };
|
|
82
|
+
const total = days.reduce((a, d) => a + d.switches, 0);
|
|
83
|
+
const average = total / days.length;
|
|
84
|
+
let worst = days[0];
|
|
85
|
+
for (const d of days.slice(1)) {
|
|
86
|
+
if (d.switches > worst.switches || (d.switches === worst.switches && d.date > worst.date)) worst = d;
|
|
87
|
+
}
|
|
88
|
+
return { days, average, worst };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Cost (or, failing that, tokens) per request by hour of day, from the
|
|
93
|
+
* per-hour rollups behind `ctx.view` (the `hourly.buckets` this dashboard
|
|
94
|
+
* already computes). The rows are shape-detected rather than assumed:
|
|
95
|
+
* - carries `cost` and `costReq` (requests a price could be assigned to) ->
|
|
96
|
+
* estimated cost per PRICED request, metric `'cost'`.
|
|
97
|
+
* - carries `total`/`tokens` and `req` only -> tokens per request, metric
|
|
98
|
+
* `'tokens'`.
|
|
99
|
+
* - neither -> `null`, so the caller can say "not derivable from the
|
|
100
|
+
* aggregate" instead of guessing.
|
|
101
|
+
*
|
|
102
|
+
* `costReq`, not `req`, is the denominator for the cost metric: `cost` is a
|
|
103
|
+
* sum over priced requests only (src/core/store.js addToCube), so dividing
|
|
104
|
+
* by every request would silently treat unpriced traffic as free — a false
|
|
105
|
+
* zero, not a real one. When an hour has requests but none of them priced,
|
|
106
|
+
* its `value` is `null` rather than 0.
|
|
107
|
+
* @param {any[]} rows
|
|
108
|
+
* @returns {{metric:'cost'|'tokens', hours:{hour:number, value:number|null, requests:number}[], costliest:{hour:number, value:number, requests:number}|null} | null}
|
|
109
|
+
*/
|
|
110
|
+
export function costliestHour(rows) {
|
|
111
|
+
if (!Array.isArray(rows) || !rows.length) return null;
|
|
112
|
+
const sample = rows[0];
|
|
113
|
+
/** @type {'cost'|'tokens'} */
|
|
114
|
+
let metric;
|
|
115
|
+
if ('cost' in sample && 'costReq' in sample) metric = 'cost';
|
|
116
|
+
else if (('total' in sample || 'tokens' in sample) && 'req' in sample) metric = 'tokens';
|
|
117
|
+
else return null;
|
|
118
|
+
|
|
119
|
+
const hours = rows.map((r) => {
|
|
120
|
+
const denom = metric === 'cost' ? (r.costReq || 0) : (r.req || 0);
|
|
121
|
+
const numer = metric === 'cost' ? (r.cost || 0) : (r.total ?? r.tokens ?? 0);
|
|
122
|
+
return { hour: r.hour, value: denom > 0 ? numer / denom : null, requests: denom };
|
|
123
|
+
});
|
|
124
|
+
const ranked = hours.filter((h) => h.value !== null);
|
|
125
|
+
const costliest = ranked.length ? ranked.reduce((a, b) => (b.value > a.value ? b : a)) : null;
|
|
126
|
+
return { metric, hours, costliest };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Days ranked by the share of that day's tokens spent in deep-work sessions,
|
|
131
|
+
* top 5. Days with zero tokens are excluded — a 0/0 share is not a real 0%.
|
|
132
|
+
* @param {any[]} sessions
|
|
133
|
+
* @returns {{date:string, total:number, deepTokens:number, sessions:number, share:number}[]}
|
|
134
|
+
*/
|
|
135
|
+
export function focusDays(sessions) {
|
|
136
|
+
if (!sessions || !sessions.length) return [];
|
|
137
|
+
const byDay = new Map();
|
|
138
|
+
for (const s of sessions) {
|
|
139
|
+
const day = s.d;
|
|
140
|
+
if (day === null || day === undefined) continue;
|
|
141
|
+
let acc = byDay.get(day);
|
|
142
|
+
if (!acc) { acc = { date: day, total: 0, deepTokens: 0, sessions: 0 }; byDay.set(day, acc); }
|
|
143
|
+
const tokens = s.total || 0;
|
|
144
|
+
acc.total += tokens;
|
|
145
|
+
acc.sessions += 1;
|
|
146
|
+
if (isDeepWork(s)) acc.deepTokens += tokens;
|
|
147
|
+
}
|
|
148
|
+
const days = [...byDay.values()]
|
|
149
|
+
.filter((d) => d.total > 0)
|
|
150
|
+
.map((d) => ({ ...d, share: d.deepTokens / d.total }));
|
|
151
|
+
days.sort((a, b) => b.share - a.share || (a.date < b.date ? -1 : 1));
|
|
152
|
+
return days.slice(0, 5);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Three story-strip sentences summarizing the rhythm of work, in the style
|
|
157
|
+
* of the Overview's insight cards.
|
|
158
|
+
* @param {{deep:ReturnType<typeof deepWork>, switching:ReturnType<typeof switching>, costliest:ReturnType<typeof costliestHour>}} o
|
|
159
|
+
* @returns {string[]}
|
|
160
|
+
*/
|
|
161
|
+
export function rhythmSummary({ deep, switching: sw, costliest }) {
|
|
162
|
+
const s1 = deep.count > 0
|
|
163
|
+
? `Deep-work sessions were ${pct(deep.shareOfSessions, 1, 'n/a')} of sessions and ${pct(deep.shareOfTokens, 1, 'n/a')} of tokens.`
|
|
164
|
+
: 'No deep-work sessions (45+ minutes, or 40+ turns when duration is unknown) in this slice.';
|
|
165
|
+
|
|
166
|
+
const s2 = sw.days.length
|
|
167
|
+
? `Projects switched ${sw.average.toFixed(1)} times a day on average, worst was ${sw.worst.switches} switch${sw.worst.switches === 1 ? '' : 'es'} on ${shortDate(sw.worst.date)}.`
|
|
168
|
+
: 'No sessions with a known day, so project switching cannot be measured.';
|
|
169
|
+
|
|
170
|
+
let s3;
|
|
171
|
+
if (!costliest) {
|
|
172
|
+
s3 = 'Cost per request by hour is not derivable from the aggregate.';
|
|
173
|
+
} else if (!costliest.costliest) {
|
|
174
|
+
s3 = costliest.metric === 'cost'
|
|
175
|
+
? 'No priced requests in this slice, so cost per request by hour is not shown.'
|
|
176
|
+
: 'No requests in this slice, so tokens per request by hour is not shown.';
|
|
177
|
+
} else if (costliest.metric === 'cost') {
|
|
178
|
+
s3 = `The costliest hour was ${hourLabel(costliest.costliest.hour)}:00, at ${usd(costliest.costliest.value)} per priced request.`;
|
|
179
|
+
} else {
|
|
180
|
+
s3 = `The heaviest hour was ${hourLabel(costliest.costliest.hour)}:00, at ${compact(costliest.costliest.value)} tokens per request.`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return [s1, s2, s3];
|
|
184
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What-if repricing: take the tokens a model already used and price them
|
|
3
|
+
* again at another model's published rates.
|
|
4
|
+
*
|
|
5
|
+
* This says nothing about quality, latency or output length. It only prices.
|
|
6
|
+
* It answers "what would this same traffic have cost at model B's rates
|
|
7
|
+
* instead of model A's", nothing more.
|
|
8
|
+
*
|
|
9
|
+
* ## Method
|
|
10
|
+
*
|
|
11
|
+
* Both the "current" and "what-if" figures are recomputed here from the raw
|
|
12
|
+
* token counts times `book.lookup(...)`, never read off the row's own
|
|
13
|
+
* `cost` field. That is deliberate: the per-model aggregate the Cost view
|
|
14
|
+
* shows (`view.dimensions.models[*].cost`) already applied each request's
|
|
15
|
+
* service-tier multiplier (Anthropic Batch, OpenAI Fast/priority, ...), but
|
|
16
|
+
* an aggregated model row only carries token totals, not a per-tier
|
|
17
|
+
* breakdown, so there is nothing left here to multiply. Recomputing "current"
|
|
18
|
+
* through the exact same code path as "what-if" is what makes an identity
|
|
19
|
+
* mapping (source === target) provably yield a zero delta, and it is also
|
|
20
|
+
* why a heavily-tiered slice will show a "current" here that sits a little
|
|
21
|
+
* off the Cost tab's tier-aware number for the same model. That is a known,
|
|
22
|
+
* reported simplification, not a bug.
|
|
23
|
+
*
|
|
24
|
+
* ## Missing rates
|
|
25
|
+
*
|
|
26
|
+
* `book.lookup(model, provider)` returns `null` when the model has no price
|
|
27
|
+
* at all: both "current" and "what-if" are `null` (n/a) for that model, and
|
|
28
|
+
* it is excluded from every overall total (current, what-if AND delta).
|
|
29
|
+
* Folding an unknown number into a sum by pretending it is zero would just
|
|
30
|
+
* be a wrong number wearing a confident face. When the model *does* have a
|
|
31
|
+
* price but one token kind on it does not (a partial user override, for
|
|
32
|
+
* example), that one kind reports `null` and the model's total is flagged
|
|
33
|
+
* `partial`; the kinds that ARE known still sum into the total.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Token kinds priced individually. Reasoning tokens are a subset of output
|
|
37
|
+
* tokens (see core/schema.js) and the shipped price table has no separate
|
|
38
|
+
* reasoning rate, so there is no fifth kind to compute today. A future rate
|
|
39
|
+
* that names one would show up as `rate.reasoning`, which nothing here reads
|
|
40
|
+
* yet, by design: inventing a reasoning charge the table does not publish
|
|
41
|
+
* would be exactly the kind of invented price this module exists to avoid. */
|
|
42
|
+
const KIND_KEYS = ['input', 'cacheRead', 'cacheWrite', 'output'];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Price one model's cache-write tokens, splitting the long-TTL (refresh)
|
|
46
|
+
* subset from the short-TTL rest exactly as `core/pricing.js`'s
|
|
47
|
+
* `estimateCost` does, but folded into the single "cache write" kind the
|
|
48
|
+
* spec lists (no separate "cache refresh" row).
|
|
49
|
+
* @param {{cacheWrite:number, cacheRefresh:number}} tok
|
|
50
|
+
* @param {{cacheWrite:number|null|undefined, cacheRefresh:number|null|undefined}} rate
|
|
51
|
+
* @returns {{value:number|null, partial:boolean}}
|
|
52
|
+
*/
|
|
53
|
+
function priceCacheWrite(tok, rate) {
|
|
54
|
+
const refresh = tok.cacheRefresh || 0;
|
|
55
|
+
const write = tok.cacheWrite || 0;
|
|
56
|
+
const shortWrite = Math.max(0, write - refresh);
|
|
57
|
+
const haveRefreshRate = rate.cacheRefresh !== null && rate.cacheRefresh !== undefined;
|
|
58
|
+
const haveWriteRate = rate.cacheWrite !== null && rate.cacheWrite !== undefined;
|
|
59
|
+
if (haveRefreshRate && haveWriteRate) {
|
|
60
|
+
return { value: (shortWrite / 1e6) * rate.cacheWrite + (refresh / 1e6) * rate.cacheRefresh, partial: false };
|
|
61
|
+
}
|
|
62
|
+
if (haveWriteRate) {
|
|
63
|
+
// No distinct refresh rate published: bill the whole write total,
|
|
64
|
+
// refresh subset included, at the plain write rate. This is the same
|
|
65
|
+
// fallback core/pricing.js's estimateCost takes.
|
|
66
|
+
return { value: (write / 1e6) * rate.cacheWrite, partial: false };
|
|
67
|
+
}
|
|
68
|
+
return { value: write > 0 ? null : 0, partial: write > 0 };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Price one model's token counts against one resolved rate entry.
|
|
73
|
+
* @param {{input:number, output:number, cacheRead:number, cacheWrite:number, cacheRefresh:number}} tok
|
|
74
|
+
* @param {{in:number, out:number, cacheRead:number|null, cacheWrite:number|null, cacheRefresh:number|null}|null} rate
|
|
75
|
+
* @returns {{byKind:Record<string, number|null>, total:number|null, partial:boolean}}
|
|
76
|
+
*/
|
|
77
|
+
function priceTokens(tok, rate) {
|
|
78
|
+
if (!rate) {
|
|
79
|
+
const na = Object.fromEntries(KIND_KEYS.map((k) => [k, null]));
|
|
80
|
+
return { byKind: na, total: null, partial: false };
|
|
81
|
+
}
|
|
82
|
+
/** @type {Record<string, number|null>} */
|
|
83
|
+
const byKind = {};
|
|
84
|
+
let total = 0;
|
|
85
|
+
let partial = false;
|
|
86
|
+
const simple = (key, tokens, r) => {
|
|
87
|
+
const t = tokens || 0;
|
|
88
|
+
if (r === null || r === undefined || !Number.isFinite(r)) {
|
|
89
|
+
byKind[key] = t > 0 ? null : 0;
|
|
90
|
+
if (t > 0) partial = true;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const cost = (t / 1e6) * r;
|
|
94
|
+
byKind[key] = cost;
|
|
95
|
+
total += cost;
|
|
96
|
+
};
|
|
97
|
+
simple('input', tok.input, rate.in);
|
|
98
|
+
simple('output', tok.output, rate.out);
|
|
99
|
+
simple('cacheRead', tok.cacheRead, rate.cacheRead);
|
|
100
|
+
const cw = priceCacheWrite(tok, rate);
|
|
101
|
+
byKind.cacheWrite = cw.value;
|
|
102
|
+
if (cw.value !== null) total += cw.value;
|
|
103
|
+
if (cw.partial) partial = true;
|
|
104
|
+
return { byKind, total, partial };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reprice a set of per-model aggregate rows at another model's rates.
|
|
109
|
+
*
|
|
110
|
+
* @param {{
|
|
111
|
+
* rows: {key:string, provider:string, total:number, requests:number,
|
|
112
|
+
* input:number, output:number, cacheRead:number, cacheWrite:number,
|
|
113
|
+
* cacheRefresh:number}[],
|
|
114
|
+
* book: {lookup:(model:string, provider:string)=>({in:number,out:number,
|
|
115
|
+
* cacheRead:number|null,cacheWrite:number|null,cacheRefresh:number|null}|null)},
|
|
116
|
+
* mapping?: Record<string,string>,
|
|
117
|
+
* }} args `rows` is the per-model aggregate the Cost view already computes
|
|
118
|
+
* (`view.dimensions.models`, or any subset of it for the current filters).
|
|
119
|
+
* `mapping` is `{ fromModel: toModel }`; a model absent from `mapping`
|
|
120
|
+
* reprices against itself (identity, zero delta).
|
|
121
|
+
* @returns {{
|
|
122
|
+
* models: {model:string, provider:string, target:string, tokens:number,
|
|
123
|
+
* requests:number, current:number|null, currentByKind:Record<string,number|null>,
|
|
124
|
+
* whatif:number|null, whatifByKind:Record<string,number|null>,
|
|
125
|
+
* delta:number|null, partial:boolean}[],
|
|
126
|
+
* overall: {current:number|null, whatif:number|null, delta:number|null,
|
|
127
|
+
* excluded:number, partial:boolean},
|
|
128
|
+
* }}
|
|
129
|
+
*/
|
|
130
|
+
export function reprice({ rows, book, mapping = {} }) {
|
|
131
|
+
const models = (rows || []).map((row) => {
|
|
132
|
+
const target = mapping[row.key] ?? row.key;
|
|
133
|
+
const tok = {
|
|
134
|
+
input: row.input, output: row.output,
|
|
135
|
+
cacheRead: row.cacheRead, cacheWrite: row.cacheWrite, cacheRefresh: row.cacheRefresh,
|
|
136
|
+
};
|
|
137
|
+
const fromRate = book.lookup(row.key, row.provider);
|
|
138
|
+
const toRate = book.lookup(target, row.provider);
|
|
139
|
+
const cur = priceTokens(tok, fromRate);
|
|
140
|
+
const wi = priceTokens(tok, toRate);
|
|
141
|
+
const delta = cur.total !== null && wi.total !== null ? wi.total - cur.total : null;
|
|
142
|
+
return {
|
|
143
|
+
model: row.key,
|
|
144
|
+
provider: row.provider,
|
|
145
|
+
target,
|
|
146
|
+
tokens: row.total,
|
|
147
|
+
requests: row.requests,
|
|
148
|
+
current: cur.total,
|
|
149
|
+
currentByKind: cur.byKind,
|
|
150
|
+
whatif: wi.total,
|
|
151
|
+
whatifByKind: wi.byKind,
|
|
152
|
+
delta,
|
|
153
|
+
// A gap on either side of the mapping is still a gap: either number
|
|
154
|
+
// being incomplete makes the pairing incomplete.
|
|
155
|
+
partial: cur.partial || wi.partial,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// A model missing on either side cannot honestly contribute to an overall
|
|
160
|
+
// current/what-if/delta trio: the three must stay `delta === whatif -
|
|
161
|
+
// current`, which only holds over the same set of rows on all three.
|
|
162
|
+
const comparable = models.filter((m) => m.current !== null && m.whatif !== null);
|
|
163
|
+
const sum = (get) => comparable.reduce((a, m) => a + get(m), 0);
|
|
164
|
+
const overall = {
|
|
165
|
+
current: comparable.length ? sum((m) => m.current) : null,
|
|
166
|
+
whatif: comparable.length ? sum((m) => m.whatif) : null,
|
|
167
|
+
delta: comparable.length ? sum((m) => m.delta) : null,
|
|
168
|
+
excluded: models.length - comparable.length,
|
|
169
|
+
partial: models.length > comparable.length || models.some((m) => m.partial),
|
|
170
|
+
};
|
|
171
|
+
return { models, overall };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------- target list ----
|
|
175
|
+
|
|
176
|
+
const SRC_PROVIDER = {
|
|
177
|
+
anthropic: 'Anthropic',
|
|
178
|
+
openai: 'OpenAI',
|
|
179
|
+
'openai-thirdparty': 'OpenAI',
|
|
180
|
+
deepseek: 'DeepSeek',
|
|
181
|
+
zai: 'Z.ai',
|
|
182
|
+
google: 'Google',
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** Prefix fallback for entries whose `src` does not name a provider directly
|
|
186
|
+
* (`legacy`, `user`): only changes which optgroup a name is filed under, not
|
|
187
|
+
* any rate, since every shipped rate is looked up by model name alone. */
|
|
188
|
+
function inferProvider(name) {
|
|
189
|
+
if (/^claude/i.test(name)) return 'Anthropic';
|
|
190
|
+
if (/^(gpt|o3|o4|chat)/i.test(name)) return 'OpenAI';
|
|
191
|
+
if (/^deepseek/i.test(name)) return 'DeepSeek';
|
|
192
|
+
if (/^gemini/i.test(name)) return 'Google';
|
|
193
|
+
if (/^glm/i.test(name)) return 'Z.ai';
|
|
194
|
+
return 'Other';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The first top-level alternative of a regex source string: the substring
|
|
199
|
+
* up to the first `|` that sits outside any `(...)` group.
|
|
200
|
+
* @param {string} pattern
|
|
201
|
+
* @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
function firstTopLevelAlt(pattern) {
|
|
204
|
+
let depth = 0;
|
|
205
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
206
|
+
const ch = pattern[i];
|
|
207
|
+
if (ch === '\\') { i++; continue; }
|
|
208
|
+
if (ch === '(') depth++;
|
|
209
|
+
else if (ch === ')') depth--;
|
|
210
|
+
else if (ch === '|' && depth === 0) return pattern.slice(0, i);
|
|
211
|
+
}
|
|
212
|
+
return pattern;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Turn one `BUILTIN_PRICES`-style `match` regex into one representative,
|
|
217
|
+
* literal model name: take the first top-level alternative, drop the
|
|
218
|
+
* anchors, and resolve every `(...)` group to its own first alternative. An
|
|
219
|
+
* alternative that is bare `$` (an end-of-string anchor standing in for
|
|
220
|
+
* "nothing more") resolves to the empty string, not the literal text "$".
|
|
221
|
+
* @param {string} pattern
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
export function deriveModelName(pattern) {
|
|
225
|
+
let s = firstTopLevelAlt(pattern);
|
|
226
|
+
s = s.replace(/^\^/, '').replace(/\$$/, '');
|
|
227
|
+
s = s.replace(/\(([^()]*)\)/g, (_, inner) => {
|
|
228
|
+
const alt = firstTopLevelAlt(inner);
|
|
229
|
+
return alt === '$' ? '' : alt;
|
|
230
|
+
});
|
|
231
|
+
s = s.replace(/\\(.)/g, '$1');
|
|
232
|
+
return s;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Every target a "reprice at" select may offer: one representative name per
|
|
237
|
+
* price-table entry (builtin and user overrides), unioned with every priced
|
|
238
|
+
* model actually seen in the dataset. That way migrating *to* a model you
|
|
239
|
+
* have never used is possible, and a model an imperfect regex-derivation
|
|
240
|
+
* missed is not silently dropped just because it showed up in real usage.
|
|
241
|
+
* Every name is round-tripped through `book.lookup` before being offered, so
|
|
242
|
+
* a derivation slip never produces an option that reprices to n/a.
|
|
243
|
+
* @param {{book:object, seenModels?:({value:string}|string)[]}} args
|
|
244
|
+
* @returns {{name:string, provider:string}[]} sorted by provider, then name.
|
|
245
|
+
*/
|
|
246
|
+
export function targetModelOptions({ book, seenModels = [] }) {
|
|
247
|
+
const byName = new Map();
|
|
248
|
+
for (const e of book.entries || []) {
|
|
249
|
+
const name = e.origin === 'user' ? e.key : deriveModelName(e.match);
|
|
250
|
+
if (!name || byName.has(name)) continue;
|
|
251
|
+
if (!book.lookup(name, 'unknown')) continue;
|
|
252
|
+
byName.set(name, SRC_PROVIDER[e.src] || inferProvider(name));
|
|
253
|
+
}
|
|
254
|
+
for (const s of seenModels) {
|
|
255
|
+
const name = typeof s === 'string' ? s : s.value;
|
|
256
|
+
if (!name || byName.has(name)) continue;
|
|
257
|
+
if (!book.lookup(name, 'unknown')) continue;
|
|
258
|
+
byName.set(name, inferProvider(name));
|
|
259
|
+
}
|
|
260
|
+
return [...byName.entries()]
|
|
261
|
+
.map(([name, provider]) => ({ name, provider }))
|
|
262
|
+
.sort((a, b) => (a.provider === b.provider ? a.name.localeCompare(b.name) : a.provider.localeCompare(b.provider)));
|
|
263
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tokenflow budget` scoped budgets — repo and team caps alongside the single
|
|
3
|
+
* monthly budget in src/core/budget.js.
|
|
4
|
+
*
|
|
5
|
+
* Config (config.yaml):
|
|
6
|
+
*
|
|
7
|
+
* budgets:
|
|
8
|
+
* - id: api-monthly
|
|
9
|
+
* scope: repo # total | repo | team
|
|
10
|
+
* repo: api # required when scope is repo; matched against
|
|
11
|
+
* # the same repository identity `receipt` uses
|
|
12
|
+
* # (worktrees folded into their main checkout)
|
|
13
|
+
* monthlyUsd: 150
|
|
14
|
+
* warnAt: 0.8 # optional, default 0.8 (a fraction, like limits[].warnAt)
|
|
15
|
+
*
|
|
16
|
+
* "Spent" per scope, this calendar month (UTC date, same convention `tokenflow
|
|
17
|
+
* budget` already uses for the single monthly cap):
|
|
18
|
+
* - total: every priced turn in the store this month.
|
|
19
|
+
* - repo: turns this month attributed to that repository, resolved the same
|
|
20
|
+
* way `tokenflow receipt` resolves it (worktrees folded into their
|
|
21
|
+
* main checkout) — a plain `project`/`repository` field match would
|
|
22
|
+
* split one repo's spend across every worktree it ever had.
|
|
23
|
+
* - team: this machine's shared sync folder (`sync.dir`), if enabled; the
|
|
24
|
+
* budget engine never invents a team total nobody's synced.
|
|
25
|
+
*
|
|
26
|
+
* Repo/team scope needs a fresh, month-bounded receipt pass (buildReceiptsForStore
|
|
27
|
+
* caches the *whole* store with no date window, so it cannot answer "this
|
|
28
|
+
* month" by itself) — built here with the exact same pieces buildReceiptsForStore
|
|
29
|
+
* uses internally (createReceiptBuilder + makeRepoResolver). buildReceiptsForStore
|
|
30
|
+
* itself is still called, to tell "this repo has no spend yet this month" apart
|
|
31
|
+
* from "this repo has never been seen in the store" in the row's note.
|
|
32
|
+
*/
|
|
33
|
+
import { paths } from '../core/config.js';
|
|
34
|
+
import { Store, readJson, decodeRecord } from '../core/store.js';
|
|
35
|
+
import { buildPriceBook } from '../core/pricing.js';
|
|
36
|
+
import { MEASUREMENT } from '../core/schema.js';
|
|
37
|
+
import { makeRepoResolver } from '../core/repo.js';
|
|
38
|
+
import { createReceiptBuilder } from '../analytics/receipt.js';
|
|
39
|
+
import { buildReceiptsForStore } from '../core/bundle.js';
|
|
40
|
+
import { isEnabled, syncDir } from '../core/sync.js';
|
|
41
|
+
import { aggregate } from '../core/team.js';
|
|
42
|
+
import { scopedBudgetState } from '../core/budget.js';
|
|
43
|
+
import { usd, pct } from '../core/units.js';
|
|
44
|
+
|
|
45
|
+
function monthWindow(now) {
|
|
46
|
+
const d = now instanceof Date ? now : new Date(now || Date.now());
|
|
47
|
+
const today = d.toISOString().slice(0, 10);
|
|
48
|
+
return { from: `${today.slice(0, 7)}-01`, to: today };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {object} b configured budget entry
|
|
53
|
+
* @param {{spentUsd:number, note?:string}} extra
|
|
54
|
+
*/
|
|
55
|
+
function row(b, { spentUsd, note }) {
|
|
56
|
+
const st = scopedBudgetState({
|
|
57
|
+
spentUsd,
|
|
58
|
+
monthlyUsd: b.monthlyUsd,
|
|
59
|
+
warnAt: typeof b.warnAt === 'number' ? b.warnAt : 0.8,
|
|
60
|
+
});
|
|
61
|
+
const label = b.scope === 'repo' ? (b.repo || b.id) : b.scope === 'team' ? 'Team' : 'Total';
|
|
62
|
+
return {
|
|
63
|
+
id: b.id,
|
|
64
|
+
scope: b.scope,
|
|
65
|
+
label,
|
|
66
|
+
spentUsd: st.spentUsd,
|
|
67
|
+
monthlyUsd: st.monthlyUsd,
|
|
68
|
+
share: st.share,
|
|
69
|
+
state: st.state,
|
|
70
|
+
note: note || null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Evaluate every configured scoped budget against this month's spend.
|
|
76
|
+
* @param {{config?:object, store?:object, now?:Date|string}} [opt]
|
|
77
|
+
* @returns {object[]} rows: `{id, scope, label, spentUsd, monthlyUsd, share, state, note}`
|
|
78
|
+
*/
|
|
79
|
+
export function evaluateScopedBudgets({ config, store, now } = {}) {
|
|
80
|
+
const cfg = config || {};
|
|
81
|
+
const budgets = Array.isArray(cfg.budgets) ? cfg.budgets : [];
|
|
82
|
+
const valid = budgets.filter((b) => b && b.id && b.scope && b.monthlyUsd > 0);
|
|
83
|
+
if (!valid.length) return [];
|
|
84
|
+
|
|
85
|
+
const st = store || new Store();
|
|
86
|
+
const { from, to } = monthWindow(now);
|
|
87
|
+
const pricing = readJson(paths().pricing, {});
|
|
88
|
+
const book = buildPriceBook(pricing);
|
|
89
|
+
|
|
90
|
+
// One month-bounded receipts pass, reused by every total/repo row below.
|
|
91
|
+
const builder = createReceiptBuilder({ book, repoOf: makeRepoResolver() });
|
|
92
|
+
st.scanRecords((o) => {
|
|
93
|
+
if (o.ms !== MEASUREMENT.PRIMARY) return;
|
|
94
|
+
if (o.d < from || o.d > to) return;
|
|
95
|
+
builder.add(decodeRecord(o));
|
|
96
|
+
});
|
|
97
|
+
const scoped = builder.finish();
|
|
98
|
+
|
|
99
|
+
const rows = [];
|
|
100
|
+
for (const b of valid) {
|
|
101
|
+
if (b.scope === 'total') {
|
|
102
|
+
rows.push(row(b, { spentUsd: scoped.totals.cost ?? 0 }));
|
|
103
|
+
} else if (b.scope === 'repo') {
|
|
104
|
+
const R = scoped.repos.find((r) => r.repo === b.repo);
|
|
105
|
+
if (R) {
|
|
106
|
+
rows.push(row(b, { spentUsd: R.cost ?? 0 }));
|
|
107
|
+
} else {
|
|
108
|
+
const allTime = buildReceiptsForStore(st, pricing);
|
|
109
|
+
const known = allTime.repos.some((r) => r.repo === b.repo);
|
|
110
|
+
rows.push(row(b, { spentUsd: 0, note: known ? 'no spend this month' : 'repository not seen in the store' }));
|
|
111
|
+
}
|
|
112
|
+
} else if (b.scope === 'team') {
|
|
113
|
+
if (!isEnabled(cfg)) { rows.push(row(b, { spentUsd: 0, note: 'no team data' })); continue; }
|
|
114
|
+
const dir = syncDir(cfg);
|
|
115
|
+
const team = aggregate(dir, { from, to });
|
|
116
|
+
if (!team) { rows.push(row(b, { spentUsd: 0, note: 'no team data' })); continue; }
|
|
117
|
+
rows.push(row(b, { spentUsd: team.totals.estCostUsd || 0 }));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return rows;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @param {object[]} rows from evaluateScopedBudgets() */
|
|
124
|
+
export function renderScopedBudgets(rows) {
|
|
125
|
+
if (!rows.length) return 'No scoped budgets configured. Add one to config.yaml under `budgets:`.';
|
|
126
|
+
const L = [];
|
|
127
|
+
for (const r of rows) {
|
|
128
|
+
const tag = r.state === 'over' ? 'OVER' : r.state === 'warn' ? 'WARN' : 'ok';
|
|
129
|
+
const shareTxt = pct(r.share, 0, 'n/a');
|
|
130
|
+
L.push(`[${tag}] ${r.label} (${r.scope}): ${usd(r.spentUsd, 'n/a')} of ${usd(r.monthlyUsd, 'n/a')} (${shareTxt})${r.note ? ` ${r.note}` : ''}`);
|
|
131
|
+
}
|
|
132
|
+
return L.join('\n');
|
|
133
|
+
}
|