From 0db84df97950bb97b7a04604818ad7e676ea4711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=B5=8E=E6=BA=90?= Date: Mon, 16 Mar 2026 19:05:23 +0800 Subject: [PATCH] feat: add bulk Codex usage viewer Add a dedicated Codex channel action to inspect all OAuth account usage in one place, with backend aggregation and an inline modal showing sorted rate-limit windows and raw payloads. Co-Authored-By: Claude Opus 4.6 --- controller/codex_usage.go | 228 +++++++++++--- router/api-router.go | 1 + .../table/channels/ChannelsActions.jsx | 13 + .../table/channels/modals/CodexUsageModal.jsx | 288 +++++++++++++++++- web/src/hooks/channels/useChannelsData.jsx | 14 +- 5 files changed, 497 insertions(+), 47 deletions(-) diff --git a/controller/codex_usage.go b/controller/codex_usage.go index 52fdbdf6fbce..7a5907aaf184 100644 --- a/controller/codex_usage.go +++ b/controller/codex_usage.go @@ -4,10 +4,13 @@ import ( "context" "fmt" "net/http" + "sort" "strconv" "strings" "time" + "encoding/json" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" @@ -17,6 +20,32 @@ import ( "github.com/gin-gonic/gin" ) +type codexUsageFetchResult struct { + Success bool + Message string + UpstreamStatus int + Payload any + UsageValue float64 +} + +type codexBulkUsageItem struct { + ChannelID int `json:"channel_id"` + ChannelName string `json:"channel_name"` + ChannelStatus int `json:"channel_status"` + Success bool `json:"success"` + Message string `json:"message"` + UpstreamStatus int `json:"upstream_status"` + UsageValue float64 `json:"usage_value"` + Data any `json:"data,omitempty"` +} + +type codexBulkUsageSummary struct { + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + Finished int `json:"finished"` +} + func GetCodexChannelUsage(c *gin.Context) { channelId, err := strconv.Atoi(c.Param("id")) if err != nil { @@ -29,54 +58,138 @@ func GetCodexChannelUsage(c *gin.Context) { common.ApiError(c, err) return } - if ch == nil { - c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel not found"}) + + result := fetchCodexChannelUsage(c.Request.Context(), ch) + resp := gin.H{ + "success": result.Success, + "message": result.Message, + "upstream_status": result.UpstreamStatus, + "data": result.Payload, + } + c.JSON(http.StatusOK, resp) +} + +func GetAllCodexChannelUsage(c *gin.Context) { + var channels []*model.Channel + err := model.DB.Where("type = ?", constant.ChannelTypeCodex).Order("id desc").Find(&channels).Error + if err != nil { + common.SysError("failed to get codex channels: " + err.Error()) + c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取 Codex 渠道失败,请稍后重试"}) return } + + items := make([]codexBulkUsageItem, 0, len(channels)) + summary := codexBulkUsageSummary{Total: len(channels)} + for _, ch := range channels { + if ch == nil { + continue + } + result := fetchCodexChannelUsage(c.Request.Context(), ch) + item := codexBulkUsageItem{ + ChannelID: ch.Id, + ChannelName: ch.Name, + ChannelStatus: ch.Status, + Success: result.Success, + Message: result.Message, + UpstreamStatus: result.UpstreamStatus, + UsageValue: result.UsageValue, + Data: result.Payload, + } + items = append(items, item) + summary.Finished++ + if item.Success { + summary.Success++ + } else { + summary.Failed++ + } + } + + sortCodexBulkUsageItems(items) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "summary": summary, + "data": items, + }) +} + +func sortCodexBulkUsageItems(items []codexBulkUsageItem) { + sort.SliceStable(items, func(i, j int) bool { + if items[i].UsageValue == items[j].UsageValue { + return items[i].ChannelID < items[j].ChannelID + } + return items[i].UsageValue > items[j].UsageValue + }) +} + +func fetchCodexChannelUsage(ctx context.Context, ch *model.Channel) codexUsageFetchResult { + if ch == nil { + return codexUsageFetchResult{Success: false, Message: "channel not found"} + } if ch.Type != constant.ChannelTypeCodex { - c.JSON(http.StatusOK, gin.H{"success": false, "message": "channel type is not Codex"}) - return + return codexUsageFetchResult{Success: false, Message: "channel type is not Codex"} } if ch.ChannelInfo.IsMultiKey { - c.JSON(http.StatusOK, gin.H{"success": false, "message": "multi-key channel is not supported"}) - return + return codexUsageFetchResult{Success: false, Message: "multi-key channel is not supported"} } oauthKey, err := codex.ParseOAuthKey(strings.TrimSpace(ch.Key)) if err != nil { common.SysError("failed to parse oauth key: " + err.Error()) - c.JSON(http.StatusOK, gin.H{"success": false, "message": "解析凭证失败,请检查渠道配置"}) - return + return codexUsageFetchResult{Success: false, Message: "解析凭证失败,请检查渠道配置"} } accessToken := strings.TrimSpace(oauthKey.AccessToken) accountID := strings.TrimSpace(oauthKey.AccountID) if accessToken == "" { - c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: access_token is required"}) - return + return codexUsageFetchResult{Success: false, Message: "codex channel: access_token is required"} } if accountID == "" { - c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: account_id is required"}) - return + return codexUsageFetchResult{Success: false, Message: "codex channel: account_id is required"} } client, err := service.NewProxyHttpClient(ch.GetSetting().Proxy) if err != nil { - common.ApiError(c, err) - return + return codexUsageFetchResult{Success: false, Message: err.Error()} } - ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + statusCode, body, err := requestCodexUsageWithRefresh(ctx, client, ch, oauthKey, accountID) + if err != nil { + common.SysError("failed to fetch codex usage: " + err.Error()) + return codexUsageFetchResult{Success: false, Message: "获取用量信息失败,请稍后重试"} + } + + var payload any + if common.Unmarshal(body, &payload) != nil { + payload = string(body) + } + + ok := statusCode >= 200 && statusCode < 300 + message := "" + if !ok { + message = fmt.Sprintf("upstream status: %d", statusCode) + } + + return codexUsageFetchResult{ + Success: ok, + Message: message, + UpstreamStatus: statusCode, + Payload: payload, + UsageValue: extractCodexUsageValue(payload), + } +} + +func requestCodexUsageWithRefresh(ctx context.Context, client *http.Client, ch *model.Channel, oauthKey *codex.OAuthKey, accountID string) (int, []byte, error) { + requestCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() - statusCode, body, err := service.FetchCodexWhamUsage(ctx, client, ch.GetBaseURL(), accessToken, accountID) + statusCode, body, err := service.FetchCodexWhamUsage(requestCtx, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID) if err != nil { - common.SysError("failed to fetch codex usage: " + err.Error()) - c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取用量信息失败,请稍后重试"}) - return + return 0, nil, err } if (statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden) && strings.TrimSpace(oauthKey.RefreshToken) != "" { - refreshCtx, refreshCancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + refreshCtx, refreshCancel := context.WithTimeout(ctx, 10*time.Second) defer refreshCancel() res, refreshErr := service.RefreshCodexOAuthTokenWithProxy(refreshCtx, oauthKey.RefreshToken, ch.GetSetting().Proxy) @@ -96,31 +209,70 @@ func GetCodexChannelUsage(c *gin.Context) { service.ResetProxyClientCache() } - ctx2, cancel2 := context.WithTimeout(c.Request.Context(), 15*time.Second) + requestCtx2, cancel2 := context.WithTimeout(ctx, 15*time.Second) defer cancel2() - statusCode, body, err = service.FetchCodexWhamUsage(ctx2, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID) + statusCode, body, err = service.FetchCodexWhamUsage(requestCtx2, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID) if err != nil { - common.SysError("failed to fetch codex usage after refresh: " + err.Error()) - c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取用量信息失败,请稍后重试"}) - return + return 0, nil, err } } } - var payload any - if common.Unmarshal(body, &payload) != nil { - payload = string(body) - } + return statusCode, body, nil +} - ok := statusCode >= 200 && statusCode < 300 - resp := gin.H{ - "success": ok, - "message": "", - "upstream_status": statusCode, - "data": payload, - } +func extractCodexUsageValue(payload any) float64 { + m, ok := payload.(map[string]any) if !ok { - resp["message"] = fmt.Sprintf("upstream status: %d", statusCode) + return 0 } - c.JSON(http.StatusOK, resp) + if v, ok := lookupFloat(m, "total_usage", "total", "used", "usage", "amount", "usd", "credits_used"); ok { + return v + } + if rateLimit, ok := m["rate_limit"].(map[string]any); ok { + maxVal := 0.0 + for _, key := range []string{"primary_window", "secondary_window"} { + window, ok := rateLimit[key].(map[string]any) + if !ok { + continue + } + if v, ok := lookupFloat(window, "used_percent", "usage_percent", "percent", "used"); ok && v > maxVal { + maxVal = v + } + } + return maxVal + } + return 0 +} + +func lookupFloat(m map[string]any, keys ...string) (float64, bool) { + for _, key := range keys { + value, ok := m[key] + if !ok { + continue + } + switch v := value.(type) { + case float64: + return v, true + case float32: + return float64(v), true + case int: + return float64(v), true + case int64: + return float64(v), true + case int32: + return float64(v), true + case json.Number: + f, err := v.Float64() + if err == nil { + return f, true + } + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + if err == nil { + return f, true + } + } + } + return 0, false } diff --git a/router/api-router.go b/router/api-router.go index 9836083df7e8..9691ec507bc3 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -223,6 +223,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.POST("/fix", controller.FixChannelsAbilities) channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) channelRoute.POST("/fetch_models", controller.FetchModels) + channelRoute.GET("/codex/usage/all", controller.GetAllCodexChannelUsage) channelRoute.POST("/codex/oauth/start", controller.StartCodexOAuth) channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth) channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel) diff --git a/web/src/components/table/channels/ChannelsActions.jsx b/web/src/components/table/channels/ChannelsActions.jsx index 3f185c55d7d1..4229eb13d771 100644 --- a/web/src/components/table/channels/ChannelsActions.jsx +++ b/web/src/components/table/channels/ChannelsActions.jsx @@ -52,6 +52,7 @@ const ChannelsActions = ({ getFormValues, loadChannels, searchChannels, + openAllCodexUsage, activeTypeKey, activePage, pageSize, @@ -230,6 +231,18 @@ const ChannelsActions = ({ /> + {activeTypeKey === '57' && !enableTagMode ? ( + + ) : null} + {/* 右侧:设置开关区域 */}
diff --git a/web/src/components/table/channels/modals/CodexUsageModal.jsx b/web/src/components/table/channels/modals/CodexUsageModal.jsx index 5e1317ac6b76..4fa3a796f00d 100644 --- a/web/src/components/table/channels/modals/CodexUsageModal.jsx +++ b/web/src/components/table/channels/modals/CodexUsageModal.jsx @@ -18,14 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { - Modal, - Button, - Progress, - Tag, - Typography, - Spin, -} from '@douyinfe/semi-ui'; +import { Button, Modal, Progress, Spin, Tag, Typography } from '@douyinfe/semi-ui'; import { API, showError } from '../../../../helpers'; const { Text } = Typography; @@ -66,6 +59,12 @@ const formatUnixSeconds = (unixSeconds) => { } }; +const formatUsageValue = (value) => { + const v = Number(value); + if (!Number.isFinite(v)) return '-'; + return v.toFixed(v >= 100 ? 0 : 2); +}; + const RateLimitWindowCard = ({ t, title, windowData }) => { const tt = typeof t === 'function' ? t : (v) => v; const percent = clampPercent(windowData?.used_percent ?? 0); @@ -279,6 +278,260 @@ const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => { ); }; +const BulkCodexUsageList = ({ t, items, summary, loading, onCopy, onRefresh }) => { + const tt = typeof t === 'function' ? t : (v) => v; + const finishedCount = Number(summary?.finished || 0); + const totalCount = Number(summary?.total || items.length || 0); + const successCount = Number(summary?.success || 0); + const failedCount = Number(summary?.failed || 0); + + if (!items.length && !loading) { + return ( +
+ {tt('暂无 Codex 用量数据')} +
+ +
+
+ ); + } + + return ( +
+
+
+ + {tt('共')} {totalCount} {tt('个账号,按用量从大到小排序')} + + + {tt('已完成')} {finishedCount}/{totalCount} + + + {tt('成功')} {successCount} + + + {tt('失败')} {failedCount} + +
+
+ {loading ? : null} + +
+
+
+ {items.map((item, index) => { + const rawText = JSON.stringify(item?.data ?? item, null, 2); + const rateLimit = item?.data?.rate_limit ?? {}; + const primary = rateLimit?.primary_window ?? null; + const secondary = rateLimit?.secondary_window ?? null; + return ( +
+
+
+ #{index + 1} + {item.channel_name || '-'} + ID {item.channel_id} + + {item.success + ? tt('成功') + : item.finished + ? tt('失败') + : tt('加载中')} + + + {tt('用量')} {formatUsageValue(item.usage_value)} + +
+
+ + {tt('上游状态码:')} + {item.finished ? item.upstream_status ?? '-' : '-'} + + {item.message ? ( + + {item.message} + + ) : null} +
+
+ +
+ + +
+ +
+
+
{tt('原始 JSON')}
+ +
+
+                  {item.finished ? rawText : tt('加载中...')}
+                
+
+
+ ); + })} +
+
+ ); +}; + +const BulkCodexUsageLoader = ({ t, onCopy }) => { + const tt = typeof t === 'function' ? t : (v) => v; + const [loading, setLoading] = useState(true); + const [items, setItems] = useState([]); + const [summary, setSummary] = useState({ + total: 0, + success: 0, + failed: 0, + finished: 0, + }); + const mountedRef = useRef(true); + const hasShownErrorRef = useRef(false); + + const normalizeSummary = useCallback((nextItems) => { + let success = 0; + let failed = 0; + let finished = 0; + nextItems.forEach((item) => { + if (!item?.finished) return; + finished += 1; + if (item?.success) success += 1; + else failed += 1; + }); + return { + total: nextItems.length, + success, + failed, + finished, + }; + }, []); + + const buildPlaceholderItems = useCallback((channelItems) => { + return (channelItems || []).map((item) => ({ + channel_id: item.channel_id, + channel_name: item.channel_name, + channel_status: item.channel_status, + success: false, + finished: false, + message: '', + upstream_status: null, + usage_value: 0, + data: null, + })); + }, []); + + const sortItems = useCallback((nextItems) => { + return [...nextItems].sort((a, b) => { + const aUsage = Number(a?.usage_value || 0); + const bUsage = Number(b?.usage_value || 0); + if (aUsage === bUsage) { + return Number(a?.channel_id || 0) - Number(b?.channel_id || 0); + } + return bUsage - aUsage; + }); + }, []); + + const fetchUsage = useCallback(async () => { + if (mountedRef.current) { + setLoading(true); + setItems([]); + setSummary({ total: 0, success: 0, failed: 0, finished: 0 }); + } + try { + const res = await API.get('/api/channel/codex/usage/all', { + skipErrorHandler: true, + onDownloadProgress: (progressEvent) => { + const xhr = progressEvent?.event?.target; + const responseText = xhr?.responseText; + if (!mountedRef.current || !responseText) return; + const lastLine = responseText + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .pop(); + if (!lastLine) return; + try { + const parsed = JSON.parse(lastLine); + const nextItems = sortItems(buildPlaceholderItems(parsed?.channels || [])); + setItems(nextItems); + setSummary(normalizeSummary(nextItems)); + } catch (error) {} + }, + }); + if (!mountedRef.current) return; + const responseData = res?.data ?? null; + const nextItems = sortItems( + (responseData?.data || []).map((item) => ({ + ...item, + finished: true, + })), + ); + setItems(nextItems); + setSummary( + responseData?.summary || normalizeSummary(nextItems), + ); + if (!responseData?.success && !hasShownErrorRef.current) { + hasShownErrorRef.current = true; + showError(tt('获取全部用量失败')); + } + } catch (error) { + if (!mountedRef.current) return; + if (!hasShownErrorRef.current) { + hasShownErrorRef.current = true; + showError(tt('获取全部用量失败')); + } + setItems([]); + setSummary({ total: 0, success: 0, failed: 0, finished: 0 }); + } finally { + if (mountedRef.current) setLoading(false); + } + }, [buildPlaceholderItems, normalizeSummary, sortItems, tt]); + + useEffect(() => { + mountedRef.current = true; + fetchUsage().catch(() => {}); + return () => { + mountedRef.current = false; + }; + }, [fetchUsage]); + + return ( + + ); +}; + export const openCodexUsageModal = ({ t, record, payload, onCopy }) => { const tt = typeof t === 'function' ? t : (v) => v; @@ -304,3 +557,22 @@ export const openCodexUsageModal = ({ t, record, payload, onCopy }) => { ), }); }; + +export const openBulkCodexUsageModal = ({ t, onCopy }) => { + const tt = typeof t === 'function' ? t : (v) => v; + + Modal.info({ + title: tt('全部 Codex 用量'), + centered: true, + width: 1000, + style: { maxWidth: '96vw' }, + content: , + footer: ( +
+ +
+ ), + }); +}; diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx index 37ee5010b201..0c4636dd9773 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/web/src/hooks/channels/useChannelsData.jsx @@ -38,7 +38,7 @@ import { useTableCompactMode } from '../common/useTableCompactMode'; import { useChannelUpstreamUpdates } from './useChannelUpstreamUpdates'; import { parseUpstreamUpdateMeta } from './upstreamUpdateUtils'; import { Modal, Button } from '@douyinfe/semi-ui'; -import { openCodexUsageModal } from '../../components/table/channels/modals/CodexUsageModal'; +import { openCodexUsageModal, openBulkCodexUsageModal } from '../../components/table/channels/modals/CodexUsageModal'; export const useChannelsData = () => { const { t } = useTranslation(); @@ -753,6 +753,17 @@ export const useChannelsData = () => { } }; + const openAllCodexUsage = () => { + openBulkCodexUsageModal({ + t, + onCopy: async (text) => { + const ok = await copy(text); + if (ok) showSuccess(t('已复制')); + else showError(t('复制失败')); + }, + }); + }; + const updateChannelBalance = async (record) => { if (record?.type === 57) { openCodexUsageModal({ @@ -1231,6 +1242,7 @@ export const useChannelsData = () => { deleteAllDisabledChannels, updateAllChannelsBalance, updateChannelBalance, + openAllCodexUsage, fixChannelsAbilities, checkOllamaVersion, testChannel,