diff --git a/apps/server/src/providers/types.ts b/apps/server/src/providers/types.ts index 6a05b6df..87be4f77 100644 --- a/apps/server/src/providers/types.ts +++ b/apps/server/src/providers/types.ts @@ -48,6 +48,29 @@ export interface ContentBlock { content?: string; } +/** + * Token usage statistics from SDK execution + */ +export interface TokenUsage { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + totalTokens: number; + costUSD: number; +} + +/** + * Per-model usage breakdown from SDK result + */ +export interface ModelUsageData { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + costUSD: number; +} + /** * Message returned by a provider (matches Claude SDK streaming format) */ @@ -62,6 +85,15 @@ export interface ProviderMessage { result?: string; error?: string; parent_tool_use_id?: string | null; + // Token usage fields (present in result messages) + usage?: { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + total_cost_usd?: number; + modelUsage?: Record; } /** diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 14fdf724..aa77aed2 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -10,7 +10,7 @@ */ import { ProviderFactory } from "../providers/provider-factory.js"; -import type { ExecuteOptions } from "../providers/types.js"; +import type { ExecuteOptions, TokenUsage } from "../providers/types.js"; import { exec } from "child_process"; import { promisify } from "util"; import path from "path"; @@ -1878,6 +1878,30 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. : ""; let specDetected = false; + // Token usage accumulator - tracks total usage across all streams + const accumulatedUsage: TokenUsage = { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalTokens: 0, + costUSD: 0, + }; + + // Helper to accumulate usage from a result message + const accumulateUsage = (msg: { usage?: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }; total_cost_usd?: number }): void => { + if (msg.usage) { + accumulatedUsage.inputTokens += msg.usage.input_tokens || 0; + accumulatedUsage.outputTokens += msg.usage.output_tokens || 0; + accumulatedUsage.cacheReadInputTokens += msg.usage.cache_read_input_tokens || 0; + accumulatedUsage.cacheCreationInputTokens += msg.usage.cache_creation_input_tokens || 0; + accumulatedUsage.totalTokens = accumulatedUsage.inputTokens + accumulatedUsage.outputTokens; + } + if (msg.total_cost_usd !== undefined) { + accumulatedUsage.costUSD = msg.total_cost_usd; + } + }; + // Agent output goes to .automaker directory // Note: We use projectPath here, not workDir, because workDir might be a worktree path const featureDirForOutput = getFeatureDir(projectPath, featureId); @@ -2101,6 +2125,7 @@ After generating the revised spec, output: throw new Error(msg.error || "Error during plan revision"); } else if (msg.type === "result" && msg.subtype === "success") { revisionText += msg.result || ""; + accumulateUsage(msg); } } @@ -2238,6 +2263,7 @@ After generating the revised spec, output: } else if (msg.type === "result" && msg.subtype === "success") { taskOutput += msg.result || ""; responseText += msg.result || ""; + accumulateUsage(msg); } } @@ -2318,6 +2344,7 @@ Implement all the changes described in the plan above.`; throw new Error(msg.error || "Unknown error during implementation"); } else if (msg.type === "result" && msg.subtype === "success") { responseText += msg.result || ""; + accumulateUsage(msg); } } } @@ -2365,6 +2392,8 @@ Implement all the changes described in the plan above.`; // The msg.result is just a summary which would lose all tool use details // Just ensure final write happens scheduleWrite(); + // Capture token usage from the result message + accumulateUsage(msg); } } @@ -2374,6 +2403,31 @@ Implement all the changes described in the plan above.`; } // Final write - ensure all accumulated content is saved await writeToFile(); + + // Save token usage to the feature if any tokens were consumed + if (accumulatedUsage.totalTokens > 0) { + try { + // Load existing feature to check for previous token usage + const existingFeature = await this.featureLoader.get(projectPath, featureId); + if (existingFeature) { + // If feature already has token usage, add to it (for follow-ups) + const existingUsage = existingFeature.tokenUsage; + if (existingUsage) { + accumulatedUsage.inputTokens += existingUsage.inputTokens; + accumulatedUsage.outputTokens += existingUsage.outputTokens; + accumulatedUsage.cacheReadInputTokens += existingUsage.cacheReadInputTokens; + accumulatedUsage.cacheCreationInputTokens += existingUsage.cacheCreationInputTokens; + accumulatedUsage.totalTokens = accumulatedUsage.inputTokens + accumulatedUsage.outputTokens; + accumulatedUsage.costUSD += existingUsage.costUSD; + } + } + await this.featureLoader.update(projectPath, featureId, { tokenUsage: accumulatedUsage }); + console.log(`[AutoMode] Token usage:`, accumulatedUsage); + console.log(`[AutoMode] Saved token usage for ${featureId}: ${accumulatedUsage.totalTokens} tokens, $${accumulatedUsage.costUSD.toFixed(4)}`); + } catch (error) { + console.error(`[AutoMode] Failed to save token usage for ${featureId}:`, error); + } + } } private async executeFeatureWithContext( diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 42fabbb2..0f976f6f 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -11,6 +11,7 @@ import { getFeatureImagesDir, ensureAutomakerDir, } from "../lib/automaker-paths.js"; +import type { TokenUsage } from "../providers/types.js"; export interface Feature { id: string; @@ -43,6 +44,7 @@ export interface Feature { error?: string; summary?: string; startedAt?: string; + tokenUsage?: TokenUsage; [key: string]: unknown; // Keep catch-all for extensibility } diff --git a/apps/ui/src/components/ui/log-viewer.tsx b/apps/ui/src/components/ui/log-viewer.tsx index af339adf..99513ed3 100644 --- a/apps/ui/src/components/ui/log-viewer.tsx +++ b/apps/ui/src/components/ui/log-viewer.tsx @@ -24,6 +24,7 @@ import { Circle, Play, Loader2, + Coins, } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -34,10 +35,35 @@ import { type LogEntryType, type ToolCategory, } from "@/lib/log-parser"; +import type { TokenUsage } from "@/store/app-store"; interface LogViewerProps { output: string; className?: string; + tokenUsage?: TokenUsage; +} + +/** + * Formats token counts for compact display (e.g., 12500 -> "12.5K") + */ +function formatTokenCount(count: number): string { + if (count >= 1000000) { + return `${(count / 1000000).toFixed(1)}M`; + } + if (count >= 1000) { + return `${(count / 1000).toFixed(1)}K`; + } + return count.toString(); +} + +/** + * Formats cost for display (e.g., 0.0847 -> "$0.0847") + */ +function formatCost(cost: number): string { + if (cost < 0.01) { + return `$${cost.toFixed(4)}`; + } + return `$${cost.toFixed(2)}`; } const getLogIcon = (type: LogEntryType) => { @@ -413,7 +439,7 @@ interface ToolCategoryStats { other: number; } -export function LogViewer({ output, className }: LogViewerProps) { +export function LogViewer({ output, className, tokenUsage }: LogViewerProps) { const [expandedIds, setExpandedIds] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(""); const [hiddenTypes, setHiddenTypes] = useState>(new Set()); @@ -615,6 +641,40 @@ export function LogViewer({ output, className }: LogViewerProps) { return (
+ {/* Token Usage Summary Header */} + {tokenUsage && tokenUsage.totalTokens > 0 && ( +
+
+ + + {formatTokenCount(tokenUsage.totalTokens)} + tokens + + | + + IN: + {formatTokenCount(tokenUsage.inputTokens)} + + + OUT: + {formatTokenCount(tokenUsage.outputTokens)} + + {tokenUsage.cacheReadInputTokens > 0 && ( + <> + | + + Cache: + {formatTokenCount(tokenUsage.cacheReadInputTokens)} + + + )} + | + + {formatCost(tokenUsage.costUSD)} + +
+
+ )} {/* Sticky header with search, stats, and filters */} {/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */}
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card.tsx index 7030c1f9..5a154f51 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card.tsx @@ -57,6 +57,7 @@ import { Wand2, Archive, Lock, + Coins, } from "lucide-react"; import { CountUpTimer } from "@/components/ui/count-up-timer"; import { getElectronAPI } from "@/lib/electron"; @@ -90,6 +91,29 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string { return labels[level]; } +/** + * Formats token counts for compact display (e.g., 12500 -> "12.5K") + */ +function formatTokenCount(count: number): string { + if (count >= 1000000) { + return `${(count / 1000000).toFixed(1)}M`; + } + if (count >= 1000) { + return `${(count / 1000).toFixed(1)}K`; + } + return count.toString(); +} + +/** + * Formats cost for display (e.g., 0.0847 -> "$0.0847") + */ +function formatCost(cost: number): string { + if (cost < 0.01) { + return `$${cost.toFixed(4)}`; + } + return `$${cost.toFixed(2)}`; +} + interface KanbanCardProps { feature: Feature; onEdit: () => void; @@ -873,6 +897,36 @@ export const KanbanCard = memo(function KanbanCard({ )}
)} + {/* Token Usage Display */} + {feature.tokenUsage && feature.tokenUsage.totalTokens > 0 && ( +
+ + + + + + {formatTokenCount(feature.tokenUsage.totalTokens)} tokens + + ({formatCost(feature.tokenUsage.costUSD)}) + + + + +
+

Input: {formatTokenCount(feature.tokenUsage.inputTokens)}

+

Output: {formatTokenCount(feature.tokenUsage.outputTokens)}

+ {feature.tokenUsage.cacheReadInputTokens > 0 && ( +

Cache read: {formatTokenCount(feature.tokenUsage.cacheReadInputTokens)}

+ )} +

+ Cost: {formatCost(feature.tokenUsage.costUSD)} +

+
+
+
+
+
+ )} )}
diff --git a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx index f91e8f65..3aa22fca 100644 --- a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx @@ -44,6 +44,8 @@ export function AgentOutputModal({ const autoScrollRef = useRef(true); const projectPathRef = useRef(""); const useWorktrees = useAppStore((state) => state.useWorktrees); + const features = useAppStore((state) => state.features); + const feature = features.find((f) => f.id === featureId); // Auto-scroll to bottom when output changes useEffect(() => { @@ -387,7 +389,7 @@ export function AgentOutputModal({ No output yet. The agent will stream output here as it works. ) : viewMode === "parsed" ? ( - + ) : (
{output} diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index bec00c75..1c777cc9 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -278,6 +278,16 @@ export interface AIProfile { icon?: string; // Optional icon name from lucide } +// Token usage statistics for tracking API costs per task +export interface TokenUsage { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + totalTokens: number; + costUSD: number; +} + export interface Feature { id: string; category: string; @@ -305,6 +315,7 @@ export interface Feature { planningMode?: PlanningMode; // Planning mode for this feature planSpec?: PlanSpec; // Generated spec/plan data requirePlanApproval?: boolean; // Whether to pause and require manual approval before implementation + tokenUsage?: TokenUsage; // Token consumption tracking for this task } // Parsed task from spec (for spec and full planning modes)