Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions vscode-splitrail/.vscodeignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.vscode/**
out/test/**
test/**
src/**
tsconfig.json
**/*.map
Expand Down
2 changes: 1 addition & 1 deletion vscode-splitrail/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 6 additions & 3 deletions vscode-splitrail/src/dashboardView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions vscode-splitrail/src/usageView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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[]
): {
Expand All @@ -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;
Expand Down
134 changes: 134 additions & 0 deletions vscode-splitrail/test/cost.test.cjs
Original file line number Diff line number Diff line change
@@ -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(/<script nonce="[^"]+">([\s\S]*?)<\/script>/)[1];
const context = vm.createContext({
document: { querySelector: () => root, createElement: element },
window: { addEventListener: (name, callback) => listeners.set(name, callback) },
acquireVsCodeApi: () => ({ postMessage() {} }),
});
vm.runInContext(script, context);
return {
elements,
send(items) { listeners.get("message")({ data: { type: "stats", payload: { analyzers: items } } }); },
scope(value) { vm.runInContext(`onScopeChange({ target: { value: ${JSON.stringify(value)} } })`, context); },
};
}

test("dashboard renders dollar costs in hero, tools and models across scopes", () => {
const view = dashboard();
view.send(analyzers);
assert.equal(view.elements.get(".hero-cost").textContent, "$2.00");
const tools = view.elements.get(".by-tool-body").children;
assert.match(tools[0].innerHTML, /Tool A.*\$1\.18.*59%/);
assert.match(tools[1].innerHTML, /Tool B.*\$0\.82.*41%/);
assert.match(view.elements.get(".by-model-body").children[0].innerHTML, /test-model.*\$2\.00.*100%/);
view.scope("all");
assert.equal(view.elements.get(".hero-cost").textContent, "$4.50");
assert.match(view.elements.get(".by-tool-body").children[0].innerHTML, /Tool A.*\$3\.68/);
assert.match(view.elements.get(".by-model-body").children[0].innerHTML, /\$4\.50/);
});

test("dashboard keeps proportional model allocation and handles zero/missing costs", () => {
const view = dashboard();
const day = daily(today, 118);
day.models = { "Model A": 3, "Model B": 1 };
view.send([analyzer("Tool", [day])]);
const models = view.elements.get(".by-model-body").children;
assert.match(models[0].innerHTML, /Model A.*\$0\.89.*75%/);
assert.match(models[1].innerHTML, /Model B.*\$0\.29.*25%/);
for (const cost of [0, undefined]) {
view.send([analyzer("Tool", [daily(today, cost)])]);
assert.equal(view.elements.get(".hero-cost").textContent, "$0.00");
assert.match(view.elements.get(".by-tool-body").children[0].innerHTML, /\$0\.00/);
assert.match(view.elements.get(".by-model-body").children[0].innerHTML, /\$0\.00/);
}
view.send([]);
assert.equal(view.elements.get(".hero-cost").textContent, "$0.00");
});
Loading