-
Notifications
You must be signed in to change notification settings - Fork 2.6k
feat: add AI Inference Summary for total costs and token usage #6824
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import { z } from "zod" | ||
|
|
||
| /** | ||
| * Schema for persistent usage tracking item | ||
| * Stores cost and token data for completed tasks | ||
| */ | ||
| export const PersistentUsageItemSchema = z.object({ | ||
| taskId: z.string(), | ||
| taskWorkspace: z.string(), // Workspace path for filtering | ||
| mode: z.string().optional(), | ||
| timestamp: z.number(), | ||
| cost: z.number().default(0), | ||
| inputTokens: z.number().default(0), | ||
| outputTokens: z.number().default(0), | ||
| cacheReads: z.number().default(0), | ||
| cacheWrites: z.number().default(0), | ||
| }) | ||
|
|
||
| export type PersistentUsageItem = z.infer<typeof PersistentUsageItemSchema> | ||
|
|
||
| /** | ||
| * Usage summary data structure for frontend display | ||
| */ | ||
| export interface UsageSummary { | ||
| totalCost: number | ||
| totalInputTokens: number | ||
| totalOutputTokens: number | ||
| totalCacheReads: number | ||
| totalCacheWrites: number | ||
| modeBreakdown: Record< | ||
| string, | ||
| { | ||
| cost: number | ||
| inputTokens: number | ||
| outputTokens: number | ||
| cacheReads: number | ||
| cacheWrites: number | ||
| count: number | ||
| } | ||
| > | ||
| workspaceName?: string | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1432,6 +1432,42 @@ export class ClineProvider | |
| if (!this.checkMdmCompliance()) { | ||
| await this.postMessageToWebview({ type: "action", action: "accountButtonClicked" }) | ||
| } | ||
|
|
||
| // Load persistent usage data asynchronously | ||
| this.loadPersistentUsageDataAsync() | ||
| } | ||
|
|
||
| /** | ||
| * Load persistent usage data asynchronously and send to webview | ||
| */ | ||
| public async loadPersistentUsageDataAsync() { | ||
| try { | ||
| const { getUsageTrackingService } = await import("../../services/usage-tracking") | ||
| const usageService = getUsageTrackingService() | ||
|
|
||
| // Get both all workspaces and current workspace data | ||
| const [allData, currentData] = await Promise.all([ | ||
| usageService.getUsageSummary(), | ||
| usageService.getUsageSummary(this.cwd), | ||
| ]) | ||
|
|
||
| // Send to webview | ||
| await this.postMessageToWebview({ | ||
| type: "persistentUsageData", | ||
| persistentUsageData: { | ||
| all: allData, | ||
| current: currentData, | ||
| }, | ||
| }) | ||
|
|
||
| // Migrate existing task history if needed | ||
| const taskHistory = this.getGlobalState("taskHistory") ?? [] | ||
| if (taskHistory.length > 0) { | ||
| await usageService.migrateFromTaskHistory(taskHistory, this.cwd) | ||
| } | ||
| } catch (error) { | ||
| this.log(`Failed to load persistent usage data: ${error}`) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The error handling here only logs failures. Should we consider showing a user-friendly notification if usage data loading fails? Users might wonder why their cost summary isn't appearing. |
||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -1957,6 +1993,16 @@ export class ClineProvider | |
| } | ||
|
|
||
| await this.updateGlobalState("taskHistory", history) | ||
|
|
||
| // Update persistent usage tracking | ||
| try { | ||
| const { getUsageTrackingService } = await import("../../services/usage-tracking") | ||
| const usageService = getUsageTrackingService() | ||
| await usageService.updateUsageTracking(item, this.cwd) | ||
| } catch (error) { | ||
| this.log(`Failed to update usage tracking: ${error}`) | ||
| } | ||
|
|
||
| return history | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,250 @@ | ||
| import * as path from "path" | ||
| import * as os from "os" | ||
| import { PersistentUsageItem, PersistentUsageItemSchema, UsageSummary, HistoryItem } from "@roo-code/types" | ||
| import { safeWriteJson } from "../../utils/safeWriteJson" | ||
| import { fileExistsAtPath } from "../../utils/fs" | ||
| import * as fs from "fs/promises" | ||
|
|
||
| /** | ||
| * Service for managing persistent usage tracking data | ||
| * Stores data in ~/.roo/usage-tracking.json | ||
| */ | ||
| export class UsageTrackingService { | ||
| private static instance: UsageTrackingService | null = null | ||
| private readonly filePath: string | ||
|
|
||
| private constructor() { | ||
| // Store in global ~/.roo directory | ||
| const rooDir = path.join(os.homedir(), ".roo") | ||
| this.filePath = path.join(rooDir, "usage-tracking.json") | ||
| } | ||
|
|
||
| public static getInstance(): UsageTrackingService { | ||
| if (!UsageTrackingService.instance) { | ||
| UsageTrackingService.instance = new UsageTrackingService() | ||
| } | ||
| return UsageTrackingService.instance | ||
| } | ||
|
|
||
| /** | ||
| * Load existing usage data from disk | ||
| */ | ||
| private async loadData(): Promise<PersistentUsageItem[]> { | ||
| try { | ||
| if (!(await fileExistsAtPath(this.filePath))) { | ||
| return [] | ||
| } | ||
|
|
||
| const content = await fs.readFile(this.filePath, "utf-8") | ||
| const data = JSON.parse(content) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. JSON.parse is wrapped in a try/catch, which is good. For easier debugging, consider including the file path in the error log when parsing fails. This comment was generated because it violated a code review rule: irule_PTI8rjtnhwrWq6jS. |
||
|
|
||
| // Validate and parse each item | ||
| if (Array.isArray(data)) { | ||
| return data | ||
| .map((item) => { | ||
| try { | ||
| return PersistentUsageItemSchema.parse(item) | ||
| } catch { | ||
| // Skip invalid items | ||
| return null | ||
| } | ||
| }) | ||
| .filter((item): item is PersistentUsageItem => item !== null) | ||
| } | ||
|
|
||
| return [] | ||
| } catch (error) { | ||
| console.error("Failed to load usage tracking data:", error) | ||
| return [] | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Save usage data to disk | ||
| */ | ||
| private async saveData(data: PersistentUsageItem[]): Promise<void> { | ||
| try { | ||
| await safeWriteJson(this.filePath, data) | ||
| } catch (error) { | ||
| console.error("Failed to save usage tracking data:", error) | ||
| throw error | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Update usage tracking with new task data | ||
| */ | ||
| public async updateUsageTracking(historyItem: HistoryItem, workspacePath: string): Promise<void> { | ||
| try { | ||
| const data = await this.loadData() | ||
|
|
||
| // Remove existing entry for this task if it exists | ||
| const filteredData = data.filter((item) => item.taskId !== historyItem.id) | ||
|
|
||
| // Calculate total cost and tokens from the history item | ||
| let totalCost = 0 | ||
| let totalInputTokens = 0 | ||
| let totalOutputTokens = 0 | ||
| let totalCacheReads = 0 | ||
| let totalCacheWrites = 0 | ||
|
|
||
| // Sum up all API request info from the task | ||
| if (historyItem.totalCost) { | ||
| totalCost = historyItem.totalCost | ||
| } | ||
| if (historyItem.tokensIn) { | ||
| totalInputTokens = historyItem.tokensIn | ||
| } | ||
| if (historyItem.tokensOut) { | ||
| totalOutputTokens = historyItem.tokensOut | ||
| } | ||
| if (historyItem.cacheReads) { | ||
| totalCacheReads = historyItem.cacheReads | ||
| } | ||
| if (historyItem.cacheWrites) { | ||
| totalCacheWrites = historyItem.cacheWrites | ||
| } | ||
|
|
||
| // Add new usage item | ||
| const newItem: PersistentUsageItem = { | ||
| taskId: historyItem.id, | ||
| taskWorkspace: workspacePath, | ||
| mode: historyItem.mode, | ||
| timestamp: historyItem.ts, | ||
| cost: totalCost, | ||
| inputTokens: totalInputTokens, | ||
| outputTokens: totalOutputTokens, | ||
| cacheReads: totalCacheReads, | ||
| cacheWrites: totalCacheWrites, | ||
| } | ||
|
|
||
| filteredData.push(newItem) | ||
|
|
||
| // Save updated data | ||
| await this.saveData(filteredData) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For users with extensive history, this JSON file could grow indefinitely. Should we implement data pruning for records older than a certain threshold (e.g., 90 days)? |
||
| } catch (error) { | ||
| console.error("Failed to update usage tracking:", error) | ||
| // Don't throw - we don't want to break the main flow | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Get usage summary for display | ||
| */ | ||
| public async getUsageSummary(workspacePath?: string): Promise<UsageSummary> { | ||
| try { | ||
| const data = await this.loadData() | ||
|
|
||
| // Filter by workspace if specified | ||
| const filteredData = workspacePath ? data.filter((item) => item.taskWorkspace === workspacePath) : data | ||
|
|
||
| // Calculate totals | ||
| let totalCost = 0 | ||
| let totalInputTokens = 0 | ||
| let totalOutputTokens = 0 | ||
| let totalCacheReads = 0 | ||
| let totalCacheWrites = 0 | ||
| const modeBreakdown: UsageSummary["modeBreakdown"] = {} | ||
|
|
||
| for (const item of filteredData) { | ||
| totalCost += item.cost | ||
| totalInputTokens += item.inputTokens | ||
| totalOutputTokens += item.outputTokens | ||
| totalCacheReads += item.cacheReads | ||
| totalCacheWrites += item.cacheWrites | ||
|
|
||
| // Update mode breakdown | ||
| const mode = item.mode || "unknown" | ||
| if (!modeBreakdown[mode]) { | ||
| modeBreakdown[mode] = { | ||
| cost: 0, | ||
| inputTokens: 0, | ||
| outputTokens: 0, | ||
| cacheReads: 0, | ||
| cacheWrites: 0, | ||
| count: 0, | ||
| } | ||
| } | ||
|
|
||
| modeBreakdown[mode].cost += item.cost | ||
| modeBreakdown[mode].inputTokens += item.inputTokens | ||
| modeBreakdown[mode].outputTokens += item.outputTokens | ||
| modeBreakdown[mode].cacheReads += item.cacheReads | ||
| modeBreakdown[mode].cacheWrites += item.cacheWrites | ||
| modeBreakdown[mode].count += 1 | ||
| } | ||
|
|
||
| // Get workspace name from path | ||
| const workspaceName = workspacePath ? path.basename(workspacePath) : undefined | ||
|
|
||
| return { | ||
| totalCost, | ||
| totalInputTokens, | ||
| totalOutputTokens, | ||
| totalCacheReads, | ||
| totalCacheWrites, | ||
| modeBreakdown, | ||
| workspaceName, | ||
| } | ||
| } catch (error) { | ||
| console.error("Failed to get usage summary:", error) | ||
| // Return empty summary on error | ||
| return { | ||
| totalCost: 0, | ||
| totalInputTokens: 0, | ||
| totalOutputTokens: 0, | ||
| totalCacheReads: 0, | ||
| totalCacheWrites: 0, | ||
| modeBreakdown: {}, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Migrate existing task history to persistent storage | ||
| */ | ||
| public async migrateFromTaskHistory(taskHistory: HistoryItem[], workspacePath: string): Promise<void> { | ||
| try { | ||
| const data = await this.loadData() | ||
| const existingTaskIds = new Set(data.map((item) => item.taskId)) | ||
|
|
||
| // Only migrate tasks that aren't already in persistent storage | ||
| const tasksToMigrate = taskHistory.filter((task) => !existingTaskIds.has(task.id)) | ||
|
|
||
| for (const task of tasksToMigrate) { | ||
| if (task.totalCost || task.tokensIn || task.tokensOut) { | ||
| await this.updateUsageTracking(task, workspacePath) | ||
| } | ||
| } | ||
| } catch (error) { | ||
| console.error("Failed to migrate task history:", error) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Clear all usage data (for testing or reset) | ||
| */ | ||
| public async clearAllData(): Promise<void> { | ||
| try { | ||
| await this.saveData([]) | ||
| } catch (error) { | ||
| console.error("Failed to clear usage data:", error) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Remove usage data for a specific task | ||
| */ | ||
| public async removeTask(taskId: string): Promise<void> { | ||
| try { | ||
| const data = await this.loadData() | ||
| const filteredData = data.filter((item) => item.taskId !== taskId) | ||
| await this.saveData(filteredData) | ||
| } catch (error) { | ||
| console.error("Failed to remove task from usage tracking:", error) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Export singleton instance getter | ||
| export const getUsageTrackingService = () => UsageTrackingService.getInstance() | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This service needs test coverage. The PR claims tests pass but doesn't add any tests for this critical new functionality. Could we add unit tests to verify data persistence, workspace filtering, and migration logic? |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This migration could run multiple times if loadPersistentUsageDataAsync() is called repeatedly. Consider adding a flag to track if migration has already been attempted for this session?