Skip to content

Commit 944fe0d

Browse files
committed
feat(api-keys): token usage tab with time range, search, and column sorting
Adds a "Token Usage" tab on the API Keys page showing every key's token consumption—not just the top 3—with time range chips, free-text search, and sortable columns. Backend: - new database.APIKeyTokenStat type with input/output/cached/total token splits (UsageAPIKeyStat had only an aggregate total) - new database.ListAPIKeyTokenStats(ctx, start, end) — single SQL that works on both sqlite and postgres, no row limit - new GET /api/admin/usage/api-keys?start=&end= endpoint Frontend: - new APIKeyTokenUsagePanel component: - Today / This week / This month / Custom range chips (calendar-aligned, not "last N hours") - search by key name or masked key fragment - 8 columns, each click-to-sort with asc/desc toggle - default sort: total_tokens DESC - tabbed view on /api-keys: "Keys" (existing list) | "Token Usage" - localized strings for zh/en Closes #162.
1 parent f5e878e commit 944fe0d

8 files changed

Lines changed: 631 additions & 2 deletions

File tree

admin/handler.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
236236
api.GET("/accounts/event-trend", h.GetAccountEventTrend)
237237
api.POST("/accounts/usage/probe", h.ForceUsageProbe)
238238
api.GET("/usage/stats", h.GetUsageStats)
239+
api.GET("/usage/api-keys", h.GetAPIKeyTokenStats)
239240
api.GET("/usage/logs", h.GetUsageLogs)
240241
api.GET("/usage/chart-data", h.GetChartData)
241242
api.DELETE("/usage/logs", h.ClearUsageLogs)
@@ -2811,6 +2812,30 @@ func parseUsageStatsRange(startStr, endStr string) (time.Time, time.Time, error)
28112812
return start, end, nil
28122813
}
28132814

