Browse Source

feat: add model usage statistics with input/output token breakdown to stats command (#6296)

processtrader 1 month ago
parent
commit
7349626757
1 changed files with 89 additions and 2 deletions
  1. 89 2
      packages/opencode/src/cli/cmd/stats.ts

+ 89 - 2
packages/opencode/src/cli/cmd/stats.ts

@@ -20,6 +20,17 @@ interface SessionStats {
     }
   }
   toolUsage: Record<string, number>
+  modelUsage: Record<
+    string,
+    {
+      messages: number
+      tokens: {
+        input: number
+        output: number
+      }
+      cost: number
+    }
+  >
   dateRange: {
     earliest: number
     latest: number
@@ -43,6 +54,9 @@ export const StatsCommand = cmd({
         describe: "number of tools to show (default: all)",
         type: "number",
       })
+      .option("models", {
+        describe: "show model statistics (default: hidden). Pass a number to show top N, otherwise shows all",
+      })
       .option("project", {
         describe: "filter by project (default: all projects, empty string: current project)",
         type: "string",
@@ -51,7 +65,15 @@ export const StatsCommand = cmd({
   handler: async (args) => {
     await bootstrap(process.cwd(), async () => {
       const stats = await aggregateSessionStats(args.days, args.project)
-      displayStats(stats, args.tools)
+
+      let modelLimit: number | undefined
+      if (args.models === true) {
+        modelLimit = Infinity
+      } else if (typeof args.models === "number") {
+        modelLimit = args.models
+      }
+
+      displayStats(stats, args.tools, modelLimit)
     })
   },
 })
@@ -121,6 +143,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
       },
     },
     toolUsage: {},
+    modelUsage: {},
     dateRange: {
       earliest: Date.now(),
       latest: Date.now(),
@@ -154,17 +177,43 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
       let sessionCost = 0
       let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
       let sessionToolUsage: Record<string, number> = {}
+      let sessionModelUsage: Record<
+        string,
+        {
+          messages: number
+          tokens: {
+            input: number
+            output: number
+          }
+          cost: number
+        }
+      > = {}
 
       for (const message of messages) {
         if (message.info.role === "assistant") {
           sessionCost += message.info.cost || 0
 
+          const modelKey = `${message.info.providerID}/${message.info.modelID}`
+          if (!sessionModelUsage[modelKey]) {
+            sessionModelUsage[modelKey] = {
+              messages: 0,
+              tokens: { input: 0, output: 0 },
+              cost: 0,
+            }
+          }
+          sessionModelUsage[modelKey].messages++
+          sessionModelUsage[modelKey].cost += message.info.cost || 0
+
           if (message.info.tokens) {
             sessionTokens.input += message.info.tokens.input || 0
             sessionTokens.output += message.info.tokens.output || 0
             sessionTokens.reasoning += message.info.tokens.reasoning || 0
             sessionTokens.cache.read += message.info.tokens.cache?.read || 0
             sessionTokens.cache.write += message.info.tokens.cache?.write || 0
+
+            sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
+            sessionModelUsage[modelKey].tokens.output +=
+              (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
           }
         }
 
@@ -181,6 +230,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
         sessionTokens,
         sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
         sessionToolUsage,
+        sessionModelUsage,
         earliestTime: session.time.created,
         latestTime: session.time.updated,
       }
@@ -204,6 +254,20 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
       for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
         stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
       }
+
+      for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
+        if (!stats.modelUsage[model]) {
+          stats.modelUsage[model] = {
+            messages: 0,
+            tokens: { input: 0, output: 0 },
+            cost: 0,
+          }
+        }
+        stats.modelUsage[model].messages += usage.messages
+        stats.modelUsage[model].tokens.input += usage.tokens.input
+        stats.modelUsage[model].tokens.output += usage.tokens.output
+        stats.modelUsage[model].cost += usage.cost
+      }
     }
   }
 
@@ -228,7 +292,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
   return stats
 }
 
-export function displayStats(stats: SessionStats, toolLimit?: number) {
+export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
   const width = 56
 
   function renderRow(label: string, value: string): string {
@@ -267,6 +331,29 @@ export function displayStats(stats: SessionStats, toolLimit?: number) {
   console.log("└────────────────────────────────────────────────────────┘")
   console.log()
 
+  // Model Usage section
+  if (modelLimit !== undefined && Object.keys(stats.modelUsage).length > 0) {
+    const sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.messages - a.messages)
+    const modelsToDisplay = modelLimit === Infinity ? sortedModels : sortedModels.slice(0, modelLimit)
+
+    console.log("┌────────────────────────────────────────────────────────┐")
+    console.log("│                      MODEL USAGE                       │")
+    console.log("├────────────────────────────────────────────────────────┤")
+
+    for (const [model, usage] of modelsToDisplay) {
+      console.log(`│ ${model.padEnd(54)} │`)
+      console.log(renderRow("  Messages", usage.messages.toLocaleString()))
+      console.log(renderRow("  Input Tokens", formatNumber(usage.tokens.input)))
+      console.log(renderRow("  Output Tokens", formatNumber(usage.tokens.output)))
+      console.log(renderRow("  Cost", `$${usage.cost.toFixed(4)}`))
+      console.log("├────────────────────────────────────────────────────────┤")
+    }
+    // Remove last separator and add bottom border
+    process.stdout.write("\x1B[1A") // Move up one line
+    console.log("└────────────────────────────────────────────────────────┘")
+  }
+  console.log()
+
   // Tool Usage section
   if (Object.keys(stats.toolUsage).length > 0) {
     const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)