Skip to content
Closed
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
228 changes: 190 additions & 38 deletions controller/codex_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import (
"context"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"

"encoding/json"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the file structure and locate the file
find . -name "codex_usage.go" -type f 2>/dev/null | head -5

Repository: QuantumNous/new-api

Length of output: 90


🏁 Script executed:

# Get the file size to determine how much we can read
wc -l controller/codex_usage.go 2>/dev/null || echo "File not found"

Repository: QuantumNous/new-api

Length of output: 92


🏁 Script executed:

# Read the import section (around line 12) and the problematic section (248-275)
echo "=== Lines 1-25 (import section) ===" && sed -n '1,25p' controller/codex_usage.go && echo -e "\n=== Lines 245-280 (problematic section) ===" && sed -n '245,280p' controller/codex_usage.go

Repository: QuantumNous/new-api

Length of output: 1207


🏁 Script executed:

# Check if encoding/json is directly used in the file
rg "encoding/json|json\\.Number" controller/codex_usage.go -n

Repository: QuantumNous/new-api

Length of output: 106


🏁 Script executed:

# Check what's available in common/json.go
cat -n common/json.go | head -100

Repository: QuantumNous/new-api

Length of output: 1132


🏁 Script executed:

# Check all usages of json.Number in the file
rg "json\\.Number" controller/codex_usage.go -n -B 2 -A 2

Repository: QuantumNous/new-api

Length of output: 187


🏁 Script executed:

# Check if json.Number or UseNumber is used anywhere in the codebase
rg "json\\.Number|UseNumber" --type go | head -20

Repository: QuantumNous/new-api

Length of output: 451


🏁 Script executed:

# Check the full lookupFloat function context and where it's called
sed -n '248,277p' controller/codex_usage.go

Repository: QuantumNous/new-api

Length of output: 663


🏁 Script executed:

# Check if there are any alternatives or patterns for handling json.Number in common/json.go
rg "UseNumber|json\.Number|RawMessage|Number" common/json.go -n

Repository: QuantumNous/new-api

Length of output: 113


🏁 Script executed:

# Check all uses of the json package (from encoding/json import) in the file
rg "json\." controller/codex_usage.go -n | grep -v "gjson\." | grep -v "json\""

Repository: QuantumNous/new-api

Length of output: 86


🏁 Script executed:

# Check the context where lookupFloat is called to understand the data source
rg "lookupFloat" controller/codex_usage.go -n -B 3 -A 3

Repository: QuantumNous/new-api

Length of output: 662


🏁 Script executed:

# Check if service/error.go also imports encoding/json
head -20 service/error.go

Repository: QuantumNous/new-api

Length of output: 422


🏁 Script executed:

# Check how error.go uses json.Number to see if there's a pattern we should follow
rg "json\.Number" service/error.go -n -B 3 -A 3

Repository: QuantumNous/new-api

Length of output: 242


🏁 Script executed:

# Find where the maps being passed to lookupFloat are created
sed -n '220,246p' controller/codex_usage.go

Repository: QuantumNous/new-api

Length of output: 716


🏁 Script executed:

# Search for UseNumber in the codebase to understand the source of json.Number values
rg "UseNumber" --type go

Repository: QuantumNous/new-api

Length of output: 45


🏁 Script executed:

# Check if there's any comment or documentation about why json.Number handling is needed
sed -n '200,248p' controller/codex_usage.go | cat -n

Repository: QuantumNous/new-api

Length of output: 1775


🏁 Script executed:

# Find where FetchCodexWhamUsage is defined and how it unmarshals
rg "FetchCodexWhamUsage" --type go -A 30 | head -50

Repository: QuantumNous/new-api

Length of output: 3109


🏁 Script executed:

# Search more specifically in service for codex usage fetching
rg "extractCodexUsageValue" controller/codex_usage.go -B 10 | head -30

Repository: QuantumNous/new-api

Length of output: 606


🏁 Script executed:

# Check if any decoder.UseNumber() calls exist in the codebase for codex handling
rg "Decode|Unmarshal" controller/codex_usage.go -n -B 2 -A 2

Repository: QuantumNous/new-api

Length of output: 174


🏁 Script executed:

# Let's see the full context of how the payload arrives at extractCodexUsageValue
sed -n '150,225p' controller/codex_usage.go

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.go

Repository: QuantumNous/new-api

Length of output: 1132


🏁 Script executed:

# Check if there's any UseNumber usage in codex_wham_usage.go
cat service/codex_wham_usage.go | head -50

Repository: QuantumNous/new-api

Length of output: 1171


🏁 Script executed:

# Verify the actual behavior: check if json.Number is possible without UseNumber()
cat > /tmp/test_unmarshal.go << 'EOF'
package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	// Standard Unmarshal without UseNumber
	var payload any
	err := json.Unmarshal([]byte(`{"value": 123.45}`), &payload)
	if err != nil {
		return
	}
	m := payload.(map[string]any)
	v := m["value"]
	fmt.Printf("Type of value: %T\n", v)
	
	// With UseNumber via Decoder
	var payload2 any
	decoder := json.NewDecoder(nil)
	decoder.UseNumber()
	// Would produce json.Number, but not with plain Unmarshal
}
EOF
cat /tmp/test_unmarshal.go

