FN-7448: Fix 30-day token model ranges
Command Center token analytics now attributes Last 30 days model usage from durable per-model timestamps. - Filter per-model token buckets by their own last-used timestamps while preserving legacy task-level fallback rows. - Count unique tasks across totals, groups, and time series to avoid double-counting multi-model usage. - Cover model/provider groups, API routing, and TokensArea rendering for Last 30 days multi-model data. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-7448-token-usage-last-30-days.md | 7 ++ .../core/src/__tests__/token-analytics.test.ts | 128 +++++++++++++++++++-- packages/core/src/token-analytics.ts | 92 ++++++++++----- .../areas/__tests__/TokensArea.test.tsx | 50 ++++++++ .../register-command-center-routes.test.ts | 86 ++++++++++++++ 5 files changed, 327 insertions(+), 36 deletions(-) Fusion-Task-Id: FN-7448 Fusion-Task-Lineage: 5d5c976b-5623-4d7a-a728-5a3959deac1d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7448-token-usage-last-30-days.md
Normal file
7
.changeset/fn-7448-token-usage-last-30-days.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix Last 30 days token usage to include every model in Command Center.
|
||||||
|
category: fix
|
||||||
|
dev: Corrects Command Center token analytics range attribution for durable multi-model task usage.
|
||||||
@@ -156,11 +156,11 @@ describe("token-analytics", () => {
|
|||||||
expect([...modelGroups.values()].reduce((sum, group) => sum + group.nTasks, 0)).toBe(2);
|
expect([...modelGroups.values()].reduce((sum, group) => sum + group.nTasks, 0)).toBe(2);
|
||||||
expect(byModel.totals.nTasks).toBe(1);
|
expect(byModel.totals.nTasks).toBe(1);
|
||||||
|
|
||||||
const expectedTaskCost = costFor(
|
expect(byModel.cost.usd).toBeCloseTo(
|
||||||
{ inputTokens: 950, outputTokens: 450, cachedTokens: 0, cacheWriteTokens: 0 },
|
(modelGroups.get("claude-sonnet-4-5")?.cost.usd ?? 0) + (modelGroups.get("gpt-5")?.cost.usd ?? 0),
|
||||||
{ provider: "openai", model: "gpt-5" },
|
10,
|
||||||
);
|
);
|
||||||
expect(byModel.cost).toEqual(expectedTaskCost);
|
expect(byModel.cost.unavailable).toBe(false);
|
||||||
|
|
||||||
const byProvider = aggregateTokenAnalytics(db, { groupBy: "provider" });
|
const byProvider = aggregateTokenAnalytics(db, { groupBy: "provider" });
|
||||||
expect(byProvider.totals).toEqual(byModel.totals);
|
expect(byProvider.totals).toEqual(byModel.totals);
|
||||||
@@ -171,6 +171,121 @@ describe("token-analytics", () => {
|
|||||||
expect(byProvider.totals.nTasks).toBe(1);
|
expect(byProvider.totals.nTasks).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("filters Last 30 days model groups by durable per-model bucket timestamps", () => {
|
||||||
|
const from = "2026-06-02T00:00:00.000Z";
|
||||||
|
const to = "2026-07-02T00:00:00.000Z";
|
||||||
|
insertTask(db, {
|
||||||
|
id: "last-30-multi",
|
||||||
|
inputTokens: 1_520,
|
||||||
|
outputTokens: 730,
|
||||||
|
cachedTokens: 110,
|
||||||
|
cacheWriteTokens: 40,
|
||||||
|
totalTokens: 2_400,
|
||||||
|
lastUsedAt: "2026-07-05T00:00:00.000Z",
|
||||||
|
tokenUsageModelProvider: "openai",
|
||||||
|
tokenUsageModelId: "gpt-5",
|
||||||
|
tokenUsagePerModel: [
|
||||||
|
{
|
||||||
|
modelProvider: "anthropic",
|
||||||
|
modelId: "claude-sonnet-4-5",
|
||||||
|
inputTokens: 700,
|
||||||
|
outputTokens: 300,
|
||||||
|
cachedTokens: 20,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 1_020,
|
||||||
|
firstUsedAt: "2026-06-02T00:00:00.000Z",
|
||||||
|
lastUsedAt: "2026-06-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelProvider: "openai",
|
||||||
|
modelId: "gpt-5",
|
||||||
|
inputTokens: 250,
|
||||||
|
outputTokens: 150,
|
||||||
|
cachedTokens: 10,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 410,
|
||||||
|
firstUsedAt: "2026-06-15T00:00:00.000Z",
|
||||||
|
lastUsedAt: "2026-06-15T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelProvider: "openai",
|
||||||
|
modelId: "gpt-5",
|
||||||
|
inputTokens: 50,
|
||||||
|
outputTokens: 50,
|
||||||
|
cachedTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 100,
|
||||||
|
firstUsedAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
lastUsedAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputTokens: 25,
|
||||||
|
outputTokens: 25,
|
||||||
|
cachedTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 50,
|
||||||
|
firstUsedAt: "2026-06-20T00:00:00.000Z",
|
||||||
|
lastUsedAt: "2026-06-20T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelProvider: "zai",
|
||||||
|
modelId: "glm-outside",
|
||||||
|
inputTokens: 495,
|
||||||
|
outputTokens: 205,
|
||||||
|
cachedTokens: 80,
|
||||||
|
cacheWriteTokens: 40,
|
||||||
|
totalTokens: 820,
|
||||||
|
firstUsedAt: "2026-07-03T00:00:00.000Z",
|
||||||
|
lastUsedAt: "2026-07-03T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
insertTask(db, {
|
||||||
|
id: "malformed-fallback",
|
||||||
|
inputTokens: 40,
|
||||||
|
outputTokens: 10,
|
||||||
|
totalTokens: 50,
|
||||||
|
lastUsedAt: "2026-06-18T00:00:00.000Z",
|
||||||
|
tokenUsageModelProvider: "anthropic",
|
||||||
|
tokenUsageModelId: "claude-haiku-3-5",
|
||||||
|
tokenUsagePerModel: "not-json",
|
||||||
|
});
|
||||||
|
insertTask(db, {
|
||||||
|
id: "legacy-missing-per-model",
|
||||||
|
inputTokens: 30,
|
||||||
|
outputTokens: 20,
|
||||||
|
totalTokens: 50,
|
||||||
|
lastUsedAt: "2026-06-19T00:00:00.000Z",
|
||||||
|
tokenUsageModelProvider: "openai",
|
||||||
|
tokenUsageModelId: "gpt-4o-mini",
|
||||||
|
});
|
||||||
|
|
||||||
|
const byModel = aggregateTokenAnalytics(db, { from, to, groupBy: "model", granularity: "day" });
|
||||||
|
const groups = new Map(byModel.groups.map((group) => [group.key, group]));
|
||||||
|
|
||||||
|
expect(groups.get("claude-sonnet-4-5")).toMatchObject({ totalTokens: 1_020, inputTokens: 700, nTasks: 1 });
|
||||||
|
expect(groups.get("gpt-5")).toMatchObject({ totalTokens: 510, inputTokens: 300, nTasks: 1 });
|
||||||
|
expect(groups.get(null)).toMatchObject({ totalTokens: 50, inputTokens: 25, nTasks: 1 });
|
||||||
|
expect(groups.get("claude-haiku-3-5")).toMatchObject({ totalTokens: 50, nTasks: 1 });
|
||||||
|
expect(groups.get("gpt-4o-mini")).toMatchObject({ totalTokens: 50, nTasks: 1 });
|
||||||
|
expect(groups.has("glm-outside")).toBe(false);
|
||||||
|
expect(byModel.totals).toMatchObject({ inputTokens: 1_095, outputTokens: 555, cachedTokens: 30, cacheWriteTokens: 0, totalTokens: 1_680, nTasks: 3 });
|
||||||
|
expect(byModel.series?.map((point) => [point.bucket, point.totalTokens])).toEqual([
|
||||||
|
["2026-06-02", 1_020],
|
||||||
|
["2026-06-15", 410],
|
||||||
|
["2026-06-18", 50],
|
||||||
|
["2026-06-19", 50],
|
||||||
|
["2026-06-20", 50],
|
||||||
|
["2026-07-02", 100],
|
||||||
|
]);
|
||||||
|
expect(byModel.cost.unavailable).toBe(true);
|
||||||
|
|
||||||
|
const byProvider = aggregateTokenAnalytics(db, { from, to, groupBy: "provider" });
|
||||||
|
expect(new Map(byProvider.groups.map((group) => [group.key, group.totalTokens]))).toEqual(
|
||||||
|
new Map([["anthropic", 1_070], ["openai", 560], [null, 50]]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("marks unpriced per-model buckets as cost unavailable instead of zero", () => {
|
it("marks unpriced per-model buckets as cost unavailable instead of zero", () => {
|
||||||
insertTask(db, {
|
insertTask(db, {
|
||||||
id: "unpriced-bucket",
|
id: "unpriced-bucket",
|
||||||
@@ -199,10 +314,7 @@ describe("token-analytics", () => {
|
|||||||
|
|
||||||
expect(result.groups).toHaveLength(1);
|
expect(result.groups).toHaveLength(1);
|
||||||
expect(result.groups[0]).toMatchObject({ key: "unknown-model", totalTokens: 100, cost: { usd: null, unavailable: true } });
|
expect(result.groups[0]).toMatchObject({ key: "unknown-model", totalTokens: 100, cost: { usd: null, unavailable: true } });
|
||||||
expect(result.cost).toEqual(costFor(
|
expect(result.cost).toEqual({ usd: null, unavailable: true, stale: false });
|
||||||
{ inputTokens: 60, outputTokens: 40, cachedTokens: 0, cacheWriteTokens: 0 },
|
|
||||||
{ provider: "openai", model: "gpt-5" },
|
|
||||||
));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the legacy snapshot when per-model JSON is malformed", () => {
|
it("falls back to the legacy snapshot when per-model JSON is malformed", () => {
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ function emptyTotals(): TokenTotals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TaskTokenRow {
|
interface TaskTokenRow {
|
||||||
|
id: string;
|
||||||
inputTokens: number | null;
|
inputTokens: number | null;
|
||||||
outputTokens: number | null;
|
outputTokens: number | null;
|
||||||
cachedTokens: number | null;
|
cachedTokens: number | null;
|
||||||
@@ -192,12 +193,17 @@ function finalizeCost(acc: CostAccumulator): CostResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePerModelRows(row: TaskTokenRow): TaskTokenRow[] {
|
interface ParsedPerModelRows {
|
||||||
if (!row.tokenUsagePerModel) return [];
|
valid: boolean;
|
||||||
|
rows: TaskTokenRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePerModelRows(row: TaskTokenRow): ParsedPerModelRows {
|
||||||
|
if (!row.tokenUsagePerModel) return { valid: false, rows: [] };
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(row.tokenUsagePerModel) as unknown;
|
const parsed = JSON.parse(row.tokenUsagePerModel) as unknown;
|
||||||
if (!Array.isArray(parsed) || parsed.length === 0) return [];
|
if (!Array.isArray(parsed) || parsed.length === 0) return { valid: false, rows: [] };
|
||||||
return parsed
|
const rows = parsed
|
||||||
.filter((entry): entry is Partial<TaskTokenUsagePerModel> => entry !== null && typeof entry === "object")
|
.filter((entry): entry is Partial<TaskTokenUsagePerModel> => entry !== null && typeof entry === "object")
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const inputTokens = Number.isFinite(entry.inputTokens) ? Number(entry.inputTokens) : 0;
|
const inputTokens = Number.isFinite(entry.inputTokens) ? Number(entry.inputTokens) : 0;
|
||||||
@@ -216,14 +222,20 @@ function parsePerModelRows(row: TaskTokenRow): TaskTokenRow[] {
|
|||||||
totalTokens,
|
totalTokens,
|
||||||
tokenUsageModelProvider: typeof entry.modelProvider === "string" ? entry.modelProvider : null,
|
tokenUsageModelProvider: typeof entry.modelProvider === "string" ? entry.modelProvider : null,
|
||||||
tokenUsageModelId: typeof entry.modelId === "string" ? entry.modelId : null,
|
tokenUsageModelId: typeof entry.modelId === "string" ? entry.modelId : null,
|
||||||
|
tokenUsageLastUsedAt: typeof entry.lastUsedAt === "string" ? entry.lastUsedAt : row.tokenUsageLastUsedAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
return { valid: rows.length > 0, rows };
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return { valid: false, rows: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRow(totals: TokenTotals, row: TaskTokenRow): void {
|
function isWithinRange(isoTimestamp: string, from?: string, to?: string): boolean {
|
||||||
|
return (from === undefined || isoTimestamp >= from) && (to === undefined || isoTimestamp <= to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow(totals: TokenTotals, row: TaskTokenRow, taskIds?: Set<string>): void {
|
||||||
totals.inputTokens += row.inputTokens ?? 0;
|
totals.inputTokens += row.inputTokens ?? 0;
|
||||||
totals.outputTokens += row.outputTokens ?? 0;
|
totals.outputTokens += row.outputTokens ?? 0;
|
||||||
totals.cachedTokens += row.cachedTokens ?? 0;
|
totals.cachedTokens += row.cachedTokens ?? 0;
|
||||||
@@ -237,7 +249,10 @@ function addRow(totals: TokenTotals, row: TaskTokenRow): void {
|
|||||||
(row.outputTokens ?? 0) +
|
(row.outputTokens ?? 0) +
|
||||||
(row.cachedTokens ?? 0) +
|
(row.cachedTokens ?? 0) +
|
||||||
(row.cacheWriteTokens ?? 0);
|
(row.cacheWriteTokens ?? 0);
|
||||||
totals.nTasks += 1;
|
if (!taskIds || !taskIds.has(row.id)) {
|
||||||
|
totals.nTasks += 1;
|
||||||
|
taskIds?.add(row.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isoWeekBucket(isoTimestamp: string): string {
|
function isoWeekBucket(isoTimestamp: string): string {
|
||||||
@@ -277,19 +292,28 @@ export function aggregateTokenAnalytics(
|
|||||||
): TokenAnalytics {
|
): TokenAnalytics {
|
||||||
const clauses: string[] = ["tokenUsageLastUsedAt IS NOT NULL"];
|
const clauses: string[] = ["tokenUsageLastUsedAt IS NOT NULL"];
|
||||||
const params: string[] = [];
|
const params: string[] = [];
|
||||||
|
const rangeClauses: string[] = [];
|
||||||
if (query.from !== undefined) {
|
if (query.from !== undefined) {
|
||||||
clauses.push("tokenUsageLastUsedAt >= ?");
|
rangeClauses.push("tokenUsageLastUsedAt >= ?");
|
||||||
params.push(query.from);
|
params.push(query.from);
|
||||||
}
|
}
|
||||||
if (query.to !== undefined) {
|
if (query.to !== undefined) {
|
||||||
clauses.push("tokenUsageLastUsedAt <= ?");
|
rangeClauses.push("tokenUsageLastUsedAt <= ?");
|
||||||
params.push(query.to);
|
params.push(query.to);
|
||||||
}
|
}
|
||||||
|
if (rangeClauses.length > 0) {
|
||||||
|
/*
|
||||||
|
* FNXC:CommandCenterTokenRanges 2026-07-02-00:00:
|
||||||
|
* Last 30 days model analytics must evaluate durable tokenUsagePerModel bucket timestamps, not only the task-level latest usage timestamp. Include candidate multi-model rows for in-memory bucket filtering while legacy rows stay narrowed by task tokenUsageLastUsedAt.
|
||||||
|
*/
|
||||||
|
clauses.push(`((${rangeClauses.join(" AND ")}) OR tokenUsagePerModel IS NOT NULL)`);
|
||||||
|
}
|
||||||
const where = `WHERE ${clauses.join(" AND ")}`;
|
const where = `WHERE ${clauses.join(" AND ")}`;
|
||||||
|
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT
|
`SELECT
|
||||||
|
id,
|
||||||
tokenUsageInputTokens AS inputTokens,
|
tokenUsageInputTokens AS inputTokens,
|
||||||
tokenUsageOutputTokens AS outputTokens,
|
tokenUsageOutputTokens AS outputTokens,
|
||||||
tokenUsageCachedTokens AS cachedTokens,
|
tokenUsageCachedTokens AS cachedTokens,
|
||||||
@@ -318,34 +342,46 @@ export function aggregateTokenAnalytics(
|
|||||||
const now = query.now;
|
const now = query.now;
|
||||||
const pricingOverrides = query.pricingOverrides;
|
const pricingOverrides = query.pricingOverrides;
|
||||||
|
|
||||||
|
const totalTaskIds = new Set<string>();
|
||||||
|
const groupTaskIds = new Map<string | null, Set<string>>();
|
||||||
|
const seriesTaskIds = new Map<string, Set<string>>();
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
addRow(totals, row);
|
const perModel = parsePerModelRows(row);
|
||||||
addRowCost(totalCost, row, now, pricingOverrides);
|
const rowInRange = isWithinRange(row.tokenUsageLastUsedAt, query.from, query.to);
|
||||||
if (groupBy) {
|
const contributionRows = perModel.valid
|
||||||
const groupRows = (groupBy === "model" || groupBy === "provider") ? parsePerModelRows(row) : [];
|
? perModel.rows.filter((bucketRow) => isWithinRange(bucketRow.tokenUsageLastUsedAt, query.from, query.to))
|
||||||
const rowsForGroup = groupRows.length > 0 ? groupRows : [row];
|
: rowInRange
|
||||||
for (const groupRow of rowsForGroup) {
|
? [row]
|
||||||
const key = groupKeyFor(groupRow, groupBy);
|
: [];
|
||||||
|
|
||||||
|
for (const contributionRow of contributionRows) {
|
||||||
|
addRow(totals, contributionRow, totalTaskIds);
|
||||||
|
addRowCost(totalCost, contributionRow, now, pricingOverrides);
|
||||||
|
if (groupBy) {
|
||||||
|
const key = groupKeyFor(contributionRow, groupBy);
|
||||||
let group = groupMap.get(key);
|
let group = groupMap.get(key);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
group = { key, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } };
|
group = { key, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } };
|
||||||
groupMap.set(key, group);
|
groupMap.set(key, group);
|
||||||
groupCostMap.set(key, emptyCostAccumulator());
|
groupCostMap.set(key, emptyCostAccumulator());
|
||||||
|
groupTaskIds.set(key, new Set<string>());
|
||||||
}
|
}
|
||||||
addRow(group, groupRow);
|
addRow(group, contributionRow, groupTaskIds.get(key)!);
|
||||||
addRowCost(groupCostMap.get(key)!, groupRow, now, pricingOverrides);
|
addRowCost(groupCostMap.get(key)!, contributionRow, now, pricingOverrides);
|
||||||
}
|
}
|
||||||
}
|
if (granularity) {
|
||||||
if (granularity) {
|
const bucket = bucketFor(contributionRow, granularity);
|
||||||
const bucket = bucketFor(row, granularity);
|
let point = seriesMap.get(bucket);
|
||||||
let point = seriesMap.get(bucket);
|
if (!point) {
|
||||||
if (!point) {
|
point = { bucket, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } };
|
||||||
point = { bucket, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } };
|
seriesMap.set(bucket, point);
|
||||||
seriesMap.set(bucket, point);
|
seriesCostMap.set(bucket, emptyCostAccumulator());
|
||||||
seriesCostMap.set(bucket, emptyCostAccumulator());
|
seriesTaskIds.set(bucket, new Set<string>());
|
||||||
|
}
|
||||||
|
addRow(point, contributionRow, seriesTaskIds.get(bucket)!);
|
||||||
|
addRowCost(seriesCostMap.get(bucket)!, contributionRow, now, pricingOverrides);
|
||||||
}
|
}
|
||||||
addRow(point, row);
|
|
||||||
addRowCost(seriesCostMap.get(bucket)!, row, now, pricingOverrides);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ vi.mock("../../../ProviderIcon", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" };
|
const range7d: DateRange = { from: "2026-06-08", to: null, preset: "7d" };
|
||||||
|
const range30d: DateRange = { from: "2026-06-02", to: null, preset: "30d" };
|
||||||
|
|
||||||
function makeTokenGroup(key: string | null, totalTokens: number) {
|
function makeTokenGroup(key: string | null, totalTokens: number) {
|
||||||
const inputTokens = Math.round(totalTokens * 0.6);
|
const inputTokens = Math.round(totalTokens * 0.6);
|
||||||
@@ -113,6 +114,31 @@ function glmMixedProviderTokenFixture() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function last30DaysMultiModelFixture() {
|
||||||
|
return {
|
||||||
|
from: "2026-06-02T00:00:00.000Z",
|
||||||
|
to: "2026-07-02T00:00:00.000Z",
|
||||||
|
groupBy: "model",
|
||||||
|
totals: {
|
||||||
|
inputTokens: 950,
|
||||||
|
outputTokens: 450,
|
||||||
|
cachedTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 1_400,
|
||||||
|
nTasks: 1,
|
||||||
|
},
|
||||||
|
cost: { usd: 4.2, unavailable: false, stale: false },
|
||||||
|
series: [
|
||||||
|
{ bucket: "2026-06-15", inputTokens: 700, outputTokens: 300, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000, nTasks: 1, cost: { usd: 3, unavailable: false, stale: false } },
|
||||||
|
{ bucket: "2026-06-20", inputTokens: 250, outputTokens: 150, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 400, nTasks: 1, cost: { usd: 1.2, unavailable: false, stale: false } },
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{ key: "claude-sonnet-4-5", inputTokens: 700, outputTokens: 300, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000, nTasks: 1, cost: { usd: 3, unavailable: false, stale: false } },
|
||||||
|
{ key: "gpt-5", inputTokens: 250, outputTokens: 150, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 400, nTasks: 1, cost: { usd: 1.2, unavailable: false, stale: false } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function manyModelTokenFixture() {
|
function manyModelTokenFixture() {
|
||||||
const groups = [
|
const groups = [
|
||||||
makeTokenGroup("claude-sonnet-4-5", 2_000),
|
makeTokenGroup("claude-sonnet-4-5", 2_000),
|
||||||
@@ -205,6 +231,30 @@ describe("TokensArea provider model icons", () => {
|
|||||||
expect(table.querySelectorAll('.provider-icon[data-provider="zai"]').length).toBe(3);
|
expect(table.querySelectorAll('.provider-icon[data-provider="zai"]').length).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders Last 30 days multi-model groups across bar, pie, line, and table surfaces", async () => {
|
||||||
|
apiMock.mockResolvedValue(last30DaysMultiModelFixture());
|
||||||
|
render(<TokensArea range={range30d} />);
|
||||||
|
|
||||||
|
const byModelChart = await screen.findByRole("list", { name: "Tokens by model" });
|
||||||
|
const pie = screen.getByTestId("cc-tokens-pie");
|
||||||
|
const table = screen.getByTestId("cc-tokens-table");
|
||||||
|
const line = screen.getByTestId("cc-tokens-line");
|
||||||
|
|
||||||
|
expect(apiMock).toHaveBeenCalledWith(
|
||||||
|
"/command-center/tokens?groupBy=model&granularity=day&from=2026-06-02",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
for (const label of ["claude-sonnet-4-5", "gpt-5"]) {
|
||||||
|
expect(within(byModelChart).getAllByText(label).length).toBeGreaterThan(0);
|
||||||
|
expect(within(pie).getAllByText(label).length).toBeGreaterThan(0);
|
||||||
|
expect(within(table).getAllByText(label).length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
expect(within(table).getByTestId("cc-tokens-row-claude-sonnet-4-5")).toHaveTextContent("1,000");
|
||||||
|
expect(within(table).getByTestId("cc-tokens-row-gpt-5")).toHaveTextContent("400");
|
||||||
|
expect(line).toHaveTextContent("Total");
|
||||||
|
expect(screen.getByTestId("cc-tokens-total")).toHaveTextContent("1,400");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders every analytics model group in detail bar, pie, and table even beyond the old cap", async () => {
|
it("renders every analytics model group in detail bar, pie, and table even beyond the old cap", async () => {
|
||||||
apiMock.mockResolvedValue(manyModelTokenFixture());
|
apiMock.mockResolvedValue(manyModelTokenFixture());
|
||||||
render(<TokensArea range={range7d} />);
|
render(<TokensArea range={range7d} />);
|
||||||
|
|||||||
@@ -366,6 +366,92 @@ describe("register-command-center-routes", () => {
|
|||||||
expect((body.totals as { totalTokens: number }).totalTokens).toBe(200);
|
expect((body.totals as { totalTokens: number }).totalTokens).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns every Last 30 days per-model bucket in JSON and CSV token analytics", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z"));
|
||||||
|
dbA.prepare(
|
||||||
|
`INSERT INTO tasks
|
||||||
|
(id, description, "column", modelProvider, modelId,
|
||||||
|
tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, tokenUsageCacheWriteTokens, tokenUsageTotalTokens,
|
||||||
|
tokenUsageLastUsedAt, tokenUsageModelProvider, tokenUsageModelId, tokenUsagePerModel, createdAt, updatedAt)
|
||||||
|
VALUES (?, 'desc', 'todo', NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
"FN-last-30-multi",
|
||||||
|
950,
|
||||||
|
450,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1_400,
|
||||||
|
"2026-07-05T00:00:00.000Z",
|
||||||
|
"openai",
|
||||||
|
"gpt-5",
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
modelProvider: "anthropic",
|
||||||
|
modelId: "claude-sonnet-4-5",
|
||||||
|
inputTokens: 700,
|
||||||
|
outputTokens: 300,
|
||||||
|
cachedTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 1_000,
|
||||||
|
firstUsedAt: "2026-06-15T00:00:00.000Z",
|
||||||
|
lastUsedAt: "2026-06-15T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelProvider: "openai",
|
||||||
|
modelId: "gpt-5",
|
||||||
|
inputTokens: 250,
|
||||||
|
outputTokens: 150,
|
||||||
|
cachedTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 400,
|
||||||
|
firstUsedAt: "2026-06-20T00:00:00.000Z",
|
||||||
|
lastUsedAt: "2026-06-20T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelProvider: "zai",
|
||||||
|
modelId: "glm-outside",
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 10,
|
||||||
|
cachedTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 20,
|
||||||
|
firstUsedAt: "2026-07-03T00:00:00.000Z",
|
||||||
|
lastUsedAt: "2026-07-03T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
"2026-06-15T00:00:00.000Z",
|
||||||
|
"2026-07-05T00:00:00.000Z",
|
||||||
|
);
|
||||||
|
|
||||||
|
const json = await request(
|
||||||
|
app,
|
||||||
|
"GET",
|
||||||
|
"/api/command-center/tokens?from=2026-06-02T00%3A00%3A00.000Z&groupBy=model&granularity=day&projectId=proj-a",
|
||||||
|
);
|
||||||
|
expect(json.status).toBe(200);
|
||||||
|
expect(json.body).toMatchObject({ from: "2026-06-02T00:00:00.000Z", to: "2026-07-02T00:00:00.000Z", groupBy: "model" });
|
||||||
|
const groups = new Map((json.body as { groups: { key: string | null; totalTokens: number }[] }).groups.map((group) => [group.key, group.totalTokens]));
|
||||||
|
expect(groups.get("claude-sonnet-4-5")).toBe(1_000);
|
||||||
|
expect(groups.get("gpt-5")).toBe(400);
|
||||||
|
expect(groups.has("glm-outside")).toBe(false);
|
||||||
|
expect((json.body as { totals: { totalTokens: number; nTasks: number } }).totals).toMatchObject({ totalTokens: 1_400, nTasks: 1 });
|
||||||
|
expect((json.body as { series: { bucket: string; totalTokens: number }[] }).series.map((point) => [point.bucket, point.totalTokens])).toEqual([
|
||||||
|
["2026-06-15", 1_000],
|
||||||
|
["2026-06-20", 400],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csv = await request(
|
||||||
|
app,
|
||||||
|
"GET",
|
||||||
|
"/api/command-center/tokens?from=2026-06-02T00%3A00%3A00.000Z&groupBy=model&projectId=proj-a&format=csv",
|
||||||
|
);
|
||||||
|
expect(csv.status).toBe(200);
|
||||||
|
expect(csv.body as string).toContain("claude-sonnet-4-5");
|
||||||
|
expect(csv.body as string).toContain("gpt-5");
|
||||||
|
expect(csv.body as string).not.toContain("glm-outside");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns token time-series buckets when granularity is requested", async () => {
|
it("returns token time-series buckets when granularity is requested", async () => {
|
||||||
const res = await request(
|
const res = await request(
|
||||||
app,
|
app,
|
||||||
|
|||||||
Reference in New Issue
Block a user