feat: add bulk Codex usage viewer - #3272
Conversation
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 <noreply@anthropic.com>
WalkthroughThis PR extends Codex usage tracking by introducing bulk aggregation across all channels. The backend adds a new endpoint and refactors usage fetching with token refresh and upstream error handling. The frontend adds UI components and modal flows to display aggregated usage data. Changes
Sequence DiagramsequenceDiagram
actor User
participant Frontend as Frontend UI
participant Backend as Controller
participant Upstream as Upstream API
User->>Frontend: Click "View All Codex Usage"
Frontend->>Backend: GET /api/channel/codex/usage/all
loop For each channel
Backend->>Backend: Validate channel & OAuth key
Backend->>Upstream: Request usage (with current token)
Upstream-->>Backend: 401/403 response
Backend->>Backend: Refresh OAuth token
Backend->>Upstream: Retry request (with new token)
Upstream-->>Backend: Usage data
Backend->>Backend: Extract usage value & build item
end
Backend->>Backend: Accumulate summary (Total, Finished, Success, Failed)
Backend->>Backend: Sort items by UsageValue descending
Backend-->>Frontend: Return bulk usage list + summary
Frontend->>User: Display aggregated usage table with summary
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Tip CodeRabbit can suggest fixes for GitHub Check annotations.Configure the |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
controller/codex_usage.go (1)
205-210:⚠️ Potential issue | 🟠 MajorDon’t swallow refreshed-token persistence failures.
If the
keyupdate fails here, this request can still succeed once with the in-memory token, but the next read falls back to the stale credential from DB/cache and refreshes again. Please surface the write error and only invalidate caches after the update succeeds.Proposed fix
encoded, encErr := common.Marshal(oauthKey) - if encErr == nil { - _ = model.DB.Model(&model.Channel{}).Where("id = ?", ch.Id).Update("key", string(encoded)).Error - model.InitChannelCache() - service.ResetProxyClientCache() - } + if encErr != nil { + return 0, nil, encErr + } + if dbErr := model.DB.Model(&model.Channel{}).Where("id = ?", ch.Id).Update("key", string(encoded)).Error; dbErr != nil { + return 0, nil, dbErr + } + model.InitChannelCache() + service.ResetProxyClientCache()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@controller/codex_usage.go` around lines 205 - 210, The update to persist the refreshed token is currently swallowed; change the block that marshals oauthKey and calls Model(&model.Channel{}).Where("id = ?", ch.Id).Update("key", string(encoded)).Error so you capture the update error (e.g., updErr := ... .Error), return or surface that error instead of ignoring it, and only call model.InitChannelCache() and service.ResetProxyClientCache() when updErr == nil; if the update fails, log/return the error to the caller so the failure is not silently ignored and caches are not invalidated prematurely.
🧹 Nitpick comments (1)
web/src/components/table/channels/modals/CodexUsageModal.jsx (1)
281-283: UseuseTranslation()in the new bulk modal components.
BulkCodexUsageListandBulkCodexUsageLoaderkeep extending thetprop chain instead of reading fromreact-i18nextlocally. Since these are new components, this is a good place to stop the prop drilling.As per coding guidelines, "Frontend i18n: Use
i18next+react-i18next+i18next-browser-languagedetector. Translation files inweb/src/i18n/locales/{lang}.jsonmust be flat JSON with Chinese source strings as keys. UseuseTranslation()hook and callt('中文key')in components."Also applies to: 403-405
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@web/src/components/table/channels/modals/CodexUsageModal.jsx` around lines 281 - 283, Replace the manual t prop usage in the new modal components by using react-i18next's hook: import useTranslation from 'react-i18next' and inside BulkCodexUsageList (and BulkCodexUsageLoader) call const { t } = useTranslation(); remove the tt fallback and the t parameter from the component props/signature, update all t(...) references to the local t, and remove t from any parent prop drilling for these components so translations come from useTranslation() locally.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@controller/codex_usage.go`:
- Around line 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.
- 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.
In `@web/src/components/table/channels/modals/CodexUsageModal.jsx`:
- Around line 468-483: The progress handler (onDownloadProgress) is treating the
response as NDJSON and looking for parsed.channels, but GetAllCodexChannelUsage
returns a single JSON object { summary, data }, so parsed?.channels is always
empty; change onDownloadProgress to parse the full responseText once (not
line-by-line), read parsed.data (the channels array) instead of parsed.channels,
and seed placeholders via buildPlaceholderItems(parsed.data) -> sortItems ->
setItems and setSummary(normalizeSummary(...)). To avoid reparsing on every
progress event, add a small parsedOnceRef (similar to mountedRef) or compare
responseText length and only run the parsing/setting logic once per new full
payload; keep the same guards using mountedRef when setting state.
---
Outside diff comments:
In `@controller/codex_usage.go`:
- Around line 205-210: The update to persist the refreshed token is currently
swallowed; change the block that marshals oauthKey and calls
Model(&model.Channel{}).Where("id = ?", ch.Id).Update("key",
string(encoded)).Error so you capture the update error (e.g., updErr := ...
.Error), return or surface that error instead of ignoring it, and only call
model.InitChannelCache() and service.ResetProxyClientCache() when updErr == nil;
if the update fails, log/return the error to the caller so the failure is not
silently ignored and caches are not invalidated prematurely.
---
Nitpick comments:
In `@web/src/components/table/channels/modals/CodexUsageModal.jsx`:
- Around line 281-283: Replace the manual t prop usage in the new modal
components by using react-i18next's hook: import useTranslation from
'react-i18next' and inside BulkCodexUsageList (and BulkCodexUsageLoader) call
const { t } = useTranslation(); remove the tt fallback and the t parameter from
the component props/signature, update all t(...) references to the local t, and
remove t from any parent prop drilling for these components so translations come
from useTranslation() locally.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 9febb4ca-b0f4-4d72-aa31-0e0e4c802512
📒 Files selected for processing (5)
controller/codex_usage.gorouter/api-router.goweb/src/components/table/channels/ChannelsActions.jsxweb/src/components/table/channels/modals/CodexUsageModal.jsxweb/src/hooks/channels/useChannelsData.jsx
| "strings" | ||
| "time" | ||
|
|
||
| "encoding/json" |
There was a problem hiding this comment.
🧩 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 -5Repository: 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.goRepository: 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 -nRepository: QuantumNous/new-api
Length of output: 106
🏁 Script executed:
# Check what's available in common/json.go
cat -n common/json.go | head -100Repository: 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 2Repository: 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 -20Repository: 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.goRepository: 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 -nRepository: 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 3Repository: 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:
# 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 3Repository: 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.goRepository: 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 goRepository: 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 -nRepository: 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 -50Repository: 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 -30Repository: 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 2Repository: 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.goRepository: 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:
# Check if there's any UseNumber usage in codex_wham_usage.go
cat service/codex_wham_usage.go | head -50Repository: 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.goRepository: 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 goRepository: 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.goRepository: 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.")
EOFRepository: 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.goRepository: 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.
| 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++ | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| 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) {} |
There was a problem hiding this comment.
The progress parser is out of sync with /api/channel/codex/usage/all.
GetAllCodexChannelUsage returns { summary, data } in one JSON document, not a channels field or an NDJSON stream. parsed?.channels is therefore always empty here, so this handler never seeds placeholder rows and just keeps reparsing the growing response buffer for no UI gain.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@web/src/components/table/channels/modals/CodexUsageModal.jsx` around lines
468 - 483, The progress handler (onDownloadProgress) is treating the response as
NDJSON and looking for parsed.channels, but GetAllCodexChannelUsage returns a
single JSON object { summary, data }, so parsed?.channels is always empty;
change onDownloadProgress to parse the full responseText once (not
line-by-line), read parsed.data (the channels array) instead of parsed.channels,
and seed placeholders via buildPlaceholderItems(parsed.data) -> sortItems ->
setItems and setSummary(normalizeSummary(...)). To avoid reparsing on every
progress event, add a small parsedOnceRef (similar to mountedRef) or compare
responseText length and only run the parsing/setting logic once per new full
payload; keep the same guards using mountedRef when setting state.
|
不考虑增加对codex渠道做批量操作的功能,这个渠道是仅供个人使用,不向号池发展。 |
Summary
Test plan
🤖 Generated with Claude Code
Summary by CodeRabbit