@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,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Efficiency + cost.
|
|
3
|
+
*
|
|
4
|
+
* "Efficiency" here means observable ratios, nothing more. A high
|
|
5
|
+
* output/input ratio is not automatically good and a low one is not
|
|
6
|
+
* automatically bad — the UI presents these as measurements with an
|
|
7
|
+
* explanation, not as a score.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function calculateEfficiency(totals, { sessions = 0, activeDays = 0, requests = null } = {}) {
|
|
11
|
+
const req = requests ?? totals.req;
|
|
12
|
+
const t = totals.total;
|
|
13
|
+
return {
|
|
14
|
+
outputPerInput: totals.in ? totals.out / totals.in : null,
|
|
15
|
+
outputPerPromptToken: (totals.in + totals.cr + totals.cw) ? totals.out / (totals.in + totals.cr + totals.cw) : null,
|
|
16
|
+
cacheRatio: t ? (totals.cr + totals.cw) / t : null,
|
|
17
|
+
cacheHitRate: totals.in + totals.cr ? totals.cr / (totals.in + totals.cr) : null,
|
|
18
|
+
// How many prompt tokens were re-sent fresh for every one served from
|
|
19
|
+
// cache. Above 1 means the cache is mostly missing.
|
|
20
|
+
freshPerCachedPrompt: totals.cr ? totals.in / totals.cr : null,
|
|
21
|
+
tokensPerSession: sessions ? t / sessions : null,
|
|
22
|
+
outputPerSession: sessions ? totals.out / sessions : null,
|
|
23
|
+
requestsPerSession: sessions ? req / sessions : null,
|
|
24
|
+
tokensPerActiveDay: activeDays ? t / activeDays : null,
|
|
25
|
+
tokensPerRequest: req ? t / req : null,
|
|
26
|
+
outputPerRequest: req ? totals.out / req : null,
|
|
27
|
+
reasoningShareOfOutput: totals.out ? totals.rs / totals.out : null,
|
|
28
|
+
refreshShareOfCacheWrite: totals.cw ? totals.cf / totals.cw : null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cost analysis with explicit coverage reporting.
|
|
34
|
+
*
|
|
35
|
+
* `coverage` is the fraction of requests in the slice whose model had a price.
|
|
36
|
+
* Presenting a total cost without it would imply a completeness that isn't
|
|
37
|
+
* there, so the UI always shows them together and lists the unpriced models
|
|
38
|
+
* by volume so the gap is actionable rather than mysterious.
|
|
39
|
+
*/
|
|
40
|
+
export function calculateCost(totals, { sessions = 0, activeDays = 0, unpriced = [], tiers = [], premiumTiers = [], overlayMeasured = null, overlayRequests = 0 } = {}) {
|
|
41
|
+
const covered = totals.costReq || 0;
|
|
42
|
+
const coverage = totals.req ? covered / totals.req : null;
|
|
43
|
+
const est = covered > 0 ? totals.cost : null;
|
|
44
|
+
// Measured cost comes from two places: a primary record that carried a
|
|
45
|
+
// billed amount, and an overlay source whose tokens are excluded from the
|
|
46
|
+
// totals but whose money is real. They are added together and kept strictly
|
|
47
|
+
// apart from the estimate — one is evidence, the other is arithmetic.
|
|
48
|
+
const measuredIn = totals.costMeasured > 0 ? totals.costMeasured : 0;
|
|
49
|
+
const measuredOverlay = overlayMeasured > 0 ? overlayMeasured : 0;
|
|
50
|
+
const measured = measuredIn + measuredOverlay > 0 ? measuredIn + measuredOverlay : null;
|
|
51
|
+
const premium = tiers.filter((t) => premiumTiers.includes(t.key));
|
|
52
|
+
const premiumTokens = premium.reduce((a, t) => a + t.total, 0);
|
|
53
|
+
return {
|
|
54
|
+
tiers,
|
|
55
|
+
premiumTierShare: totals.total ? premiumTokens / totals.total : null,
|
|
56
|
+
premiumTierTokens: premiumTokens,
|
|
57
|
+
premiumTierNames: premium.map((t) => t.key),
|
|
58
|
+
// Long-context premiums are a known, stated gap rather than a silent one.
|
|
59
|
+
underEstimateNote: 'Long-context premium tiers are not applied (they need a per-request prompt size plus a per-model threshold), so a long-context-heavy workload is under-estimated.',
|
|
60
|
+
estimated: est,
|
|
61
|
+
measured,
|
|
62
|
+
measuredInSlice: measuredIn || null,
|
|
63
|
+
measuredOverlay: measuredOverlay || null,
|
|
64
|
+
measuredNote: measuredOverlay
|
|
65
|
+
? `Includes $${measuredOverlay.toFixed(2)} billed through a gateway across ${overlayRequests.toLocaleString()} request(s). Those tokens are excluded from the totals above so the client adapter's count is not doubled — the cost is not.`
|
|
66
|
+
: null,
|
|
67
|
+
basisNote: est === null && measured === null
|
|
68
|
+
? 'No pricing configured for any model in this slice.'
|
|
69
|
+
: coverage !== null && coverage < 0.999
|
|
70
|
+
? `Estimate covers ${(coverage * 100).toFixed(1)}% of requests; the rest have no configured price.`
|
|
71
|
+
: 'Estimate covers every request in this slice.',
|
|
72
|
+
coverage,
|
|
73
|
+
coveredRequests: covered,
|
|
74
|
+
totalRequests: totals.req,
|
|
75
|
+
perDay: est !== null && activeDays ? est / activeDays : null,
|
|
76
|
+
perSession: est !== null && sessions ? est / sessions : null,
|
|
77
|
+
perMillionTokens: est !== null && totals.total ? est / (totals.total / 1e6) : null,
|
|
78
|
+
perMillionOutput: est !== null && totals.out ? est / (totals.out / 1e6) : null,
|
|
79
|
+
unpriced,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Models with no configured price, ranked by the tokens they'd account for.
|
|
85
|
+
* This is the list the "Configure pricing" call-to-action shows.
|
|
86
|
+
*/
|
|
87
|
+
export function unpricedModels(rows, ix, priceLookup) {
|
|
88
|
+
const by = new Map();
|
|
89
|
+
for (const r of rows) {
|
|
90
|
+
if (r[ix.m.costReq] > 0) continue;
|
|
91
|
+
const model = r[ix.d.m];
|
|
92
|
+
const e = by.get(model) || { model, provider: r[ix.d.p], total: 0, requests: 0 };
|
|
93
|
+
e.total += r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
|
|
94
|
+
e.requests += r[ix.m.req];
|
|
95
|
+
by.set(model, e);
|
|
96
|
+
}
|
|
97
|
+
const out = [...by.values()].filter((e) => e.requests > 0 && (!priceLookup || !priceLookup(e.model, e.provider)));
|
|
98
|
+
out.sort((a, b) => b.total - a.total);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Session shape distribution — the "long vs short session" analysis. */
|
|
103
|
+
export function calculateSessionProfile(sessions) {
|
|
104
|
+
if (!sessions.length) {
|
|
105
|
+
return { count: 0, buckets: [], medianTokens: null, medianDurationMs: null, p90Tokens: null, longSessions: 0, shortSessions: 0, highOutput: 0 };
|
|
106
|
+
}
|
|
107
|
+
const tokens = sessions.map((s) => s.total || 0).sort((a, b) => a - b);
|
|
108
|
+
const durs = sessions.map((s) => s.durationMs || 0).filter((x) => x > 0).sort((a, b) => a - b);
|
|
109
|
+
const p = (arr, q) => (arr.length ? arr[Math.min(arr.length - 1, Math.floor(arr.length * q))] : null);
|
|
110
|
+
const medianTokens = p(tokens, 0.5);
|
|
111
|
+
const p90 = p(tokens, 0.9);
|
|
112
|
+
// Buckets are relative to this dataset's own distribution.
|
|
113
|
+
const edges = [p(tokens, 0.25), medianTokens, p(tokens, 0.75), p90];
|
|
114
|
+
const labels = ['Smallest 25%', '25–50%', '50–75%', '75–90%', 'Top 10%'];
|
|
115
|
+
const counts = new Array(5).fill(0);
|
|
116
|
+
const totals = new Array(5).fill(0);
|
|
117
|
+
for (const s of sessions) {
|
|
118
|
+
const t = s.total || 0;
|
|
119
|
+
let i = 0;
|
|
120
|
+
while (i < edges.length && t > edges[i]) i++;
|
|
121
|
+
counts[i]++;
|
|
122
|
+
totals[i] += t;
|
|
123
|
+
}
|
|
124
|
+
const grand = tokens.reduce((a, b) => a + b, 0);
|
|
125
|
+
return {
|
|
126
|
+
count: sessions.length,
|
|
127
|
+
medianTokens,
|
|
128
|
+
p90Tokens: p90,
|
|
129
|
+
medianDurationMs: durs.length ? durs[Math.floor(durs.length / 2)] : null,
|
|
130
|
+
buckets: labels.map((label, i) => ({
|
|
131
|
+
label, sessions: counts[i], tokens: totals[i], share: grand ? totals[i] / grand : null,
|
|
132
|
+
upperEdge: i < edges.length ? edges[i] : null,
|
|
133
|
+
})),
|
|
134
|
+
longSessions: sessions.filter((s) => (s.durationMs || 0) > 30 * 60000).length,
|
|
135
|
+
shortSessions: sessions.filter((s) => (s.durationMs || 0) > 0 && s.durationMs < 2 * 60000).length,
|
|
136
|
+
highOutput: sessions.filter((s) => medianTokens && (s.out || 0) > 2 * (medianTokens * 0.15)).length,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forecasting.
|
|
3
|
+
*
|
|
4
|
+
* A deliberately conservative model: an ordinary least-squares trend over the
|
|
5
|
+
* most recent N calendar days, a robust (median/MAD) residual spread, and hard
|
|
6
|
+
* minimum-data gates. When there is not enough history the answer is `null`
|
|
7
|
+
* with the reason attached — never a confident-looking number built on three
|
|
8
|
+
* data points.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is pure and deterministic: same series in, same forecast
|
|
11
|
+
* out. The browser, the CLI and the watch daemon all call these functions, so
|
|
12
|
+
* no surface can disagree about the future.
|
|
13
|
+
*/
|
|
14
|
+
import { daysBetween } from './aggregate.js';
|
|
15
|
+
|
|
16
|
+
export const MIN_POINTS = 5;
|
|
17
|
+
const MAD_SCALE = 1.4826; // consistency constant for normally distributed errors
|
|
18
|
+
|
|
19
|
+
/** Ordinary least squares fit of y = intercept + slope * x over index pairs. */
|
|
20
|
+
function ols(ys) {
|
|
21
|
+
const n = ys.length;
|
|
22
|
+
let sx = 0;
|
|
23
|
+
let sy = 0;
|
|
24
|
+
let sxx = 0;
|
|
25
|
+
let sxy = 0;
|
|
26
|
+
for (let i = 0; i < n; i++) {
|
|
27
|
+
sx += i;
|
|
28
|
+
sy += ys[i];
|
|
29
|
+
sxx += i * i;
|
|
30
|
+
sxy += i * ys[i];
|
|
31
|
+
}
|
|
32
|
+
const denom = n * sxx - sx * sx;
|
|
33
|
+
if (denom === 0) return { slope: 0, intercept: sy / n };
|
|
34
|
+
const slope = (n * sxy - sx * sy) / denom;
|
|
35
|
+
return { slope, intercept: (sy - slope * sx) / n };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function median(sortedAsc) {
|
|
39
|
+
if (!sortedAsc.length) return null;
|
|
40
|
+
const mid = Math.floor(sortedAsc.length / 2);
|
|
41
|
+
return sortedAsc.length % 2 ? sortedAsc[mid] : (sortedAsc[mid - 1] + sortedAsc[mid]) / 2;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Median absolute deviation scaled to a standard-deviation estimate. */
|
|
45
|
+
export function robustSigma(values) {
|
|
46
|
+
if (values.length < 2) return null;
|
|
47
|
+
const med = median([...values].sort((a, b) => a - b));
|
|
48
|
+
const devs = values.map((v) => Math.abs(v - med));
|
|
49
|
+
const mad = median(devs.sort((a, b) => a - b));
|
|
50
|
+
return mad * MAD_SCALE;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Forecast the next value(s) of a daily series with a linear trend.
|
|
55
|
+
*
|
|
56
|
+
* @param {{key:string, [k:string]:any}[]} series calendar-complete daily rows
|
|
57
|
+
* as produced by calculateDailyUsage (must be sorted by key ascending)
|
|
58
|
+
* @param {{window?:number, metric?:string, horizonDays?:number}} opt
|
|
59
|
+
* @returns {{
|
|
60
|
+
* next: number|null, interval:[number,number]|null,
|
|
61
|
+
* horizon: {days:number, total:number}|null,
|
|
62
|
+
* slope:number|null, confidence:'high'|'medium'|'low'|null,
|
|
63
|
+
* n:number, reason?:string,
|
|
64
|
+
* }} nulls when the sample is too thin; `reason` says why
|
|
65
|
+
*/
|
|
66
|
+
export function linearForecast(series, opt = {}) {
|
|
67
|
+
const metric = opt.metric || 'total';
|
|
68
|
+
const window = Math.max(MIN_POINTS, opt.window || 14);
|
|
69
|
+
const tail = series.slice(-window);
|
|
70
|
+
const ys = tail.map((d) => Number(d[metric])).filter((v) => Number.isFinite(v));
|
|
71
|
+
|
|
72
|
+
if (ys.length < MIN_POINTS) {
|
|
73
|
+
return {
|
|
74
|
+
next: null, interval: null, horizon: null, slope: null, confidence: null,
|
|
75
|
+
n: ys.length, reason: `${ys.length} usable day(s); need at least ${MIN_POINTS}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { slope, intercept } = ols(ys);
|
|
80
|
+
const residuals = ys.map((y, i) => y - (intercept + slope * i));
|
|
81
|
+
const sigma = robustSigma(residuals) ?? 0;
|
|
82
|
+
const nextX = ys.length;
|
|
83
|
+
const predict = (x) => Math.max(0, intercept + slope * x);
|
|
84
|
+
|
|
85
|
+
// A robust sigma near zero means the model fits well but may still miss
|
|
86
|
+
// regime changes; widen slightly so "high confidence" stays honest.
|
|
87
|
+
const sigmaEff = Math.max(sigma, 1e-9);
|
|
88
|
+
const meanY = ys.reduce((a, b) => a + b, 0) / ys.length;
|
|
89
|
+
const cv = meanY > 0 ? sigma / meanY : Infinity;
|
|
90
|
+
|
|
91
|
+
const confidence = ys.length >= 10 && cv <= 0.35 && sigmaEff > 0
|
|
92
|
+
? 'high'
|
|
93
|
+
: ys.length >= 7 && cv <= 0.8 ? 'medium' : 'low';
|
|
94
|
+
|
|
95
|
+
const horizonDays = opt.horizonDays || 7;
|
|
96
|
+
let horizonSum = 0;
|
|
97
|
+
for (let i = 1; i <= horizonDays; i++) horizonSum += predict(nextX + i - 1);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
next: predict(nextX),
|
|
101
|
+
interval: [Math.max(0, predict(nextX) - 1.96 * sigma), predict(nextX) + 1.96 * sigma],
|
|
102
|
+
horizon: { days: horizonDays, total: horizonSum },
|
|
103
|
+
slope,
|
|
104
|
+
confidence,
|
|
105
|
+
n: ys.length,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Project how the current calendar month ends.
|
|
111
|
+
*
|
|
112
|
+
* Month-to-date is measured fact; the remainder is forecast. The two are never
|
|
113
|
+
* added silently without saying which part is which.
|
|
114
|
+
*
|
|
115
|
+
* @param {{key:string, [k:string]:any}[]} daily calendar-complete series
|
|
116
|
+
* @param {string} todayIso YYYY-MM-DD
|
|
117
|
+
* @param {{metric?:string}} opt
|
|
118
|
+
*/
|
|
119
|
+
export function monthEndProjection(daily, todayIso, opt = {}) {
|
|
120
|
+
const metric = opt.metric || 'total';
|
|
121
|
+
const monthStart = `${todayIso.slice(0, 7)}-01`;
|
|
122
|
+
const mtd = daily.filter((d) => d.key >= monthStart && d.key <= todayIso);
|
|
123
|
+
const actualToDate = mtd.reduce((a, d) => a + (Number(d[metric]) || 0), 0);
|
|
124
|
+
const remainingDays = Math.max(
|
|
125
|
+
0,
|
|
126
|
+
daysBetween(todayIso, lastDayOfMonth(todayIso)),
|
|
127
|
+
);
|
|
128
|
+
if (mtd.length < MIN_POINTS && remainingDays > 0) {
|
|
129
|
+
return {
|
|
130
|
+
projected: null, actualToDate, remainingDays,
|
|
131
|
+
forecastRemainder: null, confidence: null,
|
|
132
|
+
reason: `${mtd.length} day(s) this month; need at least ${MIN_POINTS} to project`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// The remainder comes from one call so measured and forecast can never be
|
|
136
|
+
// mixed twice, and the projection uses exactly the model whose confidence we
|
|
137
|
+
// report.
|
|
138
|
+
const fc = linearForecast(daily.filter((d) => d.key <= todayIso), { ...opt, horizonDays: remainingDays });
|
|
139
|
+
const remainder = remainingDays === 0 ? 0 : fc.horizon ? fc.horizon.total : null;
|
|
140
|
+
return {
|
|
141
|
+
projected: remainder === null ? null : actualToDate + remainder,
|
|
142
|
+
actualToDate,
|
|
143
|
+
remainingDays,
|
|
144
|
+
forecastRemainder: remainder,
|
|
145
|
+
confidence: fc.confidence,
|
|
146
|
+
perDay: fc.next,
|
|
147
|
+
reason: fc.reason,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Last calendar day of the month containing `iso`. */
|
|
152
|
+
export function lastDayOfMonth(iso) {
|
|
153
|
+
const [y, m] = iso.split('-').map(Number);
|
|
154
|
+
return `${y}-${String(m).padStart(2, '0')}-${String(new Date(Date.UTC(y, m, 0)).getUTCDate()).padStart(2, '0')}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Hours until `remaining` is exhausted at a given burn rate.
|
|
159
|
+
*
|
|
160
|
+
* @param {{remaining:number, burnPerHour:number|null, burnPerDay?:number|null, nowMs:number}} p
|
|
161
|
+
* @returns {{hours:number|null, via:'hourly'|'daily'} | null}
|
|
162
|
+
* null when there is nothing to exhaust or no measurable burn.
|
|
163
|
+
*/
|
|
164
|
+
export function exhaustionEta({ remaining, burnPerHour, burnPerDay = null, nowMs }) {
|
|
165
|
+
if (!Number.isFinite(remaining) || remaining <= 0) return null;
|
|
166
|
+
if (burnPerHour !== null && Number.isFinite(burnPerHour) && burnPerHour > 0) {
|
|
167
|
+
return { hours: remaining / burnPerHour, via: 'hourly' };
|
|
168
|
+
}
|
|
169
|
+
if (burnPerDay !== null && Number.isFinite(burnPerDay) && burnPerDay > 0) {
|
|
170
|
+
return { hours: (remaining / burnPerDay) * 24, via: 'daily' };
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Convenience wrapper used by live status + CLI: one object describing where
|
|
177
|
+
* usage is heading.
|
|
178
|
+
*
|
|
179
|
+
* @param {{key:string, cost?:number|null, [k:string]:any}[]} daily
|
|
180
|
+
* @param {string} todayIso
|
|
181
|
+
*/
|
|
182
|
+
export function buildForecast(daily, todayIso) {
|
|
183
|
+
const tokens = linearForecast(daily, { metric: 'total' });
|
|
184
|
+
const costSeries = daily.some((d) => Number.isFinite(Number(d.cost)) && Number(d.cost) > 0);
|
|
185
|
+
const cost = costSeries ? linearForecast(daily, { metric: 'cost' }) : null;
|
|
186
|
+
const monthTokens = monthEndProjection(daily, todayIso, { metric: 'total' });
|
|
187
|
+
const monthCost = costSeries ? monthEndProjection(daily, todayIso, { metric: 'cost' }) : null;
|
|
188
|
+
return {
|
|
189
|
+
generatedFor: todayIso,
|
|
190
|
+
tomorrow: tokens.next,
|
|
191
|
+
tomorrowInterval: tokens.interval,
|
|
192
|
+
next7days: tokens.horizon ? tokens.horizon.total : null,
|
|
193
|
+
next7daysCost: cost?.horizon ? cost.horizon.total : null,
|
|
194
|
+
monthEnd: monthTokens.projected,
|
|
195
|
+
monthEndActualToDate: monthTokens.actualToDate,
|
|
196
|
+
monthEndCost: monthCost?.projected ?? null,
|
|
197
|
+
monthEndCostActualToDate: monthCost?.actualToDate ?? null,
|
|
198
|
+
confidence: tokens.confidence,
|
|
199
|
+
n: tokens.n,
|
|
200
|
+
reason: tokens.reason || null,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The analytics facade + plugin registry.
|
|
3
|
+
*
|
|
4
|
+
* `computeView(bundle, filters)` is the single entry point that turns a data
|
|
5
|
+
* bundle plus a filter state into everything a dashboard needs. The browser
|
|
6
|
+
* calls it on every filter change; the CLI calls it for `status`; the snapshot
|
|
7
|
+
* exporter calls it once. One implementation, so the numbers can never disagree
|
|
8
|
+
* between surfaces.
|
|
9
|
+
*
|
|
10
|
+
* ## Plugin model
|
|
11
|
+
*
|
|
12
|
+
* Analytics modules register themselves with `registerAnalytics({id, section,
|
|
13
|
+
* compute})`. `compute` receives the fully prepared slice and returns whatever
|
|
14
|
+
* shape it likes, exposed at `view.plugins[id]`. That is how cost, git,
|
|
15
|
+
* carbon, team, or prompt-efficiency analytics get added later without
|
|
16
|
+
* touching this file.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
indexCube, filterCube, filterSessions, sumRows, facet, EMPTY_FILTERS,
|
|
20
|
+
weekStart, monthKey, dateRange, daysBetween, addDays,
|
|
21
|
+
} from './aggregate.js';
|
|
22
|
+
import {
|
|
23
|
+
calculateDailyUsage, movingAverage, calculateAverageUsage, calculateComposition,
|
|
24
|
+
calculateUsageTrend, calculateHourlyUsage, calculateDowUsage, calculateHourDow,
|
|
25
|
+
calendarLevels, calculateStreaks,
|
|
26
|
+
} from './token-usage.js';
|
|
27
|
+
import {
|
|
28
|
+
calculateDimensionUsage, calculateDimensionSeries, calculateDimensionGrowth,
|
|
29
|
+
calculateModelEfficiency, calculateInterfaceTrend,
|
|
30
|
+
} from './dimensions.js';
|
|
31
|
+
import { calculatePeakUsage, dayDetail } from './peak.js';
|
|
32
|
+
import { calculateEfficiency, calculateCost, unpricedModels, calculateSessionProfile } from './efficiency.js';
|
|
33
|
+
import { calculatePeriodComparison, previousPeriod } from './comparison.js';
|
|
34
|
+
import { calculateActivityProxies, calculateWorkSeries, calculateCorrelations, calculateActivityContrast } from './productivity.js';
|
|
35
|
+
import { generateInsights } from './insights.js';
|
|
36
|
+
import { buildForecast, linearForecast, monthEndProjection, exhaustionEta } from './forecast.js';
|
|
37
|
+
import { detectAnomalies, firstSeenEntities } from './anomalies.js';
|
|
38
|
+
import { evaluateLimits, summarizeCapacity, normalizeLimits } from './capacity.js';
|
|
39
|
+
import { buildPriceBook } from '../core/pricing.js';
|
|
40
|
+
|
|
41
|
+
const plugins = new Map();
|
|
42
|
+
|
|
43
|
+
/** @param {{id:string, section?:string, title?:string, compute:(slice:object)=>any}} def */
|
|
44
|
+
export function registerAnalytics(def) {
|
|
45
|
+
if (!def || !def.id || typeof def.compute !== 'function') {
|
|
46
|
+
throw new Error('registerAnalytics requires { id, compute }');
|
|
47
|
+
}
|
|
48
|
+
plugins.set(def.id, { section: 'custom', title: def.id, ...def });
|
|
49
|
+
return def;
|
|
50
|
+
}
|
|
51
|
+
export function listAnalytics() {
|
|
52
|
+
return [...plugins.values()].map((p) => ({ id: p.id, section: p.section, title: p.title }));
|
|
53
|
+
}
|
|
54
|
+
export function clearAnalytics() {
|
|
55
|
+
plugins.clear();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const QUICK_RANGES = [
|
|
59
|
+
{ id: 'today', label: 'Today' },
|
|
60
|
+
{ id: 'yesterday', label: 'Yesterday' },
|
|
61
|
+
{ id: '7d', label: 'Last 7 days' },
|
|
62
|
+
{ id: '30d', label: 'Last 30 days' },
|
|
63
|
+
{ id: '90d', label: 'Last 90 days' },
|
|
64
|
+
{ id: 'mtd', label: 'This month' },
|
|
65
|
+
{ id: 'lastmonth', label: 'Last month' },
|
|
66
|
+
{ id: 'all', label: 'All data' },
|
|
67
|
+
{ id: 'custom', label: 'Custom' },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve a quick range against the dataset's own coverage, so "last 30 days"
|
|
72
|
+
* means the last 30 days of the calendar, and "all data" means exactly what is
|
|
73
|
+
* in the store.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveRange(id, coverage, today) {
|
|
76
|
+
const to = today || coverage.to;
|
|
77
|
+
const from = coverage.from;
|
|
78
|
+
switch (id) {
|
|
79
|
+
case 'today': return { from: to, to };
|
|
80
|
+
case 'yesterday': return { from: addDays(to, -1), to: addDays(to, -1) };
|
|
81
|
+
case '7d': return { from: addDays(to, -6), to };
|
|
82
|
+
case '30d': return { from: addDays(to, -29), to };
|
|
83
|
+
case '90d': return { from: addDays(to, -89), to };
|
|
84
|
+
case 'mtd': return { from: `${to.slice(0, 7)}-01`, to };
|
|
85
|
+
case 'lastmonth': {
|
|
86
|
+
const first = `${to.slice(0, 7)}-01`;
|
|
87
|
+
const lastMonthEnd = addDays(first, -1);
|
|
88
|
+
return { from: `${lastMonthEnd.slice(0, 7)}-01`, to: lastMonthEnd };
|
|
89
|
+
}
|
|
90
|
+
case 'all':
|
|
91
|
+
default:
|
|
92
|
+
return { from, to };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {{cube:object, sessions:any[], activity:object, meta?:object,
|
|
98
|
+
* pricing?:object, limits?:object[]}} bundle
|
|
99
|
+
* @param {Partial<typeof EMPTY_FILTERS> & {granularity?:'day'|'week'|'month', compare?:{a:object,b:object}, drillDate?:string}} [filters]
|
|
100
|
+
*/
|
|
101
|
+
export function computeView(bundle, filters = {}) {
|
|
102
|
+
const ix = indexCube(bundle.cube);
|
|
103
|
+
const f = { ...EMPTY_FILTERS, ...filters };
|
|
104
|
+
const coverage = bundle.meta?.coverage || datasetCoverage(ix);
|
|
105
|
+
const range = {
|
|
106
|
+
from: f.from || coverage.from,
|
|
107
|
+
to: f.to || coverage.to,
|
|
108
|
+
};
|
|
109
|
+
const eff = { ...f, from: range.from, to: range.to };
|
|
110
|
+
|
|
111
|
+
const rows = filterCube(ix, eff);
|
|
112
|
+
const sessions = filterSessions(bundle.sessions || [], eff);
|
|
113
|
+
const totals = sumRows(rows, ix);
|
|
114
|
+
const granularity = filters.granularity || 'day';
|
|
115
|
+
|
|
116
|
+
const daily = calculateDailyUsage(rows, ix, { granularity: 'day', from: range.from, to: range.to });
|
|
117
|
+
const series = granularity === 'day'
|
|
118
|
+
? daily
|
|
119
|
+
: calculateDailyUsage(rows, ix, { granularity, from: range.from, to: range.to, fill: false });
|
|
120
|
+
const bucketOf = granularity === 'week' ? weekStart : granularity === 'month' ? monthKey : (d) => d;
|
|
121
|
+
const buckets = series.map((s) => s.key);
|
|
122
|
+
|
|
123
|
+
const averages = calculateAverageUsage(daily);
|
|
124
|
+
const composition = calculateComposition(totals);
|
|
125
|
+
const trend = calculateUsageTrend(daily, 30);
|
|
126
|
+
const hourly = calculateHourlyUsage(rows, ix);
|
|
127
|
+
const dowUsage = calculateDowUsage(rows, ix, daily);
|
|
128
|
+
const hourDow = calculateHourDow(rows, ix);
|
|
129
|
+
const levels = calendarLevels(daily);
|
|
130
|
+
const streaks = calculateStreaks(daily);
|
|
131
|
+
const peaks = calculatePeakUsage(rows, ix, { series: daily, sessions, topN: 10 });
|
|
132
|
+
|
|
133
|
+
const providers = calculateDimensionUsage(rows, ix, 'p', { sessions, grandTotal: totals.total });
|
|
134
|
+
const models = calculateDimensionUsage(rows, ix, 'm', { sessions, grandTotal: totals.total });
|
|
135
|
+
const families = calculateDimensionUsage(rows, ix, 'mf', { sessions, grandTotal: totals.total });
|
|
136
|
+
const clients = calculateDimensionUsage(rows, ix, 'c', { sessions, grandTotal: totals.total });
|
|
137
|
+
const interfaces = calculateDimensionUsage(rows, ix, 'i', { sessions, grandTotal: totals.total });
|
|
138
|
+
const projects = calculateDimensionUsage(rows, ix, 'pj', { sessions, grandTotal: totals.total, limit: 25 });
|
|
139
|
+
const gateways = calculateDimensionUsage(rows, ix, 'g', { sessions, grandTotal: totals.total });
|
|
140
|
+
const tiers = calculateDimensionUsage(rows, ix, 'st', { sessions, grandTotal: totals.total });
|
|
141
|
+
|
|
142
|
+
const providerSeries = calculateDimensionSeries(rows, ix, 'p', buckets, { topN: 6, bucketOf });
|
|
143
|
+
const modelSeries = calculateDimensionSeries(rows, ix, 'm', buckets, { topN: 6, bucketOf });
|
|
144
|
+
const interfaceTrend = calculateInterfaceTrend(rows, ix, buckets, bucketOf);
|
|
145
|
+
const efficiency = calculateEfficiency(totals, { sessions: sessions.length, activeDays: averages.activeDays });
|
|
146
|
+
const sessionProfile = calculateSessionProfile(sessions);
|
|
147
|
+
const modelEfficiency = calculateModelEfficiency(rows, ix, sessions, { limit: 24 });
|
|
148
|
+
|
|
149
|
+
const book = buildPriceBook(bundle.pricing || {});
|
|
150
|
+
const unpriced = unpricedModels(rows, ix, (m, p) => book.lookup(m, p));
|
|
151
|
+
// Attach the rate provenance to each model row so the Cost page can show
|
|
152
|
+
// where every number came from rather than asserting it.
|
|
153
|
+
// Rows are decorated in place with rate provenance the dimension helper
|
|
154
|
+
// does not know about.
|
|
155
|
+
for (const m of /** @type {Record<string, any>[]} */ (models)) {
|
|
156
|
+
const entry = book.lookup(m.key, m.provider) || book.lookup(m.key, 'unknown');
|
|
157
|
+
m.priceSource = entry ? (entry.origin === 'user' ? 'your override' : entry.src) : null;
|
|
158
|
+
m.rates = entry ? { in: entry.in, out: entry.out, cacheRead: entry.cacheRead, cacheWrite: entry.cacheWrite, cacheRefresh: entry.cacheRefresh } : null;
|
|
159
|
+
}
|
|
160
|
+
// An overlay source is a gateway's own billing log: its tokens describe
|
|
161
|
+
// traffic a client adapter already counted, so they stay out of the totals —
|
|
162
|
+
// but its cost is *measured*, not estimated, and dropping it would throw away
|
|
163
|
+
// the only hard money number in the dataset. Sum it from an overlay-only
|
|
164
|
+
// slice and report it beside the estimate, never inside it.
|
|
165
|
+
const overlayRows = f.includeOverlay ? [] : filterCube(ix, { ...eff, includeOverlay: true }).filter((r) => r[ix.d.ms] === 'overlay');
|
|
166
|
+
const overlay = overlayRows.length ? sumRows(overlayRows, ix) : null;
|
|
167
|
+
const cost = calculateCost(totals, {
|
|
168
|
+
sessions: sessions.length, activeDays: averages.activeDays, unpriced, tiers,
|
|
169
|
+
// Tiers billed above the standard rate, so the UI can call them out.
|
|
170
|
+
premiumTiers: ['priority', 'fast'],
|
|
171
|
+
overlayMeasured: overlay ? (overlay.costMeasured || 0) + (overlay.cost || 0) : null,
|
|
172
|
+
overlayRequests: overlay ? overlay.req : 0,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const providerGrowth = calculateDimensionGrowth(ix, 'p', eff, range);
|
|
176
|
+
const modelGrowth = calculateDimensionGrowth(ix, 'm', eff, range);
|
|
177
|
+
|
|
178
|
+
const work = calculateWorkSeries(bundle.activity || { rows: {} }, { from: range.from, to: range.to, projects: f.project });
|
|
179
|
+
const correlations = calculateCorrelations(daily, work);
|
|
180
|
+
const contrast = calculateActivityContrast(daily, work, 'insertions');
|
|
181
|
+
const proxies = calculateActivityProxies(daily, sessions, totals);
|
|
182
|
+
|
|
183
|
+
const comparison = filters.compare
|
|
184
|
+
? calculatePeriodComparison(ix, bundle.sessions || [], eff, filters.compare.a, filters.compare.b)
|
|
185
|
+
: null;
|
|
186
|
+
|
|
187
|
+
const drill = filters.drillDate ? dayDetail(rows, ix, filters.drillDate, sessions) : null;
|
|
188
|
+
|
|
189
|
+
// ---- forward-looking + structural intelligence --------------------------
|
|
190
|
+
// `today` is the dataset timezone's calendar today (meta), so "this month"
|
|
191
|
+
// and reset windows mean the user's local calendar, never the host's.
|
|
192
|
+
const todayIso = bundle.meta?.today || range.to;
|
|
193
|
+
const forecast = buildForecast(daily, todayIso);
|
|
194
|
+
const anomalies = detectAnomalies(daily);
|
|
195
|
+
const firstSeen = {
|
|
196
|
+
models: firstSeenEntities(ix, { today: todayIso, dim: 'm', withinDays: 7 }).slice(0, 3),
|
|
197
|
+
providers: firstSeenEntities(ix, { today: todayIso, dim: 'p', withinDays: 7 }).slice(0, 3),
|
|
198
|
+
};
|
|
199
|
+
// Capacity is evaluated against the WHOLE primary dataset on purpose: a
|
|
200
|
+
// quota window is a fact about your accounts, not about the dashboard's
|
|
201
|
+
// current filter state. Per-limit provider/model/project scoping happens
|
|
202
|
+
// inside evaluateLimits.
|
|
203
|
+
const capEval = evaluateLimits(bundle.limits || [], {
|
|
204
|
+
cube: bundle.cube,
|
|
205
|
+
today: todayIso,
|
|
206
|
+
nowMs: Date.now(),
|
|
207
|
+
tzOffsetMinutes: bundle.meta?.tzOffsetMinutes ?? 0,
|
|
208
|
+
coverageFrom: coverage.from,
|
|
209
|
+
});
|
|
210
|
+
const capacity = { ...capEval, summary: summarizeCapacity(capEval.states) };
|
|
211
|
+
|
|
212
|
+
const view = {
|
|
213
|
+
range,
|
|
214
|
+
coverage,
|
|
215
|
+
filters: eff,
|
|
216
|
+
granularity,
|
|
217
|
+
kpis: buildKpis(totals, averages, peaks, sessions, providers, models, daily),
|
|
218
|
+
totals,
|
|
219
|
+
series,
|
|
220
|
+
daily,
|
|
221
|
+
movingAverages: {
|
|
222
|
+
ma7: movingAverage(daily, 7),
|
|
223
|
+
ma30: movingAverage(daily, 30),
|
|
224
|
+
},
|
|
225
|
+
averages,
|
|
226
|
+
composition,
|
|
227
|
+
trend,
|
|
228
|
+
hourly,
|
|
229
|
+
dowUsage,
|
|
230
|
+
hourDow,
|
|
231
|
+
calendar: { days: daily, levels },
|
|
232
|
+
streaks,
|
|
233
|
+
peaks,
|
|
234
|
+
dimensions: { providers, models, families, clients, interfaces, projects, gateways, tiers },
|
|
235
|
+
stacks: { providerSeries, modelSeries, interfaceTrend },
|
|
236
|
+
growth: { providers: providerGrowth, models: modelGrowth },
|
|
237
|
+
efficiency,
|
|
238
|
+
sessionProfile,
|
|
239
|
+
modelEfficiency,
|
|
240
|
+
cost,
|
|
241
|
+
productivity: { proxies, work, correlations, contrast },
|
|
242
|
+
comparison,
|
|
243
|
+
drill,
|
|
244
|
+
forecast,
|
|
245
|
+
anomalies,
|
|
246
|
+
firstSeen,
|
|
247
|
+
capacity,
|
|
248
|
+
facets: {
|
|
249
|
+
provider: facet(ix, 'p'),
|
|
250
|
+
model: facet(ix, 'm'),
|
|
251
|
+
model_family: facet(ix, 'mf'),
|
|
252
|
+
client: facet(ix, 'c'),
|
|
253
|
+
interface: facet(ix, 'i'),
|
|
254
|
+
gateway: facet(ix, 'g'),
|
|
255
|
+
project: facet(ix, 'pj'),
|
|
256
|
+
repository: facet(ix, 'rp'),
|
|
257
|
+
service_tier: facet(ix, 'st'),
|
|
258
|
+
},
|
|
259
|
+
plugins: {},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
view.insights = generateInsights({
|
|
263
|
+
ix, rows, totals, series: daily, sessions, filters: eff, range,
|
|
264
|
+
hourly, peaks, composition, trend, cost, correlations,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const slice = { ix, rows, sessions, totals, view, bundle, filters: eff, range };
|
|
268
|
+
for (const p of plugins.values()) {
|
|
269
|
+
try {
|
|
270
|
+
view.plugins[p.id] = p.compute(slice);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
view.plugins[p.id] = { error: err.message };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return view;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function buildKpis(totals, averages, peaks, sessions, providers, models, daily) {
|
|
279
|
+
const activeDays = averages.activeDays;
|
|
280
|
+
return {
|
|
281
|
+
total: { value: totals.total, label: 'Total usage', unit: 'tokens', measured: true },
|
|
282
|
+
input: { value: totals.in, label: 'Input', unit: 'tokens', na: totals.naIn },
|
|
283
|
+
output: { value: totals.out, label: 'Output', unit: 'tokens', na: totals.naOut },
|
|
284
|
+
cache: { value: totals.cr + totals.cw, label: 'Cache', unit: 'tokens', na: totals.naCr + totals.naCw },
|
|
285
|
+
avgPerDay: { value: averages.perActiveDay, label: 'Avg / active day', unit: 'tokens' },
|
|
286
|
+
peak: {
|
|
287
|
+
value: peaks.peakDay?.total ?? null,
|
|
288
|
+
label: 'Peak day',
|
|
289
|
+
unit: 'tokens',
|
|
290
|
+
detail: peaks.peakDay?.date ?? null,
|
|
291
|
+
},
|
|
292
|
+
activeDays: { value: activeDays, label: 'Active days', unit: 'days', detail: `${daily.length} in range` },
|
|
293
|
+
sessions: { value: sessions.length, label: 'Sessions', unit: 'sessions' },
|
|
294
|
+
sessionsPerDay: { value: activeDays ? sessions.length / activeDays : null, label: 'Avg sessions / day', unit: '' },
|
|
295
|
+
providers: { value: providers.length, label: 'Providers', unit: '' },
|
|
296
|
+
models: { value: models.length, label: 'Models', unit: '' },
|
|
297
|
+
requests: { value: totals.req, label: 'Requests', unit: '' },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function datasetCoverage(ix) {
|
|
302
|
+
let from = null;
|
|
303
|
+
let to = null;
|
|
304
|
+
for (const r of ix.rows) {
|
|
305
|
+
const d = r[ix.d.d];
|
|
306
|
+
if (from === null || d < from) from = d;
|
|
307
|
+
if (to === null || d > to) to = d;
|
|
308
|
+
}
|
|
309
|
+
return { from, to };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export {
|
|
313
|
+
indexCube, filterCube, filterSessions, sumRows, facet, EMPTY_FILTERS,
|
|
314
|
+
calculateDailyUsage, movingAverage, calculateAverageUsage, calculateComposition,
|
|
315
|
+
calculateUsageTrend, calculateHourlyUsage, calculateDowUsage, calculateHourDow,
|
|
316
|
+
calendarLevels, calculateStreaks,
|
|
317
|
+
calculateDimensionUsage, calculateDimensionSeries, calculateDimensionGrowth,
|
|
318
|
+
calculateModelEfficiency, calculateInterfaceTrend,
|
|
319
|
+
calculatePeakUsage, dayDetail,
|
|
320
|
+
calculateEfficiency, calculateCost, unpricedModels, calculateSessionProfile,
|
|
321
|
+
calculatePeriodComparison, previousPeriod,
|
|
322
|
+
calculateActivityProxies, calculateWorkSeries, calculateCorrelations, calculateActivityContrast,
|
|
323
|
+
generateInsights, daysBetween, addDays, dateRange, weekStart, monthKey,
|
|
324
|
+
buildForecast, linearForecast, monthEndProjection, exhaustionEta,
|
|
325
|
+
detectAnomalies, firstSeenEntities,
|
|
326
|
+
evaluateLimits, summarizeCapacity, normalizeLimits,
|
|
327
|
+
};
|