Skip to content

Commit 44c265b

Browse files
Merge pull request olasunkanmi-SE#372 from olasunkanmi-SE/feature/cost_tracking
Feature/cost tracking
2 parents 38b4d63 + 649a575 commit 44c265b

13 files changed

Lines changed: 905 additions & 0 deletions

File tree

src/services/cost-tracking.service.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,30 @@ export interface IConversationCost extends ITokenUsage {
1818
requestCount: number;
1919
}
2020

21+
export interface IProviderBreakdown {
22+
provider: string;
23+
inputTokens: number;
24+
outputTokens: number;
25+
totalTokens: number;
26+
estimatedCostUSD: number;
27+
requestCount: number;
28+
}
29+
30+
export interface ICostTotals {
31+
inputTokens: number;
32+
outputTokens: number;
33+
totalTokens: number;
34+
estimatedCostUSD: number;
35+
requestCount: number;
36+
conversationCount: number;
37+
}
38+
39+
export interface ICostSummary {
40+
totals: ICostTotals;
41+
providers: IProviderBreakdown[];
42+
conversations: Array<{ threadId: string } & IConversationCost>;
43+
}
44+
2145
interface IPricing {
2246
inputPerMillion: number;
2347
outputPerMillion: number;
@@ -142,6 +166,59 @@ export class CostTrackingService {
142166
this.conversations.clear();
143167
}
144168

169+
/**
170+
* Returns an aggregated cost summary across all tracked conversations.
171+
*/
172+
getCostSummary(): ICostSummary {
173+
const conversations: ICostSummary["conversations"] = [];
174+
const providerMap = new Map<string, IProviderBreakdown>();
175+
176+
let totalInput = 0;
177+
let totalOutput = 0;
178+
let totalCost = 0;
179+
let totalRequests = 0;
180+
181+
for (const [threadId, cost] of this.conversations) {
182+
conversations.push({ threadId, ...cost });
183+
184+
totalInput += cost.inputTokens;
185+
totalOutput += cost.outputTokens;
186+
totalCost += cost.estimatedCostUSD;
187+
totalRequests += cost.requestCount;
188+
189+
const existing = providerMap.get(cost.provider);
190+
if (existing) {
191+
existing.inputTokens += cost.inputTokens;
192+
existing.outputTokens += cost.outputTokens;
193+
existing.totalTokens += cost.totalTokens;
194+
existing.estimatedCostUSD += cost.estimatedCostUSD;
195+
existing.requestCount += cost.requestCount;
196+
} else {
197+
providerMap.set(cost.provider, {
198+
provider: cost.provider,
199+
inputTokens: cost.inputTokens,
200+
outputTokens: cost.outputTokens,
201+
totalTokens: cost.totalTokens,
202+
estimatedCostUSD: cost.estimatedCostUSD,
203+
requestCount: cost.requestCount,
204+
});
205+
}
206+
}
207+
208+
return {
209+
totals: {
210+
inputTokens: totalInput,
211+
outputTokens: totalOutput,
212+
totalTokens: totalInput + totalOutput,
213+
estimatedCostUSD: Math.round(totalCost * 1_000_000) / 1_000_000,
214+
requestCount: totalRequests,
215+
conversationCount: conversations.length,
216+
},
217+
providers: [...providerMap.values()],
218+
conversations,
219+
};
220+
}
221+
145222
private getPricing(model: string): IPricing {
146223
// Try exact match first
147224
if (MODEL_PRICING[model]) {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import * as assert from "assert";
2+
import * as sinon from "sinon";
3+
import { CostTrackingHandler } from "../../webview-providers/handlers/cost-tracking-handler";
4+
import { HandlerContext } from "../../webview-providers/handlers/types";
5+
import { CostTrackingService } from "../../services/cost-tracking.service";
6+
7+
suite("CostTrackingHandler", () => {
8+
let handler: CostTrackingHandler;
9+
let ctx: HandlerContext;
10+
let postMessageStub: sinon.SinonStub;
11+
let service: CostTrackingService;
12+
13+
setup(() => {
14+
service = CostTrackingService.getInstance();
15+
service.resetAll();
16+
17+
handler = new CostTrackingHandler();
18+
19+
postMessageStub = sinon.stub().resolves(true);
20+
ctx = {
21+
webview: { webview: { postMessage: postMessageStub } },
22+
logger: { warn: sinon.stub(), info: sinon.stub(), error: sinon.stub() },
23+
extensionUri: {} as any,
24+
sendResponse: sinon.stub(),
25+
} as unknown as HandlerContext;
26+
});
27+
28+
teardown(() => {
29+
service.resetAll();
30+
sinon.restore();
31+
});
32+
33+
// ── commands ────────────────────────────────────────────────────
34+
35+
test("registers cost-summary and cost-reset commands", () => {
36+
assert.ok(handler.commands.includes("cost-summary"));
37+
assert.ok(handler.commands.includes("cost-reset"));
38+
assert.strictEqual(handler.commands.length, 2);
39+
});
40+
41+
// ── cost-summary ───────────────────────────────────────────────
42+
43+
test("cost-summary posts empty result when no data", async () => {
44+
await handler.handle({ command: "cost-summary" }, ctx);
45+
46+
assert.ok(postMessageStub.calledOnce);
47+
const msg = postMessageStub.firstCall.args[0];
48+
assert.strictEqual(msg.type, "cost-summary-result");
49+
assert.strictEqual(msg.totals.conversationCount, 0);
50+
assert.strictEqual(msg.totals.totalTokens, 0);
51+
assert.deepStrictEqual(msg.providers, []);
52+
assert.deepStrictEqual(msg.conversations, []);
53+
});
54+
55+
test("cost-summary returns aggregated data", async () => {
56+
service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500);
57+
service.recordUsage("t2", "openai", "gpt-4o", 2000, 1000);
58+
59+
await handler.handle({ command: "cost-summary" }, ctx);
60+
61+
assert.ok(postMessageStub.calledOnce);
62+
const msg = postMessageStub.firstCall.args[0];
63+
assert.strictEqual(msg.type, "cost-summary-result");
64+
assert.strictEqual(msg.totals.conversationCount, 2);
65+
assert.strictEqual(msg.totals.inputTokens, 3000);
66+
assert.strictEqual(msg.totals.outputTokens, 1500);
67+
assert.strictEqual(msg.totals.requestCount, 2);
68+
assert.strictEqual(msg.providers.length, 2);
69+
assert.strictEqual(msg.conversations.length, 2);
70+
});
71+
72+
// ── cost-reset ─────────────────────────────────────────────────
73+
74+
test("cost-reset clears data and posts null totals", async () => {
75+
service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500);
76+
77+
await handler.handle({ command: "cost-reset" }, ctx);
78+
79+
assert.ok(postMessageStub.calledOnce);
80+
const msg = postMessageStub.firstCall.args[0];
81+
assert.strictEqual(msg.type, "cost-summary-result");
82+
assert.strictEqual(msg.totals, null);
83+
assert.deepStrictEqual(msg.providers, []);
84+
assert.deepStrictEqual(msg.conversations, []);
85+
86+
// Service should be cleared
87+
assert.strictEqual(service.getConversationCost("t1"), null);
88+
});
89+
90+
// ── unknown messages ───────────────────────────────────────────
91+
92+
test("ignores unrelated commands", async () => {
93+
await handler.handle({ command: "some-other-command" }, ctx);
94+
assert.ok(postMessageStub.notCalled);
95+
});
96+
97+
test("ignores malformed messages", async () => {
98+
await handler.handle({} as any, ctx);
99+
assert.ok(postMessageStub.notCalled);
100+
101+
await handler.handle({ command: 123 } as any, ctx);
102+
assert.ok(postMessageStub.notCalled);
103+
});
104+
});
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import * as assert from "assert";
2+
import { CostTrackingService } from "../../services/cost-tracking.service";
3+
4+
suite("CostTrackingService", () => {
5+
let service: CostTrackingService;
6+
7+
setup(() => {
8+
// Reset singleton state between tests
9+
service = CostTrackingService.getInstance();
10+
service.resetAll();
11+
});
12+
13+
teardown(() => {
14+
service.resetAll();
15+
});
16+
17+
// ── recordUsage ─────────────────────────────────────────────────
18+
19+
test("recordUsage tracks a single turn", () => {
20+
const result = service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500);
21+
22+
assert.strictEqual(result.inputTokens, 1000);
23+
assert.strictEqual(result.outputTokens, 500);
24+
assert.strictEqual(result.totalTokens, 1500);
25+
assert.strictEqual(result.requestCount, 1);
26+
assert.strictEqual(result.provider, "anthropic");
27+
assert.strictEqual(result.model, "claude-sonnet-4-6");
28+
assert.ok(result.estimatedCostUSD > 0);
29+
});
30+
31+
test("recordUsage accumulates across multiple turns", () => {
32+
service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500);
33+
const result = service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 2000, 1000);
34+
35+
assert.strictEqual(result.inputTokens, 3000);
36+
assert.strictEqual(result.outputTokens, 1500);
37+
assert.strictEqual(result.totalTokens, 4500);
38+
assert.strictEqual(result.requestCount, 2);
39+
});
40+
41+
test("recordUsage tracks separate conversations independently", () => {
42+
service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500);
43+
service.recordUsage("t2", "openai", "gpt-4o", 2000, 1000);
44+
45+
const c1 = service.getConversationCost("t1");
46+
const c2 = service.getConversationCost("t2");
47+
48+
assert.strictEqual(c1!.inputTokens, 1000);
49+
assert.strictEqual(c2!.inputTokens, 2000);
50+
assert.strictEqual(c1!.provider, "anthropic");
51+
assert.strictEqual(c2!.provider, "openai");
52+
});
53+
54+
test("recordUsage uses default pricing for unknown models", () => {
55+
const result = service.recordUsage("t1", "custom", "some-unknown-model", 1000000, 0);
56+
// Default pricing: 3/M input → $3.00 for 1M tokens
57+
assert.strictEqual(result.estimatedCostUSD, 3);
58+
});
59+
60+
// ── getConversationCost ─────────────────────────────────────────
61+
62+
test("getConversationCost returns null for untracked thread", () => {
63+
assert.strictEqual(service.getConversationCost("nonexistent"), null);
64+
});
65+
66+
test("getConversationCost returns data for tracked thread", () => {
67+
service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 100, 50);
68+
const cost = service.getConversationCost("t1");
69+
70+
assert.ok(cost !== null);
71+
assert.strictEqual(cost!.inputTokens, 100);
72+
assert.strictEqual(cost!.outputTokens, 50);
73+
});
74+
75+
// ── resetConversation ───────────────────────────────────────────
76+
77+
test("resetConversation clears a single conversation", () => {
78+
service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 100, 50);
79+
service.recordUsage("t2", "openai", "gpt-4o", 200, 100);
80+
81+
service.resetConversation("t1");
82+
83+
assert.strictEqual(service.getConversationCost("t1"), null);
84+
assert.ok(service.getConversationCost("t2") !== null);
85+
});
86+
87+
// ── resetAll ────────────────────────────────────────────────────
88+
89+
test("resetAll clears all conversations", () => {
90+
service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 100, 50);
91+
service.recordUsage("t2", "openai", "gpt-4o", 200, 100);
92+
93+
service.resetAll();
94+
95+
assert.strictEqual(service.getConversationCost("t1"), null);
96+
assert.strictEqual(service.getConversationCost("t2"), null);
97+
});
98+
99+
// ── getCostSummary ──────────────────────────────────────────────
100+
101+
test("getCostSummary returns zero totals when empty", () => {
102+
const summary = service.getCostSummary();
103+
104+
assert.strictEqual(summary.totals.inputTokens, 0);
105+
assert.strictEqual(summary.totals.outputTokens, 0);
106+
assert.strictEqual(summary.totals.totalTokens, 0);
107+
assert.strictEqual(summary.totals.estimatedCostUSD, 0);
108+
assert.strictEqual(summary.totals.requestCount, 0);
109+
assert.strictEqual(summary.totals.conversationCount, 0);
110+
assert.deepStrictEqual(summary.providers, []);
111+
assert.deepStrictEqual(summary.conversations, []);
112+
});
113+
114+
test("getCostSummary aggregates a single conversation", () => {
115+
service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500);
116+
117+
const summary = service.getCostSummary();
118+
119+
assert.strictEqual(summary.totals.inputTokens, 1000);
120+
assert.strictEqual(summary.totals.outputTokens, 500);
121+
assert.strictEqual(summary.totals.totalTokens, 1500);
122+
assert.strictEqual(summary.totals.requestCount, 1);
123+
assert.strictEqual(summary.totals.conversationCount, 1);
124+
assert.strictEqual(summary.providers.length, 1);
125+
assert.strictEqual(summary.providers[0].provider, "anthropic");
126+
assert.strictEqual(summary.conversations.length, 1);
127+
assert.strictEqual(summary.conversations[0].threadId, "t1");
128+
});
129+
130+
test("getCostSummary aggregates multiple conversations per provider", () => {
131+
service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500);
132+
service.recordUsage("t2", "anthropic", "claude-sonnet-4-6", 2000, 1000);
133+
134+
const summary = service.getCostSummary();
135+
136+
assert.strictEqual(summary.totals.conversationCount, 2);
137+
assert.strictEqual(summary.totals.inputTokens, 3000);
138+
assert.strictEqual(summary.totals.outputTokens, 1500);
139+
assert.strictEqual(summary.providers.length, 1);
140+
assert.strictEqual(summary.providers[0].inputTokens, 3000);
141+
assert.strictEqual(summary.providers[0].requestCount, 2);
142+
});
143+
144+
test("getCostSummary groups by provider correctly", () => {
145+
service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1000, 500);
146+
service.recordUsage("t2", "openai", "gpt-4o", 2000, 1000);
147+
service.recordUsage("t3", "anthropic", "claude-3-5-haiku-20241022", 500, 250);
148+
149+
const summary = service.getCostSummary();
150+
151+
assert.strictEqual(summary.totals.conversationCount, 3);
152+
assert.strictEqual(summary.providers.length, 2);
153+
154+
const anthropic = summary.providers.find((p) => p.provider === "anthropic");
155+
const openai = summary.providers.find((p) => p.provider === "openai");
156+
157+
assert.ok(anthropic);
158+
assert.ok(openai);
159+
assert.strictEqual(anthropic!.inputTokens, 1500);
160+
assert.strictEqual(openai!.inputTokens, 2000);
161+
assert.strictEqual(anthropic!.requestCount, 2);
162+
assert.strictEqual(openai!.requestCount, 1);
163+
});
164+
165+
test("getCostSummary rounds cost to 6 decimal places", () => {
166+
// Use a known model with known pricing to verify rounding
167+
service.recordUsage("t1", "anthropic", "claude-sonnet-4-6", 1, 1);
168+
169+
const summary = service.getCostSummary();
170+
const costStr = summary.totals.estimatedCostUSD.toString();
171+
const decimals = costStr.includes(".") ? costStr.split(".")[1].length : 0;
172+
assert.ok(decimals <= 6, `Cost should have at most 6 decimals, got ${decimals}`);
173+
});
174+
});

src/webview-providers/base.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
BrowserHandler,
5656
ComposerHandler,
5757
ConnectorHandler,
58+
CostTrackingHandler,
5859
SkillHandler,
5960
DiffReviewHandler,
6061
CheckpointHandler,
@@ -270,6 +271,7 @@ export abstract class BaseWebViewProvider implements vscode.Disposable {
270271
this.handlerRegistry.register(new ComposerHandler());
271272
this.handlerRegistry.register(new StandupHandler());
272273
this.handlerRegistry.register(new TeamGraphHandler());
274+
this.handlerRegistry.register(new CostTrackingHandler());
273275
this.handlerRegistry.register(new DoctorHandler());
274276
this.handlerRegistry.register(new OnboardingHandler());
275277
this.handlerRegistry.register(

0 commit comments

Comments
 (0)