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(byModel.totals.nTasks).toBe(1);
|
||||
|
||||
const expectedTaskCost = costFor(
|
||||
{ inputTokens: 950, outputTokens: 450, cachedTokens: 0, cacheWriteTokens: 0 },
|
||||
{ provider: "openai", model: "gpt-5" },
|
||||
expect(byModel.cost.usd).toBeCloseTo(
|
||||
(modelGroups.get("claude-sonnet-4-5")?.cost.usd ?? 0) + (modelGroups.get("gpt-5")?.cost.usd ?? 0),
|
||||
10,
|
||||
);
|
||||
expect(byModel.cost).toEqual(expectedTaskCost);
|
||||
expect(byModel.cost.unavailable).toBe(false);
|
||||
|
||||
const byProvider = aggregateTokenAnalytics(db, { groupBy: "provider" });
|
||||
expect(byProvider.totals).toEqual(byModel.totals);
|
||||
@@ -171,6 +171,121 @@ describe("token-analytics", () => {
|
||||
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", () => {
|
||||
insertTask(db, {
|
||||
id: "unpriced-bucket",
|
||||
@@ -199,10 +314,7 @@ describe("token-analytics", () => {
|
||||
|
||||
expect(result.groups).toHaveLength(1);
|
||||
expect(result.groups[0]).toMatchObject({ key: "unknown-model", totalTokens: 100, cost: { usd: null, unavailable: true } });
|
||||
expect(result.cost).toEqual(costFor(
|
||||
{ inputTokens: 60, outputTokens: 40, cachedTokens: 0, cacheWriteTokens: 0 },
|
||||
{ provider: "openai", model: "gpt-5" },
|
||||
));
|
||||
expect(result.cost).toEqual({ usd: null, unavailable: true, stale: false });
|
||||
});
|
||||
|
||||
it("falls back to the legacy snapshot when per-model JSON is malformed", () => {
|
||||
|
||||
@@ -101,6 +101,7 @@ function emptyTotals(): TokenTotals {
|
||||
}
|
||||
|
||||
interface TaskTokenRow {
|
||||
id: string;
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
cachedTokens: number | null;
|
||||
@@ -192,12 +193,17 @@ function finalizeCost(acc: CostAccumulator): CostResult {
|
||||
};
|
||||
}
|
||||
|
||||
function parsePerModelRows(row: TaskTokenRow): TaskTokenRow[] {
|
||||
if (!row.tokenUsagePerModel) return [];
|
||||
interface ParsedPerModelRows {
|
||||
valid: boolean;
|
||||
rows: TaskTokenRow[];
|
||||
}
|
||||
|
||||
function parsePerModelRows(row: TaskTokenRow): ParsedPerModelRows {
|
||||
if (!row.tokenUsagePerModel) return { valid: false, rows: [] };
|
||||
try {
|
||||
const parsed = JSON.parse(row.tokenUsagePerModel) as unknown;
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return [];
|
||||
return parsed
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return { valid: false, rows: [] };
|
||||
const rows = parsed
|
||||
.filter((entry): entry is Partial<TaskTokenUsagePerModel> => entry !== null && typeof entry === "object")
|
||||
.map((entry) => {
|
||||
const inputTokens = Number.isFinite(entry.inputTokens) ? Number(entry.inputTokens) : 0;
|
||||
@@ -216,14 +222,20 @@ function parsePerModelRows(row: TaskTokenRow): TaskTokenRow[] {
|
||||
totalTokens,
|
||||
tokenUsageModelProvider: typeof entry.modelProvider === "string" ? entry.modelProvider : 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 {
|
||||
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.outputTokens += row.outputTokens ?? 0;
|
||||
totals.cachedTokens += row.cachedTokens ?? 0;
|
||||
@@ -237,7 +249,10 @@ function addRow(totals: TokenTotals, row: TaskTokenRow): void {
|
||||
(row.outputTokens ?? 0) +
|
||||
(row.cachedTokens ?? 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 {
|
||||
@@ -277,19 +292,28 @@ export function aggregateTokenAnalytics(
|
||||
): TokenAnalytics {
|
||||
const clauses: string[] = ["tokenUsageLastUsedAt IS NOT NULL"];
|
||||
const params: string[] = [];
|
||||
const rangeClauses: string[] = [];
|
||||
if (query.from !== undefined) {
|
||||
clauses.push("tokenUsageLastUsedAt >= ?");
|
||||
rangeClauses.push("tokenUsageLastUsedAt >= ?");
|
||||
params.push(query.from);
|
||||
}
|
||||
if (query.to !== undefined) {
|
||||
clauses.push("tokenUsageLastUsedAt <= ?");
|
||||
rangeClauses.push("tokenUsageLastUsedAt <= ?");
|
||||
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 rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
id,
|
||||
tokenUsageInputTokens AS inputTokens,
|
||||
tokenUsageOutputTokens AS outputTokens,
|
||||
tokenUsageCachedTokens AS cachedTokens,
|
||||
@@ -318,34 +342,46 @@ export function aggregateTokenAnalytics(
|
||||
const now = query.now;
|
||||
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) {
|
||||
addRow(totals, row);
|
||||
addRowCost(totalCost, row, now, pricingOverrides);
|
||||
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);
|
||||
const perModel = parsePerModelRows(row);
|
||||
const rowInRange = isWithinRange(row.tokenUsageLastUsedAt, query.from, query.to);
|
||||
const contributionRows = perModel.valid
|
||||
? perModel.rows.filter((bucketRow) => isWithinRange(bucketRow.tokenUsageLastUsedAt, query.from, query.to))
|
||||
: rowInRange
|
||||
? [row]
|
||||
: [];
|
||||
|
||||
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);
|
||||
if (!group) {
|
||||
group = { key, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } };
|
||||
groupMap.set(key, group);
|
||||
groupCostMap.set(key, emptyCostAccumulator());
|
||||
groupTaskIds.set(key, new Set<string>());
|
||||
}
|
||||
addRow(group, groupRow);
|
||||
addRowCost(groupCostMap.get(key)!, groupRow, now, pricingOverrides);
|
||||
addRow(group, contributionRow, groupTaskIds.get(key)!);
|
||||
addRowCost(groupCostMap.get(key)!, contributionRow, now, pricingOverrides);
|
||||
}
|
||||
}
|
||||
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());
|
||||
if (granularity) {
|
||||
const bucket = bucketFor(contributionRow, 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());
|
||||
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 range30d: DateRange = { from: "2026-06-02", to: null, preset: "30d" };
|
||||
|
||||
function makeTokenGroup(key: string | null, totalTokens: number) {
|
||||
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() {
|
||||
const groups = [
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
apiMock.mockResolvedValue(manyModelTokenFixture());
|
||||
render(<TokensArea range={range7d} />);
|
||||
|
||||
@@ -366,6 +366,92 @@ describe("register-command-center-routes", () => {
|
||||
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 () => {
|
||||
const res = await request(
|
||||
app,
|
||||
|
||||
Reference in New Issue
Block a user