main
1/**
2 * Cost Tracker Extension
3 *
4 * Shows session cost and token usage in the footer status bar,
5 * similar to GitHub Copilot's premium request multiplier display.
6 */
7
8import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
10// Premium request multipliers (cost relative to Sonnet baseline)
11const MULTIPLIERS: Record<string, number> = {
12 // Anthropic
13 "claude-sonnet-4-20250514": 1,
14 "claude-sonnet-4-5-20250514": 1,
15 "claude-sonnet-4-5-20241022": 1,
16 "claude-haiku-3-5-20241022": 0.33,
17 "claude-opus-4-20250514": 3,
18 "claude-opus-4-5-20250120": 3,
19 // OpenAI
20 "gpt-4.1": 0,
21 "gpt-4o": 1,
22 "gpt-4o-mini": 0,
23 "o3": 3,
24 "o3-mini": 0.33,
25 "o4-mini": 0.33,
26 // Google
27 "gemini-2.5-pro": 1,
28 "gemini-2.5-flash": 0.33,
29 "gemini-2.0-flash": 0,
30};
31
32function getMultiplier(modelId: string): string {
33 for (const [pattern, mult] of Object.entries(MULTIPLIERS)) {
34 if (modelId.includes(pattern)) {
35 return `${mult}x`;
36 }
37 }
38 return "?x";
39}
40
41function formatCost(cost: number): string {
42 if (cost < 0.01) return `$${cost.toFixed(4)}`;
43 if (cost < 1) return `$${cost.toFixed(3)}`;
44 return `$${cost.toFixed(2)}`;
45}
46
47function formatTokens(n: number): string {
48 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
49 if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
50 return `${n}`;
51}
52
53export default function (pi: ExtensionAPI) {
54 let totalCost = 0;
55 let totalInput = 0;
56 let totalOutput = 0;
57 let totalCacheRead = 0;
58 let currentModel = "";
59
60 function updateStatus(ctx: { ui: any }) {
61 const theme = ctx.ui.theme;
62 const mult = getMultiplier(currentModel);
63 const parts = [
64 theme.fg("dim", "cost:"),
65 theme.fg("accent", formatCost(totalCost)),
66 theme.fg("dim", `[${mult}]`),
67 theme.fg("dim", `in:${formatTokens(totalInput)}`),
68 theme.fg("dim", `out:${formatTokens(totalOutput)}`),
69 ];
70 if (totalCacheRead > 0) {
71 parts.push(theme.fg("dim", `cache:${formatTokens(totalCacheRead)}`));
72 }
73 ctx.ui.setStatus("cost-tracker", parts.join(" "));
74 }
75
76 pi.on("session_start", async (_event, ctx) => {
77 // Restore from existing session entries
78 for (const entry of ctx.sessionManager.getBranch()) {
79 if (entry.type === "message" && entry.message.role === "assistant") {
80 const msg = entry.message as any;
81 if (msg.usage) {
82 totalInput += msg.usage.input || 0;
83 totalOutput += msg.usage.output || 0;
84 totalCacheRead += msg.usage.cacheRead || 0;
85 if (msg.usage.cost) {
86 totalCost += msg.usage.cost.total || 0;
87 }
88 }
89 if (msg.model) currentModel = msg.model;
90 }
91 }
92 updateStatus(ctx);
93 });
94
95 pi.on("model_select", async (event, ctx) => {
96 currentModel = (event as any).modelId || "";
97 updateStatus(ctx);
98 });
99
100 pi.on("turn_end", async (_event, ctx) => {
101 // Recalculate from session to stay accurate
102 totalCost = 0;
103 totalInput = 0;
104 totalOutput = 0;
105 totalCacheRead = 0;
106 for (const entry of ctx.sessionManager.getBranch()) {
107 if (entry.type === "message" && entry.message.role === "assistant") {
108 const msg = entry.message as any;
109 if (msg.usage) {
110 totalInput += msg.usage.input || 0;
111 totalOutput += msg.usage.output || 0;
112 totalCacheRead += msg.usage.cacheRead || 0;
113 if (msg.usage.cost) {
114 totalCost += msg.usage.cost.total || 0;
115 }
116 }
117 if (msg.model) currentModel = msg.model;
118 }
119 }
120 updateStatus(ctx);
121 });
122}