feat:add usage
This commit is contained in:
parent
e89913a28b
commit
9713de691a
12 changed files with 1003 additions and 0 deletions
|
|
@ -2206,6 +2206,51 @@ turnLoop:
|
||||||
Content: response.Content,
|
Content: response.Content,
|
||||||
ReasoningContent: response.ReasoningContent,
|
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 {
|
for _, tc := range normalizedToolCalls {
|
||||||
argumentsJSON, _ := json.Marshal(tc.Arguments)
|
argumentsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
extraContent := tc.ExtraContent
|
extraContent := tc.ExtraContent
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,13 @@ type Message struct {
|
||||||
SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
|
SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,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 {
|
type ToolDefinition struct {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ type (
|
||||||
GoogleExtra = protocoltypes.GoogleExtra
|
GoogleExtra = protocoltypes.GoogleExtra
|
||||||
ContentBlock = protocoltypes.ContentBlock
|
ContentBlock = protocoltypes.ContentBlock
|
||||||
CacheControl = protocoltypes.CacheControl
|
CacheControl = protocoltypes.CacheControl
|
||||||
|
MessageExtra = protocoltypes.MessageExtra
|
||||||
)
|
)
|
||||||
|
|
||||||
type LLMProvider interface {
|
type LLMProvider interface {
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
|
||||||
// WeCom QR login flow
|
// WeCom QR login flow
|
||||||
h.registerWecomRoutes(mux)
|
h.registerWecomRoutes(mux)
|
||||||
|
|
||||||
|
// Usage statistics
|
||||||
|
h.registerUsageRoutes(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler.
|
// Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler.
|
||||||
|
|
|
||||||
509
web/backend/api/usage.go
Normal file
509
web/backend/api/usage.go
Normal 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
|
||||||
|
}
|
||||||
48
web/frontend/src/api/usage.ts
Normal file
48
web/frontend/src/api/usage.ts
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { IconChevronRight } from "@tabler/icons-react"
|
import { IconChevronRight } from "@tabler/icons-react"
|
||||||
import {
|
import {
|
||||||
IconAtom,
|
IconAtom,
|
||||||
|
IconChartBar,
|
||||||
IconChevronsDown,
|
IconChevronsDown,
|
||||||
IconChevronsUp,
|
IconChevronsUp,
|
||||||
IconKey,
|
IconKey,
|
||||||
|
|
@ -145,6 +146,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
icon: IconTools,
|
icon: IconTools,
|
||||||
translateTitle: true,
|
translateTitle: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "navigation.usage",
|
||||||
|
url: "/agent/usage",
|
||||||
|
icon: IconChartBar,
|
||||||
|
translateTitle: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
287
web/frontend/src/components/usage/usage-page.tsx
Normal file
287
web/frontend/src/components/usage/usage-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"agent_group": "Agent",
|
"agent_group": "Agent",
|
||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"tools": "Tools",
|
"tools": "Tools",
|
||||||
|
"usage": "Usage",
|
||||||
"services": "Services",
|
"services": "Services",
|
||||||
"channels_group": "Channels",
|
"channels_group": "Channels",
|
||||||
"show_more_channels": "More",
|
"show_more_channels": "More",
|
||||||
|
|
@ -572,5 +573,36 @@
|
||||||
"title": "View Documentation",
|
"title": "View Documentation",
|
||||||
"description": "Need more help? Click the documentation button in the top right corner to view detailed guides and configuration docs."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"agent_group": "智能体",
|
"agent_group": "智能体",
|
||||||
"skills": "技能",
|
"skills": "技能",
|
||||||
"tools": "工具",
|
"tools": "工具",
|
||||||
|
"usage": "使用统计",
|
||||||
"services": "服务",
|
"services": "服务",
|
||||||
"channels_group": "频道",
|
"channels_group": "频道",
|
||||||
"show_more_channels": "更多",
|
"show_more_channels": "更多",
|
||||||
|
|
@ -572,5 +573,36 @@
|
||||||
"title": "查看文档",
|
"title": "查看文档",
|
||||||
"description": "需要更多帮助?点击右上角的文档按钮,查看详细的使用文档和配置指南。"
|
"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": "会话数"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { Route as ChannelsRouteRouteImport } from './routes/channels/route'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as ConfigRawRouteImport } from './routes/config.raw'
|
import { Route as ConfigRawRouteImport } from './routes/config.raw'
|
||||||
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
|
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 AgentToolsRouteImport } from './routes/agent/tools'
|
||||||
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
|
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
|
||||||
|
|
||||||
|
|
@ -72,6 +73,11 @@ const ChannelsNameRoute = ChannelsNameRouteImport.update({
|
||||||
path: '/$name',
|
path: '/$name',
|
||||||
getParentRoute: () => ChannelsRouteRoute,
|
getParentRoute: () => ChannelsRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AgentUsageRoute = AgentUsageRouteImport.update({
|
||||||
|
id: '/usage',
|
||||||
|
path: '/usage',
|
||||||
|
getParentRoute: () => AgentRoute,
|
||||||
|
} as any)
|
||||||
const AgentToolsRoute = AgentToolsRouteImport.update({
|
const AgentToolsRoute = AgentToolsRouteImport.update({
|
||||||
id: '/tools',
|
id: '/tools',
|
||||||
path: '/tools',
|
path: '/tools',
|
||||||
|
|
@ -94,6 +100,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
'/agent/tools': typeof AgentToolsRoute
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
|
'/agent/usage': typeof AgentUsageRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
'/config/raw': typeof ConfigRawRoute
|
'/config/raw': typeof ConfigRawRoute
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +115,7 @@ export interface FileRoutesByTo {
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
'/agent/tools': typeof AgentToolsRoute
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
|
'/agent/usage': typeof AgentUsageRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
'/config/raw': typeof ConfigRawRoute
|
'/config/raw': typeof ConfigRawRoute
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +131,7 @@ export interface FileRoutesById {
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
'/agent/tools': typeof AgentToolsRoute
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
|
'/agent/usage': typeof AgentUsageRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
'/config/raw': typeof ConfigRawRoute
|
'/config/raw': typeof ConfigRawRoute
|
||||||
}
|
}
|
||||||
|
|
@ -139,6 +148,7 @@ export interface FileRouteTypes {
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
| '/agent/tools'
|
| '/agent/tools'
|
||||||
|
| '/agent/usage'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
| '/config/raw'
|
| '/config/raw'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
|
@ -153,6 +163,7 @@ export interface FileRouteTypes {
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
| '/agent/tools'
|
| '/agent/tools'
|
||||||
|
| '/agent/usage'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
| '/config/raw'
|
| '/config/raw'
|
||||||
id:
|
id:
|
||||||
|
|
@ -167,6 +178,7 @@ export interface FileRouteTypes {
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
| '/agent/tools'
|
| '/agent/tools'
|
||||||
|
| '/agent/usage'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
| '/config/raw'
|
| '/config/raw'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
|
|
@ -254,6 +266,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof ChannelsNameRouteImport
|
preLoaderRoute: typeof ChannelsNameRouteImport
|
||||||
parentRoute: typeof ChannelsRouteRoute
|
parentRoute: typeof ChannelsRouteRoute
|
||||||
}
|
}
|
||||||
|
'/agent/usage': {
|
||||||
|
id: '/agent/usage'
|
||||||
|
path: '/usage'
|
||||||
|
fullPath: '/agent/usage'
|
||||||
|
preLoaderRoute: typeof AgentUsageRouteImport
|
||||||
|
parentRoute: typeof AgentRoute
|
||||||
|
}
|
||||||
'/agent/tools': {
|
'/agent/tools': {
|
||||||
id: '/agent/tools'
|
id: '/agent/tools'
|
||||||
path: '/tools'
|
path: '/tools'
|
||||||
|
|
@ -286,11 +305,13 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
|
||||||
interface AgentRouteChildren {
|
interface AgentRouteChildren {
|
||||||
AgentSkillsRoute: typeof AgentSkillsRoute
|
AgentSkillsRoute: typeof AgentSkillsRoute
|
||||||
AgentToolsRoute: typeof AgentToolsRoute
|
AgentToolsRoute: typeof AgentToolsRoute
|
||||||
|
AgentUsageRoute: typeof AgentUsageRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AgentRouteChildren: AgentRouteChildren = {
|
const AgentRouteChildren: AgentRouteChildren = {
|
||||||
AgentSkillsRoute: AgentSkillsRoute,
|
AgentSkillsRoute: AgentSkillsRoute,
|
||||||
AgentToolsRoute: AgentToolsRoute,
|
AgentToolsRoute: AgentToolsRoute,
|
||||||
|
AgentUsageRoute: AgentUsageRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AgentRouteWithChildren = AgentRoute._addFileChildren(AgentRouteChildren)
|
const AgentRouteWithChildren = AgentRoute._addFileChildren(AgentRouteChildren)
|
||||||
|
|
|
||||||
11
web/frontend/src/routes/agent/usage.tsx
Normal file
11
web/frontend/src/routes/agent/usage.tsx
Normal 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 />
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue