From 9713de691a82ce7294b82b6c916ec2d3dd879b58 Mon Sep 17 00:00:00 2001 From: xiongfei <278837230@qq.com> Date: Wed, 1 Apr 2026 15:54:24 +0800 Subject: [PATCH] feat:add usage --- pkg/agent/loop.go | 45 ++ pkg/providers/protocoltypes/types.go | 7 + pkg/providers/types.go | 1 + web/backend/api/router.go | 3 + web/backend/api/usage.go | 509 ++++++++++++++++++ web/frontend/src/api/usage.ts | 48 ++ web/frontend/src/components/app-sidebar.tsx | 7 + .../src/components/usage/usage-page.tsx | 287 ++++++++++ web/frontend/src/i18n/locales/en.json | 32 ++ web/frontend/src/i18n/locales/zh.json | 32 ++ web/frontend/src/routeTree.gen.ts | 21 + web/frontend/src/routes/agent/usage.tsx | 11 + 12 files changed, 1003 insertions(+) create mode 100644 web/backend/api/usage.go create mode 100644 web/frontend/src/api/usage.ts create mode 100644 web/frontend/src/components/usage/usage-page.tsx create mode 100644 web/frontend/src/routes/agent/usage.tsx diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d7461e76f..3bf81ffb9 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -2206,6 +2206,51 @@ turnLoop: Content: response.Content, ReasoningContent: response.ReasoningContent, } + // Include usage info in the message for usage statistics tracking + logger.InfoCF("agent", "LLM response usage info", map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "has_usage": response.Usage != nil, + "prompt_tokens": func() int { + if response.Usage != nil { + return response.Usage.PromptTokens + } + return 0 + }(), + "output_tokens": func() int { + if response.Usage != nil { + return response.Usage.CompletionTokens + } + return 0 + }(), + "total_tokens": func() int { + if response.Usage != nil { + return response.Usage.TotalTokens + } + return 0 + }(), + }) + if response.Usage != nil { + // Store usage as extra content for persistence and retrieval + assistantMsg.ExtraContent = &providers.MessageExtra{ + Usage: map[string]any{ + "prompt_tokens": response.Usage.PromptTokens, + "completion_tokens": response.Usage.CompletionTokens, + "total_tokens": response.Usage.TotalTokens, + }, + } + logger.InfoCF("agent", "Saved token usage to assistant message", map[string]any{ + "agent_id": ts.agent.ID, + "prompt_tokens": response.Usage.PromptTokens, + "output_tokens": response.Usage.CompletionTokens, + "total_tokens": response.Usage.TotalTokens, + }) + } else { + logger.WarnCF("agent", "LLM response has no usage info", map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + }) + } for _, tc := range normalizedToolCalls { argumentsJSON, _ := json.Marshal(tc.Arguments) extraContent := tc.ExtraContent diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 194c1aa6f..869f9beb3 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -70,6 +70,13 @@ type Message struct { SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` + ExtraContent *MessageExtra `json:"extra_content,omitempty"` // additional metadata (usage, model, etc.) +} + +// MessageExtra holds optional metadata attached to a message. +type MessageExtra struct { + Usage map[string]any `json:"usage,omitempty"` // token usage info + Model string `json:"model,omitempty"` // model name used } type ToolDefinition struct { diff --git a/pkg/providers/types.go b/pkg/providers/types.go index f98ae9243..78844b84e 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -19,6 +19,7 @@ type ( GoogleExtra = protocoltypes.GoogleExtra ContentBlock = protocoltypes.ContentBlock CacheControl = protocoltypes.CacheControl + MessageExtra = protocoltypes.MessageExtra ) type LLMProvider interface { diff --git a/web/backend/api/router.go b/web/backend/api/router.go index af490d8b5..2ee6b0744 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -84,6 +84,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // WeCom QR login flow h.registerWecomRoutes(mux) + + // Usage statistics + h.registerUsageRoutes(mux) } // Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler. diff --git a/web/backend/api/usage.go b/web/backend/api/usage.go new file mode 100644 index 000000000..cb96cdf8c --- /dev/null +++ b/web/backend/api/usage.go @@ -0,0 +1,509 @@ +package api + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// registerUsageRoutes binds usage statistics endpoints to the ServeMux. +func (h *Handler) registerUsageRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/usage", h.handleGetUsage) +} + +// UsageStats represents aggregated usage statistics for a model. +type UsageStats struct { + ModelName string `json:"model_name"` + Model string `json:"model"` + MessageCount int `json:"message_count"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + EstimatedCost float64 `json:"estimated_cost"` + Currency string `json:"currency"` + SessionCount int `json:"session_count"` +} + +// UsageResponse is the response for GET /api/usage. +type UsageResponse struct { + Stats []UsageStats `json:"stats"` + TotalInputTokens int `json:"total_input_tokens"` + TotalOutputTokens int `json:"total_output_tokens"` + TotalTokens int `json:"total_tokens"` + TotalMessageCount int `json:"total_message_count"` + TotalEstimatedCost float64 `json:"total_estimated_cost"` + Currency string `json:"currency"` + DateRange string `json:"date_range"` +} + +// modelPricing holds pricing information for a model. +type modelPricing struct { + InputPricePerMTok float64 // USD per 1M input tokens + OutputPricePerMTok float64 // USD per 1M output tokens +} + +// modelPricingDB contains known model pricing information. +// Prices are approximate and may vary. Update as needed. +var modelPricingDB = map[string]modelPricing{ + // OpenAI models + "gpt-4o": {InputPricePerMTok: 2.50, OutputPricePerMTok: 10.00}, + "gpt-4o-mini": {InputPricePerMTok: 0.15, OutputPricePerMTok: 0.60}, + "gpt-4": {InputPricePerMTok: 30.00, OutputPricePerMTok: 60.00}, + "gpt-4-turbo": {InputPricePerMTok: 10.00, OutputPricePerMTok: 30.00}, + "gpt-3.5-turbo": {InputPricePerMTok: 0.50, OutputPricePerMTok: 1.50}, + "o1": {InputPricePerMTok: 15.00, OutputPricePerMTok: 60.00}, + "o1-mini": {InputPricePerMTok: 1.10, OutputPricePerMTok: 4.40}, + "o3-mini": {InputPricePerMTok: 1.10, OutputPricePerMTok: 4.40}, + + // Anthropic models + "claude-sonnet-4-20250514": {InputPricePerMTok: 3.00, OutputPricePerMTok: 15.00}, + "claude-sonnet-4-20250514-thinking": {InputPricePerMTok: 3.00, OutputPricePerMTok: 15.00}, + "claude-3-5-sonnet-20241022": {InputPricePerMTok: 3.00, OutputPricePerMTok: 15.00}, + "claude-3-5-haiku-20241022": {InputPricePerMTok: 0.80, OutputPricePerMTok: 4.00}, + "claude-3-opus-20240229": {InputPricePerMTok: 15.00, OutputPricePerMTok: 75.00}, + "claude-sonnet-4-5-20250929": {InputPricePerMTok: 3.00, OutputPricePerMTok: 15.00}, + + // Google Gemini models + "gemini-2.0-flash": {InputPricePerMTok: 0.10, OutputPricePerMTok: 0.40}, + "gemini-2.0-flash-lite": {InputPricePerMTok: 0.075, OutputPricePerMTok: 0.30}, + "gemini-1.5-pro": {InputPricePerMTok: 1.25, OutputPricePerMTok: 5.00}, + "gemini-1.5-flash": {InputPricePerMTok: 0.075, OutputPricePerMTok: 0.30}, + "gemini-2.5-pro": {InputPricePerMTok: 1.25, OutputPricePerMTok: 10.00}, + + // Groq models + "llama-3.3-70b-versatile": {InputPricePerMTok: 0.59, OutputPricePerMTok: 0.79}, + "llama-3.1-8b-instant": {InputPricePerMTok: 0.05, OutputPricePerMTok: 0.08}, + "mixtral-8x7b-32768": {InputPricePerMTok: 0.24, OutputPricePerMTok: 0.24}, + "gemma2-9b-it": {InputPricePerMTok: 0.20, OutputPricePerMTok: 0.20}, + + // DeepSeek models + "deepseek-chat": {InputPricePerMTok: 0.14, OutputPricePerMTok: 0.28}, + "deepseek-reasoner": {InputPricePerMTok: 0.14, OutputPricePerMTok: 1.10}, + + // Qwen models (common on OpenRouter) + "qwen/qwen-plus": {InputPricePerMTok: 0.40, OutputPricePerMTok: 1.20}, + "qwen/qwen-turbo": {InputPricePerMTok: 0.05, OutputPricePerMTok: 0.20}, + "qwen/qwen-max": {InputPricePerMTok: 1.60, OutputPricePerMTok: 6.40}, + "qwen/qwen2.5-72b-instruct": {InputPricePerMTok: 0.40, OutputPricePerMTok: 1.20}, + "qwen/qwen3-235b-a22b": {InputPricePerMTok: 0.40, OutputPricePerMTok: 1.20}, + "qwen/qwen3-30b-a3b": {InputPricePerMTok: 0.10, OutputPricePerMTok: 0.30}, + "qwen/qwen3-32b": {InputPricePerMTok: 0.10, OutputPricePerMTok: 0.30}, + "qwen/qwen3-8b": {InputPricePerMTok: 0.05, OutputPricePerMTok: 0.15}, + "qwen/qwen3-14b": {InputPricePerMTok: 0.08, OutputPricePerMTok: 0.24}, + "qwen/qwen3-coder": {InputPricePerMTok: 0.10, OutputPricePerMTok: 0.30}, + + // Meta Llama models (common on OpenRouter) + "meta-llama/llama-3.3-70b-instruct": {InputPricePerMTok: 0.12, OutputPricePerMTok: 0.30}, + "meta-llama/llama-3.1-405b-instruct": {InputPricePerMTok: 0.80, OutputPricePerMTok: 0.80}, + "meta-llama/llama-3.1-70b-instruct": {InputPricePerMTok: 0.12, OutputPricePerMTok: 0.30}, + "meta-llama/llama-3.1-8b-instruct": {InputPricePerMTok: 0.02, OutputPricePerMTok: 0.05}, + "meta-llama/llama-3-70b-instruct": {InputPricePerMTok: 0.12, OutputPricePerMTok: 0.30}, + "meta-llama/llama-3-8b-instruct": {InputPricePerMTok: 0.02, OutputPricePerMTok: 0.05}, + "meta-llama/llama-4-maverick": {InputPricePerMTok: 0.15, OutputPricePerMTok: 0.60}, + "meta-llama/llama-4-scout": {InputPricePerMTok: 0.05, OutputPricePerMTok: 0.15}, + + // Mistral models (common on OpenRouter) + "mistralai/mistral-large": {InputPricePerMTok: 1.00, OutputPricePerMTok: 3.00}, + "mistralai/mistral-medium": {InputPricePerMTok: 0.40, OutputPricePerMTok: 2.00}, + "mistralai/mistral-small": {InputPricePerMTok: 0.10, OutputPricePerMTok: 0.30}, + "mistralai/mistral-7b-instruct": {InputPricePerMTok: 0.02, OutputPricePerMTok: 0.05}, + "mistralai/mixtral-8x7b-instruct": {InputPricePerMTok: 0.05, OutputPricePerMTok: 0.05}, + "mistralai/mixtral-8x22b-instruct": {InputPricePerMTok: 0.30, OutputPricePerMTok: 0.30}, + "mistralai/mistral-nemo": {InputPricePerMTok: 0.03, OutputPricePerMTok: 0.09}, + "mistralai/codestral-2501": {InputPricePerMTok: 0.15, OutputPricePerMTok: 0.45}, + + // Free models (OpenRouter free tier, etc.) + "qwen/qwen3.6-plus-preview:free": {InputPricePerMTok: 0, OutputPricePerMTok: 0}, +} + +// getModelPricing returns pricing for a model, trying exact match first, +// then prefix match for model families. +func getModelPricing(modelName string) modelPricing { + // Try exact match first + if pricing, ok := modelPricingDB[modelName]; ok { + return pricing + } + + // Try prefix match for model families + lowerName := strings.ToLower(modelName) + for pattern, pricing := range modelPricingDB { + if strings.HasPrefix(lowerName, strings.ToLower(pattern)) { + return pricing + } + } + + // Check for common model patterns + switch { + case strings.Contains(lowerName, "gpt-4o-mini"): + return modelPricingDB["gpt-4o-mini"] + case strings.Contains(lowerName, "gpt-4o"): + return modelPricingDB["gpt-4o"] + case strings.Contains(lowerName, "gpt-4-turbo"): + return modelPricingDB["gpt-4-turbo"] + case strings.Contains(lowerName, "gpt-4"): + return modelPricingDB["gpt-4"] + case strings.Contains(lowerName, "gpt-3.5"): + return modelPricingDB["gpt-3.5-turbo"] + case strings.Contains(lowerName, "claude-sonnet-4-5"): + return modelPricingDB["claude-sonnet-4-5-20250929"] + case strings.Contains(lowerName, "claude-sonnet-4"): + return modelPricingDB["claude-sonnet-4-20250514"] + case strings.Contains(lowerName, "claude-3-5-sonnet"): + return modelPricingDB["claude-3-5-sonnet-20241022"] + case strings.Contains(lowerName, "claude-3-5-haiku"): + return modelPricingDB["claude-3-5-haiku-20241022"] + case strings.Contains(lowerName, "claude-3-opus"): + return modelPricingDB["claude-3-opus-20240229"] + case strings.Contains(lowerName, "gemini-2.5"): + return modelPricingDB["gemini-2.5-pro"] + case strings.Contains(lowerName, "gemini-2.0-flash-lite"): + return modelPricingDB["gemini-2.0-flash-lite"] + case strings.Contains(lowerName, "gemini-2.0-flash"): + return modelPricingDB["gemini-2.0-flash"] + case strings.Contains(lowerName, "gemini-1.5-pro"): + return modelPricingDB["gemini-1.5-pro"] + case strings.Contains(lowerName, "gemini-1.5-flash"): + return modelPricingDB["gemini-1.5-flash"] + case strings.Contains(lowerName, "deepseek-reasoner"): + return modelPricingDB["deepseek-reasoner"] + case strings.Contains(lowerName, "deepseek-chat"): + return modelPricingDB["deepseek-chat"] + case strings.Contains(lowerName, "llama-3.3-70b"): + return modelPricingDB["llama-3.3-70b-versatile"] + case strings.Contains(lowerName, "llama-3.1-8b"): + return modelPricingDB["llama-3.1-8b-instant"] + case strings.Contains(lowerName, "free"): + return modelPricing{InputPricePerMTok: 0, OutputPricePerMTok: 0} + } + + // Default: unknown model, no pricing + return modelPricing{} +} + +// calculateCost computes the estimated cost based on token usage and model pricing. +func calculateCost(inputTokens, outputTokens int, pricing modelPricing) float64 { + inputCost := float64(inputTokens) / 1_000_000 * pricing.InputPricePerMTok + outputCost := float64(outputTokens) / 1_000_000 * pricing.OutputPricePerMTok + return inputCost + outputCost +} + +// usageDateFilter holds the date range for filtering usage data. +type usageDateFilter struct { + StartDate time.Time + EndDate time.Time +} + +// parseUsageDateFilter parses date filter parameters from the request. +// Defaults to today's date if no parameters provided. +func parseUsageDateFilter(r *http.Request) usageDateFilter { + now := time.Now() + startDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + endDate := startDate.Add(24 * time.Hour) + + if startStr := r.URL.Query().Get("start_date"); startStr != "" { + if t, err := time.Parse("2006-01-02", startStr); err == nil { + startDate = t + if endStr := r.URL.Query().Get("end_date"); endStr != "" { + if endT, err := time.Parse("2006-01-02", endStr); err == nil { + endDate = endT.Add(24 * time.Hour) + } else { + endDate = startDate.Add(24 * time.Hour) + } + } else { + endDate = startDate.Add(24 * time.Hour) + } + } + } + + return usageDateFilter{ + StartDate: startDate, + EndDate: endDate, + } +} + +// messageWithUsage is a custom struct to parse usage info from message content. +// The agent loop stores usage info in the message content as JSON fields. +type messageWithUsage struct { + Role string `json:"role"` + Content string `json:"content"` + Media []string `json:"media,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []providers.ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + // Usage fields stored by agent loop + PromptTokens int `json:"prompt_tokens,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` + TotalTokens int `json:"total_tokens,omitempty"` + // Model info + Model string `json:"model,omitempty"` +} + +// handleGetUsage returns usage statistics aggregated by model. +// +// GET /api/usage?start_date=2024-01-01&end_date=2024-01-31 +func (h *Handler) handleGetUsage(w http.ResponseWriter, r *http.Request) { + dateFilter := parseUsageDateFilter(r) + + dir, err := h.sessionsDir() + if err != nil { + http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) + return + } + + // Load config to map model names to model identifiers + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, "failed to load config", http.StatusInternalServerError) + return + } + + // Build model name to model identifier mapping + modelNameToModel := make(map[string]string) + for _, m := range cfg.ModelList { + modelNameToModel[m.ModelName] = m.Model + } + + // Read all session files and aggregate usage data + entries, err := os.ReadDir(dir) + if err != nil { + // Directory doesn't exist yet = no usage data + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(UsageResponse{ + Stats: []UsageStats{}, + Currency: "USD", + DateRange: fmt.Sprintf("%s to %s", dateFilter.StartDate.Format("2006-01-02"), dateFilter.EndDate.Add(-24*time.Hour).Format("2006-01-02")), + }) + return + } + + // Track per-model stats and session counts + type modelStats struct { + MessageCount int + InputTokens int + OutputTokens int + TotalTokens int + SessionKeys map[string]struct{} + } + statsByModel := make(map[string]*modelStats) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if !strings.HasSuffix(name, ".jsonl") { + continue + } + + // Skip meta files + if strings.HasSuffix(name, ".meta.json") { + continue + } + + baseName := strings.TrimSuffix(name, ".jsonl") + sess, err := h.readSessionByBaseName(dir, baseName) + if err != nil { + continue + } + + // Check if session falls within the date filter + // Use Created or Updated, whichever is available + sessionTime := sess.Updated + if sessionTime.IsZero() { + sessionTime = sess.Created + } + if !sessionTime.IsZero() { + if sessionTime.Before(dateFilter.StartDate) || sessionTime.After(dateFilter.EndDate) { + continue + } + } + + // Get default model name from config + defaultModelName := cfg.Agents.Defaults.GetModelName() + if defaultModelName == "" { + // Try to get from first model in list + if len(cfg.ModelList) > 0 { + defaultModelName = cfg.ModelList[0].ModelName + } + } + + // Process messages to extract usage info + for _, msg := range sess.Messages { + if msg.Role != "assistant" { + continue + } + + // Try to parse message as messageWithUsage to extract token info + var msgWithUsage messageWithUsage + msgData, _ := json.Marshal(msg) + if err := json.Unmarshal(msgData, &msgWithUsage); err != nil { + continue + } + + // Determine model name + modelName := msgWithUsage.Model + if modelName == "" { + modelName = defaultModelName + } + if modelName == "" { + // Use "unknown" as fallback model name + modelName = "unknown" + } + + if _, exists := statsByModel[modelName]; !exists { + statsByModel[modelName] = &modelStats{ + SessionKeys: make(map[string]struct{}), + } + } + + ms := statsByModel[modelName] + ms.MessageCount++ + ms.InputTokens += msgWithUsage.PromptTokens + ms.OutputTokens += msgWithUsage.CompletionTokens + ms.TotalTokens += msgWithUsage.TotalTokens + ms.SessionKeys[sess.Key] = struct{}{} + } + } + + // Build response + stats := make([]UsageStats, 0, len(statsByModel)) + var totalInputTokens, totalOutputTokens, totalTokens, totalMessageCount int + var totalEstimatedCost float64 + + for modelName, ms := range statsByModel { + modelIdentifier := modelNameToModel[modelName] + if modelIdentifier == "" { + modelIdentifier = modelName + } + + pricing := getModelPricing(modelIdentifier) + if pricing.InputPricePerMTok == 0 && pricing.OutputPricePerMTok == 0 { + // Try with model name as well + pricing = getModelPricing(modelName) + } + + estimatedCost := calculateCost(ms.InputTokens, ms.OutputTokens, pricing) + + stat := UsageStats{ + ModelName: modelName, + Model: modelIdentifier, + MessageCount: ms.MessageCount, + InputTokens: ms.InputTokens, + OutputTokens: ms.OutputTokens, + TotalTokens: ms.TotalTokens, + EstimatedCost: estimatedCost, + Currency: "USD", + SessionCount: len(ms.SessionKeys), + } + stats = append(stats, stat) + + totalInputTokens += ms.InputTokens + totalOutputTokens += ms.OutputTokens + totalTokens += ms.TotalTokens + totalMessageCount += ms.MessageCount + totalEstimatedCost += estimatedCost + } + + // Sort by total tokens descending + sort.Slice(stats, func(i, j int) bool { + return stats[i].TotalTokens > stats[j].TotalTokens + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(UsageResponse{ + Stats: stats, + TotalInputTokens: totalInputTokens, + TotalOutputTokens: totalOutputTokens, + TotalTokens: totalTokens, + TotalMessageCount: totalMessageCount, + TotalEstimatedCost: totalEstimatedCost, + Currency: "USD", + DateRange: fmt.Sprintf("%s to %s", dateFilter.StartDate.Format("2006-01-02"), dateFilter.EndDate.Add(-24*time.Hour).Format("2006-01-02")), + }) +} + +// readSessionByBaseName reads a session file by its base name (sanitized key without extension). +func (h *Handler) readSessionByBaseName(dir, baseName string) (sessionFile, error) { + jsonlPath := filepath.Join(dir, baseName+".jsonl") + metaPath := filepath.Join(dir, baseName+".meta.json") + + // Reconstruct session key from base name + sessionKey := strings.ReplaceAll(baseName, "_", ":") + + meta, err := h.readSessionMeta(metaPath, sessionKey) + if err != nil { + return sessionFile{}, err + } + + messages, err := h.readSessionMessages(jsonlPath, meta.Skip) + if err != nil { + return sessionFile{}, err + } + + updated := meta.UpdatedAt + created := meta.CreatedAt + if created.IsZero() || updated.IsZero() { + if info, statErr := os.Stat(jsonlPath); statErr == nil { + if created.IsZero() { + created = info.ModTime() + } + if updated.IsZero() { + updated = info.ModTime() + } + } + } + + return sessionFile{ + Key: meta.Key, + Messages: messages, + Summary: meta.Summary, + Created: created, + Updated: updated, + }, nil +} + +// readSessionMessagesForUsage reads session messages and extracts usage information. +func (h *Handler) readSessionMessagesForUsage(path string, skip int) ([]providers.Message, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + msgs := make([]providers.Message, 0) + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), maxSessionJSONLLineSize) + + seen := 0 + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + seen++ + if seen <= skip { + continue + } + + var msg providers.Message + if err := json.Unmarshal(line, &msg); err != nil { + continue + } + msgs = append(msgs, msg) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return msgs, nil +} diff --git a/web/frontend/src/api/usage.ts b/web/frontend/src/api/usage.ts new file mode 100644 index 000000000..a941781ff --- /dev/null +++ b/web/frontend/src/api/usage.ts @@ -0,0 +1,48 @@ +// Usage API — fetch usage statistics + +import { launcherFetch } from "@/api/http" + +export interface UsageStats { + model_name: string + model: string + message_count: number + input_tokens: number + output_tokens: number + total_tokens: number + estimated_cost: number + currency: string + session_count: number +} + +export interface UsageResponse { + stats: UsageStats[] + total_input_tokens: number + total_output_tokens: number + total_tokens: number + total_message_count: number + total_estimated_cost: number + currency: string + date_range: string +} + +export async function getUsage( + startDate?: string, + endDate?: string, +): Promise { + const params = new URLSearchParams() + if (startDate) { + params.set("start_date", startDate) + } + if (endDate) { + params.set("end_date", endDate) + } + + const queryString = params.toString() + const url = `/api/usage${queryString ? `?${queryString}` : ""}` + + const res = await launcherFetch(url) + if (!res.ok) { + throw new Error(`Failed to fetch usage: ${res.status}`) + } + return res.json() +} diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx index 1ba255693..c5523e155 100644 --- a/web/frontend/src/components/app-sidebar.tsx +++ b/web/frontend/src/components/app-sidebar.tsx @@ -1,6 +1,7 @@ import { IconChevronRight } from "@tabler/icons-react" import { IconAtom, + IconChartBar, IconChevronsDown, IconChevronsUp, IconKey, @@ -145,6 +146,12 @@ export function AppSidebar({ ...props }: React.ComponentProps) { icon: IconTools, translateTitle: true, }, + { + title: "navigation.usage", + url: "/agent/usage", + icon: IconChartBar, + translateTitle: true, + }, ], }, { diff --git a/web/frontend/src/components/usage/usage-page.tsx b/web/frontend/src/components/usage/usage-page.tsx new file mode 100644 index 000000000..061ffca73 --- /dev/null +++ b/web/frontend/src/components/usage/usage-page.tsx @@ -0,0 +1,287 @@ +import { IconCalendar, IconChartBar, IconCoin, IconMessage, IconAbc } from "@tabler/icons-react" +import { useQuery } from "@tanstack/react-query" +import dayjs from "dayjs" +import * as React from "react" +import { useTranslation } from "react-i18next" + +import { getUsage, type UsageStats } from "@/api/usage" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +// Quick date range presets +const DATE_RANGES = [ + { label: "today", value: "today" }, + { label: "yesterday", value: "yesterday" }, + { label: "last_7_days", value: "last_7_days" }, + { label: "last_30_days", value: "last_30_days" }, + { label: "this_month", value: "this_month" }, + { label: "last_month", value: "last_month" }, +] + +function getDateRange(value: string): { start: string; end: string } { + const now = dayjs() + let start: dayjs.Dayjs + let end: dayjs.Dayjs + + switch (value) { + case "today": + start = now.startOf("day") + end = now.endOf("day") + break + case "yesterday": + start = now.subtract(1, "day").startOf("day") + end = now.subtract(1, "day").endOf("day") + break + case "last_7_days": + start = now.subtract(6, "day").startOf("day") + end = now.endOf("day") + break + case "last_30_days": + start = now.subtract(29, "day").startOf("day") + end = now.endOf("day") + break + case "this_month": + start = now.startOf("month") + end = now.endOf("month") + break + case "last_month": + start = now.subtract(1, "month").startOf("month") + end = now.subtract(1, "month").endOf("month") + break + default: + start = now.startOf("day") + end = now.endOf("day") + } + + return { + start: start.format("YYYY-MM-DD"), + end: end.format("YYYY-MM-DD"), + } +} + +function formatNumber(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(2)}M` + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(1)}K` + } + return n.toString() +} + +function formatCost(n: number, _currency: string): string { + return `$${n.toFixed(4)}` +} + +export function UsagePage() { + const { t } = useTranslation() + const [dateRange, setDateRange] = React.useState("today") + + const { start, end } = React.useMemo(() => getDateRange(dateRange), [dateRange]) + + const { data, isLoading, error } = useQuery({ + queryKey: ["usage", start, end], + queryFn: () => getUsage(start, end), + }) + + const stats = data?.stats ?? [] + const totalInputTokens = data?.total_input_tokens ?? 0 + const totalOutputTokens = data?.total_output_tokens ?? 0 + const totalMessageCount = data?.total_message_count ?? 0 + const totalEstimatedCost = data?.total_estimated_cost ?? 0 + const currency = data?.currency ?? "USD" + + return ( +
+
+
+

