FN-7786: price analytics from token-usage model snapshot to fix empty estimated cost
Estimated cost on the dashboard could render empty because team/workflow analytics priced rows using the legacy task.modelProvider/modelId columns, which can be NULL even when a durable tokenUsageModelProvider/tokenUsageModelId snapshot exists on the row. - Select tokenUsageModelProvider/tokenUsageModelId alongside the legacy model columns in the team-analytics and workflow-analytics task token queries - Price each row using the token-usage model snapshot first, falling back to the legacy model columns, so cost survives model-resolution drift and empty legacy columns - Add regression coverage in team-analytics, workflow-analytics, and model-pricing tests, plus a dashboard TaskDetailModal summary-tab test asserting estimated cost renders - Add a patch changeset documenting the fix for @runfusion/fusion Files changed: .changeset/fn-7786-estimated-cost.md | 7 +++++ packages/core/src/__tests__/model-pricing.test.ts | 10 +++++++ packages/core/src/__tests__/team-analytics.test.ts | 34 ++++++++++++++++++++-- .../core/src/__tests__/workflow-analytics.test.ts | 33 +++++++++++++++++++-- packages/core/src/team-analytics.ts | 15 ++++++++-- packages/core/src/workflow-analytics.ts | 15 ++++++++-- .../__tests__/TaskDetailModal.summary-tab.test.tsx | 34 ++++++++++++++++++++++ 7 files changed, 140 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7786 Fusion-Task-Lineage: dab32809-4bb8-42c0-aed0-a0c1131ec641 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7786-estimated-cost.md
Normal file
7
.changeset/fn-7786-estimated-cost.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Estimated cost on the dashboard stays populated as runtime model catalogs drift.
|
||||||
|
category: fix
|
||||||
|
dev: Durable token-usage snapshot pricing for Team/Workflow analytics so cost survives empty legacy task model columns; cost stays read-time derived in costFor/token-analytics.
|
||||||
@@ -36,6 +36,16 @@ describe("model-pricing", () => {
|
|||||||
expect(result.usd).toBeCloseTo(10.0, 2);
|
expect(result.usd).toBeCloseTo(10.0, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prices the current runtime Anthropic Claude Sonnet 5 identity captured from token usage", () => {
|
||||||
|
const result = costFor(
|
||||||
|
{ ...ZERO, inputTokens: 1_000_000, outputTokens: 200_000 },
|
||||||
|
{ provider: "anthropic", model: "claude-sonnet-5" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ unavailable: false, stale: false });
|
||||||
|
expect(result.usd).toBeCloseTo(4, 2);
|
||||||
|
});
|
||||||
|
|
||||||
it("prices direct Anthropic Claude Sonnet 5 from the restored static catalog row", () => {
|
it("prices direct Anthropic Claude Sonnet 5 from the restored static catalog row", () => {
|
||||||
const usage = {
|
const usage = {
|
||||||
inputTokens: 1_000_000,
|
inputTokens: 1_000_000,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface TaskSeed {
|
|||||||
tokenUsageLastUsedAt?: string | null;
|
tokenUsageLastUsedAt?: string | null;
|
||||||
modelProvider?: string | null;
|
modelProvider?: string | null;
|
||||||
modelId?: string | null;
|
modelId?: string | null;
|
||||||
|
tokenUsageModelProvider?: string | null;
|
||||||
|
tokenUsageModelId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertAgent(db: Database, id: string, name: string, role = "executor", state = "idle"): void {
|
function insertAgent(db: Database, id: string, name: string, role = "executor", state = "idle"): void {
|
||||||
@@ -44,8 +46,9 @@ function insertTask(db: Database, task: TaskSeed): void {
|
|||||||
`INSERT INTO tasks
|
`INSERT INTO tasks
|
||||||
(id, description, "column", createdAt, updatedAt, columnMovedAt, assignedAgentId,
|
(id, description, "column", createdAt, updatedAt, columnMovedAt, assignedAgentId,
|
||||||
modifiedFiles, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
|
modifiedFiles, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
|
||||||
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId)
|
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId,
|
||||||
VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
tokenUsageModelProvider, tokenUsageModelId)
|
||||||
|
VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
task.id,
|
task.id,
|
||||||
task.column ?? "todo",
|
task.column ?? "todo",
|
||||||
@@ -62,6 +65,8 @@ function insertTask(db: Database, task: TaskSeed): void {
|
|||||||
task.tokenUsageLastUsedAt ?? null,
|
task.tokenUsageLastUsedAt ?? null,
|
||||||
task.modelProvider ?? null,
|
task.modelProvider ?? null,
|
||||||
task.modelId ?? null,
|
task.modelId ?? null,
|
||||||
|
task.tokenUsageModelProvider ?? null,
|
||||||
|
task.tokenUsageModelId ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +165,31 @@ describe("team-analytics", () => {
|
|||||||
expect(result.totals.tasksInReview).toBe(1);
|
expect(result.totals.tasksInReview).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("prices token usage from the actually-used model snapshot when task model columns are empty", () => {
|
||||||
|
insertAgent(db, "agent-a", "Alpha");
|
||||||
|
insertTask(db, {
|
||||||
|
id: "snapshot-priced",
|
||||||
|
agentId: "agent-a",
|
||||||
|
inputTokens: 1_000_000,
|
||||||
|
outputTokens: 200_000,
|
||||||
|
cachedTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 1_200_000,
|
||||||
|
tokenUsageLastUsedAt: "2026-07-10T15:22:50.837Z",
|
||||||
|
modelProvider: null,
|
||||||
|
modelId: null,
|
||||||
|
tokenUsageModelProvider: "anthropic",
|
||||||
|
tokenUsageModelId: "claude-sonnet-5",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = aggregateTeamAnalytics(db, {});
|
||||||
|
|
||||||
|
expect(result.agents[0].cost).toMatchObject({ unavailable: false, stale: false });
|
||||||
|
expect(result.agents[0].cost.usd).toBeCloseTo(4, 2);
|
||||||
|
expect(result.totals.cost.usd).toBeCloseTo(4, 2);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns zeroed totals and an empty agent array for an empty database", () => {
|
it("returns zeroed totals and an empty agent array for an empty database", () => {
|
||||||
const result = aggregateTeamAnalytics(db, {});
|
const result = aggregateTeamAnalytics(db, {});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface TaskSeed {
|
|||||||
tokenUsageLastUsedAt?: string | null;
|
tokenUsageLastUsedAt?: string | null;
|
||||||
modelProvider?: string | null;
|
modelProvider?: string | null;
|
||||||
modelId?: string | null;
|
modelId?: string | null;
|
||||||
|
tokenUsageModelProvider?: string | null;
|
||||||
|
tokenUsageModelId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function modifiedFilesValue(value: unknown): string | null {
|
function modifiedFilesValue(value: unknown): string | null {
|
||||||
@@ -44,8 +46,9 @@ function insertTask(db: Database, task: TaskSeed): void {
|
|||||||
`INSERT INTO tasks
|
`INSERT INTO tasks
|
||||||
(id, description, "column", createdAt, updatedAt, columnMovedAt,
|
(id, description, "column", createdAt, updatedAt, columnMovedAt,
|
||||||
modifiedFiles, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
|
modifiedFiles, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
|
||||||
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId)
|
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId,
|
||||||
VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
tokenUsageModelProvider, tokenUsageModelId)
|
||||||
|
VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
task.id,
|
task.id,
|
||||||
task.column ?? "todo",
|
task.column ?? "todo",
|
||||||
@@ -61,6 +64,8 @@ function insertTask(db: Database, task: TaskSeed): void {
|
|||||||
task.tokenUsageLastUsedAt ?? null,
|
task.tokenUsageLastUsedAt ?? null,
|
||||||
task.modelProvider ?? null,
|
task.modelProvider ?? null,
|
||||||
task.modelId ?? null,
|
task.modelId ?? null,
|
||||||
|
task.tokenUsageModelProvider ?? null,
|
||||||
|
task.tokenUsageModelId ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (task.workflowId !== undefined && task.workflowId !== null) {
|
if (task.workflowId !== undefined && task.workflowId !== null) {
|
||||||
@@ -170,6 +175,30 @@ describe("workflow-analytics", () => {
|
|||||||
expect(result.totals.tasksInReview).toBe(1);
|
expect(result.totals.tasksInReview).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("prices token usage from the actually-used model snapshot when task model columns are empty", () => {
|
||||||
|
insertTask(db, {
|
||||||
|
id: "snapshot-priced",
|
||||||
|
workflowId: "builtin:coding",
|
||||||
|
inputTokens: 1_000_000,
|
||||||
|
outputTokens: 200_000,
|
||||||
|
cachedTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 1_200_000,
|
||||||
|
tokenUsageLastUsedAt: "2026-07-10T15:22:50.837Z",
|
||||||
|
modelProvider: null,
|
||||||
|
modelId: null,
|
||||||
|
tokenUsageModelProvider: "anthropic",
|
||||||
|
tokenUsageModelId: "claude-sonnet-5",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = aggregateWorkflowAnalytics(db, { defaultWorkflowId: "builtin:coding" });
|
||||||
|
|
||||||
|
expect(result.workflows[0].cost).toMatchObject({ unavailable: false, stale: false });
|
||||||
|
expect(result.workflows[0].cost.usd).toBeCloseTo(4, 2);
|
||||||
|
expect(result.totals.cost.usd).toBeCloseTo(4, 2);
|
||||||
|
});
|
||||||
|
|
||||||
it("marks unpriced workflow costs unavailable instead of treating them as zero", () => {
|
it("marks unpriced workflow costs unavailable instead of treating them as zero", () => {
|
||||||
insertTask(db, {
|
insertTask(db, {
|
||||||
id: "unknown-model",
|
id: "unknown-model",
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ interface TaskTokenRow {
|
|||||||
totalTokens: number | null;
|
totalTokens: number | null;
|
||||||
modelProvider: string | null;
|
modelProvider: string | null;
|
||||||
modelId: string | null;
|
modelId: string | null;
|
||||||
|
tokenUsageModelProvider: string | null;
|
||||||
|
tokenUsageModelId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CountByAgentRow {
|
interface CountByAgentRow {
|
||||||
@@ -121,7 +123,14 @@ function addRowCost(
|
|||||||
cachedTokens: row.cachedTokens ?? 0,
|
cachedTokens: row.cachedTokens ?? 0,
|
||||||
cacheWriteTokens: row.cacheWriteTokens ?? 0,
|
cacheWriteTokens: row.cacheWriteTokens ?? 0,
|
||||||
},
|
},
|
||||||
{ provider: row.modelProvider, model: row.modelId },
|
{
|
||||||
|
/*
|
||||||
|
* FNXC:CommandCenter 2026-07-10-08:25:
|
||||||
|
* Current runtime usage rows persist the actually-used model in tokenUsageModelProvider/tokenUsageModelId while task-level modelProvider/modelId can stay empty. Team cost analytics must price the usage snapshot first so estimated cost survives model-resolution hierarchy and catalog drift instead of reverting to the unavailable sentinel.
|
||||||
|
*/
|
||||||
|
provider: row.tokenUsageModelProvider ?? row.modelProvider,
|
||||||
|
model: row.tokenUsageModelId ?? row.modelId,
|
||||||
|
},
|
||||||
now,
|
now,
|
||||||
pricingOverrides,
|
pricingOverrides,
|
||||||
);
|
);
|
||||||
@@ -228,7 +237,9 @@ export function aggregateTeamAnalytics(
|
|||||||
tokenUsageCacheWriteTokens AS cacheWriteTokens,
|
tokenUsageCacheWriteTokens AS cacheWriteTokens,
|
||||||
tokenUsageTotalTokens AS totalTokens,
|
tokenUsageTotalTokens AS totalTokens,
|
||||||
modelProvider,
|
modelProvider,
|
||||||
modelId
|
modelId,
|
||||||
|
tokenUsageModelProvider,
|
||||||
|
tokenUsageModelId
|
||||||
FROM tasks
|
FROM tasks
|
||||||
WHERE ${tokenClauses.join(" AND ")}`,
|
WHERE ${tokenClauses.join(" AND ")}`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ interface TaskTokenRow {
|
|||||||
totalTokens: number | null;
|
totalTokens: number | null;
|
||||||
modelProvider: string | null;
|
modelProvider: string | null;
|
||||||
modelId: string | null;
|
modelId: string | null;
|
||||||
|
tokenUsageModelProvider: string | null;
|
||||||
|
tokenUsageModelId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CountByWorkflowRow {
|
interface CountByWorkflowRow {
|
||||||
@@ -123,7 +125,14 @@ function addRowCost(
|
|||||||
cachedTokens: row.cachedTokens ?? 0,
|
cachedTokens: row.cachedTokens ?? 0,
|
||||||
cacheWriteTokens: row.cacheWriteTokens ?? 0,
|
cacheWriteTokens: row.cacheWriteTokens ?? 0,
|
||||||
},
|
},
|
||||||
{ provider: row.modelProvider, model: row.modelId },
|
{
|
||||||
|
/*
|
||||||
|
* FNXC:CommandCenter 2026-07-10-08:25:
|
||||||
|
* Workflow cost analytics must mirror token analytics by pricing the actually-used token-usage model snapshot before legacy task model columns. FN-7757's static catalog rows could not help rows whose legacy model columns are NULL, so this fixes the durable resolution path without guessing prices for unknown models.
|
||||||
|
*/
|
||||||
|
provider: row.tokenUsageModelProvider ?? row.modelProvider,
|
||||||
|
model: row.tokenUsageModelId ?? row.modelId,
|
||||||
|
},
|
||||||
now,
|
now,
|
||||||
pricingOverrides,
|
pricingOverrides,
|
||||||
);
|
);
|
||||||
@@ -235,7 +244,9 @@ export function aggregateWorkflowAnalytics(
|
|||||||
t.tokenUsageCacheWriteTokens AS cacheWriteTokens,
|
t.tokenUsageCacheWriteTokens AS cacheWriteTokens,
|
||||||
t.tokenUsageTotalTokens AS totalTokens,
|
t.tokenUsageTotalTokens AS totalTokens,
|
||||||
t.modelProvider,
|
t.modelProvider,
|
||||||
t.modelId
|
t.modelId,
|
||||||
|
t.tokenUsageModelProvider,
|
||||||
|
t.tokenUsageModelId
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
LEFT JOIN task_workflow_selection s ON s.taskId = t.id
|
LEFT JOIN task_workflow_selection s ON s.taskId = t.id
|
||||||
WHERE ${tokenClauses.join(" AND ")}`,
|
WHERE ${tokenClauses.join(" AND ")}`,
|
||||||
|
|||||||
@@ -312,6 +312,40 @@ describe("TaskDetailModal Summary tab", () => {
|
|||||||
expect(within(rows[0]).getByText("7,014")).toBeTruthy();
|
expect(within(rows[0]).getByText("7,014")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a dollar amount for the current runtime token-usage model snapshot", () => {
|
||||||
|
render(
|
||||||
|
<TaskSummaryTab
|
||||||
|
task={doneTask({
|
||||||
|
tokenUsage: tokenUsage({
|
||||||
|
inputTokens: 1_000_000,
|
||||||
|
outputTokens: 200_000,
|
||||||
|
cachedTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 1_200_000,
|
||||||
|
modelProvider: "anthropic",
|
||||||
|
modelId: "claude-sonnet-5",
|
||||||
|
perModel: [
|
||||||
|
{
|
||||||
|
modelProvider: "anthropic",
|
||||||
|
modelId: "claude-sonnet-5",
|
||||||
|
inputTokens: 1_000_000,
|
||||||
|
outputTokens: 200_000,
|
||||||
|
cachedTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
totalTokens: 1_200_000,
|
||||||
|
firstUsedAt: "2026-07-10T15:22:50.837Z",
|
||||||
|
lastUsedAt: "2026-07-10T15:22:50.837Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByText("$4.00").length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText("claude-sonnet-5")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("applies pricing overrides passed into the summary component", () => {
|
it("applies pricing overrides passed into the summary component", () => {
|
||||||
const pricingOverrides: ModelPricingOverrides = {
|
const pricingOverrides: ModelPricingOverrides = {
|
||||||
"custom:override-model": {
|
"custom:override-model": {
|
||||||
|
|||||||
Reference in New Issue
Block a user