From b05bd880bfa8590baeac7318b641158dee94e14a Mon Sep 17 00:00:00 2001 From: Mike Date: Tue, 8 Sep 2026 18:26:54 -0600 Subject: [PATCH] fix(vscode): Display CLI cost cents as dollars Read costCents instead of the nonexistent cost field in usage summaries\nand dashboard aggregations. Keep dollar-valued summary outputs and\nexisting model allocation behavior.\n\nAdd executable summary and webview regression tests for non-zero costs,\nscoped totals, model shares, and missing or zero values. --- vscode-splitrail/.vscodeignore | 1 + vscode-splitrail/package.json | 2 +- vscode-splitrail/src/dashboardView.ts | 9 +- vscode-splitrail/src/usageView.ts | 20 +++- vscode-splitrail/test/cost.test.cjs | 134 ++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 vscode-splitrail/test/cost.test.cjs diff --git a/vscode-splitrail/.vscodeignore b/vscode-splitrail/.vscodeignore index 2e9c8b5..b74ef23 100644 --- a/vscode-splitrail/.vscodeignore +++ b/vscode-splitrail/.vscodeignore @@ -1,5 +1,6 @@ .vscode/** out/test/** +test/** src/** tsconfig.json **/*.map diff --git a/vscode-splitrail/package.json b/vscode-splitrail/package.json index 8be196d..03b845d 100644 --- a/vscode-splitrail/package.json +++ b/vscode-splitrail/package.json @@ -143,7 +143,7 @@ "compile": "tsc -p ./", "watch": "tsc -watch -p ./", "lint": "echo \"No lint configured\"", - "test": "echo \"No tests configured\"" + "test": "npm run compile && node --test test/*.test.cjs" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/vscode-splitrail/src/dashboardView.ts b/vscode-splitrail/src/dashboardView.ts index de51f90..3b315e5 100644 --- a/vscode-splitrail/src/dashboardView.ts +++ b/vscode-splitrail/src/dashboardView.ts @@ -371,7 +371,8 @@ export class SplitrailDashboardProvider implements vscode.WebviewViewProvider { const input = stats.inputTokens ?? 0; const output = stats.outputTokens ?? 0; const tokens = input + output; - const cost = stats.cost ?? 0; + // The CLI emits cents, but the dashboard formats dollars. + const cost = (stats.costCents ?? 0) / 100; totalTokens += tokens; totalCost += cost; @@ -417,7 +418,8 @@ export class SplitrailDashboardProvider implements vscode.WebviewViewProvider { const input = stats.inputTokens ?? 0; const output = stats.outputTokens ?? 0; tokens += input + output; - cost += stats.cost ?? 0; + // Match the hero's cents-to-dollars conversion for tool totals. + cost += (stats.costCents ?? 0) / 100; } byTool.push({ name: analyzer.analyzer_name, tokens, cost }); } @@ -453,7 +455,8 @@ export class SplitrailDashboardProvider implements vscode.WebviewViewProvider { for (const analyzer of scopedAnalyzers) { for (const daily of Object.values(analyzer.daily_stats || {})) { const models = daily.models || {}; - const dailyCost = daily.stats?.cost ?? 0; + // Allocate dollars, not raw cents, across the existing model shares. + const dailyCost = (daily.stats?.costCents ?? 0) / 100; const totalModelMessages = Object.values(models).reduce( (a, b) => a + Number(b), 0 diff --git a/vscode-splitrail/src/usageView.ts b/vscode-splitrail/src/usageView.ts index a476616..ac7a763 100644 --- a/vscode-splitrail/src/usageView.ts +++ b/vscode-splitrail/src/usageView.ts @@ -20,10 +20,12 @@ export interface JsonDailyStats { stats: JsonInnerStats; } +/** CLI token and cost fields; omitted metrics are treated as zero by summaries. */ export interface JsonInnerStats { inputTokens?: number; outputTokens?: number; - cost?: number; + /** Cost in integer cents, as serialized by the CLI; divide by 100 for dollars. */ + costCents?: number; // Other fields are allowed but not explicitly modeled [key: string]: unknown; } @@ -143,6 +145,11 @@ export class UsageTreeDataProvider } } +/** + * Sum an analyzer's daily token counts and cost for usage-tree consumers. + * @param analyzer CLI analyzer data with costs expressed in cents. + * @returns Token total and cost in dollars, treating omitted metrics as zero. + */ export function summarizeAnalyzer(analyzer: JsonAnalyzerStats): { totalTokens: number; totalCost: number; @@ -155,12 +162,18 @@ export function summarizeAnalyzer(analyzer: JsonAnalyzerStats): { const input = stats.inputTokens ?? 0; const output = stats.outputTokens ?? 0; totalTokens += input + output; - totalCost += stats.cost ?? 0; + // CLI costs are cents; summary consumers format dollar amounts. + totalCost += (stats.costCents ?? 0) / 100; } return { totalTokens, totalCost }; } +/** + * Sum all-time and local-today usage for the status bar and popup. + * @param analyzers CLI analyzer data with costs expressed in cents. + * @returns Token counts and dollar costs; empty input produces zero totals. + */ export function summarizeAllAnalyzers( analyzers: JsonAnalyzerStats[] ): { @@ -182,7 +195,8 @@ export function summarizeAllAnalyzers( const input = stats.inputTokens ?? 0; const output = stats.outputTokens ?? 0; const tokens = input + output; - const cost = stats.cost ?? 0; + // Convert at the JSON boundary so both total and today remain dollars. + const cost = (stats.costCents ?? 0) / 100; totalTokens += tokens; totalCost += cost; diff --git a/vscode-splitrail/test/cost.test.cjs b/vscode-splitrail/test/cost.test.cjs new file mode 100644 index 0000000..e7f3d21 --- /dev/null +++ b/vscode-splitrail/test/cost.test.cjs @@ -0,0 +1,134 @@ +// Exercise compiled extension code and its emitted webview script without a +// VS Code host. Only host/DOM plumbing is stubbed; aggregation and rendering +// execute unchanged so a JSON field mismatch cannot hide behind a helper test. +const assert = require("node:assert/strict"); +const { readFileSync } = require("node:fs"); +const path = require("node:path"); +const { test } = require("node:test"); +const vm = require("node:vm"); + +function loadExtensionModule(name) { + const filename = path.join(__dirname, "../out", `${name}.js`); + const module = { exports: {} }; + const wrapper = vm.runInThisContext( + `(function(require, module, exports) {${readFileSync(filename, "utf8")}\n})`, + { filename } + ); + wrapper((id) => { + if (id === "vscode") return { TreeItem: class {} }; + if (id === "./usageView") return loadExtensionModule("usageView"); + throw new Error(`Unexpected extension dependency: ${id}`); + }, module, module.exports); + return module.exports; +} + +const { summarizeAnalyzer, summarizeAllAnalyzers } = loadExtensionModule("usageView"); +const { SplitrailDashboardProvider } = loadExtensionModule("dashboardView"); +const now = new Date(); +const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + +function daily(date, costCents) { + return { + date, user_messages: 1, ai_messages: 2, conversations: 1, + models: { "test-model": 2 }, + stats: { inputTokens: 86, outputTokens: 24845, ...(costCents === undefined ? {} : { costCents }) }, + }; +} + +function analyzer(name, days) { + return { + analyzer_name: name, num_conversations: days.length, + daily_stats: Object.fromEntries(days.map(day => [day.date, day])), + }; +} + +// The issue's 118-cent payload must display as $1.18, not $0 or $118. +const analyzers = [ + analyzer("Tool A", [daily(today, 118), daily("2000-01-01", 250)]), + analyzer("Tool B", [daily(today, 82)]), +]; + +test("summaries convert CLI cents and preserve today/all-time and token totals", () => { + const summary = summarizeAnalyzer(analyzers[0]); + assert.equal(summary.totalTokens, 49862); + // Dollar sums use binary floats; assert the precision shown by summary consumers. + assert.equal(summary.totalCost.toFixed(4), "3.6800"); + assert.deepEqual(summarizeAllAnalyzers(analyzers), { + totalTokens: 74793, totalCost: 4.5, todayTokens: 49862, todayCost: 2, + }); +}); + +test("summaries retain zero defaults for missing costs and empty data", () => { + for (const cost of [0, undefined]) { + const item = analyzer("Tool", [daily(today, cost)]); + assert.equal(summarizeAnalyzer(item).totalCost, 0); + assert.equal(summarizeAllAnalyzers([item]).todayCost, 0); + } + assert.deepEqual(summarizeAllAnalyzers([]), { + totalTokens: 0, totalCost: 0, todayTokens: 0, todayCost: 0, + }); +}); + +function dashboard() { + const elements = new Map(); + function element() { + return { + textContent: "", children: [], + set innerHTML(value) { this.html = value; this.children = []; }, + get innerHTML() { return this.html; }, + appendChild(child) { this.children.push(child); }, + querySelector(selector) { + if (!elements.has(selector)) elements.set(selector, element()); + return elements.get(selector); + }, + }; + } + const root = element(); + const listeners = new Map(); + const webview = { onDidReceiveMessage() {}, postMessage() {} }; + new SplitrailDashboardProvider({}).resolveWebviewView({ webview }, {}, {}); + const script = webview.html.match(/