2815+
// GetAPIKeyTokenStats 返回按 API Key 聚合的 token 用量列表(issue #162)。
2816+
// 支持可选 query 参数 start/end (RFC3339);缺省回落到"今日"。
2817+
// 不分页/不限条数:前端做排序、搜索、分页。
2818+
func (h *Handler) GetAPIKeyTokenStats(c *gin.Context) {
2819+
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
2820+
defer cancel()
2821+
2822+
rangeStart, rangeEnd, err := parseUsageStatsRange(c.Query("start"), c.Query("end"))
2823+
if err != nil {
2824+
writeError(c, http.StatusBadRequest, err.Error())
2825+
return
2826+
}
2827+
2828+
items, err := h.db.ListAPIKeyTokenStats(ctx, rangeStart, rangeEnd)
2829+
if err != nil {
2830+
writeInternalError(c, err)
2831+
return
2832+
}
2833+
if items == nil {
2834+
items = []database.APIKeyTokenStat{}
2835+
}
2836+
c.JSON(http.StatusOK, gin.H{"items": items})
2837+
}
2838+
28142839
// GetChartData 返回图表聚合数据(服务端分桶 + 内存缓存)
28152840
func (h *Handler) GetChartData(c *gin.Context) {
28162841
startStr := c.Query("start")

database/api_key_usage.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,91 @@ func (db *DB) GetAPIKeyWindowUsage(ctx context.Context, apiKeyID int64, window t
4040
}
4141
return usage, nil
4242
}
43+
44+
// APIKeyTokenStat 是 API Key 在某时间区间内的 token 使用排行项。
45+
// 比 UsageAPIKeyStat 更细——分列 input / output / cached token,便于 UI 单独排序。
46+
type APIKeyTokenStat struct {
47+
APIKeyID int64 `json:"api_key_id"`
48+
APIKeyName string `json:"api_key_name"`
49+
APIKeyMasked string `json:"api_key_masked"`
50+
Label string `json:"label"`
51+
Requests int64 `json:"requests"`
52+
InputTokens int64 `json:"input_tokens"`
53+
OutputTokens int64 `json:"output_tokens"`
54+
CachedTokens int64 `json:"cached_tokens"`
55+
TotalTokens int64 `json:"total_tokens"`
56+
ErrorCount int64 `json:"error_count"`
57+
UserBilled float64 `json:"user_billed"`
58+
}
59+
60+
// ListAPIKeyTokenStats 返回 [rangeStart, rangeEnd) 区间内按 API Key 聚合的 token 用量。
61+
// 两个时间都可零值;rangeStart 零值表示"今日 0 点",rangeEnd 零值表示"至今"。
62+
// 返回结果**不限条数**,与 issue #162 一致;前端负责排序 / 搜索 / 分页。
63+
func (db *DB) ListAPIKeyTokenStats(ctx context.Context, rangeStart, rangeEnd time.Time) ([]APIKeyTokenStat, error) {
64+
now := time.Now()
65+
if rangeStart.IsZero() {
66+
rangeStart = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
67+
}
68+
69+
query := `
70+
SELECT
71+
COALESCE(api_key_id, 0) AS api_key_id,
72+
COALESCE(api_key_name, '') AS api_key_name,
73+
COALESCE(api_key_masked, '') AS api_key_masked,
74+
COUNT(*) AS requests,
75+
COALESCE(SUM(input_tokens), 0) AS input_tokens,
76+
COALESCE(SUM(output_tokens), 0) AS output_tokens,
77+
COALESCE(SUM(cached_tokens), 0) AS cached_tokens,
78+
COALESCE(SUM(total_tokens), 0) AS total_tokens,
79+
COALESCE(SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END), 0) AS error_count,
80+
COALESCE(SUM(user_billed), 0) AS user_billed
81+
FROM usage_logs
82+
WHERE status_code <> 499
83+
AND created_at >= $1
84+
`
85+
args := []interface{}{db.timeArg(rangeStart)}
86+
if !rangeEnd.IsZero() {
87+
query += " AND created_at < $2"
88+
args = append(args, db.timeArg(rangeEnd))
89+
}
90+
query += " GROUP BY 1, 2, 3 ORDER BY total_tokens DESC, requests DESC"
91+
92+
rows, err := db.conn.QueryContext(ctx, query, args...)
93+
if err != nil {
94+
return nil, err
95+
}
96+
defer rows.Close()
97+
98+
items := make([]APIKeyTokenStat, 0, 16)
99+
for rows.Next() {
100+
var item APIKeyTokenStat
101+
if err := rows.Scan(
102+
&item.APIKeyID,
103+
&item.APIKeyName,
104+
&item.APIKeyMasked,
105+
&item.Requests,
106+
&item.InputTokens,
107+
&item.OutputTokens,
108+
&item.CachedTokens,
109+
&item.TotalTokens,
110+
&item.ErrorCount,
111+
&item.UserBilled,
112+
); err != nil {
113+
return nil, err
114+
}
115+
// 计算 label(前端可直接展示):优先 name,其次 masked,否则 "unknown"
116+
switch {
117+
case item.APIKeyName != "":
118+
item.Label = item.APIKeyName
119+
case item.APIKeyMasked != "":
120+
item.Label = item.APIKeyMasked
121+
default:
122+
item.Label = "unknown"
123+
}
124+
items = append(items, item)
125+
}
126+
if err := rows.Err(); err != nil {
127+
return nil, err
128+
}
129+
return items, nil
130+
}

frontend/src/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
AddOpenAIResponsesAccountRequest,
77
AdminErrorResponse,
88
APIKeysResponse,
9+
APIKeyTokenStat,
910
AccountsResponse,
1011
ChartAggregation,
1112
CreateAccountResponse,
@@ -283,6 +284,15 @@ export const api = {
283284
const qs = searchParams.toString()
284285
return request<UsageStats>(qs ? `/usage/stats?${qs}` : '/usage/stats')
285286
},
287+
getAPIKeyTokenStats: (params: { start?: string; end?: string } = {}) => {
288+
const searchParams = new URLSearchParams()
289+
if (params.start) searchParams.set('start', params.start)
290+
if (params.end) searchParams.set('end', params.end)
291+
const qs = searchParams.toString()
292+
return request<{ items: APIKeyTokenStat[] }>(
293+
qs ? `/usage/api-keys?${qs}` : '/usage/api-keys',
294+
)
295+
},
286296
getUsageLogs: (params: { start?: string; end?: string; limit?: number } = {}) => {
287297
const searchParams = new URLSearchParams()
288298
if (params.start && params.end) {

0 commit comments

Comments
 (0)