diff --git a/.changeset/fn-7757-estimated-cost.md b/.changeset/fn-7757-estimated-cost.md new file mode 100644 index 0000000000..14a3cb312e --- /dev/null +++ b/.changeset/fn-7757-estimated-cost.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix empty estimated cost on the dashboard so priced runs show a dollar amount. +category: fix +dev: Root-caused model-identity → pricing-key resolution; cost stays read-time derived in costFor/token-analytics. diff --git a/packages/core/src/__tests__/model-pricing.test.ts b/packages/core/src/__tests__/model-pricing.test.ts index 0830d805e2..760cbdaff2 100644 --- a/packages/core/src/__tests__/model-pricing.test.ts +++ b/packages/core/src/__tests__/model-pricing.test.ts @@ -65,17 +65,25 @@ describe("model-pricing", () => { expect(result.usd).toBeCloseTo(3.25, 2); }); - it("prices OpenAI Codex GPT-5.6 codenamed models instead of reporting unavailable", () => { - // FN-7742: gpt-5.6-luna / gpt-5.6-sol / gpt-5.6-terra mirror the gpt-5.3-codex rate - // ($1.25/1M input, $10/1M output). 1,000,000 input + 200,000 output = 1.25 + 2.00 = 3.25 - for (const model of ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]) { + it("prices current OpenAI Codex catalog models instead of reporting unavailable", () => { + const cases = [ + ["gpt-5.3-codex-spark", 4.55], + ["gpt-5.4", 5.5], + ["gpt-5.4-mini", 1.65], + ["gpt-5.5", 11], + ["gpt-5.6-luna", 2.2], + ["gpt-5.6-sol", 11], + ["gpt-5.6-terra", 5.5], + ] as const; + + for (const [model, expectedUsd] of cases) { const result = costFor( { ...ZERO, inputTokens: 1_000_000, outputTokens: 200_000 }, { provider: "openai-codex", model }, ); expect(result.unavailable).toBe(false); expect(result.usd).not.toBeNull(); - expect(result.usd).toBeCloseTo(3.25, 2); + expect(result.usd).toBeCloseTo(expectedUsd, 2); } }); diff --git a/packages/core/src/__tests__/team-analytics.test.ts b/packages/core/src/__tests__/team-analytics.test.ts index 715691c4f8..0950224bac 100644 --- a/packages/core/src/__tests__/team-analytics.test.ts +++ b/packages/core/src/__tests__/team-analytics.test.ts @@ -90,8 +90,8 @@ describe("team-analytics", () => { outputTokens: 1_000_000, totalTokens: 2_000_000, tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z", - modelProvider: "openai", - modelId: "gpt-4o", + modelProvider: "openai-codex", + modelId: "gpt-5.5", }); insertTask(db, { id: "a-done", @@ -143,7 +143,7 @@ describe("team-analytics", () => { tasksInReview: 0, }); expect(byAgent.get("agent-a")?.tokens.totalTokens).toBe(2_000_000); - expect(byAgent.get("agent-a")?.cost).toEqual({ usd: 12.5, unavailable: false, stale: false }); + expect(byAgent.get("agent-a")?.cost).toEqual({ usd: 35, unavailable: false, stale: false }); expect(byAgent.get("agent-b")).toMatchObject({ agentName: "Beta", role: "reviewer", diff --git a/packages/core/src/__tests__/token-analytics.test.ts b/packages/core/src/__tests__/token-analytics.test.ts index 91557e0542..11df50f269 100644 --- a/packages/core/src/__tests__/token-analytics.test.ts +++ b/packages/core/src/__tests__/token-analytics.test.ts @@ -660,6 +660,47 @@ describe("token-analytics", () => { expect(result.series?.[0].cost).toEqual({ usd: 12.5, unavailable: true, stale: false }); }); + it("prices current OpenAI Codex runtime identities across token analytics surfaces", () => { + const usage = { inputTokens: 1_000_000, outputTokens: 200_000, cachedTokens: 500_000, cacheWriteTokens: 100_000 }; + const expected = costFor(usage, { provider: "openai-codex", model: "gpt-5.5" }); + expect(expected).toEqual({ usd: 11.25, unavailable: false, stale: false }); + + insertTask(db, { + id: "codex-current", + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedTokens: usage.cachedTokens, + cacheWriteTokens: usage.cacheWriteTokens, + totalTokens: 1_800_000, + lastUsedAt: "2026-03-01T00:00:00.000Z", + tokenUsageModelProvider: "openai-codex", + tokenUsageModelId: "gpt-5.5", + tokenUsagePerModel: [ + { + modelProvider: "openai-codex", + modelId: "gpt-5.5", + ...usage, + totalTokens: 1_800_000, + lastUsedAt: "2026-03-01T00:00:00.000Z", + }, + ], + modelProvider: "openai", + modelId: "gpt-4o-mini", + nodeId: "node-codex", + agentId: "agent-codex", + }); + + const byModel = aggregateTokenAnalytics(db, { groupBy: "model" }); + expect(byModel.cost).toEqual(expected); + expect(byModel.groups.find((group) => group.key === "gpt-5.5")?.cost).toEqual(expected); + + const byProvider = aggregateTokenAnalytics(db, { groupBy: "provider" }); + expect(byProvider.groups.find((group) => group.key === "openai-codex")?.cost).toEqual(expected); + + const byDay = aggregateTokenAnalytics(db, { granularity: "day" }); + expect(byDay.series?.[0].cost).toEqual(expected); + }); + it("prices resolved-model token usage costs from the usage snapshot across analytics surfaces", () => { const usage = { inputTokens: 1_000_000, outputTokens: 1_000_000, cachedTokens: 0, cacheWriteTokens: 0 }; const expected = costFor(usage, { provider: "openai", model: "gpt-4o" }); diff --git a/packages/core/src/__tests__/workflow-analytics.test.ts b/packages/core/src/__tests__/workflow-analytics.test.ts index 5ed8cf2daf..6f239b5230 100644 --- a/packages/core/src/__tests__/workflow-analytics.test.ts +++ b/packages/core/src/__tests__/workflow-analytics.test.ts @@ -95,8 +95,8 @@ describe("workflow-analytics", () => { outputTokens: 1_000_000, totalTokens: 2_000_000, tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z", - modelProvider: "openai", - modelId: "gpt-4o", + modelProvider: "openai-codex", + modelId: "gpt-5.5", modifiedFiles: ["src/custom.ts", "src/shared.ts"], updatedAt: "2026-03-02T00:00:00.000Z", }); @@ -149,7 +149,7 @@ describe("workflow-analytics", () => { tasksInReview: 0, }); expect(byWorkflow.get("WF-custom")?.tokens.totalTokens).toBe(2_000_000); - expect(byWorkflow.get("WF-custom")?.cost).toEqual({ usd: 12.5, unavailable: false, stale: false }); + expect(byWorkflow.get("WF-custom")?.cost).toEqual({ usd: 35, unavailable: false, stale: false }); expect(byWorkflow.get("builtin:quick-fix")).toMatchObject({ workflowName: "Quick fix", isBuiltin: true, diff --git a/packages/core/src/model-pricing.ts b/packages/core/src/model-pricing.ts index 8cd5edf5e1..5ee4c53b2b 100644 --- a/packages/core/src/model-pricing.ts +++ b/packages/core/src/model-pricing.ts @@ -294,36 +294,56 @@ export const MODEL_PRICING: Readonly> = { source: "openai.com/api/pricing", }, /* - * FNXC:CommandCenter 2026-07-09-00:00: - * FN-7742 bumped the bundled pi SDK to 0.80.5 and inspected its generated model catalogs - * directly (node_modules/@earendil-works/pi-ai/dist/providers/{openai,openai-codex}.models.js) - * rather than guessing. There is no `gpt-5.6-codex` id in either catalog — OpenAI dropped the - * separate `-codex`-suffixed tier naming starting at the 5.4 generation. GPT-5.6 instead ships - * as three codenamed variants (`gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) that are present - * under BOTH the `openai` and `openai-codex` provider catalogs, so they are the faithful - * equivalent of the prior `gpt-5.x-codex` entries. Rates mirror the existing gpt-5.3-codex rate - * (no confirmed different published OpenAI rate for these codenamed models) so Command Center - * token cost does not show `unavailable` for pi-sourced GPT-5.6 codex runs. + * FNXC:CommandCenter 2026-07-09-21:30: + * FN-7757 found that pi-ai 0.80.5 now persists OpenAI Codex model ids from the generated `openai-codex.models.js` catalog (`gpt-5.3-codex-spark`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, and codenamed GPT-5.6 variants). The old static table covered only earlier `gpt-5.x-codex` ids, so lookupPricing returned unavailable and every dashboard cost surface rendered `—`. Keep this block aligned with the pinned runtime catalog rates while preserving unknown-model `—` semantics. */ + "openai-codex:gpt-5.3-codex-spark": { + inputPer1M: 1.75, + outputPer1M: 14, + cacheReadPer1M: 0.175, + cacheWritePer1M: 0, + source: "openai.com/api/pricing", + }, + "openai-codex:gpt-5.4": { + inputPer1M: 2.5, + outputPer1M: 15, + cacheReadPer1M: 0.25, + cacheWritePer1M: 0, + source: "openai.com/api/pricing", + }, + "openai-codex:gpt-5.4-mini": { + inputPer1M: 0.75, + outputPer1M: 4.5, + cacheReadPer1M: 0.075, + cacheWritePer1M: 0, + source: "openai.com/api/pricing", + }, + "openai-codex:gpt-5.5": { + inputPer1M: 5, + outputPer1M: 30, + cacheReadPer1M: 0.5, + cacheWritePer1M: 0, + source: "openai.com/api/pricing", + }, "openai-codex:gpt-5.6-luna": { - inputPer1M: 1.25, - outputPer1M: 10, - cacheReadPer1M: 0.125, - cacheWritePer1M: 1.25, + inputPer1M: 1, + outputPer1M: 6, + cacheReadPer1M: 0.1, + cacheWritePer1M: 0, source: "openai.com/api/pricing", }, "openai-codex:gpt-5.6-sol": { - inputPer1M: 1.25, - outputPer1M: 10, - cacheReadPer1M: 0.125, - cacheWritePer1M: 1.25, + inputPer1M: 5, + outputPer1M: 30, + cacheReadPer1M: 0.5, + cacheWritePer1M: 0, source: "openai.com/api/pricing", }, "openai-codex:gpt-5.6-terra": { - inputPer1M: 1.25, - outputPer1M: 10, - cacheReadPer1M: 0.125, - cacheWritePer1M: 1.25, + inputPer1M: 2.5, + outputPer1M: 15, + cacheReadPer1M: 0.25, + cacheWritePer1M: 0, source: "openai.com/api/pricing", }, "openai-codex:codex-mini-latest": { diff --git a/packages/dashboard/app/components/__tests__/TaskSummaryTab.prior-attempts.test.tsx b/packages/dashboard/app/components/__tests__/TaskSummaryTab.prior-attempts.test.tsx index e08f04f348..07a5507240 100644 --- a/packages/dashboard/app/components/__tests__/TaskSummaryTab.prior-attempts.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskSummaryTab.prior-attempts.test.tsx @@ -13,6 +13,42 @@ function doneTaskWithResults(workflowStepResults: unknown[]) { } describe("TaskSummaryTab prior-attempts history (FN-7727)", () => { + it("renders current OpenAI Codex token costs as dollars instead of the unavailable sentinel", () => { + render( + , + ); + + const section = screen.getByTestId("task-summary-token-cost-section"); + expect(section).toHaveTextContent("$11.25"); + expect(section).not.toHaveTextContent("—"); + }); + it("renders a collapsed prior-attempts affordance for a step with history", () => { render( { render(); await screen.findByTestId("command-center-overview-charts"); + expect(screen.getByTestId("command-center-stat-tokens")).toHaveTextContent("$9.00"); + expect(screen.getByTestId("command-center-stat-tokens")).not.toHaveTextContent("—"); expect(screen.getByTestId("command-center-overview-chart-tokens")).toBeTruthy(); expect(screen.getByTestId("cc-overview-pie")).toBeTruthy(); expect(screen.getByTestId("cc-overview-line")).toBeTruthy(); diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx index da841b65e6..480487bf14 100644 --- a/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx +++ b/packages/dashboard/app/components/command-center/areas/__tests__/TokensArea.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { render, screen, within } from "@testing-library/react"; +import { costFor } from "@fusion/core"; import { TokensArea } from "../TokensArea"; import type { DateRange } from "../../DateRangePicker"; @@ -179,6 +180,28 @@ afterEach(() => { }); describe("TokensArea provider model icons", () => { + it("renders current OpenAI Codex priced costs as dollars instead of the unavailable sentinel", async () => { + const usage = { inputTokens: 1_000_000, outputTokens: 200_000, cachedTokens: 500_000, cacheWriteTokens: 100_000 }; + const cost = costFor(usage, { provider: "openai-codex", model: "gpt-5.5" }); + expect(cost).toEqual({ usd: 11.25, unavailable: false, stale: false }); + + apiMock.mockResolvedValue({ + from: "2026-06-08", + to: null, + groupBy: "model", + totals: { ...usage, totalTokens: 1_800_000, nTasks: 1 }, + cost, + series: [{ bucket: "2026-06-18", ...usage, totalTokens: 1_800_000, nTasks: 1, cost }], + groups: [{ key: "gpt-5.5", ...usage, totalTokens: 1_800_000, nTasks: 1, cost }], + }); + render(); + + await screen.findByTestId("cc-area-tokens"); + expect(screen.getByTestId("cc-tokens-cost")).toHaveTextContent("$11.25"); + expect(screen.getByTestId("cc-tokens-cost")).not.toHaveTextContent("—"); + expect(screen.getByTestId("cc-tokens-row-gpt-5.5")).toHaveTextContent("$11.25"); + }); + it("renders inferred provider icons in the per-model table and Tokens by model bars", async () => { render(); diff --git a/packages/dashboard/src/__tests__/task-planner-chat-metrics.test.ts b/packages/dashboard/src/__tests__/task-planner-chat-metrics.test.ts index d5790fd2e6..d6f39a59e6 100644 --- a/packages/dashboard/src/__tests__/task-planner-chat-metrics.test.ts +++ b/packages/dashboard/src/__tests__/task-planner-chat-metrics.test.ts @@ -150,6 +150,27 @@ describe("formatTaskPlannerChatMetrics", () => { expect(result.summaryText).toContain("estimated cost $0.0085"); }); + it("prices current OpenAI Codex runtime identities in task-planner chat metrics", () => { + const result = formatTaskPlannerChatMetrics(makeTask({ + tokenUsage: { + inputTokens: 1_000_000, + outputTokens: 200_000, + cachedTokens: 500_000, + cacheWriteTokens: 100_000, + totalTokens: 1_800_000, + firstUsedAt: "2026-07-01T10:00:00.000Z", + lastUsedAt: "2026-07-01T10:30:00.000Z", + modelProvider: "openai-codex", + modelId: "gpt-5.5", + }, + }), { nowMs: Date.parse("2026-07-01T10:31:00.000Z") }); + + expect(result.metrics.tokens.cost).toEqual({ usd: 11.25, costUnavailable: false, pricingStale: false }); + expect(result.metrics.tokens.perModel[0].cost).toEqual({ usd: 11.25, costUnavailable: false, pricingStale: false }); + expect(result.summaryText).toContain("estimated cost $11.2500"); + expect(result.summaryText).not.toContain("cost unavailable"); + }); + it("marks unpriced and stale model costs unavailable instead of reporting zero", () => { const result = formatTaskPlannerChatMetrics(makeTask({ tokenUsage: {