-
Notifications
You must be signed in to change notification settings - Fork 11.2k
feat: add bulk Codex usage viewer #3272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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++ | ||
| } | ||
| } | ||
|
Comment on lines
+83
to
+105
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bound the bulk fetch with concurrency instead of serial calls. This loop does the upstream request, optional token refresh, and retry one channel at a time. With the 15s/10s/15s timeouts in 🤖 Prompt for AI Agents |
||
|
|
||
| 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 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 90
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 92
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 1207
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 106
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 1132
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 187
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 451
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 663
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 113
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 86
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 662
🏁 Script executed:
# Check if service/error.go also imports encoding/json head -20 service/error.goRepository: QuantumNous/new-api
Length of output: 422
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 242
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 716
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 45
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 1775
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 3109
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 606
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 174
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 2585
🏁 Script executed:
# Check how common.Unmarshal is implemented to see if it uses UseNumber cat -n common/json.goRepository: QuantumNous/new-api
Length of output: 1132
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 1171
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 528
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 45
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 287
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 347
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 45
🏁 Script executed:
Repository: QuantumNous/new-api
Length of output: 86
Remove the direct
encoding/jsonimport — it violates the wrapper-only JSON rule and is unreachable.The
encoding/jsonimport at line 12 breaks the codebase's requirement to use onlycommon/json.gowrappers for JSON operations. Thejson.Numbercase at line 265 is dead code:common.Unmarshal()uses standardjson.UnmarshalwithoutUseNumber(), which producesfloat64for JSON numbers, neverjson.Number. Delete the import and remove the unreachable case statement.🤖 Prompt for AI Agents