@vimoxshah/tokenflow 1.1.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/CONTRIBUTING.md +84 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/Refresh & Open Dashboard.command +22 -0
- package/SECURITY.md +42 -0
- package/bin/tokenflow.js +1342 -0
- package/docs/architecture.md +193 -0
- package/docs/cli.md +390 -0
- package/docs/configuration.md +281 -0
- package/docs/creating-provider.md +262 -0
- package/docs/data-model.md +213 -0
- package/docs/getting-started.md +266 -0
- package/docs/live-mode.md +199 -0
- package/docs/media/architecture-hero.svg +86 -0
- package/docs/media/cost-editorial-dark.png +0 -0
- package/docs/media/health-terminal-light.png +0 -0
- package/docs/media/menubar-dark.png +0 -0
- package/docs/media/menubar-light.png +0 -0
- package/docs/media/models-terminal-dark.png +0 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/time-aurora-light.png +0 -0
- package/docs/providers.md +309 -0
- package/docs/skill.md +64 -0
- package/docs/troubleshooting.md +207 -0
- package/examples/config.example.yaml +92 -0
- package/examples/demo-data/README.md +38 -0
- package/examples/demo-data/sample-usage.csv +11 -0
- package/package.json +74 -0
- package/scripts/build-dmg.sh +33 -0
- package/scripts/build-menubar-app.sh +67 -0
- package/scripts/lint.js +111 -0
- package/scripts/validate-install.js +140 -0
- package/skills/tokenflow/SKILL.md +392 -0
- package/skills/tokenflow/examples/config.yaml +92 -0
- package/skills/tokenflow/examples/generic-mapping.json +26 -0
- package/skills/tokenflow/examples/session-transcript.md +191 -0
- package/skills/tokenflow/providers/adapter-template.js +135 -0
- package/skills/tokenflow/providers/detection-matrix.md +142 -0
- package/skills/tokenflow/schemas/config.schema.json +107 -0
- package/skills/tokenflow/schemas/normalized-record.json +63 -0
- package/src/analytics/aggregate.js +247 -0
- package/src/analytics/anomalies.js +222 -0
- package/src/analytics/capacity.js +278 -0
- package/src/analytics/comparison.js +96 -0
- package/src/analytics/dimensions.js +230 -0
- package/src/analytics/efficiency.js +138 -0
- package/src/analytics/forecast.js +202 -0
- package/src/analytics/index.js +327 -0
- package/src/analytics/insights.js +283 -0
- package/src/analytics/milestones.js +91 -0
- package/src/analytics/peak.js +106 -0
- package/src/analytics/productivity.js +166 -0
- package/src/analytics/token-usage.js +267 -0
- package/src/commands/diagnostics.js +88 -0
- package/src/commands/digest.js +155 -0
- package/src/commands/models-compare.js +96 -0
- package/src/core/budget.js +142 -0
- package/src/core/bundle.js +191 -0
- package/src/core/config.js +202 -0
- package/src/core/delivery.js +109 -0
- package/src/core/geo.js +99 -0
- package/src/core/ingest.js +457 -0
- package/src/core/interface-map.js +55 -0
- package/src/core/jsonl.js +124 -0
- package/src/core/live-status.js +417 -0
- package/src/core/model-map.js +157 -0
- package/src/core/notify.js +83 -0
- package/src/core/pricing.js +288 -0
- package/src/core/prompt-analytics.js +127 -0
- package/src/core/registry.js +107 -0
- package/src/core/restore.js +261 -0
- package/src/core/schedule.js +120 -0
- package/src/core/schema.js +316 -0
- package/src/core/sqlite.js +96 -0
- package/src/core/store.js +493 -0
- package/src/core/sync.js +151 -0
- package/src/core/units.js +147 -0
- package/src/core/validate.js +123 -0
- package/src/core/watch.js +287 -0
- package/src/core/yaml.js +209 -0
- package/src/export/bundler.js +107 -0
- package/src/export/csv.js +100 -0
- package/src/export/html-snapshot.js +101 -0
- package/src/export/menubar.js +158 -0
- package/src/index.js +18 -0
- package/src/providers/anthropic/index.js +294 -0
- package/src/providers/cline/index.js +120 -0
- package/src/providers/cursor/index.js +143 -0
- package/src/providers/generic/index.js +268 -0
- package/src/providers/git/index.js +188 -0
- package/src/providers/headroom/index.js +114 -0
- package/src/providers/hermes/index.js +299 -0
- package/src/providers/mock/index.js +117 -0
- package/src/providers/openai/index.js +370 -0
- package/src/providers/opencode/index.js +245 -0
- package/src/sdk.js +46 -0
- package/src/server/server.js +264 -0
- package/src/ui/app.js +2473 -0
- package/src/ui/charts.js +925 -0
- package/src/ui/index.html +42 -0
- package/src/ui/styles.css +644 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capacity planning over user-declared limits.
|
|
3
|
+
*
|
|
4
|
+
* The engine never invents vendor quota data — no screen here claims to know
|
|
5
|
+
* what Anthropic or OpenAI think your remaining balance is. Instead this module
|
|
6
|
+
* answers the question with data TokenFlow actually has: a limit you declared
|
|
7
|
+
* in config, evaluated against the measured consumption in the store.
|
|
8
|
+
*
|
|
9
|
+
* ```yaml
|
|
10
|
+
* limits:
|
|
11
|
+
* - id: anthropic-monthly
|
|
12
|
+
* provider: anthropic # optional cube filters: provider | model | project
|
|
13
|
+
* scope: month # day | week | month
|
|
14
|
+
* metric: tokens # tokens | input | output | requests | cost
|
|
15
|
+
* cap: 120000000 # tokens (or dollars when metric: cost)
|
|
16
|
+
* warnAt: 0.8 # optional warn threshold, default 0.8
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* From that it derives, per limit: consumption in the current window, percent
|
|
20
|
+
* used, remaining capacity, burn rate (today's hourly pace and the trailing
|
|
21
|
+
* 7-day average), projected exhaustion, and exactly when the window resets.
|
|
22
|
+
*
|
|
23
|
+
* Pure module: all wall-clock facts enter through arguments (`nowMs`,
|
|
24
|
+
* `tzOffsetMinutes`, `today`) so results are testable and identical across
|
|
25
|
+
* CLI, server and browser.
|
|
26
|
+
*/
|
|
27
|
+
import { indexCube, filterCube, sumRows, finalize, weekStart } from './aggregate.js';
|
|
28
|
+
|
|
29
|
+
export const LIMIT_SCOPES = /** @type {const} */ ({ DAY: 'day', WEEK: 'week', MONTH: 'month' });
|
|
30
|
+
export const LIMIT_METRICS = /** @type {const} */ ({
|
|
31
|
+
TOKENS: 'tokens', INPUT: 'input', OUTPUT: 'output', REQUESTS: 'requests', COST: 'cost',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Validate one limit definition from config. @returns {{ok:boolean, errors:string[], value?:object}} */
|
|
35
|
+
export function normalizeLimit(def) {
|
|
36
|
+
const errors = [];
|
|
37
|
+
if (!def || typeof def !== 'object') return { ok: false, errors: ['limit must be an object'] };
|
|
38
|
+
const id = typeof def.id === 'string' && def.id.trim() ? def.id.trim() : null;
|
|
39
|
+
if (!id) errors.push('id is required');
|
|
40
|
+
const scope = String(def.scope || '').toLowerCase();
|
|
41
|
+
const knownScopes = /** @type {string[]} */ (Object.values(LIMIT_SCOPES));
|
|
42
|
+
if (!knownScopes.includes(scope)) {
|
|
43
|
+
errors.push(`scope must be one of ${knownScopes.join(' | ')}`);
|
|
44
|
+
}
|
|
45
|
+
const metric = String(def.metric || LIMIT_METRICS.TOKENS).toLowerCase();
|
|
46
|
+
const knownMetrics = /** @type {string[]} */ (Object.values(LIMIT_METRICS));
|
|
47
|
+
if (!knownMetrics.includes(metric)) {
|
|
48
|
+
errors.push(`metric must be one of ${knownMetrics.join(' | ')}`);
|
|
49
|
+
}
|
|
50
|
+
const cap = Number(def.cap);
|
|
51
|
+
if (!Number.isFinite(cap) || cap <= 0) errors.push('cap must be a positive number');
|
|
52
|
+
let warnAt = def.warnAt === undefined || def.warnAt === null ? 0.8 : Number(def.warnAt);
|
|
53
|
+
if (!Number.isFinite(warnAt) || warnAt <= 0 || warnAt > 1) errors.push('warnAt must be a fraction between 0 and 1');
|
|
54
|
+
for (const k of ['provider', 'model', 'project']) {
|
|
55
|
+
if (def[k] !== undefined && def[k] !== null && typeof def[k] !== 'string') {
|
|
56
|
+
errors.push(`${k} must be a string`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (errors.length) return { ok: false, errors };
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
errors: [],
|
|
63
|
+
value: {
|
|
64
|
+
id,
|
|
65
|
+
label: typeof def.label === 'string' && def.label.trim() ? def.label.trim() : id,
|
|
66
|
+
scope,
|
|
67
|
+
metric,
|
|
68
|
+
cap,
|
|
69
|
+
warnAt,
|
|
70
|
+
provider: def.provider ?? null,
|
|
71
|
+
model: def.model ?? null,
|
|
72
|
+
project: def.project ?? null,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Validate a whole limits array. Used by config import + the settings dialog. */
|
|
78
|
+
export function normalizeLimits(list) {
|
|
79
|
+
const out = [];
|
|
80
|
+
const invalid = [];
|
|
81
|
+
if (!Array.isArray(list)) return { limits: out, invalid };
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
list.forEach((def, i) => {
|
|
84
|
+
const v = normalizeLimit(def);
|
|
85
|
+
if (!v.ok) { invalid.push({ index: i, id: def?.id ?? null, errors: v.errors }); return; }
|
|
86
|
+
if (seen.has(v.value.id)) {
|
|
87
|
+
invalid.push({ index: i, id: v.value.id, errors: ['duplicate id'] });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
seen.add(v.value.id);
|
|
91
|
+
out.push(v.value);
|
|
92
|
+
});
|
|
93
|
+
return { limits: out, invalid };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Local midnight of `iso` expressed as a UTC epoch ms, given the timezone's
|
|
98
|
+
* offset in minutes east of UTC. This is the anchor for every reset countdown:
|
|
99
|
+
* a window ends when the *user's* calendar rolls over, not the machine's.
|
|
100
|
+
*/
|
|
101
|
+
export function localMidnightMs(iso, tzOffsetMinutes) {
|
|
102
|
+
const [y, m, d] = iso.split('-').map(Number);
|
|
103
|
+
return Date.UTC(y, m - 1, d, 0, 0, 0) - tzOffsetMinutes * 60000;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function addDaysISO(iso, n) {
|
|
107
|
+
const [y, m, d] = iso.split('-').map(Number);
|
|
108
|
+
const dt = new Date(Date.UTC(y, m - 1, d + n));
|
|
109
|
+
return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(dt.getUTCDate()).padStart(2, '0')}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @param {string} scope @param {string} todayIso */
|
|
113
|
+
export function windowFor(scope, todayIso) {
|
|
114
|
+
switch (scope) {
|
|
115
|
+
case LIMIT_SCOPES.DAY: return { from: todayIso, to: todayIso };
|
|
116
|
+
case LIMIT_SCOPES.WEEK: return { from: weekStart(todayIso), to: todayIso };
|
|
117
|
+
case LIMIT_SCOPES.MONTH: return { from: `${todayIso.slice(0, 7)}-01`, to: todayIso };
|
|
118
|
+
default: throw new Error(`unknown scope ${scope}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** When the current window ends, as UTC ms + a human interval. */
|
|
123
|
+
export function resetFor(scope, todayIso, tzOffsetMinutes, nowMs) { const midnight = localMidnightMs(todayIso, tzOffsetMinutes);
|
|
124
|
+
let end;
|
|
125
|
+
if (scope === LIMIT_SCOPES.DAY) {
|
|
126
|
+
end = localMidnightMs(addDaysISO(todayIso, 1), tzOffsetMinutes);
|
|
127
|
+
} else if (scope === LIMIT_SCOPES.WEEK) {
|
|
128
|
+
end = localMidnightMs(addDaysISO(weekStart(todayIso), 7), tzOffsetMinutes);
|
|
129
|
+
} else {
|
|
130
|
+
const nextMonth = addDaysISO(`${todayIso.slice(0, 7)}-01`, 32).slice(0, 7) + '-01';
|
|
131
|
+
end = localMidnightMs(nextMonth, tzOffsetMinutes);
|
|
132
|
+
}
|
|
133
|
+
return { atMs: end, inMs: Math.max(0, end - nowMs), midnightMs: midnight };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @param {object} m @param {string} metric */
|
|
137
|
+
function usedFromMeasures(m, metric) {
|
|
138
|
+
switch (metric) {
|
|
139
|
+
case LIMIT_METRICS.INPUT: return { used: m.in, unit: 'tokens' };
|
|
140
|
+
case LIMIT_METRICS.OUTPUT: return { used: m.out, unit: 'tokens' };
|
|
141
|
+
case LIMIT_METRICS.REQUESTS: return { used: m.req, unit: 'requests' };
|
|
142
|
+
case LIMIT_METRICS.COST: return { used: m.cost, unit: 'usd' };
|
|
143
|
+
case LIMIT_METRICS.TOKENS:
|
|
144
|
+
default: return { used: m.total, unit: 'tokens' };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Evaluate every configured limit against the dataset.
|
|
150
|
+
*
|
|
151
|
+
* @param {object[]} rawLimits limit definitions straight from config
|
|
152
|
+
* @param {{
|
|
153
|
+
* cube: object, today: string, nowMs?: number, tzOffsetMinutes?: number,
|
|
154
|
+
* coverageFrom?: string | null,
|
|
155
|
+
* }} p
|
|
156
|
+
* `coverageFrom` (optional, YYYY-MM-DD) trims the trailing-burn denominator
|
|
157
|
+
* when the dataset is younger than the 7-day window, so a three-day-old
|
|
158
|
+
* store does not understate its own pace.
|
|
159
|
+
*/
|
|
160
|
+
export function evaluateLimits(rawLimits, p) {
|
|
161
|
+
const { limits, invalid } = normalizeLimits(rawLimits);
|
|
162
|
+
const nowMs = p.nowMs ?? Date.now();
|
|
163
|
+
const tzOff = p.tzOffsetMinutes ?? 0;
|
|
164
|
+
const states = [];
|
|
165
|
+
if (!limits.length) return { states, invalid };
|
|
166
|
+
|
|
167
|
+
const ix = indexCube(p.cube);
|
|
168
|
+
const trailingTo = addDaysISO(p.today, -1);
|
|
169
|
+
const trailingFrom = addDaysISO(p.today, -7);
|
|
170
|
+
// Quota pacing does not pause on weekends, so zeros count — but a store with
|
|
171
|
+
// less history than the window must not dilute its own burn rate with days
|
|
172
|
+
// it could not possibly have measured.
|
|
173
|
+
const effectiveFrom = p.coverageFrom && p.coverageFrom > trailingFrom ? p.coverageFrom : trailingFrom;
|
|
174
|
+
const basisDays = Math.max(1, daysInclusive(effectiveFrom, trailingTo));
|
|
175
|
+
|
|
176
|
+
for (const lim of limits) {
|
|
177
|
+
const scopeFilters = {
|
|
178
|
+
provider: lim.provider ? [lim.provider] : null,
|
|
179
|
+
model: lim.model ? [lim.model] : null,
|
|
180
|
+
project: lim.project ? [lim.project] : null,
|
|
181
|
+
};
|
|
182
|
+
const win = windowFor(lim.scope, p.today);
|
|
183
|
+
const rows = filterCube(ix, {
|
|
184
|
+
from: win.from, to: win.to, includeOverlay: false, ...scopeFilters,
|
|
185
|
+
});
|
|
186
|
+
const m = finalize(sumRows(rows, ix));
|
|
187
|
+
const { used, unit } = usedFromMeasures(m, lim.metric);
|
|
188
|
+
|
|
189
|
+
const pctUsed = lim.cap > 0 ? used / lim.cap : null;
|
|
190
|
+
let status = 'unknown';
|
|
191
|
+
if (pctUsed !== null) status = pctUsed >= 1 ? 'exceeded' : pctUsed >= lim.warnAt ? 'warn' : 'ok';
|
|
192
|
+
|
|
193
|
+
// Today's slice for the hourly burn (same filters, single day).
|
|
194
|
+
const todayRows = filterCube(ix, {
|
|
195
|
+
from: p.today, to: p.today, includeOverlay: false, ...scopeFilters,
|
|
196
|
+
});
|
|
197
|
+
const tm = finalize(sumRows(todayRows, ix));
|
|
198
|
+
const todayUsed = usedFromMeasures(tm, lim.metric).used;
|
|
199
|
+
|
|
200
|
+
const localMinutes = ((nowMs / 60000 + tzOff) % 1440 + 1440) % 1440;
|
|
201
|
+
const hoursElapsed = Math.max(localMinutes / 60, 0.25); // floor avoids a midnight divide-by-zero
|
|
202
|
+
const burnPerHour = todayUsed > 0 ? todayUsed / hoursElapsed : 0;
|
|
203
|
+
|
|
204
|
+
const trailRows = filterCube(ix, {
|
|
205
|
+
from: effectiveFrom, to: trailingTo, includeOverlay: false, ...scopeFilters,
|
|
206
|
+
});
|
|
207
|
+
const trailM = finalize(sumRows(trailRows, ix));
|
|
208
|
+
const burnPerDay = usedFromMeasures(trailM, lim.metric).used / basisDays;
|
|
209
|
+
|
|
210
|
+
const remaining = Math.max(0, lim.cap - used);
|
|
211
|
+
const eta = exhaustionFor(remaining, burnPerHour, burnPerDay);
|
|
212
|
+
const reset = resetFor(lim.scope, p.today, tzOff, nowMs);
|
|
213
|
+
|
|
214
|
+
states.push({
|
|
215
|
+
...lim,
|
|
216
|
+
unit,
|
|
217
|
+
window: win,
|
|
218
|
+
used,
|
|
219
|
+
remaining,
|
|
220
|
+
pctUsed,
|
|
221
|
+
status,
|
|
222
|
+
requests: m.req,
|
|
223
|
+
...(lim.metric === LIMIT_METRICS.COST
|
|
224
|
+
? { priceCoverage: m.req ? m.costReq / m.req : null }
|
|
225
|
+
: {}),
|
|
226
|
+
burn: {
|
|
227
|
+
perHourToday: burnPerHour || null,
|
|
228
|
+
perDayTrailing: burnPerDay || null,
|
|
229
|
+
basisDays,
|
|
230
|
+
},
|
|
231
|
+
etaHours: eta ? eta.hours : null,
|
|
232
|
+
etaVia: eta ? eta.via : null,
|
|
233
|
+
resetsAtMs: reset.atMs,
|
|
234
|
+
resetsInMs: reset.inMs,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
states.sort((a, b) => urgency(b) - urgency(a));
|
|
239
|
+
return { states, invalid };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function daysInclusive(from, to) {
|
|
243
|
+
const [y1, m1, d1] = from.split('-').map(Number);
|
|
244
|
+
const [y2, m2, d2] = to.split('-').map(Number);
|
|
245
|
+
return Math.round((Date.UTC(y2, m2 - 1, d2) - Date.UTC(y1, m1 - 1, d1)) / 86400000) + 1;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function exhaustionFor(remaining, burnPerHour, burnPerDay) {
|
|
249
|
+
if (!(remaining > 0)) return null;
|
|
250
|
+
if (burnPerHour > 0 && Number.isFinite(burnPerHour)) return { hours: remaining / burnPerHour, via: 'hourly' };
|
|
251
|
+
if (burnPerDay !== null && burnPerDay > 0) return { hours: (remaining / burnPerDay) * 24, via: 'daily' };
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function urgency(s) {
|
|
256
|
+
if (s.status === 'exceeded') return 1000;
|
|
257
|
+
const base = (s.pctUsed ?? 0) * 100;
|
|
258
|
+
// A limit that will be crossed before its reset outranks one that will not.
|
|
259
|
+
const beforeReset = s.etaHours !== null && s.resetsInMs > 0 && s.etaHours * 3600000 <= s.resetsInMs ? 200 : 0;
|
|
260
|
+
return base + beforeReset;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Cross-limit rollup for headers and menu bars. */
|
|
264
|
+
export function summarizeCapacity(states) {
|
|
265
|
+
if (!states.length) return { anyExceeded: false, anyWarn: false, worst: null, firstToHit: null };
|
|
266
|
+
const exceeded = states.filter((s) => s.status === 'exceeded');
|
|
267
|
+
const warned = states.filter((s) => s.status === 'warn');
|
|
268
|
+
const firstToHit = states
|
|
269
|
+
.filter((s) => s.status !== 'exceeded' && s.etaHours !== null && s.resetsInMs > 0 && s.etaHours * 3600000 <= s.resetsInMs)
|
|
270
|
+
.sort((a, b) => a.etaHours - b.etaHours)[0] || null;
|
|
271
|
+
return {
|
|
272
|
+
anyExceeded: exceeded.length > 0,
|
|
273
|
+
anyWarn: warned.length > 0,
|
|
274
|
+
worst: states[0],
|
|
275
|
+
firstToHit,
|
|
276
|
+
counts: { total: states.length, exceeded: exceeded.length, warn: warned.length },
|
|
277
|
+
};
|
|
278
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comparison mode — two arbitrary periods, side by side.
|
|
3
|
+
*/
|
|
4
|
+
import { filterCube, filterSessions, sumRows, groupRows, daysBetween, addDays, rank } from './aggregate.js';
|
|
5
|
+
import { interfaceClass } from '../core/schema.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {object} ix
|
|
9
|
+
* @param {any[]} sessions
|
|
10
|
+
* @param {object} filters base filters (date fields are overridden)
|
|
11
|
+
* @param {{from:string,to:string}} a
|
|
12
|
+
* @param {{from:string,to:string}} b
|
|
13
|
+
*/
|
|
14
|
+
export function calculatePeriodComparison(ix, sessions, filters, a, b) {
|
|
15
|
+
const side = (p) => {
|
|
16
|
+
const rows = filterCube(ix, { ...filters, from: p.from, to: p.to });
|
|
17
|
+
const m = sumRows(rows, ix);
|
|
18
|
+
const sess = filterSessions(sessions, { ...filters, from: p.from, to: p.to });
|
|
19
|
+
const days = new Set(rows.map((r) => r[ix.d.d]));
|
|
20
|
+
const cli = rows.reduce((acc, r) => {
|
|
21
|
+
const c = interfaceClass(r[ix.d.i]);
|
|
22
|
+
const t = r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
|
|
23
|
+
acc.total += t;
|
|
24
|
+
if (c === 'CLI / headless') acc.cli += t;
|
|
25
|
+
return acc;
|
|
26
|
+
}, { cli: 0, total: 0 });
|
|
27
|
+
const byDay = rank(rows, ix, (r) => r[ix.d.d], { limit: 1 });
|
|
28
|
+
return {
|
|
29
|
+
period: p,
|
|
30
|
+
calendarDays: daysBetween(p.from, p.to) + 1,
|
|
31
|
+
activeDays: days.size,
|
|
32
|
+
total: m.total,
|
|
33
|
+
input: m.in,
|
|
34
|
+
output: m.out,
|
|
35
|
+
cacheRead: m.cr,
|
|
36
|
+
cacheWrite: m.cw,
|
|
37
|
+
cache: m.cr + m.cw,
|
|
38
|
+
reasoning: m.rs,
|
|
39
|
+
requests: m.req,
|
|
40
|
+
sessions: sess.length,
|
|
41
|
+
cost: m.costReq > 0 ? m.cost : null,
|
|
42
|
+
avgPerActiveDay: days.size ? m.total / days.size : null,
|
|
43
|
+
avgPerSession: sess.length ? m.total / sess.length : null,
|
|
44
|
+
providers: new Set(rows.map((r) => r[ix.d.p])).size,
|
|
45
|
+
models: new Set(rows.map((r) => r[ix.d.m])).size,
|
|
46
|
+
cliShare: cli.total ? cli.cli / cli.total : null,
|
|
47
|
+
peak: byDay[0] ? { date: byDay[0].key, total: byDay[0].m.total } : null,
|
|
48
|
+
_rows: rows,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const A = side(a);
|
|
53
|
+
const B = side(b);
|
|
54
|
+
const METRICS = [
|
|
55
|
+
['total', 'Total usage'], ['input', 'Input'], ['output', 'Output'],
|
|
56
|
+
['cache', 'Cache'], ['requests', 'Requests'], ['sessions', 'Sessions'],
|
|
57
|
+
['avgPerActiveDay', 'Avg / active day'], ['avgPerSession', 'Avg / session'],
|
|
58
|
+
['activeDays', 'Active days'], ['providers', 'Providers'], ['models', 'Models'],
|
|
59
|
+
['cliShare', 'CLI share'], ['cost', 'Estimated cost'],
|
|
60
|
+
];
|
|
61
|
+
const deltas = METRICS.map(([k, label]) => {
|
|
62
|
+
const av = A[k];
|
|
63
|
+
const bv = B[k];
|
|
64
|
+
const change = av === null || bv === null || av === 0 ? null : (bv - av) / av;
|
|
65
|
+
return { key: k, label, a: av, b: bv, change, kind: k === 'cliShare' ? 'share' : k === 'cost' ? 'cost' : 'count' };
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Which providers/models moved — the "what changed" part of a comparison.
|
|
69
|
+
const shift = (dim) => {
|
|
70
|
+
const ga = groupRows(A._rows, ix, (r) => r[ix.d[dim]]);
|
|
71
|
+
const gb = groupRows(B._rows, ix, (r) => r[ix.d[dim]]);
|
|
72
|
+
const keys = new Set([...ga.keys(), ...gb.keys()]);
|
|
73
|
+
const rows = [...keys].map((k) => {
|
|
74
|
+
const x = ga.get(k)?.m.total ?? 0;
|
|
75
|
+
const y = gb.get(k)?.m.total ?? 0;
|
|
76
|
+
return { key: k, a: x, b: y, change: x ? (y - x) / x : null, absolute: y - x };
|
|
77
|
+
});
|
|
78
|
+
rows.sort((p, q) => Math.abs(q.absolute) - Math.abs(p.absolute));
|
|
79
|
+
return rows;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Compute the shifts BEFORE dropping the row references they read.
|
|
83
|
+
const providerShift = shift('p');
|
|
84
|
+
const modelShift = shift('m');
|
|
85
|
+
const interfaceShift = shift('i');
|
|
86
|
+
delete A._rows;
|
|
87
|
+
delete B._rows;
|
|
88
|
+
return { a: A, b: B, deltas, providerShift, modelShift, interfaceShift };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The previous equally-long window immediately before `from`. */
|
|
92
|
+
export function previousPeriod(from, to) {
|
|
93
|
+
const len = daysBetween(from, to) + 1;
|
|
94
|
+
const prevTo = addDays(from, -1);
|
|
95
|
+
return { from: addDays(prevTo, -(len - 1)), to: prevTo };
|
|
96
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-dimension analytics: provider, model, interface, project.
|
|
3
|
+
*
|
|
4
|
+
* All four share one shape so the UI can render any of them with the same
|
|
5
|
+
* table and chart components, and so a dimension added by a future adapter
|
|
6
|
+
* needs no new analytics code.
|
|
7
|
+
*/
|
|
8
|
+
import { rank, groupRows, filterCube, sumRows, dateRange, daysBetween, addDays, finalize, zeroMeasures, addInto } from './aggregate.js';
|
|
9
|
+
import { interfaceClass } from '../core/schema.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {any[][]} rows
|
|
13
|
+
* @param {object} ix
|
|
14
|
+
* @param {string} dim cube dimension key: p | m | mf | c | i | g | pj | rp
|
|
15
|
+
* @param {{limit?:number, activeDays?:boolean, sessions?:any[], grandTotal?:number}} opt
|
|
16
|
+
*/
|
|
17
|
+
export function calculateDimensionUsage(rows, ix, dim, opt = {}) {
|
|
18
|
+
const col = ix.d[dim];
|
|
19
|
+
const groups = rank(rows, ix, (r) => r[col], { keepRows: true });
|
|
20
|
+
const grand = opt.grandTotal ?? groups.reduce((a, g) => a + g.m.total, 0);
|
|
21
|
+
const sessionsBy = new Map();
|
|
22
|
+
if (opt.sessions) {
|
|
23
|
+
// Sessions carry the same short dimension keys as the cube.
|
|
24
|
+
const KNOWN = new Set(['p', 'm', 'mf', 'c', 'i', 'g', 'pj', 'rp', 'st']);
|
|
25
|
+
const sdim = KNOWN.has(dim) ? dim : 'rp';
|
|
26
|
+
for (const s of opt.sessions) {
|
|
27
|
+
const k = s[sdim];
|
|
28
|
+
const e = sessionsBy.get(k) || { n: 0, tokens: 0, longMs: 0 };
|
|
29
|
+
e.n++;
|
|
30
|
+
e.tokens += s.total || 0;
|
|
31
|
+
e.longMs += s.durationMs || 0;
|
|
32
|
+
sessionsBy.set(k, e);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const out = groups.map((g) => {
|
|
37
|
+
const days = new Set();
|
|
38
|
+
let peakDay = null;
|
|
39
|
+
let peakTotal = -1;
|
|
40
|
+
const byDay = new Map();
|
|
41
|
+
for (const r of g.rows) {
|
|
42
|
+
const d = r[ix.d.d];
|
|
43
|
+
days.add(d);
|
|
44
|
+
const t = r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
|
|
45
|
+
byDay.set(d, (byDay.get(d) || 0) + t);
|
|
46
|
+
}
|
|
47
|
+
for (const [d, t] of byDay) if (t > peakTotal) { peakTotal = t; peakDay = d; }
|
|
48
|
+
const sess = sessionsBy.get(g.key) || null;
|
|
49
|
+
return {
|
|
50
|
+
key: g.key,
|
|
51
|
+
// The dominant provider for this dimension value — needed to resolve a
|
|
52
|
+
// vendor-specific cache multiplier when pricing a model.
|
|
53
|
+
provider: g.rows.length ? g.rows[0][ix.d.p] : null,
|
|
54
|
+
total: g.m.total,
|
|
55
|
+
input: g.m.in,
|
|
56
|
+
output: g.m.out,
|
|
57
|
+
cacheRead: g.m.cr,
|
|
58
|
+
cacheWrite: g.m.cw,
|
|
59
|
+
cache: g.m.cr + g.m.cw,
|
|
60
|
+
cacheRefresh: g.m.cf,
|
|
61
|
+
reasoning: g.m.rs,
|
|
62
|
+
requests: g.m.req,
|
|
63
|
+
cost: g.m.costReq > 0 ? g.m.cost : null,
|
|
64
|
+
costMeasured: g.m.costMeasured || null,
|
|
65
|
+
costCoveredRequests: g.m.costReq,
|
|
66
|
+
activeDays: days.size,
|
|
67
|
+
avgPerActiveDay: days.size ? g.m.total / days.size : null,
|
|
68
|
+
avgPerRequest: g.m.req ? g.m.total / g.m.req : null,
|
|
69
|
+
peakDay,
|
|
70
|
+
peakDayTotal: peakTotal < 0 ? null : peakTotal,
|
|
71
|
+
sessions: sess ? sess.n : null,
|
|
72
|
+
avgPerSession: sess && sess.n ? g.m.total / sess.n : null,
|
|
73
|
+
share: grand ? g.m.total / grand : null,
|
|
74
|
+
missing: { input: g.m.naIn, output: g.m.naOut, cacheRead: g.m.naCr, cacheWrite: g.m.naCw },
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
return opt.limit ? out.slice(0, opt.limit) : out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const calculateProviderUsage = (rows, ix, opt) => calculateDimensionUsage(rows, ix, 'p', opt);
|
|
81
|
+
export const calculateModelUsage = (rows, ix, opt) => calculateDimensionUsage(rows, ix, 'm', opt);
|
|
82
|
+
export const calculateInterfaceUsage = (rows, ix, opt) => calculateDimensionUsage(rows, ix, 'i', opt);
|
|
83
|
+
export const calculateProjectUsage = (rows, ix, opt) => calculateDimensionUsage(rows, ix, 'pj', opt);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Stacked series per dimension value: one row per bucket, one column per
|
|
87
|
+
* top-N key, with everything else folded into "Other" — never a 9th generated
|
|
88
|
+
* colour.
|
|
89
|
+
*/
|
|
90
|
+
export function calculateDimensionSeries(rows, ix, dim, buckets, opt = {}) {
|
|
91
|
+
const topN = opt.topN ?? 6;
|
|
92
|
+
const keyFn = opt.bucketOf || ((d) => d);
|
|
93
|
+
const metric = opt.metric || 'tokens'; // tokens | requests | cost
|
|
94
|
+
const top = rank(rows, ix, (r) => r[ix.d[dim]], { limit: topN }).map((g) => g.key);
|
|
95
|
+
const topSet = new Set(top);
|
|
96
|
+
const byBucket = new Map();
|
|
97
|
+
const measureOf = (r) => {
|
|
98
|
+
if (metric === 'requests') return r[ix.m.req];
|
|
99
|
+
if (metric === 'cost') return r[ix.m.costReq] > 0 ? (r[ix.m.cost] || 0) : 0;
|
|
100
|
+
return r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
|
|
101
|
+
};
|
|
102
|
+
for (const r of rows) {
|
|
103
|
+
const b = keyFn(r[ix.d.d]);
|
|
104
|
+
let row = byBucket.get(b);
|
|
105
|
+
if (!row) {
|
|
106
|
+
row = { key: b };
|
|
107
|
+
for (const k of top) row[k] = 0;
|
|
108
|
+
row.Other = 0;
|
|
109
|
+
byBucket.set(b, row);
|
|
110
|
+
}
|
|
111
|
+
const k = r[ix.d[dim]];
|
|
112
|
+
// Cost is only counted where it was actually computed from a rate; a
|
|
113
|
+
// request with no price contributes to the unpriced remainder, not zero.
|
|
114
|
+
if (topSet.has(k)) row[k] += measureOf(r);
|
|
115
|
+
else row.Other += measureOf(r);
|
|
116
|
+
}
|
|
117
|
+
const hasOther = [...byBucket.values()].some((r) => r.Other > 0);
|
|
118
|
+
const keys = hasOther ? [...top, 'Other'] : top;
|
|
119
|
+
const series = (buckets || [...byBucket.keys()].sort()).map((b) => {
|
|
120
|
+
const row = byBucket.get(b);
|
|
121
|
+
if (row) return row;
|
|
122
|
+
const empty = { key: b };
|
|
123
|
+
for (const k of keys) empty[k] = 0;
|
|
124
|
+
return empty;
|
|
125
|
+
});
|
|
126
|
+
return { keys, series };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Period-over-period growth per dimension value. The comparison window is the
|
|
131
|
+
* same length as the current one, immediately preceding it.
|
|
132
|
+
*/
|
|
133
|
+
export function calculateDimensionGrowth(ix, dim, filters, { from, to }) {
|
|
134
|
+
// An empty store has no coverage; growth against nothing is nothing.
|
|
135
|
+
if (!from || !to) {
|
|
136
|
+
return { window: { from: null, to: null }, previousWindow: { from: null, to: null }, rows: [] };
|
|
137
|
+
}
|
|
138
|
+
const days = daysBetween(from, to) + 1;
|
|
139
|
+
const prevTo = addDays(from, -1);
|
|
140
|
+
const prevFrom = addDays(prevTo, -(days - 1));
|
|
141
|
+
const cur = filterCube(ix, { ...filters, from, to });
|
|
142
|
+
const prev = filterCube(ix, { ...filters, from: prevFrom, to: prevTo });
|
|
143
|
+
const curBy = groupRows(cur, ix, (r) => r[ix.d[dim]]);
|
|
144
|
+
const prevBy = groupRows(prev, ix, (r) => r[ix.d[dim]]);
|
|
145
|
+
const keys = new Set([...curBy.keys(), ...prevBy.keys()]);
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const k of keys) {
|
|
148
|
+
const c = curBy.get(k)?.m.total ?? 0;
|
|
149
|
+
const p = prevBy.get(k)?.m.total ?? 0;
|
|
150
|
+
out.push({
|
|
151
|
+
key: k,
|
|
152
|
+
current: c,
|
|
153
|
+
previous: p,
|
|
154
|
+
change: p > 0 ? (c - p) / p : null,
|
|
155
|
+
absolute: c - p,
|
|
156
|
+
status: p === 0 ? (c > 0 ? 'new' : 'none') : c === 0 ? 'stopped' : c > p ? 'up' : c < p ? 'down' : 'flat',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
out.sort((a, b) => b.current - a.current);
|
|
160
|
+
return { window: { from, to }, previousWindow: { from: prevFrom, to: prevTo }, rows: out };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Model efficiency scatter: tokens per session (x) against sessions per active
|
|
165
|
+
* day (y), bubble = total tokens. Grouped by provider, which is why the caller
|
|
166
|
+
* caps the colour count — a scatter needs all-pairs colour separation.
|
|
167
|
+
*/
|
|
168
|
+
export function calculateModelEfficiency(rows, ix, sessions, { limit = 24 } = {}) {
|
|
169
|
+
const byModel = rank(rows, ix, (r) => r[ix.d.m], { keepRows: true, limit });
|
|
170
|
+
const sessByModel = new Map();
|
|
171
|
+
for (const s of sessions) {
|
|
172
|
+
const e = sessByModel.get(s.m) || { n: 0, days: new Set(), tokens: 0, durations: [] };
|
|
173
|
+
e.n++;
|
|
174
|
+
e.days.add(s.d);
|
|
175
|
+
e.tokens += s.total || 0;
|
|
176
|
+
if (s.durationMs) e.durations.push(s.durationMs);
|
|
177
|
+
sessByModel.set(s.m, e);
|
|
178
|
+
}
|
|
179
|
+
return byModel.map((g) => {
|
|
180
|
+
const s = sessByModel.get(g.key);
|
|
181
|
+
const days = new Set(g.rows.map((r) => r[ix.d.d]));
|
|
182
|
+
const provider = g.rows.length ? g.rows[0][ix.d.p] : 'unknown';
|
|
183
|
+
return {
|
|
184
|
+
model: g.key,
|
|
185
|
+
provider,
|
|
186
|
+
family: g.rows.length ? g.rows[0][ix.d.mf] : 'Unknown',
|
|
187
|
+
total: g.m.total,
|
|
188
|
+
requests: g.m.req,
|
|
189
|
+
sessions: s ? s.n : null,
|
|
190
|
+
activeDays: days.size,
|
|
191
|
+
tokensPerSession: s && s.n ? g.m.total / s.n : null,
|
|
192
|
+
sessionsPerDay: s && s.days.size ? s.n / s.days.size : null,
|
|
193
|
+
tokensPerRequest: g.m.req ? g.m.total / g.m.req : null,
|
|
194
|
+
outputPerRequest: g.m.req ? g.m.out / g.m.req : null,
|
|
195
|
+
medianSessionMs: s && s.durations.length ? median(s.durations) : null,
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** CLI vs GUI over time — the "how is my tooling shifting" series. */
|
|
201
|
+
export function calculateInterfaceTrend(rows, ix, buckets, bucketOf = (d) => d) {
|
|
202
|
+
const classes = new Map();
|
|
203
|
+
for (const r of rows) {
|
|
204
|
+
const b = bucketOf(r[ix.d.d]);
|
|
205
|
+
const cls = interfaceClass(r[ix.d.i]);
|
|
206
|
+
let row = classes.get(b);
|
|
207
|
+
if (!row) { row = { key: b }; classes.set(b, row); }
|
|
208
|
+
row[cls] = (row[cls] || 0) + r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
|
|
209
|
+
}
|
|
210
|
+
const keys = [...new Set([...classes.values()].flatMap((r) => Object.keys(r).filter((k) => k !== 'key')))];
|
|
211
|
+
const series = (buckets || [...classes.keys()].sort()).map((b) => {
|
|
212
|
+
const row = classes.get(b) || { key: b };
|
|
213
|
+
for (const k of keys) if (row[k] === undefined) row[k] = 0;
|
|
214
|
+
return row;
|
|
215
|
+
});
|
|
216
|
+
// Share-of-total per bucket, which is what makes a shift legible.
|
|
217
|
+
const shares = series.map((row) => {
|
|
218
|
+
const t = keys.reduce((a, k) => a + row[k], 0);
|
|
219
|
+
const o = { key: row.key };
|
|
220
|
+
for (const k of keys) o[k] = t ? row[k] / t : 0;
|
|
221
|
+
return o;
|
|
222
|
+
});
|
|
223
|
+
return { keys, series, shares };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function median(xs) {
|
|
227
|
+
const a = [...xs].sort((x, y) => x - y);
|
|
228
|
+
const m = Math.floor(a.length / 2);
|
|
229
|
+
return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
|
|
230
|
+
}
|