{t("usage.title")}

+

+ {t("usage.description")} +

+
+
+ + +
+
+ + {/* Summary Cards */} +
+ + + + {t("usage.total_messages")} + + + + +
{formatNumber(totalMessageCount)}
+

+ {t("usage.messages_in_period")} +

+
+
+ + + + + {t("usage.input_tokens")} + + + + +
{formatNumber(totalInputTokens)}
+

+ {t("usage.tokens_in_period")} +

+
+
+ + + + + {t("usage.output_tokens")} + + + + +
{formatNumber(totalOutputTokens)}
+

+ {t("usage.tokens_in_period")} +

+
+
+ + + + + {t("usage.estimated_cost")} + + + + +
{formatCost(totalEstimatedCost, currency)}
+

+ {t("usage.cost_estimate")} +

+
+
+
+ + {/* Model Stats Table */} + + + + + {t("usage.model_breakdown")} + + + {t("usage.model_breakdown_desc")} + + + + {isLoading ? ( +
+ {t("labels.loading")} +
+ ) : error ? ( +
+ {t("usage.load_error")} +
+ ) : stats.length === 0 ? ( +
+ {t("usage.no_data")} +
+ ) : ( +
+ + + + + + + + + + + + + + {stats.map((stat: UsageStats, index: number) => ( + + + + + + + + + + ))} + +
+ {t("usage.model_name")} + + {t("usage.messages")} + + {t("usage.input")} + + {t("usage.output")} + + {t("usage.total")} + + {t("usage.cost")} + + {t("usage.sessions")} +
+
+
{stat.model_name}
+ {stat.model !== stat.model_name && ( +
+ {stat.model} +
+ )} +
+
+ {formatNumber(stat.message_count)} + + {formatNumber(stat.input_tokens)} + + {formatNumber(stat.output_tokens)} + + {formatNumber(stat.total_tokens)} + + {formatCost(stat.estimated_cost, stat.currency)} + + {stat.session_count} +
+
+ )} +
+
+
+ ) +} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 69e256758..e8d125f49 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -7,6 +7,7 @@ "agent_group": "Agent", "skills": "Skills", "tools": "Tools", + "usage": "Usage", "services": "Services", "channels_group": "Channels", "show_more_channels": "More", @@ -572,5 +573,36 @@ "title": "View Documentation", "description": "Need more help? Click the documentation button in the top right corner to view detailed guides and configuration docs." } + }, + "usage": { + "title": "Usage Statistics", + "description": "View message counts, token usage, and cost estimates per model for each session.", + "select_date_range": "Select date range", + "date_range": { + "today": "Today", + "yesterday": "Yesterday", + "last_7_days": "Last 7 days", + "last_30_days": "Last 30 days", + "this_month": "This month", + "last_month": "Last month" + }, + "total_messages": "Total Messages", + "messages_in_period": "Messages in period", + "input_tokens": "Input Tokens", + "output_tokens": "Output Tokens", + "tokens_in_period": "Total tokens in period", + "estimated_cost": "Estimated Cost", + "cost_estimate": "Based on model pricing", + "model_breakdown": "Model Breakdown", + "model_breakdown_desc": "Usage statistics broken down by model", + "load_error": "Failed to load usage statistics", + "no_data": "No usage data available", + "model_name": "Model", + "messages": "Messages", + "input": "Input", + "output": "Output", + "total": "Total", + "cost": "Cost", + "sessions": "Sessions" } } diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index e7aca1918..81bbe5823 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -7,6 +7,7 @@ "agent_group": "智能体", "skills": "技能", "tools": "工具", + "usage": "使用统计", "services": "服务", "channels_group": "频道", "show_more_channels": "更多", @@ -572,5 +573,36 @@ "title": "查看文档", "description": "需要更多帮助?点击右上角的文档按钮,查看详细的使用文档和配置指南。" } + }, + "usage": { + "title": "使用统计", + "description": "查看每个会话、每个模型的消息数量、Token 使用量和费用估算。", + "select_date_range": "选择日期范围", + "date_range": { + "today": "今天", + "yesterday": "昨天", + "last_7_days": "最近 7 天", + "last_30_days": "最近 30 天", + "this_month": "本月", + "last_month": "上月" + }, + "total_messages": "总消息数", + "messages_in_period": "期间内消息总数", + "input_tokens": "输入 Token", + "output_tokens": "输出 Token", + "tokens_in_period": "期间内 Token 总数", + "estimated_cost": "估算费用", + "cost_estimate": "基于模型定价估算", + "model_breakdown": "模型明细", + "model_breakdown_desc": "按模型分类的使用统计明细", + "load_error": "加载使用统计失败", + "no_data": "暂无使用统计数据", + "model_name": "模型", + "messages": "消息数", + "input": "输入", + "output": "输出", + "total": "总计", + "cost": "费用", + "sessions": "会话数" } } diff --git a/web/frontend/src/routeTree.gen.ts b/web/frontend/src/routeTree.gen.ts index 536ee560b..beaa2a211 100644 --- a/web/frontend/src/routeTree.gen.ts +++ b/web/frontend/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as ChannelsRouteRouteImport } from './routes/channels/route' import { Route as IndexRouteImport } from './routes/index' import { Route as ConfigRawRouteImport } from './routes/config.raw' import { Route as ChannelsNameRouteImport } from './routes/channels/$name' +import { Route as AgentUsageRouteImport } from './routes/agent/usage' import { Route as AgentToolsRouteImport } from './routes/agent/tools' import { Route as AgentSkillsRouteImport } from './routes/agent/skills' @@ -72,6 +73,11 @@ const ChannelsNameRoute = ChannelsNameRouteImport.update({ path: '/$name', getParentRoute: () => ChannelsRouteRoute, } as any) +const AgentUsageRoute = AgentUsageRouteImport.update({ + id: '/usage', + path: '/usage', + getParentRoute: () => AgentRoute, +} as any) const AgentToolsRoute = AgentToolsRouteImport.update({ id: '/tools', path: '/tools', @@ -94,6 +100,7 @@ export interface FileRoutesByFullPath { '/models': typeof ModelsRoute '/agent/skills': typeof AgentSkillsRoute '/agent/tools': typeof AgentToolsRoute + '/agent/usage': typeof AgentUsageRoute '/channels/$name': typeof ChannelsNameRoute '/config/raw': typeof ConfigRawRoute } @@ -108,6 +115,7 @@ export interface FileRoutesByTo { '/models': typeof ModelsRoute '/agent/skills': typeof AgentSkillsRoute '/agent/tools': typeof AgentToolsRoute + '/agent/usage': typeof AgentUsageRoute '/channels/$name': typeof ChannelsNameRoute '/config/raw': typeof ConfigRawRoute } @@ -123,6 +131,7 @@ export interface FileRoutesById { '/models': typeof ModelsRoute '/agent/skills': typeof AgentSkillsRoute '/agent/tools': typeof AgentToolsRoute + '/agent/usage': typeof AgentUsageRoute '/channels/$name': typeof ChannelsNameRoute '/config/raw': typeof ConfigRawRoute } @@ -139,6 +148,7 @@ export interface FileRouteTypes { | '/models' | '/agent/skills' | '/agent/tools' + | '/agent/usage' | '/channels/$name' | '/config/raw' fileRoutesByTo: FileRoutesByTo @@ -153,6 +163,7 @@ export interface FileRouteTypes { | '/models' | '/agent/skills' | '/agent/tools' + | '/agent/usage' | '/channels/$name' | '/config/raw' id: @@ -167,6 +178,7 @@ export interface FileRouteTypes { | '/models' | '/agent/skills' | '/agent/tools' + | '/agent/usage' | '/channels/$name' | '/config/raw' fileRoutesById: FileRoutesById @@ -254,6 +266,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChannelsNameRouteImport parentRoute: typeof ChannelsRouteRoute } + '/agent/usage': { + id: '/agent/usage' + path: '/usage' + fullPath: '/agent/usage' + preLoaderRoute: typeof AgentUsageRouteImport + parentRoute: typeof AgentRoute + } '/agent/tools': { id: '/agent/tools' path: '/tools' @@ -286,11 +305,13 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren( interface AgentRouteChildren { AgentSkillsRoute: typeof AgentSkillsRoute AgentToolsRoute: typeof AgentToolsRoute + AgentUsageRoute: typeof AgentUsageRoute } const AgentRouteChildren: AgentRouteChildren = { AgentSkillsRoute: AgentSkillsRoute, AgentToolsRoute: AgentToolsRoute, + AgentUsageRoute: AgentUsageRoute, } const AgentRouteWithChildren = AgentRoute._addFileChildren(AgentRouteChildren) diff --git a/web/frontend/src/routes/agent/usage.tsx b/web/frontend/src/routes/agent/usage.tsx new file mode 100644 index 000000000..d0baf30e2 --- /dev/null +++ b/web/frontend/src/routes/agent/usage.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { UsagePage } from "@/components/usage/usage-page" + +export const Route = createFileRoute("/agent/usage")({ + component: AgentUsageRoute, +}) + +function AgentUsageRoute() { + return +}