Files
fusion/packages/core/src/token-analytics.ts
gsxdsm 711bf3e1b8 FN-6746: attribute token analytics per runtime model
Persist and expand per-model token usage so Command Center reports every model used by a task.

- Add a nullable per-model token usage column and task model bucket types.
- Merge session token deltas into provider/model buckets while retaining task-level totals.
- Expand model/provider analytics from per-model buckets with legacy fallbacks for older rows.
- Cover persistence, migration, aggregation, and session accumulation behavior with tests.
- Document the per-model storage contract alongside existing token analytics tables.

Files changed:
 docs/storage.md                                    |   2 +
 packages/core/src/__tests__/db-migrate.test.ts     |  62 ++++++++++
 .../core/src/__tests__/store-persistence.test.ts   |  49 ++++++++
 .../core/src/__tests__/token-analytics.test.ts     | 127 ++++++++++++++++++++-
 packages/core/src/db.ts                            |  13 ++-
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/store.ts                         |   7 +-
 packages/core/src/token-analytics.ts               |  58 ++++++++--
 packages/core/src/types.ts                         |  32 ++++++
 .../src/__tests__/session-token-usage.test.ts      |  23 ++++
 packages/engine/src/executor.ts                    |  20 +++-
 packages/engine/src/session-token-usage.ts         |  50 +++++++-
 packages/engine/src/step-session-executor.ts       |   7 ++
 13 files changed, 429 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-6746

Fusion-Task-Lineage: 1b1a3b31-b669-4e89-ac38-a871f3349015
2026-06-19 16:47:34 -07:00

369 lines
13 KiB
TypeScript

