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
1 change: 1 addition & 0 deletions common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ var TaskEnabled = true
var DataExportEnabled = true
var DataExportInterval = 5 // unit: minute
var DataExportDefaultTime = "hour" // unit: minute
var ApiKeyStatsEnabled = false
var DefaultCollapseSidebar = false // default value of collapse sidebar

// Any options with "Secret", "Token" in its key won't be return by GetOptions
Expand Down
53 changes: 53 additions & 0 deletions controller/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"

"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -150,6 +151,58 @@ func GetLogsSelfStat(c *gin.Context) {
return
}

// GetLogStatsByToken returns API key usage statistics for admins.
func GetLogStatsByToken(c *gin.Context) {
if !isApiKeyStatsEnabled(c) {
return
}

startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
data, err := model.GetLogStatsByToken(0, startTimestamp, endTimestamp)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// GetUserLogStatsByToken returns API key usage statistics for the current user.
func GetUserLogStatsByToken(c *gin.Context) {
if !isApiKeyStatsEnabled(c) {
return
}

userId := c.GetInt("id")
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
data, err := model.GetLogStatsByToken(userId, startTimestamp, endTimestamp)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
}

func isApiKeyStatsEnabled(c *gin.Context) bool {
if operation_setting.SelfUseModeEnabled && common.ApiKeyStatsEnabled {
return true
}
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "api key statistics is disabled",
})
return false
}

func DeleteHistoryLogs(c *gin.Context) {
targetTimestamp, _ := strconv.ParseInt(c.Query("target_timestamp"), 10, 64)
if targetTimestamp == 0 {
Expand Down
67 changes: 67 additions & 0 deletions controller/log_token_stat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package controller

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)

func withApiKeyStatsSettings(t *testing.T, selfUseModeEnabled, apiKeyStatsEnabled bool) {
t.Helper()

originalSelfUseModeEnabled := operation_setting.SelfUseModeEnabled
originalApiKeyStatsEnabled := common.ApiKeyStatsEnabled
operation_setting.SelfUseModeEnabled = selfUseModeEnabled
common.ApiKeyStatsEnabled = apiKeyStatsEnabled
t.Cleanup(func() {
operation_setting.SelfUseModeEnabled = originalSelfUseModeEnabled
common.ApiKeyStatsEnabled = originalApiKeyStatsEnabled
})
}

func performTokenStatsRequest(handler gin.HandlerFunc) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodGet, "/api/log/stat/tokens", nil)
ctx.Request = req
handler(ctx)
return recorder
}

func TestGetLogStatsByTokenRejectsWhenApiKeyStatsDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
withApiKeyStatsSettings(t, true, false)

recorder := performTokenStatsRequest(GetLogStatsByToken)

require.Equal(t, http.StatusForbidden, recorder.Code)
require.Contains(t, recorder.Body.String(), "api key statistics is disabled")
}

func TestGetLogStatsByTokenRejectsWhenSelfUseModeDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
withApiKeyStatsSettings(t, false, true)

recorder := performTokenStatsRequest(GetLogStatsByToken)

require.Equal(t, http.StatusForbidden, recorder.Code)
require.Contains(t, recorder.Body.String(), "api key statistics is disabled")
}

func TestGetLogStatsByTokenAllowsWhenEnabled(t *testing.T) {
gin.SetMode(gin.TestMode)
withApiKeyStatsSettings(t, true, true)
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{}))

recorder := performTokenStatsRequest(GetLogStatsByToken)

