diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 25acc8c..293bf44 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -303,7 +303,7 @@ function graphUsageLabel(costs: Pick): string { const plan = stages.some((stage) => (stage.subscriptionRuns ?? 0) > 0); const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0); const dollars = displayCostTotals(costs).totalDollars; - return plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : `$${dollars.toFixed(2)}`; + return plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : priced ? `$${dollars.toFixed(2)}` : "—"; } /** diff --git a/src/core/cost-summary.ts b/src/core/cost-summary.ts index f6c9ff0..27045b1 100644 --- a/src/core/cost-summary.ts +++ b/src/core/cost-summary.ts @@ -106,6 +106,9 @@ function stageDollars(model: string, steps: readonly CallUsage[], price: CallPri /** Merge one step-set's tokens into an accumulating CallUsage. */ function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void { const summed = sumSteps(steps); + // Fold this before updating total writes, so an older write-bearing + // aggregate with a missing split remains unknown even after fresh data. + into.cacheWrite1hTokens = sumSteps([into, summed]).cacheWrite1hTokens; into.inputTokens = (into.inputTokens ?? 0) + (summed.inputTokens ?? 0); into.outputTokens = (into.outputTokens ?? 0) + (summed.outputTokens ?? 0); into.cacheReadTokens = (into.cacheReadTokens ?? 0) + (summed.cacheReadTokens ?? 0); diff --git a/tests/graph-pricing.test.tsx b/tests/graph-pricing.test.tsx new file mode 100644 index 0000000..ee1aa05 --- /dev/null +++ b/tests/graph-pricing.test.tsx @@ -0,0 +1,60 @@ +import React from "react"; +(globalThis as Record).React = React; +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { GraphCostControl } from "../components/graph-view.tsx"; +import { graphCostSummary } from "../src/core/cost-summary.ts"; +import { callCost } from "../src/server/pricing.ts"; +import type { CallUsage, ExtractionRunUsage } from "../src/core/contracts.ts"; + +const usage = (over: Partial = {}): CallUsage => ({ inputTokens: 1000, outputTokens: 100, cacheReadTokens: 0, cacheWriteTokens: 20_000, reasoningTokens: 0, ...over }); +const run = (steps: CallUsage[], model = "anthropic:claude-haiku-4-5", at = 1): ExtractionRunUsage => ({ startedAt: at, at: "fixture", durationMs: 1, stages: { extractor: { model, steps }, auditor: { model, steps } } }); +const summary = (runs: ExtractionRunUsage[]) => graphCostSummary({ chats: [{ title: "fixture", extractions: runs }], sources: [] }, callCost); +const headline = (runs: ExtractionRunUsage[]) => renderToStaticMarkup(); + +test("wholly unpriced historical graph costs render unknown, not known-zero dollars", () => { + const historical = run([usage()]); // missing long-cache split + const costs = summary([historical]); + assert.equal(costs.totalDollars, 0); + assert.equal(costs.unpricedRuns, 1); + assert.equal(costs.stages.extraction.unpricedRuns, 1); + const html = headline([historical]); + assert.match(html, />—<\/button>/); + assert.doesNotMatch(html, /\$0\.00/); +}); + +test("known zero, mixed priced/unknown, and subscription headline semantics stay distinct", () => { + const zero = run([usage({ inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0 })]); + assert.match(headline([zero]), />\$0\.00<\/button>/, "known zero is not unknown"); + const priced = run([usage({ cacheWrite1hTokens: 0 })]); + const historical = run([usage()], undefined, 2); + const mixed = summary([priced, historical]); + assert.equal(mixed.unpricedRuns, 1); + assert.ok(mixed.totalDollars > 0); + assert.match(headline([priced, historical]), new RegExp(`>\\$${mixed.totalDollars.toFixed(2).replace(".", "\\.")}<\\/button>`)); + const plan = run([usage()], "chatgpt:fixture"); + assert.match(headline([plan]), />ChatGPT plan<\/button>/); + assert.match(headline([plan, zero]), />\$0\.00 API \+ ChatGPT plan<\/button>/); +}); + +test("graph stage and recent-run aggregates preserve known one-hour cache writes", () => { + const costs = summary([run([usage({ cacheWrite1hTokens: 20_000 })]), run([usage({ cacheWrite1hTokens: 5000 })], undefined, 2)]); + assert.equal(costs.stages.extraction.tokens.cacheWrite1hTokens, 25_000); + assert.equal(costs.stages.audit.tokens.cacheWrite1hTokens, 25_000); + assert.deepEqual(costs.runs.map((r) => r.tokens.cacheWrite1hTokens), [10_000, 40_000]); +}); + +test("unknown historical long-cache splits stay unknown regardless of merge order", () => { + const known = run([usage({ cacheWrite1hTokens: 20_000 })]); + const historical = run([usage()], undefined, 2); + for (const runs of [[known, historical], [historical, known]]) { + const costs = summary(runs); + assert.equal(costs.stages.extraction.tokens.cacheWrite1hTokens, undefined); + assert.equal(costs.stages.audit.tokens.cacheWrite1hTokens, undefined); + } + const mixedWithinRun = summary([run([usage({ cacheWrite1hTokens: 20_000 }), usage()])]); + assert.equal(mixedWithinRun.stages.extraction.tokens.cacheWrite1hTokens, undefined); + const absentNoWrites = run([usage({ cacheWriteTokens: 0 })], undefined, 2); + assert.equal(summary([absentNoWrites, known]).stages.extraction.tokens.cacheWrite1hTokens, 20_000, "absence without writes must not poison known write detail"); +});