import type { Database } from "./db.js";
import { costFor, type CostResult } from "./model-pricing.js";
import type { TaskTokenUsagePerModel } from "./types.js";
/**
* Token-consumption analytics over the `tasks` table, generalizing the fixed
* 24h/7d/all-time windows of `agent-token-usage.ts` to an arbitrary `(from, to)`
* range. Sums the `tokenUsage*` columns filtered by `tokenUsageLastUsedAt` and
* groups by model / provider / node / agent.
*
* Inclusivity: `from`/`to` bounds are **inclusive** (`>= from AND <= to`),
* matching `usage-events.ts` and the range-scan house style. A task whose
* `tokenUsageLastUsedAt` is exactly equal to `from` is therefore included.
*
* Pure read-only aggregation: takes a `Database` handle and returns plain data.
*/
/** Dimension to group token totals by. */
export type TokenGroupBy = "model" | "provider" | "node" | "agent";
/** Bucket size for optional token-usage time-series analytics. */
export type TokenTimeGranularity = "hour" | "day" | "week";
/** Summed token counts for a group (or the grand total). */
export interface TokenTotals {
inputTokens: number;
outputTokens: number;
cachedTokens: number;
cacheWriteTokens: number;
totalTokens: number;
/** Number of tasks that contributed to these totals. */
nTasks: number;
}
/** One group's token totals, keyed by the grouped dimension value. */
export interface TokenGroupSummary extends TokenTotals {
/** The group key (model id, provider, nodeId, or agentId); null when unset. */
key: string | null;
/**
* Derived USD cost for this group (U3). Each contributing task is priced at
* its own model's rates and summed, so the cost is meaningful for any
* `groupBy`. `usd` is null when none of the group's tasks had a known price;
* `unavailable` is true when at least one task's model was unpriced.
*/
cost: CostResult;
}
/** One time bucket in the optional token-usage series. */
export interface TokenTimePoint extends TokenTotals {
/** UTC bucket key (`YYYY-MM-DDTHH`, `YYYY-MM-DD`, or ISO week `YYYY-Www`). */
bucket: string;
/** Derived USD cost for this bucket, summed per contributing task. */
cost: CostResult;
}
/** Result of {@link aggregateTokenAnalytics}. */
export interface TokenAnalytics {
from: string | null;
to: string | null;
groupBy: TokenGroupBy | null;
/** Grand total across all matched tasks. */
totals: TokenTotals;
/**
* Derived USD cost across all matched tasks (U3), each priced at its own
* model's rates. `usd` is null when no task had a known price; `unavailable`
* is true when at least one task's model had no pricing entry.
*/
cost: CostResult;
/** Per-group totals; empty array when no `groupBy` requested. */
groups: TokenGroupSummary[];
/** Optional token-usage totals over time, present only when requested. */
series?: TokenTimePoint[];
}
export interface TokenAnalyticsQuery {
/** ISO-8601 lower bound (inclusive) on `tokenUsageLastUsedAt`. */
from?: string;
/** ISO-8601 upper bound (inclusive) on `tokenUsageLastUsedAt`. */
to?: string;
groupBy?: TokenGroupBy;
/** Optional UTC bucket size for a token-usage time series. */
granularity?: TokenTimeGranularity;
/**
* Epoch ms "now" used only for pricing-staleness (U3). When omitted, derived
* cost is never marked stale. Pure: the module never reads the clock itself.
*/
now?: number;
}
function emptyTotals(): TokenTotals {
return {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
cacheWriteTokens: 0,
totalTokens: 0,
nTasks: 0,
};
}
interface TaskTokenRow {
inputTokens: number | null;
outputTokens: number | null;
cachedTokens: number | null;
cacheWriteTokens: number | null;
totalTokens: number | null;
modelProvider: string | null;
modelId: string | null;
tokenUsageModelProvider: string | null;
tokenUsageModelId: string | null;
tokenUsagePerModel: string | null;
checkoutNodeId: string | null;
assignedAgentId: string | null;
tokenUsageLastUsedAt: string;
}
function groupKeyFor(row: TaskTokenRow, groupBy: TokenGroupBy): string | null {
switch (groupBy) {
case "model":
/*
* FNXC:TokenAnalytics 2026-06-19-16:09:
* By-model analytics expands durable per-model buckets before this legacy path runs. Keep this single-snapshot fallback for pre-migration, empty, or malformed per-model rows so historical grouping never throws.
*/
return row.tokenUsageModelId ?? row.modelId;
case "provider":
return row.tokenUsageModelProvider ?? row.modelProvider;
case "node":
return row.checkoutNodeId;
case "agent":
return row.assignedAgentId;
}
}
/**
* Running cost tally. Each task is priced at its own model, then summed: `usd`
* accumulates priced tasks, `anyUnavailable` records whether any task's model
* was unpriced, `anyStale` whether the pricing map was stale, and `anyPriced`
* whether at least one task had a known price. {@link finalizeCost} converts
* this to a {@link CostResult}.
*/
interface CostAccumulator {
usd: number;
anyPriced: boolean;
anyUnavailable: boolean;
anyStale: boolean;
}
function emptyCostAccumulator(): CostAccumulator {
return { usd: 0, anyPriced: false, anyUnavailable: false, anyStale: false };
}
function addRowCost(acc: CostAccumulator, row: TaskTokenRow, now?: number): void {
/*
* FNXC:CommandCenter 2026-06-18-12:00:
* Token cost attribution must use the actually-used model snapshot first, then legacy own-model columns, matching groupKeyFor so resolved-via-settings tasks show priced Command Center costs instead of unavailable groups.
*/
const result = costFor(
{
inputTokens: row.inputTokens ?? 0,
outputTokens: row.outputTokens ?? 0,
cachedTokens: row.cachedTokens ?? 0,
cacheWriteTokens: row.cacheWriteTokens ?? 0,
},
{
provider: row.tokenUsageModelProvider ?? row.modelProvider,
model: row.tokenUsageModelId ?? row.modelId,
},
now,
);
if (result.stale) acc.anyStale = true;
if (result.unavailable || result.usd === null) {
acc.anyUnavailable = true;
} else {
acc.usd += result.usd;
acc.anyPriced = true;
}
}
function finalizeCost(acc: CostAccumulator): CostResult {
return {
usd: acc.anyPriced ? acc.usd : null,
unavailable: acc.anyUnavailable,
stale: acc.anyStale,
};
}
function parsePerModelRows(row: TaskTokenRow): TaskTokenRow[] {
if (!row.tokenUsagePerModel) return [];
try {
const parsed = JSON.parse(row.tokenUsagePerModel) as unknown;
if (!Array.isArray(parsed) || parsed.length === 0) return [];
return parsed
.filter((entry): entry is Partial<TaskTokenUsagePerModel> => entry !== null && typeof entry === "object")
.map((entry) => {
const inputTokens = Number.isFinite(entry.inputTokens) ? Number(entry.inputTokens) : 0;
const outputTokens = Number.isFinite(entry.outputTokens) ? Number(entry.outputTokens) : 0;
const cachedTokens = Number.isFinite(entry.cachedTokens) ? Number(entry.cachedTokens) : 0;
const cacheWriteTokens = Number.isFinite(entry.cacheWriteTokens) ? Number(entry.cacheWriteTokens) : 0;
const totalTokens = Number.isFinite(entry.totalTokens)
? Number(entry.totalTokens)
: inputTokens + outputTokens + cachedTokens + cacheWriteTokens;
return {
...row,
inputTokens,
outputTokens,
cachedTokens,
cacheWriteTokens,
totalTokens,
tokenUsageModelProvider: typeof entry.modelProvider === "string" ? entry.modelProvider : null,
tokenUsageModelId: typeof entry.modelId === "string" ? entry.modelId : null,
};
});
} catch {
return [];
}
}
function addRow(totals: TokenTotals, row: TaskTokenRow): void {
totals.inputTokens += row.inputTokens ?? 0;
totals.outputTokens += row.outputTokens ?? 0;
totals.cachedTokens += row.cachedTokens ?? 0;
totals.cacheWriteTokens += row.cacheWriteTokens ?? 0;
// Prefer the persisted total when present; otherwise derive it from the parts
// so callers always get a coherent `totalTokens` even on older rows.
const persistedTotal = row.totalTokens;
totals.totalTokens +=
persistedTotal ??
(row.inputTokens ?? 0) +
(row.outputTokens ?? 0) +
(row.cachedTokens ?? 0) +
(row.cacheWriteTokens ?? 0);
totals.nTasks += 1;
}
function isoWeekBucket(isoTimestamp: string): string {
const date = new Date(isoTimestamp);
if (!Number.isFinite(date.getTime())) return isoTimestamp.slice(0, 10);
const day = date.getUTCDay() || 7;
const thursday = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 4 - day));
const yearStart = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 1));
const week = Math.ceil(((thursday.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
}
function bucketFor(row: TaskTokenRow, granularity: TokenTimeGranularity): string {
switch (granularity) {
case "hour":
return row.tokenUsageLastUsedAt.slice(0, 13);
case "day":
return row.tokenUsageLastUsedAt.slice(0, 10);
case "week":
return isoWeekBucket(row.tokenUsageLastUsedAt);
}
}
/**
* Aggregate per-task token usage over a date range, optionally grouped.
*
* Tasks are matched by `tokenUsageLastUsedAt` within `[from, to]` (inclusive).
* Tasks with no token usage (`tokenUsageLastUsedAt IS NULL`) are excluded. An
* empty range yields zeroed `totals` and an empty `groups` array — never nulls.
*
* FNXC:CommandCenter 2026-06-18-00:00:
* The Command Center token view needs a live, scalable, animated token-over-time chart without changing existing CSV/OTel consumers. Keep `series` opt-in via `granularity`, bucket ISO timestamps in UTC (substring for hour/day, ISO-week in JS), and reuse per-task cost accumulation so each bucket prices mixed known/unknown models correctly.
*/
export function aggregateTokenAnalytics(
db: Database,
query: TokenAnalyticsQuery = {},
): TokenAnalytics {
const clauses: string[] = ["tokenUsageLastUsedAt IS NOT NULL"];
const params: string[] = [];
if (query.from !== undefined) {
clauses.push("tokenUsageLastUsedAt >= ?");
params.push(query.from);
}
if (query.to !== undefined) {
clauses.push("tokenUsageLastUsedAt <= ?");
params.push(query.to);
}
const where = `WHERE ${clauses.join(" AND ")}`;
const rows = db
.prepare(
`SELECT
tokenUsageInputTokens AS inputTokens,
tokenUsageOutputTokens AS outputTokens,
tokenUsageCachedTokens AS cachedTokens,
tokenUsageCacheWriteTokens AS cacheWriteTokens,
tokenUsageTotalTokens AS totalTokens,
modelProvider,
modelId,
tokenUsageModelProvider,
tokenUsageModelId,
tokenUsagePerModel,
checkoutNodeId,
assignedAgentId,
tokenUsageLastUsedAt
FROM tasks ${where}`,
)
.all(...params) as TaskTokenRow[];
const totals = emptyTotals();
const totalCost = emptyCostAccumulator();
const groupMap = new Map<string | null, TokenGroupSummary>();
const groupCostMap = new Map<string | null, CostAccumulator>();
const seriesMap = new Map<string, TokenTimePoint>();
const seriesCostMap = new Map<string, CostAccumulator>();
const groupBy = query.groupBy;
const granularity = query.granularity;
const now = query.now;
for (const row of rows) {
addRow(totals, row);
addRowCost(totalCost, row, now);
if (groupBy) {
const groupRows = (groupBy === "model" || groupBy === "provider") ? parsePerModelRows(row) : [];
const rowsForGroup = groupRows.length > 0 ? groupRows : [row];
for (const groupRow of rowsForGroup) {
const key = groupKeyFor(groupRow, groupBy);
let group = groupMap.get(key);
if (!group) {
group = { key, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } };
groupMap.set(key, group);
groupCostMap.set(key, emptyCostAccumulator());
}
addRow(group, groupRow);
addRowCost(groupCostMap.get(key)!, groupRow, now);
}
}
if (granularity) {
const bucket = bucketFor(row, granularity);
let point = seriesMap.get(bucket);
if (!point) {
point = { bucket, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } };
seriesMap.set(bucket, point);
seriesCostMap.set(bucket, emptyCostAccumulator());
}
addRow(point, row);
addRowCost(seriesCostMap.get(bucket)!, row, now);
}
}
// Finalize per-group cost from each group's accumulator.
for (const [key, group] of groupMap) {
group.cost = finalizeCost(groupCostMap.get(key)!);
}
const groups = [...groupMap.values()].sort(
(a, b) => b.totalTokens - a.totalTokens,
);
for (const [bucket, point] of seriesMap) {
point.cost = finalizeCost(seriesCostMap.get(bucket)!);
}
const series = granularity
? [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket))
: undefined;
return {
from: query.from ?? null,
to: query.to ?? null,
groupBy: groupBy ?? null,
totals,
cost: finalizeCost(totalCost),
groups,
...(granularity ? { series } : {}),
};
}