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,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ type (
|
|||
GoogleExtra = protocoltypes.GoogleExtra
|
||||
ContentBlock = protocoltypes.ContentBlock
|
||||
CacheControl = protocoltypes.CacheControl
|
||||
MessageExtra = protocoltypes.MessageExtra
|
||||
)
|
||||
|
||||
type LLMProvider interface {
|
||||
|
|
|
|||
|
|
@ -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
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 {
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "会话数"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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