diff --git a/bun.lock b/bun.lock index d0d0d406..b24b1d6f 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "21st-desktop", diff --git a/drizzle/0007_add_model_usage.sql b/drizzle/0007_add_model_usage.sql new file mode 100644 index 00000000..864e5d5b --- /dev/null +++ b/drizzle/0007_add_model_usage.sql @@ -0,0 +1,27 @@ +-- Model usage tracking table +-- Records token usage for each Claude API call +CREATE TABLE `model_usage` ( + `id` text PRIMARY KEY NOT NULL, + `sub_chat_id` text NOT NULL REFERENCES `sub_chats`(`id`) ON DELETE CASCADE, + `chat_id` text NOT NULL REFERENCES `chats`(`id`) ON DELETE CASCADE, + `project_id` text NOT NULL REFERENCES `projects`(`id`) ON DELETE CASCADE, + `model` text NOT NULL, + `input_tokens` integer NOT NULL DEFAULT 0, + `output_tokens` integer NOT NULL DEFAULT 0, + `total_tokens` integer NOT NULL DEFAULT 0, + `cost_usd` text, + `session_id` text, + `message_uuid` text, + `mode` text, + `duration_ms` integer, + `created_at` integer +);--> statement-breakpoint + +-- Indexes for query optimization +CREATE INDEX `model_usage_created_at_idx` ON `model_usage` (`created_at`);--> statement-breakpoint +CREATE INDEX `model_usage_model_idx` ON `model_usage` (`model`);--> statement-breakpoint +CREATE INDEX `model_usage_project_id_idx` ON `model_usage` (`project_id`);--> statement-breakpoint +CREATE INDEX `model_usage_chat_id_idx` ON `model_usage` (`chat_id`);--> statement-breakpoint +CREATE INDEX `model_usage_sub_chat_id_idx` ON `model_usage` (`sub_chat_id`);--> statement-breakpoint +-- Unique index for deduplication by message UUID +CREATE UNIQUE INDEX `model_usage_message_uuid_idx` ON `model_usage` (`message_uuid`); diff --git a/drizzle/0007_watery_winter_soldier.sql b/drizzle/0007_watery_winter_soldier.sql new file mode 100644 index 00000000..3b5cbecb --- /dev/null +++ b/drizzle/0007_watery_winter_soldier.sql @@ -0,0 +1,19 @@ +CREATE TABLE `model_usage` ( + `id` text PRIMARY KEY NOT NULL, + `sub_chat_id` text NOT NULL, + `chat_id` text NOT NULL, + `project_id` text NOT NULL, + `model` text NOT NULL, + `input_tokens` integer DEFAULT 0 NOT NULL, + `output_tokens` integer DEFAULT 0 NOT NULL, + `total_tokens` integer DEFAULT 0 NOT NULL, + `cost_usd` text, + `session_id` text, + `message_uuid` text, + `mode` text, + `duration_ms` integer, + `created_at` integer, + FOREIGN KEY (`sub_chat_id`) REFERENCES `sub_chats`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 00000000..ccda864f --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,578 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "66de5a0e-1d1c-41c8-8ca9-622482a8fcda", + "prevId": "b1c2d3e4-f5a6-7890-bcde-fa1234567890", + "tables": { + "anthropic_accounts": { + "name": "anthropic_accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_token": { + "name": "oauth_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "desktop_user_id": { + "name": "desktop_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "anthropic_settings": { + "name": "anthropic_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'singleton'" + }, + "active_account_id": { + "name": "active_account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chats": { + "name": "chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "chats_worktree_path_idx": { + "name": "chats_worktree_path_idx", + "columns": [ + "worktree_path" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chats_project_id_projects_id_fk": { + "name": "chats_project_id_projects_id_fk", + "tableFrom": "chats", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claude_code_credentials": { + "name": "claude_code_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "oauth_token": { + "name": "oauth_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_usage": { + "name": "model_usage", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sub_chat_id": { + "name": "sub_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_usd": { + "name": "cost_usd", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_uuid": { + "name": "message_uuid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "model_usage_sub_chat_id_sub_chats_id_fk": { + "name": "model_usage_sub_chat_id_sub_chats_id_fk", + "tableFrom": "model_usage", + "tableTo": "sub_chats", + "columnsFrom": [ + "sub_chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_usage_chat_id_chats_id_fk": { + "name": "model_usage_chat_id_chats_id_fk", + "tableFrom": "model_usage", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_usage_project_id_projects_id_fk": { + "name": "model_usage_project_id_projects_id_fk", + "tableFrom": "model_usage", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_provider": { + "name": "git_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_owner": { + "name": "git_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_repo": { + "name": "git_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_path_unique": { + "name": "projects_path_unique", + "columns": [ + "path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sub_chats": { + "name": "sub_chats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sub_chats_chat_id_chats_id_fk": { + "name": "sub_chats_chat_id_chats_id_fk", + "tableFrom": "sub_chats", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5ec7efd1..be2fb16d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1769480000000, "tag": "0006_anthropic_multi_account", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1769541435416, + "tag": "0007_watery_winter_soldier", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/main/lib/claude/transform.ts b/src/main/lib/claude/transform.ts index d275ab26..1939993b 100644 --- a/src/main/lib/claude/transform.ts +++ b/src/main/lib/claude/transform.ts @@ -60,7 +60,7 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs if (currentToolCallId) { // Track this tool ID to avoid duplicates from assistant message emittedToolIds.add(currentToolCallId) - + // Emit complete tool call with accumulated input yield { type: "tool-input-available", @@ -230,7 +230,7 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs // Thinking/reasoning streaming - emit as tool-like chunks for UI if (event.delta?.type === "thinking_delta" && currentThinkingId && inThinkingBlock) { const thinkingText = String(event.delta.thinking || "") - + // Accumulate and emit delta accumulatedThinking += thinkingText yield { @@ -239,7 +239,7 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs inputTextDelta: thinkingText, } } - + // Thinking complete (content_block_stop while in thinking block) if (event.type === "content_block_stop" && inThinkingBlock && currentThinkingId) { // Emit the complete thinking tool @@ -273,11 +273,11 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs // Check if we already streamed this thinking block // We compare by checking if accumulated thinking matches const wasStreamed = emittedToolIds.has("thinking-streamed") - + if (wasStreamed) { continue } - + // Emit as tool-input-available with special "Thinking" tool name // This allows the UI to render it like other tools const thinkingId = genId() @@ -458,14 +458,31 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs // ===== RESULT (final) ===== if (msg.type === "result") { - console.log("[transform] RESULT message, textStarted:", textStarted, "lastTextId:", lastTextId) yield* endTextBlock() yield* endToolInput() const inputTokens = msg.usage?.input_tokens const outputTokens = msg.usage?.output_tokens + + // Extract per-model usage from SDK (if available) + const modelUsage = msg.modelUsage + ? Object.fromEntries( + Object.entries(msg.modelUsage).map(([model, usage]: [string, any]) => [ + model, + { + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadInputTokens: usage.cacheReadInputTokens || 0, + cacheCreationInputTokens: usage.cacheCreationInputTokens || 0, + costUSD: usage.costUSD || 0, + }, + ]) + ) + : undefined + const metadata: MessageMetadata = { sessionId: msg.session_id, + sdkMessageUuid: emitSdkMessageUuid ? msg.uuid : undefined, inputTokens, outputTokens, totalTokens: inputTokens && outputTokens ? inputTokens + outputTokens : undefined, @@ -474,10 +491,11 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs resultSubtype: msg.subtype || "success", // Include finalTextId for collapsing tools when there's a final response finalTextId: lastTextId || undefined, + // Per-model usage breakdown + modelUsage, } yield { type: "message-metadata", messageMetadata: metadata } yield { type: "finish-step" } - console.log("[transform] YIELDING FINISH from result message") yield { type: "finish", messageMetadata: metadata } } } diff --git a/src/main/lib/claude/types.ts b/src/main/lib/claude/types.ts index 9ad956b7..47c0a11a 100644 --- a/src/main/lib/claude/types.ts +++ b/src/main/lib/claude/types.ts @@ -65,6 +65,14 @@ export type MCPServer = { error?: string } +export type ModelUsageEntry = { + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number + costUSD: number +} + export type MessageMetadata = { sessionId?: string sdkMessageUuid?: string // SDK's message UUID for resumeSessionAt (rollback support) @@ -75,4 +83,6 @@ export type MessageMetadata = { durationMs?: number resultSubtype?: string finalTextId?: string + // Per-model usage breakdown from SDK (model name -> usage) + modelUsage?: Record } diff --git a/src/main/lib/db/schema/index.ts b/src/main/lib/db/schema/index.ts index a6cf58f4..4942f86b 100644 --- a/src/main/lib/db/schema/index.ts +++ b/src/main/lib/db/schema/index.ts @@ -126,6 +126,57 @@ export const anthropicSettings = sqliteTable("anthropic_settings", { ), }) +// ============ MODEL USAGE ============ +// Records token usage for each Claude API call +export const modelUsage = sqliteTable("model_usage", { + id: text("id") + .primaryKey() + .$defaultFn(() => createId()), + // Relationships + subChatId: text("sub_chat_id") + .notNull() + .references(() => subChats.id, { onDelete: "cascade" }), + chatId: text("chat_id") + .notNull() + .references(() => chats.id, { onDelete: "cascade" }), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + // Model info + model: text("model").notNull(), + // Token usage + inputTokens: integer("input_tokens").notNull().default(0), + outputTokens: integer("output_tokens").notNull().default(0), + totalTokens: integer("total_tokens").notNull().default(0), + // Cost in USD (stored as text for decimal precision) + costUsd: text("cost_usd"), + // Session info (for deduplication) + sessionId: text("session_id"), + messageUuid: text("message_uuid"), // SDK message UUID for deduplication + // Request metadata + mode: text("mode"), // "plan" | "agent" + durationMs: integer("duration_ms"), + // Timestamp + createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( + () => new Date(), + ), +}) + +export const modelUsageRelations = relations(modelUsage, ({ one }) => ({ + subChat: one(subChats, { + fields: [modelUsage.subChatId], + references: [subChats.id], + }), + chat: one(chats, { + fields: [modelUsage.chatId], + references: [chats.id], + }), + project: one(projects, { + fields: [modelUsage.projectId], + references: [projects.id], + }), +})) + // ============ TYPE EXPORTS ============ export type Project = typeof projects.$inferSelect export type NewProject = typeof projects.$inferInsert @@ -138,3 +189,5 @@ export type NewClaudeCodeCredential = typeof claudeCodeCredentials.$inferInsert export type AnthropicAccount = typeof anthropicAccounts.$inferSelect export type NewAnthropicAccount = typeof anthropicAccounts.$inferInsert export type AnthropicSettings = typeof anthropicSettings.$inferSelect +export type ModelUsage = typeof modelUsage.$inferSelect +export type NewModelUsage = typeof modelUsage.$inferInsert diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index 880b5d86..31cbe86e 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -15,8 +15,8 @@ import { logRawClaudeMessage, type UIMessageChunk, } from "../../claude" -import { getProjectMcpServers, GLOBAL_MCP_PATH, readClaudeConfig, resolveProjectPathFromWorktree, type McpServerConfig } from "../../claude-config" -import { chats, claudeCodeCredentials, getDatabase, subChats } from "../../db" +import { getProjectMcpServers, GLOBAL_MCP_PATH, readClaudeConfig, type McpServerConfig } from "../../claude-config" +import { chats, claudeCodeCredentials, getDatabase, modelUsage, subChats } from "../../db" import { createRollbackStash } from "../../git/stash" import { ensureMcpTokensFresh, fetchMcpTools, fetchMcpToolsStdio, getMcpAuthStatus, startMcpOAuth } from "../../mcp-auth" import { fetchOAuthMetadata, getMcpBaseUrl } from "../../oauth" @@ -564,6 +564,14 @@ export const claudeRouter = router({ const existingMessages = JSON.parse(existing?.messages || "[]") const existingSessionId = existing?.sessionId || null + // Get projectId from chat record (needed for usage tracking) + const chatRecord = db + .select({ projectId: chats.projectId }) + .from(chats) + .where(eq(chats.id, input.chatId)) + .get() + const projectId = chatRecord?.projectId + // Get resumeSessionAt UUID only if shouldResume flag was set (by rollbackToMessage) const lastAssistantMsg = [...existingMessages].reverse().find( (m: any) => m.role === "assistant" @@ -1803,6 +1811,65 @@ ${prompt} if (historyEnabled && metadata.sdkMessageUuid && input.cwd) { await createRollbackStash(input.cwd, metadata.sdkMessageUuid) } + + // Record usage statistics (even on error) + // Prefer per-model breakdown from SDK for accurate model attribution + if (projectId && metadata.modelUsage && Object.keys(metadata.modelUsage).length > 0) { + try { + const existingUsage = metadata.sdkMessageUuid + ? db.select().from(modelUsage).where(eq(modelUsage.messageUuid, metadata.sdkMessageUuid)).get() + : null + + if (!existingUsage) { + for (const [model, usage] of Object.entries(metadata.modelUsage)) { + const totalTokens = usage.inputTokens + usage.outputTokens + db.insert(modelUsage).values({ + subChatId: input.subChatId, + chatId: input.chatId, + projectId, + model, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens, + costUsd: usage.costUSD?.toFixed(6), + sessionId: metadata.sessionId, + messageUuid: metadata.sdkMessageUuid ? `${metadata.sdkMessageUuid}-${model}` : undefined, + mode: input.mode, + durationMs: metadata.durationMs, + }).run() + console.log(`[Usage] Recorded ${model} (on error): ${usage.inputTokens} in, ${usage.outputTokens} out`) + } + } + } catch (usageErr) { + console.error(`[Usage] Failed to record per-model usage:`, usageErr) + } + } else if (projectId && (metadata.inputTokens || metadata.outputTokens)) { + try { + const existingUsage = metadata.sdkMessageUuid + ? db.select().from(modelUsage).where(eq(modelUsage.messageUuid, metadata.sdkMessageUuid)).get() + : null + + if (!existingUsage) { + db.insert(modelUsage).values({ + subChatId: input.subChatId, + chatId: input.chatId, + projectId, + model: finalCustomConfig?.model || "claude-sonnet-4-20250514", + inputTokens: metadata.inputTokens || 0, + outputTokens: metadata.outputTokens || 0, + totalTokens: metadata.totalTokens || 0, + costUsd: metadata.totalCostUsd?.toFixed(6), + sessionId: metadata.sessionId, + messageUuid: metadata.sdkMessageUuid, + mode: input.mode, + durationMs: metadata.durationMs, + }).run() + console.log(`[Usage] Recorded (on error, fallback): ${metadata.inputTokens || 0} in, ${metadata.outputTokens || 0} out`) + } + } catch (usageErr) { + console.error(`[Usage] Failed to record usage:`, usageErr) + } + } } console.log(`[SD] M:END sub=${subId} reason=stream_error cat=${errorCategory} n=${chunkCount} last=${lastChunkType}`) @@ -1869,6 +1936,74 @@ ${prompt} .where(eq(chats.id, input.chatId)) .run() + // Record usage statistics (if we have token data and projectId) + // Prefer per-model breakdown from SDK for accurate model attribution + if (!projectId) { + // Skip - no projectId available (shouldn't happen in normal flow) + } else if (metadata.modelUsage && Object.keys(metadata.modelUsage).length > 0) { + try { + // Check for duplicate by sdkMessageUuid + const existingUsage = metadata.sdkMessageUuid + ? db.select().from(modelUsage).where(eq(modelUsage.messageUuid, metadata.sdkMessageUuid)).get() + : null + + if (!existingUsage) { + // Record separate entries for each model used + for (const [model, usage] of Object.entries(metadata.modelUsage)) { + const totalTokens = usage.inputTokens + usage.outputTokens + db.insert(modelUsage).values({ + subChatId: input.subChatId, + chatId: input.chatId, + projectId, + model, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens, + costUsd: usage.costUSD?.toFixed(6), + sessionId: metadata.sessionId, + messageUuid: metadata.sdkMessageUuid ? `${metadata.sdkMessageUuid}-${model}` : undefined, + mode: input.mode, + durationMs: metadata.durationMs, + }).run() + console.log(`[Usage] Recorded ${model}: ${usage.inputTokens} in, ${usage.outputTokens} out, cost: ${usage.costUSD?.toFixed(4) || '?'}`) + } + } else { + console.log(`[Usage] Skipping duplicate: ${metadata.sdkMessageUuid}`) + } + } catch (usageErr) { + console.error(`[Usage] Failed to record per-model usage:`, usageErr) + } + } else if (metadata.inputTokens || metadata.outputTokens) { + // Fallback: use aggregate data if per-model breakdown not available + try { + const existingUsage = metadata.sdkMessageUuid + ? db.select().from(modelUsage).where(eq(modelUsage.messageUuid, metadata.sdkMessageUuid)).get() + : null + + if (!existingUsage) { + db.insert(modelUsage).values({ + subChatId: input.subChatId, + chatId: input.chatId, + projectId, + model: finalCustomConfig?.model || "claude-sonnet-4-20250514", + inputTokens: metadata.inputTokens || 0, + outputTokens: metadata.outputTokens || 0, + totalTokens: metadata.totalTokens || 0, + costUsd: metadata.totalCostUsd?.toFixed(6), + sessionId: metadata.sessionId, + messageUuid: metadata.sdkMessageUuid, + mode: input.mode, + durationMs: metadata.durationMs, + }).run() + console.log(`[Usage] Recorded (fallback): ${metadata.inputTokens || 0} in, ${metadata.outputTokens || 0} out, cost: ${metadata.totalCostUsd?.toFixed(4) || '?'}`) + } else { + console.log(`[Usage] Skipping duplicate: ${metadata.sdkMessageUuid}`) + } + } catch (usageErr) { + console.error(`[Usage] Failed to record usage:`, usageErr) + } + } + // Create snapshot stash for rollback support if (historyEnabled && metadata.sdkMessageUuid && input.cwd) { await createRollbackStash(input.cwd, metadata.sdkMessageUuid) diff --git a/src/main/lib/trpc/routers/index.ts b/src/main/lib/trpc/routers/index.ts index 7f35a7a0..73be21cf 100644 --- a/src/main/lib/trpc/routers/index.ts +++ b/src/main/lib/trpc/routers/index.ts @@ -16,6 +16,7 @@ import { worktreeConfigRouter } from "./worktree-config" import { sandboxImportRouter } from "./sandbox-import" import { commandsRouter } from "./commands" import { voiceRouter } from "./voice" +import { usageRouter } from "./usage" import { createGitRouter } from "../../git" import { BrowserWindow } from "electron" @@ -42,6 +43,7 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) { sandboxImport: sandboxImportRouter, commands: commandsRouter, voice: voiceRouter, + usage: usageRouter, // Git operations - named "changes" to match Superset API changes: createGitRouter(), }) diff --git a/src/main/lib/trpc/routers/usage.ts b/src/main/lib/trpc/routers/usage.ts new file mode 100644 index 00000000..b87cb16c --- /dev/null +++ b/src/main/lib/trpc/routers/usage.ts @@ -0,0 +1,381 @@ +import { and, desc, eq, gte, lte, sql } from "drizzle-orm" +import { z } from "zod" +import { getDatabase, modelUsage, projects, chats, subChats } from "../../db" +import { publicProcedure, router } from "../index" + +// Date range schema for filtering +const dateRangeSchema = z.object({ + startDate: z.string().optional(), // ISO date string, e.g., "2024-01-01" + endDate: z.string().optional(), +}) + +export const usageRouter = router({ + /** + * Record a single usage entry (called internally by claude.ts) + */ + record: publicProcedure + .input( + z.object({ + subChatId: z.string(), + chatId: z.string(), + projectId: z.string(), + model: z.string(), + inputTokens: z.number(), + outputTokens: z.number(), + totalTokens: z.number(), + costUsd: z.number().optional(), + sessionId: z.string().optional(), + messageUuid: z.string().optional(), + mode: z.enum(["plan", "agent"]).optional(), + durationMs: z.number().optional(), + }), + ) + .mutation(({ input }) => { + const db = getDatabase() + + // Check for duplicate by messageUuid + if (input.messageUuid) { + const existing = db + .select() + .from(modelUsage) + .where(eq(modelUsage.messageUuid, input.messageUuid)) + .get() + + if (existing) { + console.log(`[Usage] Skipping duplicate record: ${input.messageUuid}`) + return existing + } + } + + return db + .insert(modelUsage) + .values({ + subChatId: input.subChatId, + chatId: input.chatId, + projectId: input.projectId, + model: input.model, + inputTokens: input.inputTokens, + outputTokens: input.outputTokens, + totalTokens: input.totalTokens, + costUsd: input.costUsd?.toFixed(6), + sessionId: input.sessionId, + messageUuid: input.messageUuid, + mode: input.mode, + durationMs: input.durationMs, + }) + .returning() + .get() + }), + + /** + * Get usage summary (for settings page quick view) + */ + getSummary: publicProcedure.query(() => { + const db = getDatabase() + const now = new Date() + + // Today start + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + // Week start (Monday) + const weekStart = new Date(todayStart) + weekStart.setDate(weekStart.getDate() - ((weekStart.getDay() + 6) % 7)) + // Month start + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) + + // Today usage + const todayUsage = db + .select({ + totalInputTokens: sql`coalesce(sum(${modelUsage.inputTokens}), 0)`, + totalOutputTokens: sql`coalesce(sum(${modelUsage.outputTokens}), 0)`, + totalTokens: sql`coalesce(sum(${modelUsage.totalTokens}), 0)`, + totalCostUsd: sql`coalesce(sum(cast(${modelUsage.costUsd} as real)), 0)`, + count: sql`count(*)`, + }) + .from(modelUsage) + .where(gte(modelUsage.createdAt, todayStart)) + .get() + + // Week usage + const weekUsage = db + .select({ + totalInputTokens: sql`coalesce(sum(${modelUsage.inputTokens}), 0)`, + totalOutputTokens: sql`coalesce(sum(${modelUsage.outputTokens}), 0)`, + totalTokens: sql`coalesce(sum(${modelUsage.totalTokens}), 0)`, + totalCostUsd: sql`coalesce(sum(cast(${modelUsage.costUsd} as real)), 0)`, + count: sql`count(*)`, + }) + .from(modelUsage) + .where(gte(modelUsage.createdAt, weekStart)) + .get() + + // Month usage + const monthUsage = db + .select({ + totalInputTokens: sql`coalesce(sum(${modelUsage.inputTokens}), 0)`, + totalOutputTokens: sql`coalesce(sum(${modelUsage.outputTokens}), 0)`, + totalTokens: sql`coalesce(sum(${modelUsage.totalTokens}), 0)`, + totalCostUsd: sql`coalesce(sum(cast(${modelUsage.costUsd} as real)), 0)`, + count: sql`count(*)`, + }) + .from(modelUsage) + .where(gte(modelUsage.createdAt, monthStart)) + .get() + + // Total usage + const totalUsage = db + .select({ + totalInputTokens: sql`coalesce(sum(${modelUsage.inputTokens}), 0)`, + totalOutputTokens: sql`coalesce(sum(${modelUsage.outputTokens}), 0)`, + totalTokens: sql`coalesce(sum(${modelUsage.totalTokens}), 0)`, + totalCostUsd: sql`coalesce(sum(cast(${modelUsage.costUsd} as real)), 0)`, + count: sql`count(*)`, + }) + .from(modelUsage) + .get() + + return { + today: todayUsage, + week: weekUsage, + month: monthUsage, + total: totalUsage, + } + }), + + /** + * Get daily activity for heatmap (last 365 days) + * Returns array of { date, count, totalTokens } for contribution graph + */ + getDailyActivity: publicProcedure.query(() => { + const db = getDatabase() + const now = new Date() + const yearAgo = new Date(now) + yearAgo.setFullYear(yearAgo.getFullYear() - 1) + + return db + .select({ + date: sql`date(${modelUsage.createdAt}, 'unixepoch')`.as("date"), + count: sql`count(*)`, + totalTokens: sql`sum(${modelUsage.totalTokens})`, + }) + .from(modelUsage) + .where(gte(modelUsage.createdAt, yearAgo)) + .groupBy(sql`date(${modelUsage.createdAt}, 'unixepoch')`) + .orderBy(sql`date`) + .all() + }), + + /** + * Get usage grouped by date + */ + getByDate: publicProcedure.input(dateRangeSchema).query(({ input }) => { + const db = getDatabase() + + const conditions = [] + if (input.startDate) { + conditions.push(gte(modelUsage.createdAt, new Date(input.startDate))) + } + if (input.endDate) { + const endDate = new Date(input.endDate) + endDate.setDate(endDate.getDate() + 1) + conditions.push(lte(modelUsage.createdAt, endDate)) + } + + const baseQuery = db + .select({ + date: sql`date(${modelUsage.createdAt}, 'unixepoch')`.as( + "date", + ), + totalInputTokens: sql`sum(${modelUsage.inputTokens})`, + totalOutputTokens: sql`sum(${modelUsage.outputTokens})`, + totalTokens: sql`sum(${modelUsage.totalTokens})`, + totalCostUsd: sql`sum(cast(${modelUsage.costUsd} as real))`, + count: sql`count(*)`, + }) + .from(modelUsage) + + const query = + conditions.length > 0 + ? baseQuery.where(and(...conditions)) + : baseQuery + + return query + .groupBy(sql`date(${modelUsage.createdAt}, 'unixepoch')`) + .orderBy(desc(sql`date`)) + .all() + }), + + /** + * Get usage grouped by model + */ + getByModel: publicProcedure.input(dateRangeSchema).query(({ input }) => { + const db = getDatabase() + + const conditions = [] + if (input.startDate) { + conditions.push(gte(modelUsage.createdAt, new Date(input.startDate))) + } + if (input.endDate) { + const endDate = new Date(input.endDate) + endDate.setDate(endDate.getDate() + 1) + conditions.push(lte(modelUsage.createdAt, endDate)) + } + + const baseQuery = db + .select({ + model: modelUsage.model, + totalInputTokens: sql`sum(${modelUsage.inputTokens})`, + totalOutputTokens: sql`sum(${modelUsage.outputTokens})`, + totalTokens: sql`sum(${modelUsage.totalTokens})`, + totalCostUsd: sql`sum(cast(${modelUsage.costUsd} as real))`, + count: sql`count(*)`, + }) + .from(modelUsage) + + const query = + conditions.length > 0 + ? baseQuery.where(and(...conditions)) + : baseQuery + + return query + .groupBy(modelUsage.model) + .orderBy(desc(sql`sum(${modelUsage.totalTokens})`)) + .all() + }), + + /** + * Get usage grouped by project + */ + getByProject: publicProcedure.input(dateRangeSchema).query(({ input }) => { + const db = getDatabase() + + const conditions = [] + if (input.startDate) { + conditions.push(gte(modelUsage.createdAt, new Date(input.startDate))) + } + if (input.endDate) { + const endDate = new Date(input.endDate) + endDate.setDate(endDate.getDate() + 1) + conditions.push(lte(modelUsage.createdAt, endDate)) + } + + const baseQuery = db + .select({ + projectId: modelUsage.projectId, + projectName: projects.name, + totalInputTokens: sql`sum(${modelUsage.inputTokens})`, + totalOutputTokens: sql`sum(${modelUsage.outputTokens})`, + totalTokens: sql`sum(${modelUsage.totalTokens})`, + totalCostUsd: sql`sum(cast(${modelUsage.costUsd} as real))`, + count: sql`count(*)`, + }) + .from(modelUsage) + .leftJoin(projects, eq(modelUsage.projectId, projects.id)) + + const query = + conditions.length > 0 + ? baseQuery.where(and(...conditions)) + : baseQuery + + return query + .groupBy(modelUsage.projectId) + .orderBy(desc(sql`sum(${modelUsage.totalTokens})`)) + .all() + }), + + /** + * Get usage grouped by subchat + */ + getBySubChat: publicProcedure + .input( + z.object({ + projectId: z.string().optional(), + chatId: z.string().optional(), + ...dateRangeSchema.shape, + }), + ) + .query(({ input }) => { + const db = getDatabase() + + const conditions = [] + if (input.projectId) { + conditions.push(eq(modelUsage.projectId, input.projectId)) + } + if (input.chatId) { + conditions.push(eq(modelUsage.chatId, input.chatId)) + } + if (input.startDate) { + conditions.push(gte(modelUsage.createdAt, new Date(input.startDate))) + } + if (input.endDate) { + const endDate = new Date(input.endDate) + endDate.setDate(endDate.getDate() + 1) + conditions.push(lte(modelUsage.createdAt, endDate)) + } + + const baseQuery = db + .select({ + subChatId: modelUsage.subChatId, + subChatName: subChats.name, + chatId: modelUsage.chatId, + chatName: chats.name, + projectName: projects.name, + totalInputTokens: sql`sum(${modelUsage.inputTokens})`, + totalOutputTokens: sql`sum(${modelUsage.outputTokens})`, + totalTokens: sql`sum(${modelUsage.totalTokens})`, + totalCostUsd: sql`sum(cast(${modelUsage.costUsd} as real))`, + count: sql`count(*)`, + }) + .from(modelUsage) + .leftJoin(subChats, eq(modelUsage.subChatId, subChats.id)) + .leftJoin(chats, eq(modelUsage.chatId, chats.id)) + .leftJoin(projects, eq(modelUsage.projectId, projects.id)) + + const query = + conditions.length > 0 + ? baseQuery.where(and(...conditions)) + : baseQuery + + return query + .groupBy(modelUsage.subChatId) + .orderBy(desc(sql`sum(${modelUsage.totalTokens})`)) + .all() + }), + + /** + * Get recent usage records (paginated) + */ + getRecent: publicProcedure + .input( + z.object({ + limit: z.number().default(50), + offset: z.number().default(0), + }), + ) + .query(({ input }) => { + const db = getDatabase() + + return db + .select({ + id: modelUsage.id, + model: modelUsage.model, + inputTokens: modelUsage.inputTokens, + outputTokens: modelUsage.outputTokens, + totalTokens: modelUsage.totalTokens, + costUsd: modelUsage.costUsd, + mode: modelUsage.mode, + durationMs: modelUsage.durationMs, + createdAt: modelUsage.createdAt, + subChatName: subChats.name, + chatName: chats.name, + projectName: projects.name, + }) + .from(modelUsage) + .leftJoin(subChats, eq(modelUsage.subChatId, subChats.id)) + .leftJoin(chats, eq(modelUsage.chatId, chats.id)) + .leftJoin(projects, eq(modelUsage.projectId, projects.id)) + .orderBy(desc(modelUsage.createdAt)) + .limit(input.limit) + .offset(input.offset) + .all() + }), +}) diff --git a/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx index 3b15dd29..136e2a17 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx @@ -1,7 +1,8 @@ import { useAtom, useSetAtom } from "jotai" -import { MoreHorizontal, Plus } from "lucide-react" -import { useEffect, useState } from "react" +import { BarChart3, ChevronLeft, ChevronRight, MoreHorizontal, Plus } from "lucide-react" +import React, { useEffect, useState } from "react" import { toast } from "sonner" +import { UsageDetailsDialog } from "./usage-details-dialog" import { agentsSettingsDialogOpenAtom, anthropicOnboardingCompletedAtom, @@ -246,6 +247,325 @@ function AnthropicAccountsSection() { ) } +// Helper to format token count +function formatTokenCount(tokens: number): string { + if (!tokens) return "0" + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(2)}M` + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K` + return String(tokens) +} + +// Helper to format cost +function formatCost(cost: number): string { + if (!cost) return "$0.00" + return `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(2)}` +} + +// GitHub-style contribution heatmap component (auto-fit width with navigation) +function ContributionHeatmap() { + const { data: activity } = trpc.usage.getDailyActivity.useQuery() + const containerRef = React.useRef(null) + const [numWeeks, setNumWeeks] = useState(20) // Default, will be calculated + const [pageOffset, setPageOffset] = useState(0) // 0 = current, 1 = previous page, etc. + const [slideDirection, setSlideDirection] = useState<"left" | "right" | null>(null) + + // Calculate how many weeks fit in the container + useEffect(() => { + const calculateWeeks = () => { + if (!containerRef.current) return + const containerWidth = containerRef.current.offsetWidth + // Each week column: 10px cell + 2px gap = 12px + const cellSize = 10 + const gap = 2 + const weekWidth = cellSize + gap + const availableWidth = containerWidth - 8 // Small padding + const weeks = Math.floor(availableWidth / weekWidth) + setNumWeeks(Math.max(weeks, 4)) // Minimum 4 weeks + } + + calculateWeeks() + window.addEventListener("resize", calculateWeeks) + return () => window.removeEventListener("resize", calculateWeeks) + }, []) + + // Reset slide direction after animation + useEffect(() => { + if (slideDirection) { + const timer = setTimeout(() => setSlideDirection(null), 300) + return () => clearTimeout(timer) + } + }, [slideDirection]) + + // Build activity map for quick lookup + const activityMap = new Map() + activity?.forEach((d) => { + activityMap.set(d.date, { count: d.count, totalTokens: d.totalTokens }) + }) + + // Generate days based on calculated weeks and page offset + const today = new Date() + const days: { date: string; count: number; totalTokens: number }[] = [] + + // Calculate end date based on page offset + const endDate = new Date(today) + endDate.setDate(endDate.getDate() - pageOffset * numWeeks * 7) + + // Calculate start date: go back numWeeks weeks from endDate, align to Sunday + const daysToGoBack = (numWeeks - 1) * 7 + endDate.getDay() + const startDate = new Date(endDate) + startDate.setDate(startDate.getDate() - daysToGoBack) + + for (let i = 0; i <= daysToGoBack + (6 - endDate.getDay()); i++) { + const d = new Date(startDate) + d.setDate(d.getDate() + i) + if (d > endDate || d > today) break + + const dateStr = d.toISOString().split("T")[0]! + const data = activityMap.get(dateStr) + days.push({ + date: dateStr, + count: data?.count || 0, + totalTokens: data?.totalTokens || 0, + }) + } + + // Find max for color scaling + const maxCount = Math.max(...days.map((d) => d.count), 1) + + // Get color intensity (0-4 levels like GitHub) + const getLevel = (count: number): number => { + if (count === 0) return 0 + const ratio = count / maxCount + if (ratio <= 0.25) return 1 + if (ratio <= 0.5) return 2 + if (ratio <= 0.75) return 3 + return 4 + } + + // Colors for each level (GitHub green theme) + const levelColors = [ + "bg-muted/30", // level 0 - no activity + "bg-emerald-900/50", // level 1 + "bg-emerald-700/70", // level 2 + "bg-emerald-500/80", // level 3 + "bg-emerald-400", // level 4 + ] + + // Group days into weeks (columns) + const weeks: typeof days[] = [] + for (let i = 0; i < days.length; i += 7) { + weeks.push(days.slice(i, i + 7)) + } + + // Month labels - show at most every 4 weeks to avoid crowding + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + const monthLabels: { label: string; weekIndex: number }[] = [] + let lastMonth = -1 + weeks.forEach((week, weekIndex) => { + const firstDay = week[0] + if (firstDay) { + const month = new Date(firstDay.date).getMonth() + if (month !== lastMonth) { + // Only add label if there's enough space from the last one + const lastLabel = monthLabels[monthLabels.length - 1] + if (!lastLabel || weekIndex - lastLabel.weekIndex >= 3) { + monthLabels.push({ label: months[month]!, weekIndex }) + } + lastMonth = month + } + } + }) + + // Calculate total contributions for this view + const totalContributions = days.reduce((sum, d) => sum + d.count, 0) + + // Check if there's older data available (max ~52 weeks back) + const maxPages = Math.floor(52 / Math.max(numWeeks, 1)) + const canGoBack = pageOffset < maxPages + const canGoForward = pageOffset > 0 + + // Navigation handlers with slide animation + const goBack = () => { + if (canGoBack) { + setSlideDirection("right") + setPageOffset((p) => p + 1) + } + } + + const goForward = () => { + if (canGoForward) { + setSlideDirection("left") + setPageOffset((p) => p - 1) + } + } + + // Slide animation classes + const getSlideClass = () => { + if (!slideDirection) return "" + return slideDirection === "left" + ? "animate-slide-in-left" + : "animate-slide-in-right" + } + + return ( +
+ {/* Inline styles for slide animations */} + + + {/* Header with navigation */} +
+ {totalContributions.toLocaleString()} contributions +
+ + + {days[0]?.date} ~ {days[days.length - 1]?.date} + + +
+
+ +
+ {/* Month labels */} +
+ {monthLabels.map((m, i) => ( +
+ {m.label} +
+ ))} +
+ + {/* Grid with slide animation */} +
+ {weeks.map((week, weekIndex) => ( +
+ {[0, 1, 2, 3, 4, 5, 6].map((dayIndex) => { + const day = week[dayIndex] + if (!day) return
+ + const level = getLevel(day.count) + return ( +
+ ) + })} +
+ ))} +
+ + {/* Legend */} +
+ Less + {levelColors.map((color, i) => ( +
+ ))} + More +
+
+
+ ) +} + +// Usage Statistics Section component +function UsageStatisticsSection({ onViewDetails }: { onViewDetails: () => void }) { + const { data: summary, isLoading } = trpc.usage.getSummary.useQuery() + + if (isLoading) { + return ( +
+ Loading usage statistics... +
+ ) + } + + return ( +
+
+ {/* Contribution Heatmap - at top */} + + + {/* Summary cards */} +
+
+
Today
+
+ {formatTokenCount(summary?.today?.totalTokens || 0)} +
+
+ {formatCost(summary?.today?.totalCostUsd || 0)} +
+
+
+
This Week
+
+ {formatTokenCount(summary?.week?.totalTokens || 0)} +
+
+ {formatCost(summary?.week?.totalCostUsd || 0)} +
+
+
+
This Month
+
+ {formatTokenCount(summary?.month?.totalTokens || 0)} +
+
+ {formatCost(summary?.month?.totalCostUsd || 0)} +
+
+
+
All Time
+
+ {formatTokenCount(summary?.total?.totalTokens || 0)} +
+
+ {formatCost(summary?.total?.totalCostUsd || 0)} +
+
+
+
+ +
+ +
+
+ ) +} + export function AgentsModelsTab() { const [storedConfig, setStoredConfig] = useAtom(customClaudeConfigAtom) const [model, setModel] = useState(storedConfig.model) @@ -261,6 +581,9 @@ export function AgentsModelsTab() { trpc.claudeCode.getIntegration.useQuery() const isClaudeCodeConnected = claudeCodeIntegration?.isConnected + // Usage details dialog state + const [usageDetailsOpen, setUsageDetailsOpen] = useState(false) + // OpenAI API key state const [storedOpenAIKey, setStoredOpenAIKey] = useAtom(openaiApiKeyAtom) const [openaiKey, setOpenaiKey] = useState(storedOpenAIKey) @@ -383,6 +706,20 @@ export function AgentsModelsTab() {
+ {/* Usage Statistics Section */} +
+
+

+ Usage Statistics +

+

+ Track your token usage and estimated costs +

+
+ + setUsageDetailsOpen(true)} /> +
+

@@ -505,6 +842,12 @@ export function AgentsModelsTab() {

+ + {/* Usage Details Dialog */} +
) } diff --git a/src/renderer/components/dialogs/settings-tabs/usage-details-dialog.tsx b/src/renderer/components/dialogs/settings-tabs/usage-details-dialog.tsx new file mode 100644 index 00000000..8d1f853d --- /dev/null +++ b/src/renderer/components/dialogs/settings-tabs/usage-details-dialog.tsx @@ -0,0 +1,473 @@ +import { Calendar, Database, Download, FolderOpen, MessageSquare, X } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" +import { trpc } from "../../../lib/trpc" +import { Button } from "../../ui/button" +import { Dialog, DialogContent } from "../../ui/dialog" +import { cn } from "../../../lib/utils" + +interface UsageDetailsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +type ViewMode = "date" | "model" | "project" | "subchat" + +// Helper to format token count +function formatTokenCount(tokens: number): string { + if (!tokens) return "0" + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(2)}M` + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K` + return String(tokens) +} + +// Helper to format cost +function formatCost(cost: number): string { + if (!cost) return "$0.00" + return `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(2)}` +} + +// Get default start date (30 days ago) +function getDefaultStartDate(): string { + const date = new Date() + date.setDate(date.getDate() - 30) + return date.toISOString().split("T")[0]! +} + +// Tab button component +function TabButton({ + active, + onClick, + icon: Icon, + label, +}: { + active: boolean + onClick: () => void + icon: React.ComponentType<{ className?: string }> + label: string +}) { + return ( + + ) +} + +// Usage table component +interface UsageTableProps { + data: any[] + columns: Array<{ + key: string + label: string + format?: (value: any) => string + fallback?: string + }> +} + +function UsageTable({ data, columns }: UsageTableProps) { + if (!data || data.length === 0) { + return ( +
+ No usage data found for the selected period. +
+ ) + } + + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {data.map((row, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ {col.label} +
+ {col.format + ? col.format(row[col.key]) + : row[col.key] || col.fallback || "-"} +
+
+ ) +} + +// CSV export helper +function exportToCsv(data: any[], columns: Array<{ key: string; label: string }>, filename: string) { + if (!data || data.length === 0) { + toast.error("No data to export") + return + } + + // Build CSV header + const header = columns.map((col) => col.label).join(",") + + // Build CSV rows + const rows = data.map((row) => + columns + .map((col) => { + const value = row[col.key] + // Handle null/undefined + if (value === null || value === undefined) return "" + // Escape quotes and wrap in quotes if contains comma + const strValue = String(value) + if (strValue.includes(",") || strValue.includes('"') || strValue.includes("\n")) { + return `"${strValue.replace(/"/g, '""')}"` + } + return strValue + }) + .join(",") + ) + + const csvContent = [header, ...rows].join("\n") + + // Create and trigger download + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + + toast.success(`Exported ${data.length} rows to ${filename}`) +} + +export function UsageDetailsDialog({ + open, + onOpenChange, +}: UsageDetailsDialogProps) { + const [viewMode, setViewMode] = useState("date") + const [dateRange, setDateRange] = useState({ + startDate: getDefaultStartDate(), + endDate: new Date().toISOString().split("T")[0]!, + }) + + // Data queries + const { data: byDate, isLoading: byDateLoading } = + trpc.usage.getByDate.useQuery(dateRange, { + enabled: open && viewMode === "date", + }) + const { data: byModel, isLoading: byModelLoading } = + trpc.usage.getByModel.useQuery(dateRange, { + enabled: open && viewMode === "model", + }) + const { data: byProject, isLoading: byProjectLoading } = + trpc.usage.getByProject.useQuery(dateRange, { + enabled: open && viewMode === "project", + }) + const { data: bySubChat, isLoading: bySubChatLoading } = + trpc.usage.getBySubChat.useQuery(dateRange, { + enabled: open && viewMode === "subchat", + }) + + const isLoading = + (viewMode === "date" && byDateLoading) || + (viewMode === "model" && byModelLoading) || + (viewMode === "project" && byProjectLoading) || + (viewMode === "subchat" && bySubChatLoading) + + // Handle CSV export + const handleExport = () => { + const timestamp = new Date().toISOString().split("T")[0] + + switch (viewMode) { + case "date": + exportToCsv( + byDate || [], + [ + { key: "date", label: "Date" }, + { key: "totalInputTokens", label: "Input Tokens" }, + { key: "totalOutputTokens", label: "Output Tokens" }, + { key: "totalTokens", label: "Total Tokens" }, + { key: "totalCostUsd", label: "Cost (USD)" }, + { key: "count", label: "Requests" }, + ], + `usage-by-date-${timestamp}.csv` + ) + break + case "model": + exportToCsv( + byModel || [], + [ + { key: "model", label: "Model" }, + { key: "totalInputTokens", label: "Input Tokens" }, + { key: "totalOutputTokens", label: "Output Tokens" }, + { key: "totalTokens", label: "Total Tokens" }, + { key: "totalCostUsd", label: "Cost (USD)" }, + { key: "count", label: "Requests" }, + ], + `usage-by-model-${timestamp}.csv` + ) + break + case "project": + exportToCsv( + byProject || [], + [ + { key: "projectName", label: "Project" }, + { key: "totalInputTokens", label: "Input Tokens" }, + { key: "totalOutputTokens", label: "Output Tokens" }, + { key: "totalTokens", label: "Total Tokens" }, + { key: "totalCostUsd", label: "Cost (USD)" }, + { key: "count", label: "Requests" }, + ], + `usage-by-project-${timestamp}.csv` + ) + break + case "subchat": + exportToCsv( + bySubChat || [], + [ + { key: "subChatName", label: "Agent" }, + { key: "chatName", label: "Workspace" }, + { key: "projectName", label: "Project" }, + { key: "totalInputTokens", label: "Input Tokens" }, + { key: "totalOutputTokens", label: "Output Tokens" }, + { key: "totalTokens", label: "Total Tokens" }, + { key: "totalCostUsd", label: "Cost (USD)" }, + { key: "count", label: "Requests" }, + ], + `usage-by-agent-${timestamp}.csv` + ) + break + } + } + + const renderContent = () => { + if (isLoading) { + return ( +
+ Loading... +
+ ) + } + + switch (viewMode) { + case "date": + return ( + + ) + case "model": + return ( + + ) + case "project": + return ( + + ) + case "subchat": + return ( + + ) + default: + return null + } + } + + return ( + + + {/* Header */} +
+

Usage Details

+ +
+ + {/* Content */} +
+ {/* View Mode Tabs */} +
+ setViewMode("date")} + icon={Calendar} + label="By Date" + /> + setViewMode("model")} + icon={Database} + label="By Model" + /> + setViewMode("project")} + icon={FolderOpen} + label="By Project" + /> + setViewMode("subchat")} + icon={MessageSquare} + label="By Agent" + /> +
+ + {/* Date Range Picker and Export */} +
+
+
+ + + setDateRange((prev) => ({ + ...prev, + startDate: e.target.value, + })) + } + className="px-2 py-1 text-sm border rounded bg-background" + /> +
+
+ + + setDateRange((prev) => ({ + ...prev, + endDate: e.target.value, + })) + } + className="px-2 py-1 text-sm border rounded bg-background" + /> +
+
+ + +
+ + {/* Table */} + {renderContent()} +
+
+
+ ) +}