Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
318 changes: 318 additions & 0 deletions controller/log.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package controller

import (
"fmt"
"net/http"
"net/url"
"strconv"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/xuri/excelize/v2"

"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -122,6 +126,149 @@ func GetLogsStat(c *gin.Context) {
return
}

func checkStatisticsUsername(c *gin.Context, username string) bool {
role := c.GetInt("role")
if role >= common.RoleAdminUser {
return true
}
currentUsername := c.GetString("username")
if username != currentUsername {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "you can only query your own statistics",
})
return false
}
return true
}

func GetLogStatistics(c *gin.Context) {
username := c.Query("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "username is required",
})
return
}
if !checkStatisticsUsername(c, username) {
return
}
tokenName := c.Query("token_name")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
modelName := c.Query("model_name")

models, err := model.GetLogStatistics(username, tokenName, startTimestamp, endTimestamp, modelName)
if err != nil {
common.ApiError(c, err)
return
}
trend, err := model.GetLogStatisticsTrend(username, tokenName, startTimestamp, endTimestamp, modelName)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"models": models,
"trend": trend,
},
})
}

func ExportLogStatistics(c *gin.Context) {
username := c.Query("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "username is required",
})
return
}
if !checkStatisticsUsername(c, username) {
return
}
tokenName := c.Query("token_name")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
modelName := c.Query("model_name")

models, err := model.GetLogStatistics(username, tokenName, startTimestamp, endTimestamp, modelName)
if err != nil {
common.ApiError(c, err)
return
}

f := excelize.NewFile()
defer func() { _ = f.Close() }()
sheet := "Sheet1"

// Build title: username-token_name-timeRange
timeRange := ""
if startTimestamp > 0 && endTimestamp > 0 {
timeRange = fmt.Sprintf("%s ~ %s", time.Unix(startTimestamp, 0).Format("2006-01-02"), time.Unix(endTimestamp, 0).Format("2006-01-02"))
}
title := username
if tokenName != "" {
title += "-" + tokenName
}
if timeRange != "" {
title += "-" + timeRange
}
_ = f.SetCellValue(sheet, "A1", title)

headers := []string{"模型名称", "调用次数", "消耗额度($)", "Prompt Tokens(M)", "Completion Tokens(M)", "总 Tokens(M)"}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
_ = f.SetCellValue(sheet, cell, h)
}

var totalQuota, totalPrompt, totalCompletion, totalCount int64
for i, m := range models {
row := i + 3
promptM := float64(m.PromptTokens) / 1_000_000
completionM := float64(m.CompletionTokens) / 1_000_000
totalM := float64(m.PromptTokens+m.CompletionTokens) / 1_000_000
quotaUSD := float64(m.Quota) / float64(common.QuotaPerUnit)
_ = f.SetCellValue(sheet, fmt.Sprintf("A%d", row), m.ModelName)
_ = f.SetCellValue(sheet, fmt.Sprintf("B%d", row), m.RequestCount)
_ = f.SetCellValue(sheet, fmt.Sprintf("C%d", row), quotaUSD)
_ = f.SetCellValue(sheet, fmt.Sprintf("D%d", row), promptM)
_ = f.SetCellValue(sheet, fmt.Sprintf("E%d", row), completionM)
_ = f.SetCellValue(sheet, fmt.Sprintf("F%d", row), totalM)
totalQuota += m.Quota
totalPrompt += m.PromptTokens
totalCompletion += m.CompletionTokens
totalCount += m.RequestCount
}
summaryRow := len(models) + 3
totalQuotaUSD := float64(totalQuota) / float64(common.QuotaPerUnit)
totalPromptM := float64(totalPrompt) / 1_000_000
totalCompletionM := float64(totalCompletion) / 1_000_000
totalTM := float64(totalPrompt+totalCompletion) / 1_000_000
_ = f.SetCellValue(sheet, fmt.Sprintf("A%d", summaryRow), "合计")
_ = f.SetCellValue(sheet, fmt.Sprintf("B%d", summaryRow), totalCount)
_ = f.SetCellValue(sheet, fmt.Sprintf("C%d", summaryRow), totalQuotaUSD)
_ = f.SetCellValue(sheet, fmt.Sprintf("D%d", summaryRow), totalPromptM)
_ = f.SetCellValue(sheet, fmt.Sprintf("E%d", summaryRow), totalCompletionM)
_ = f.SetCellValue(sheet, fmt.Sprintf("F%d", summaryRow), totalTM)

buf, err := f.WriteToBuffer()
if err != nil {
common.ApiError(c, err)
return
}

