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:
gsxdsm
2026-07-02 14:04:11 -07:00
parent ffcb54b8bf
commit 22fd0da2ad
5 changed files with 327 additions and 36 deletions

View 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.

View File

@@ -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", () => {

View File

@@ -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);
} }
} }

View File

@@ -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} />);

View File

@@ -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,