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:
gsxdsm
2026-07-10 08:33:42 -07:00
parent 4d4e9ad5cb
commit cc8b1b69e0
7 changed files with 140 additions and 8 deletions

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

View File

@@ -36,6 +36,16 @@ describe("model-pricing", () => {
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", () => {
const usage = {
inputTokens: 1_000_000,

View File

@@ -22,6 +22,8 @@ interface TaskSeed {
tokenUsageLastUsedAt?: string | null;
modelProvider?: string | null;
modelId?: string | null;
tokenUsageModelProvider?: string | null;
tokenUsageModelId?: string | null;
}
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
(id, description, "column", createdAt, updatedAt, columnMovedAt, assignedAgentId,
modifiedFiles, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId)
VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId,
tokenUsageModelProvider, tokenUsageModelId)
VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
task.id,
task.column ?? "todo",
@@ -62,6 +65,8 @@ function insertTask(db: Database, task: TaskSeed): void {
task.tokenUsageLastUsedAt ?? null,
task.modelProvider ?? null,
task.modelId ?? null,
task.tokenUsageModelProvider ?? null,
task.tokenUsageModelId ?? null,
);
}
@@ -160,6 +165,31 @@ describe("team-analytics", () => {
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", () => {
const result = aggregateTeamAnalytics(db, {});

View File

@@ -22,6 +22,8 @@ interface TaskSeed {
tokenUsageLastUsedAt?: string | null;
modelProvider?: string | null;
modelId?: string | null;
tokenUsageModelProvider?: string | null;
tokenUsageModelId?: string | null;
}
function modifiedFilesValue(value: unknown): string | null {
@@ -44,8 +46,9 @@ function insertTask(db: Database, task: TaskSeed): void {
`INSERT INTO tasks
(id, description, "column", createdAt, updatedAt, columnMovedAt,
modifiedFiles, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId)
VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId,
tokenUsageModelProvider, tokenUsageModelId)
VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
task.id,
task.column ?? "todo",
@@ -61,6 +64,8 @@ function insertTask(db: Database, task: TaskSeed): void {
task.tokenUsageLastUsedAt ?? null,
task.modelProvider ?? null,
task.modelId ?? null,
task.tokenUsageModelProvider ?? null,
task.tokenUsageModelId ?? null,
);
if (task.workflowId !== undefined && task.workflowId !== null) {
@@ -170,6 +175,30 @@ describe("workflow-analytics", () => {
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", () => {
insertTask(db, {
id: "unknown-model",

View File

@@ -52,6 +52,8 @@ interface TaskTokenRow {
totalTokens: number | null;
modelProvider: string | null;
modelId: string | null;
tokenUsageModelProvider: string | null;
tokenUsageModelId: string | null;
}
interface CountByAgentRow {
@@ -121,7 +123,14 @@ function addRowCost(
cachedTokens: row.cachedTokens ?? 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,
pricingOverrides,
);
@@ -228,7 +237,9 @@ export function aggregateTeamAnalytics(
tokenUsageCacheWriteTokens AS cacheWriteTokens,
tokenUsageTotalTokens AS totalTokens,
modelProvider,
modelId
modelId,
tokenUsageModelProvider,
tokenUsageModelId
FROM tasks
WHERE ${tokenClauses.join(" AND ")}`,
)

View File

@@ -54,6 +54,8 @@ interface TaskTokenRow {
totalTokens: number | null;
modelProvider: string | null;
modelId: string | null;
tokenUsageModelProvider: string | null;
tokenUsageModelId: string | null;
}
interface CountByWorkflowRow {
@@ -123,7 +125,14 @@ function addRowCost(
cachedTokens: row.cachedTokens ?? 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,
pricingOverrides,
);
@@ -235,7 +244,9 @@ export function aggregateWorkflowAnalytics(
t.tokenUsageCacheWriteTokens AS cacheWriteTokens,
t.tokenUsageTotalTokens AS totalTokens,
t.modelProvider,
t.modelId
t.modelId,
t.tokenUsageModelProvider,
t.tokenUsageModelId
FROM tasks t
LEFT JOIN task_workflow_selection s ON s.taskId = t.id
WHERE ${tokenClauses.join(" AND ")}`,

View File

@@ -312,6 +312,40 @@ describe("TaskDetailModal Summary tab", () => {
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", () => {
const pricingOverrides: ModelPricingOverrides = {
"custom:override-model": {