feat:add usage

This commit is contained in:
xiongfei 2026-04-01 15:54:24 +08:00
parent e89913a28b
commit 9713de691a
12 changed files with 1003 additions and 0 deletions

View file

@ -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

View file

@ -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 {

View file

@ -19,6 +19,7 @@ type (
GoogleExtra = protocoltypes.GoogleExtra
ContentBlock = protocoltypes.ContentBlock
CacheControl = protocoltypes.CacheControl
MessageExtra = protocoltypes.MessageExtra
)
type LLMProvider interface {

View file

@ -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.

509
web/backend/api/usage.go Normal file
View file

@ -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
}

View file

@ -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<UsageResponse> {
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()
}

View file

@ -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<typeof Sidebar>) {
icon: IconTools,
translateTitle: true,
},
{
title: "navigation.usage",
url: "/agent/usage",
icon: IconChartBar,
translateTitle: true,
},
],
},
{

View file

@ -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 (
<div className="flex flex-col gap-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t("usage.title")}</h1>
<p className="text-muted-foreground mt-1">
{t("usage.description")}
</p>
</div>
<div className="flex items-center gap-2">
<IconCalendar className="size-4 text-muted-foreground" />
<Select value={dateRange} onValueChange={setDateRange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t("usage.select_date_range")} />
</SelectTrigger>
<SelectContent>
{DATE_RANGES.map((range) => (
<SelectItem key={range.value} value={range.value}>
{t(`usage.date_range.${range.label}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("usage.total_messages")}
</CardTitle>
<IconMessage className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(totalMessageCount)}</div>
<p className="text-muted-foreground text-xs">
{t("usage.messages_in_period")}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("usage.input_tokens")}
</CardTitle>
<IconAbc className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(totalInputTokens)}</div>
<p className="text-muted-foreground text-xs">
{t("usage.tokens_in_period")}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("usage.output_tokens")}
</CardTitle>
<IconAbc className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(totalOutputTokens)}</div>
<p className="text-muted-foreground text-xs">
{t("usage.tokens_in_period")}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("usage.estimated_cost")}
</CardTitle>
<IconCoin className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCost(totalEstimatedCost, currency)}</div>
<p className="text-muted-foreground text-xs">
{t("usage.cost_estimate")}
</p>
</CardContent>
</Card>
</div>
{/* Model Stats Table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconChartBar className="size-5" />
{t("usage.model_breakdown")}
</CardTitle>
<CardDescription>
{t("usage.model_breakdown_desc")}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
{t("labels.loading")}
</div>
) : error ? (
<div className="flex items-center justify-center py-8 text-destructive">
{t("usage.load_error")}
</div>
) : stats.length === 0 ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
{t("usage.no_data")}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground">
{t("usage.model_name")}
</th>
<th className="text-right py-3 px-4 text-sm font-medium text-muted-foreground">
{t("usage.messages")}
</th>
<th className="text-right py-3 px-4 text-sm font-medium text-muted-foreground">
{t("usage.input")}
</th>
<th className="text-right py-3 px-4 text-sm font-medium text-muted-foreground">
{t("usage.output")}
</th>
<th className="text-right py-3 px-4 text-sm font-medium text-muted-foreground">
{t("usage.total")}
</th>
<th className="text-right py-3 px-4 text-sm font-medium text-muted-foreground">
{t("usage.cost")}
</th>
<th className="text-right py-3 px-4 text-sm font-medium text-muted-foreground">
{t("usage.sessions")}
</th>
</tr>
</thead>
<tbody>
{stats.map((stat: UsageStats, index: number) => (
<tr
key={stat.model_name}
className={`border-b transition-colors hover:bg-muted/50 ${
index % 2 === 0 ? "bg-background" : "bg-muted/20"
}`}
>
<td className="py-3 px-4 text-sm font-medium">
<div>
<div>{stat.model_name}</div>
{stat.model !== stat.model_name && (
<div className="text-muted-foreground text-xs">
{stat.model}
</div>
)}
</div>
</td>
<td className="py-3 px-4 text-sm text-right">
{formatNumber(stat.message_count)}
</td>
<td className="py-3 px-4 text-sm text-right">
{formatNumber(stat.input_tokens)}
</td>
<td className="py-3 px-4 text-sm text-right">
{formatNumber(stat.output_tokens)}
</td>
<td className="py-3 px-4 text-sm text-right font-medium">
{formatNumber(stat.total_tokens)}
</td>
<td className="py-3 px-4 text-sm text-right">
{formatCost(stat.estimated_cost, stat.currency)}
</td>
<td className="py-3 px-4 text-sm text-right">
{stat.session_count}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -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"
}
}

View file

@ -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": "会话数"
}
}

View file

@ -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)

View file

@ -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 <UsagePage />
}