require.Equal(t, http.StatusOK, recorder.Code)
require.Contains(t, recorder.Body.String(), `"success":true`)
}
1 change: 1 addition & 0 deletions controller/misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ func GetStatus(c *gin.Context) {
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"api_key_stats_enabled": common.ApiKeyStatsEnabled && operation_setting.SelfUseModeEnabled,
"register_enabled": common.RegisterEnabled,
"password_register_enabled": common.PasswordRegisterEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
Expand Down
82 changes: 82 additions & 0 deletions model/log_token_stat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package model

import (
"fmt"
"time"

"github.com/QuantumNous/new-api/common"
)

const (
tokenStatsDefaultRangeSeconds = 24 * 60 * 60
tokenStatsMaxRangeSeconds = 30 * 24 * 60 * 60
)

// TokenQuotaData is hourly aggregated usage data by API key.
type TokenQuotaData struct {
TokenId int `json:"token_id"`
TokenName string `json:"token_name"`
CreatedAt int64 `json:"created_at"`
Count int `json:"count"`
Quota int `json:"quota"`
TokenUsed int `json:"token_used"`
}

// GetLogStatsByToken aggregates consume logs by API key and hour.
// userId = 0 returns all users' data; userId > 0 limits data to that user.
func GetLogStatsByToken(userId int, startTime, endTime int64) ([]*TokenQuotaData, error) {
var results []*TokenQuotaData

startTime, endTime, err := normalizeTokenStatsTimeRange(startTime, endTime, time.Now().Unix())
if err != nil {
return nil, err
}

query := LOG_DB.Table("logs").
Select(`token_id,
token_name,
(created_at - created_at % 3600) AS created_at,
COUNT(*) AS count,
COALESCE(SUM(quota), 0) AS quota,
COALESCE(SUM(prompt_tokens), 0) + COALESCE(SUM(completion_tokens), 0) AS token_used`).
Where("type = ?", LogTypeConsume).
Where("token_id != 0").
Where("token_name != ''").
Group("token_id, token_name, (created_at - created_at % 3600)").
Order("(created_at - created_at % 3600)")

if userId > 0 {
query = query.Where("user_id = ?", userId)
}
if startTime != 0 {
query = query.Where("created_at >= ?", startTime)
}
if endTime != 0 {
query = query.Where("created_at <= ?", endTime)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if err := query.Scan(&results).Error; err != nil {
common.SysError("failed to query token quota data: " + err.Error())
return nil, err
}
return results, nil
}

func normalizeTokenStatsTimeRange(startTime, endTime, now int64) (int64, int64, error) {
if startTime == 0 && endTime == 0 {
endTime = now
startTime = endTime - tokenStatsDefaultRangeSeconds
} else if endTime == 0 {
endTime = now
} else if startTime == 0 {
startTime = endTime - tokenStatsMaxRangeSeconds
}

if endTime < startTime {
return 0, 0, fmt.Errorf("invalid time range: end_timestamp < start_timestamp")
}
if endTime-startTime > tokenStatsMaxRangeSeconds {
startTime = endTime - tokenStatsMaxRangeSeconds
}
return startTime, endTime, nil
}
38 changes: 38 additions & 0 deletions model/log_token_stat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package model

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestNormalizeTokenStatsTimeRangeDefaultsToRecentDay(t *testing.T) {
startTime, endTime, err := normalizeTokenStatsTimeRange(0, 0, 1_700_000_000)
require.NoError(t, err)
require.Equal(t, int64(1_700_000_000), endTime)
require.Equal(t, int64(1_700_000_000-tokenStatsDefaultRangeSeconds), startTime)
}

func TestNormalizeTokenStatsTimeRangeFillsMissingBoundary(t *testing.T) {
startTime, endTime, err := normalizeTokenStatsTimeRange(1_699_999_000, 0, 1_700_000_000)
require.NoError(t, err)
require.Equal(t, int64(1_699_999_000), startTime)
require.Equal(t, int64(1_700_000_000), endTime)

startTime, endTime, err = normalizeTokenStatsTimeRange(0, 1_700_000_000, 1_700_000_100)
require.NoError(t, err)
require.Equal(t, int64(1_700_000_000-tokenStatsMaxRangeSeconds), startTime)
require.Equal(t, int64(1_700_000_000), endTime)
}

func TestNormalizeTokenStatsTimeRangeCapsRange(t *testing.T) {
startTime, endTime, err := normalizeTokenStatsTimeRange(1_600_000_000, 1_700_000_000, 1_700_000_000)
require.NoError(t, err)
require.Equal(t, int64(1_700_000_000-tokenStatsMaxRangeSeconds), startTime)
require.Equal(t, int64(1_700_000_000), endTime)
}

func TestNormalizeTokenStatsTimeRangeRejectsInvalidRange(t *testing.T) {
_, _, err := normalizeTokenStatsTimeRange(1_700_000_001, 1_700_000_000, 1_700_000_000)
require.Error(t, err)
}
3 changes: 3 additions & 0 deletions model/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func InitOptionMap() {
common.OptionMap["DrawingEnabled"] = strconv.FormatBool(common.DrawingEnabled)
common.OptionMap["TaskEnabled"] = strconv.FormatBool(common.TaskEnabled)
common.OptionMap["DataExportEnabled"] = strconv.FormatBool(common.DataExportEnabled)
common.OptionMap["ApiKeyStatsEnabled"] = strconv.FormatBool(common.ApiKeyStatsEnabled)
common.OptionMap["ChannelDisableThreshold"] = strconv.FormatFloat(common.ChannelDisableThreshold, 'f', -1, 64)
common.OptionMap["EmailDomainRestrictionEnabled"] = strconv.FormatBool(common.EmailDomainRestrictionEnabled)
common.OptionMap["EmailAliasRestrictionEnabled"] = strconv.FormatBool(common.EmailAliasRestrictionEnabled)
Expand Down Expand Up @@ -295,6 +296,8 @@ func updateOptionMap(key string, value string) (err error) {
common.TaskEnabled = boolValue
case "DataExportEnabled":
common.DataExportEnabled = boolValue
case "ApiKeyStatsEnabled":
common.ApiKeyStatsEnabled = boolValue
case "DefaultCollapseSidebar":
common.DefaultCollapseSidebar = boolValue
case "MjNotifyEnabled":
Expand Down
2 changes: 2 additions & 0 deletions router/api-router.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,9 @@ func SetApiRouter(router *gin.Engine) {
logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs)
logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs)
logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
logRoute.GET("/stat/tokens", middleware.AdminAuth(), controller.GetLogStatsByToken)
logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat)
logRoute.GET("/self/stat/tokens", middleware.UserAuth(), controller.GetUserLogStatsByToken)
logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats)
logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs)
logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs)
Expand Down
2 changes: 1 addition & 1 deletion web/default/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
<ul
data-slot='sidebar-menu'
data-sidebar='menu'
className={cn('flex w-full min-w-0 flex-col gap-0', className)}
className={cn('flex w-full min-w-0 flex-col gap-0.5', className)}
{...props}
/>
)
Expand Down
2 changes: 2 additions & 0 deletions web/default/src/features/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export interface SystemStatus {
turnstile_site_key?: string
email_verification?: boolean
self_use_mode_enabled?: boolean
api_key_stats_enabled?: boolean
display_in_currency?: boolean
display_token_stat_enabled?: boolean
quota_per_unit?: number
Expand Down Expand Up @@ -156,6 +157,7 @@ export interface SystemStatus {
turnstile_site_key?: string
email_verification?: boolean
self_use_mode_enabled?: boolean
api_key_stats_enabled?: boolean
display_in_currency?: boolean
display_token_stat_enabled?: boolean
quota_per_unit?: number
Expand Down
23 changes: 22 additions & 1 deletion web/default/src/features/dashboard/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { api } from '@/lib/api'
import type { QuotaDataItem, UptimeGroupResult } from './types'
import type {
QuotaDataItem,
TokenQuotaDataItem,
UptimeGroupResult,
} from './types'

// ============================================================================
// Dashboard APIs
Expand Down Expand Up @@ -68,3 +72,20 @@ export async function getUptimeStatus() {
)
return res.data
}

export async function getTokenQuotaData(
params: {
start_timestamp: number
end_timestamp: number
},
isAdmin = false
) {
const endpoint = isAdmin
? '/api/log/stat/tokens'
: '/api/log/self/stat/tokens'
const res = await api.get<{ success: boolean; data: TokenQuotaDataItem[] }>(
endpoint,
{ params }
)
return res.data
}
Loading