filename := title + ".xlsx"
asciiFallback := url.PathEscape(filename)
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"; filename*=UTF-8''%s", asciiFallback, asciiFallback))
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", buf.Bytes())
}

func GetLogsSelfStat(c *gin.Context) {
username := c.GetString("username")
logType, _ := strconv.Atoi(c.Query("type"))
Expand Down Expand Up @@ -171,3 +318,174 @@ func DeleteHistoryLogs(c *gin.Context) {
})
return
}

func GetStatisticsUserOptions(c *gin.Context) {
keyword := c.Query("keyword")
page, _ := strconv.Atoi(c.Query("p"))
if page < 1 {
page = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}

var usernames []string
if keyword != "" {
users, _, err := model.SearchUsers(keyword, "", (page-1)*pageSize, pageSize)
if err != nil {
common.ApiError(c, err)
return
}
for _, u := range users {
usernames = append(usernames, u.Username)
}
} else {
pageInfo := &common.PageInfo{Page: page, PageSize: pageSize}
users, _, err := model.GetAllUsers(pageInfo)
if err != nil {
common.ApiError(c, err)
return
}
for _, u := range users {
usernames = append(usernames, u.Username)
}
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": usernames,
})
}

func GetStatisticsTokenOptions(c *gin.Context) {
role := c.GetInt("role")
userId := c.GetInt("id")
username := c.Query("username")

if role < common.RoleAdminUser {
username = c.GetString("username")
}

var user model.User
if err := model.DB.Where("username = ?", username).First(&user).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": []any{},
"has_more": false,
})
return
}

if role < common.RoleAdminUser && user.Id != userId {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": []any{},
"has_more": false,
})
return
}

keyword := c.Query("keyword")
cursor, _ := strconv.Atoi(c.Query("cursor"))
if cursor < 0 {
cursor = 0
}
pageSize := 50

query := model.DB.Where("user_id = ?", user.Id)
if keyword != "" {
query = query.Where("name LIKE ?", keyword+"%")
}
query = query.Where("id > ?", cursor).Order("id asc").Limit(pageSize + 1)

var tokens []*model.Token
if err := query.Find(&tokens).Error; err != nil {
common.ApiError(c, err)
return
}

hasMore := len(tokens) > pageSize
if hasMore {
tokens = tokens[:pageSize]
}

type tokenOption struct {
Id int `json:"id"`
Name string `json:"name"`
}
options := make([]tokenOption, 0, len(tokens))
for _, t := range tokens {
options = append(options, tokenOption{Id: t.Id, Name: t.Name})
}

nextCursor := 0
if len(tokens) > 0 {
nextCursor = tokens[len(tokens)-1].Id
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": options,
"has_more": hasMore,
"next_cursor": nextCursor,
})
}

func GetStatisticsModelOptions(c *gin.Context) {
username := c.Query("username")
tokenName := c.Query("token_name")

if username != "" && !checkStatisticsUsername(c, username) {
return
}

if tokenName != "" && username != "" {
var user model.User
if err := model.DB.Where("username = ?", username).First(&user).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": model.GetEnabledModels(),
})
return
}

var token model.Token
if err := model.DB.Where("user_id = ? AND name = ?", user.Id, tokenName).First(&token).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": model.GetEnabledModels(),
})
return
}
Comment thread
kK-2004 marked this conversation as resolved.

if token.IsModelLimitsEnabled() {
limits := token.GetModelLimits()
if len(limits) > 0 {
enabledModels := model.GetEnabledModels()
enabledMap := make(map[string]bool, len(enabledModels))
for _, m := range enabledModels {
enabledMap[m] = true
}
var result []string
for _, m := range limits {
if enabledMap[m] {
result = append(result, m)
}
}
if len(result) > 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
return
}
}
}
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": model.GetEnabledModels(),
})
}
15 changes: 12 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,13 @@ require (
github.com/tidwall/sjson v1.2.5
github.com/tiktoken-go/tokenizer v0.6.2
github.com/waffo-com/waffo-go v1.3.1
github.com/xuri/excelize/v2 v2.10.1
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
golang.org/x/crypto v0.45.0
golang.org/x/crypto v0.48.0
golang.org/x/image v0.38.0
golang.org/x/net v0.47.0
golang.org/x/net v0.50.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.38.0
golang.org/x/sys v0.41.0
golang.org/x/text v0.35.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.4.3
Expand All @@ -62,6 +63,14 @@ require (

require github.com/waffo-com/waffo-pancake-sdk-go v0.3.1

require (
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
)

require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
Expand Down
Loading