Repository: QuantumNous/new-api

Length of output: 528


🏁 Script executed:

# Search for UseNumber in the codebase to see if any decoder is using it
rg "UseNumber" --type go

Repository: QuantumNous/new-api

Length of output: 45


🏁 Script executed:

# Double-check: is json.Number actually reachable in this code flow?
# Look at what types can be returned from the Codex API unmarshaling
sed -n '160,170p' controller/codex_usage.go

Repository: QuantumNous/new-api

Length of output: 287


🏁 Script executed:

# Create a simple test to confirm what types json.Unmarshal produces
python3 << 'EOF'
# Standard json.Unmarshal without UseNumber produces float64 for JSON numbers
# json.Number is ONLY produced when using json.NewDecoder().UseNumber()
print("Standard json.Unmarshal behavior:")
print("- JSON numbers -> Go float64 (not json.Number)")
print("- json.Number is ONLY available via json.Decoder with UseNumber()")
print("")
print("Since common.Unmarshal uses json.Unmarshal directly (no UseNumber),")
print("the case json.Number branch in lookupFloat is UNREACHABLE dead code.")
EOF

Repository: QuantumNous/new-api

Length of output: 347


🏁 Script executed:

# Verify there are no decoder.UseNumber() calls related to codex usage parsing
rg "DecodeJson|UseNumber|json.NewDecoder" service/codex_wham_usage.go

Repository: QuantumNous/new-api

Length of output: 45


🏁 Script executed:

# Final check: confirm the import is used ONLY for json.Number
rg "json\\..*" controller/codex_usage.go -n | grep -v "gjson"

Repository: QuantumNous/new-api

Length of output: 86


Remove the direct encoding/json import — it violates the wrapper-only JSON rule and is unreachable.

The encoding/json import at line 12 breaks the codebase's requirement to use only common/json.go wrappers for JSON operations. The json.Number case at line 265 is dead code: common.Unmarshal() uses standard json.Unmarshal without UseNumber(), which produces float64 for JSON numbers, never json.Number. Delete the import and remove the unreachable case statement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/codex_usage.go` at line 12, Remove the direct encoding/json import
and the unreachable json.Number case in the type switch: delete the
"encoding/json" import from the imports list and remove the switch case that
handles json.Number in the function that processes unmarshaled values (reference
common.Unmarshal() and the case for json.Number) so the file only relies on
common/json.go wrappers and no dead-case remains.


"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
Expand All @@ -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 {
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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 requestCodexUsageWithRefresh, a few slow accounts can push this endpoint past common proxy timeouts and make the bulk modal unusable. A small worker pool or errgroup limit here would keep one bad channel from stalling the whole admin action.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/codex_usage.go` around lines 83 - 105, The loop over channels
currently calls fetchCodexChannelUsage serially, letting slow upstream requests
in requestCodexUsageWithRefresh stall the whole operation; change this to a
bounded parallel fetch (e.g., use an errgroup with a semaphore or worker pool)
to run a limited number (N) of concurrent calls to fetchCodexChannelUsage,
collect results into codexBulkUsageItem and append to items safely (protect
items and summary with a mutex or send results on a channel), and update
summary.Finished/Success/Failed atomically; ensure you preserve the
ChannelID/Name/Status mapping when aggregating concurrent results.


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)
Expand All @@ -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
}
1 change: 1 addition & 0 deletions router/api-router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions web/src/components/table/channels/ChannelsActions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const ChannelsActions = ({
getFormValues,
loadChannels,
searchChannels,
openAllCodexUsage,
activeTypeKey,
activePage,
pageSize,
Expand Down Expand Up @@ -230,6 +231,18 @@ const ChannelsActions = ({
/>
</div>

{activeTypeKey === '57' && !enableTagMode ? (
<Button
size='small'
type='primary'
theme='outline'
onClick={openAllCodexUsage}
className='w-full md:w-auto order-3 md:order-none'
>
{t('查看全部 Codex 用量')}
</Button>
) : null}

{/* 右侧:设置开关区域 */}
<div className='flex flex-col md:flex-row items-start md:items-center gap-2 w-full md:w-auto order-1 md:order-2'>
<div className='flex items-center justify-between w-full md:w-auto'>
Expand Down
Loading