From 9aec1ac9f21c58467f17a8a60b87eb1c2b2d8059 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com>
Date: Sat, 26 Sep 2026 00:49:49 +0800
Subject: [PATCH] fix(codex): read account quota windows without spending a
chat turn
Refresh uses the Codex usage probe for the 5-hour and weekly windows, and a signed-in account stays on "getting quota" until a real window arrives.
---
changelog/unreleased/codex-account-quota.md | 7 ++
.../src/components/account/AccountCard.tsx | 2 +-
internal/providers/codex/client.go | 47 +++++++-----
internal/providers/codex/credential.go | 4 +
internal/providers/codex/quota.go | 73 +++++++++++++++++--
internal/providers/codex/quota_test.go | 46 ++++++++++++
internal/runtime/manager_quota.go | 14 +++-
7 files changed, 165 insertions(+), 28 deletions(-)
create mode 100644 changelog/unreleased/codex-account-quota.md
create mode 100644 internal/providers/codex/quota_test.go
diff --git a/changelog/unreleased/codex-account-quota.md b/changelog/unreleased/codex-account-quota.md
new file mode 100644
index 0000000..1097b81
--- /dev/null
+++ b/changelog/unreleased/codex-account-quota.md
@@ -0,0 +1,7 @@
+### English
+
+- Show Codex 5-hour and weekly quota from the account usage probe, and keep a signed-in account on “getting quota” until a real window arrives.
+
+### 中文
+
+- Codex 账号改为从额度探测读取 5 小时和每周窗口;已登录但还没有窗口时显示「额度获取中」,不再显示「额度不可用」或 0/0。
diff --git a/frontend/src/components/account/AccountCard.tsx b/frontend/src/components/account/AccountCard.tsx
index fde547b..2a0cbbe 100644
--- a/frontend/src/components/account/AccountCard.tsx
+++ b/frontend/src/components/account/AccountCard.tsx
@@ -257,7 +257,7 @@ export function AccountCard({
exceededLabel={t('quotaExceeded')}
provider={account.provider}
/>
- ) : {state === 'loading' ? t('quotaLoading') : t('quotaUnavailable')}}
+ ) : {state === 'hot' || state === 'ready' || state === 'loading' || state === 'starting' ? t('quotaLoading') : t('quotaUnavailable')}}
{lastError ? (
diff --git a/internal/providers/codex/client.go b/internal/providers/codex/client.go
index 7cb8249..57c9021 100644
--- a/internal/providers/codex/client.go
+++ b/internal/providers/codex/client.go
@@ -665,7 +665,32 @@ func (c *Client) Probe(ctx context.Context, accountID string) (providers.Account
// response carries, so the console can show the 5h/7d windows without a
// separate probe endpoint.
func (c *Client) observeQuotaHeaders(accountID string, header http.Header) {
- info := quotaFromHeaders(header)
+ c.rememberQuota(accountID, quotaFromHeaders(header))
+}
+
+func (c *Client) Quota(ctx context.Context, accountID string) (*providers.QuotaInfo, error) {
+ credential, err := c.credential(ctx, accountID)
+ if err != nil {
+ return nil, err
+ }
+ body, status, err := c.do(ctx, accountID, http.MethodGet, ChatBase+pathWhamUsage, nil, func(header http.Header) {
+ SetChatHeaders(header, credential, "", false)
+ })
+ if err != nil {
+ return c.cachedQuota(accountID), nil
+ }
+ if status < 300 {
+ if info := quotaFromUsage(body); info != nil {
+ c.rememberQuota(accountID, info)
+ return info, nil
+ }
+ }
+ // A probe that did not carry windows is not a zero balance. Keep the last
+ // windows observed from a chat response rather than saving an empty snapshot.
+ return c.cachedQuota(accountID), nil
+}
+
+func (c *Client) rememberQuota(accountID string, info *providers.QuotaInfo) {
if info == nil {
return
}
@@ -677,24 +702,10 @@ func (c *Client) observeQuotaHeaders(accountID string, header http.Header) {
c.mu.Unlock()
}
-func (c *Client) Quota(ctx context.Context, accountID string) (*providers.QuotaInfo, error) {
+func (c *Client) cachedQuota(accountID string) *providers.QuotaInfo {
c.mu.Lock()
- cached := c.quotaCache[accountID]
- c.mu.Unlock()
- if cached != nil {
- return cached, nil
- }
- // No standalone usage endpoint: quota rides on chat response headers. Make a
- // minimal request only when nothing is cached yet? The codex upstream has no
- // cheap ping; report empty instead of burning a turn.
- if _, err := c.credential(ctx, accountID); err != nil {
- return nil, err
- }
- return &providers.QuotaInfo{
- Unit: QuotaUnit,
- FetchedAt: time.Now().UTC().Format(time.RFC3339),
- ProviderID: "codex",
- }, nil
+ defer c.mu.Unlock()
+ return c.quotaCache[accountID]
}
func classifiedError(status int, body []byte) error {
diff --git a/internal/providers/codex/credential.go b/internal/providers/codex/credential.go
index bcc4bf6..821d53a 100644
--- a/internal/providers/codex/credential.go
+++ b/internal/providers/codex/credential.go
@@ -30,6 +30,10 @@ const (
ChatBase = "https://chatgpt.com/backend-api/codex"
pathResponses = "/responses"
+ // pathWhamUsage is the account usage probe. It returns the same rate_limits
+ // object the websocket codex.rate_limits event carries, without spending a
+ // chat turn. CLIProxyAPI reads those fields from response headers instead.
+ pathWhamUsage = "/wham/usage"
// Cloaking: upstream gates on the official codex CLI UA/originator.
UserAgent = "codex-tui/0.154.0 (Mac OS 26.5.2; arm64) iTerm.app/3.6.11 (codex-tui; 0.154.0)"
diff --git a/internal/providers/codex/quota.go b/internal/providers/codex/quota.go
index 3d2296d..93f371d 100644
--- a/internal/providers/codex/quota.go
+++ b/internal/providers/codex/quota.go
@@ -1,6 +1,7 @@
package codex
import (
+ "encoding/json"
"net/http"
"strconv"
"strings"
@@ -15,8 +16,64 @@ import (
// x-codex-secondary-used-percent, x-codex-secondary-reset-after-seconds (7d),
// plus x-codex-*-window-minutes variants and x-codex-plan-type.
func quotaFromHeaders(h http.Header) *providers.QuotaInfo {
- primary := windowFromHeaders(h, "X-Codex-Primary-", "primary")
- secondary := windowFromHeaders(h, "X-Codex-Secondary-", "secondary")
+ primary := windowFromHeaders(h, "X-Codex-Primary-", "fiveHour", "5-hour limit")
+ secondary := windowFromHeaders(h, "X-Codex-Secondary-", "weeklyLimit", "weekly limit")
+ return quotaFromWindows(primary, secondary, h.Get("X-Codex-Plan-Type"))
+}
+
+// quotaFromUsage decodes the /wham/usage body. The shape matches the websocket
+// codex.rate_limits event: rate_limits.primary / secondary each carry
+// used_percent, window_minutes, and reset_after_seconds or reset_at.
+func quotaFromUsage(body []byte) *providers.QuotaInfo {
+ var payload struct {
+ RateLimits struct {
+ Primary codexRateWindow `json:"primary"`
+ Secondary codexRateWindow `json:"secondary"`
+ } `json:"rate_limits"`
+ PlanType string `json:"plan_type"`
+ }
+ if json.Unmarshal(body, &payload) != nil {
+ return nil
+ }
+ primary := payload.RateLimits.Primary.window("fiveHour", "5-hour limit")
+ secondary := payload.RateLimits.Secondary.window("weeklyLimit", "weekly limit")
+ return quotaFromWindows(primary, secondary, payload.PlanType)
+}
+
+type codexRateWindow struct {
+ UsedPercent float64 `json:"used_percent"`
+ WindowMinutes int64 `json:"window_minutes"`
+ ResetAfterSeconds int64 `json:"reset_after_seconds"`
+ ResetAt int64 `json:"reset_at"`
+}
+
+func (w codexRateWindow) window(id, label string) *providers.QuotaWindow {
+ if w.UsedPercent < 0 || w.UsedPercent > 100 || w.WindowMinutes <= 0 {
+ return nil
+ }
+ if w.ResetAfterSeconds < 0 && w.ResetAt <= 0 {
+ return nil
+ }
+ window := &providers.QuotaWindow{
+ ID: id,
+ Label: label,
+ Used: w.UsedPercent,
+ Total: 100,
+ Remaining: 100 - w.UsedPercent,
+ Percentage: w.UsedPercent,
+ Unit: "percent",
+ Exceeded: w.UsedPercent >= 100,
+ }
+ switch {
+ case w.ResetAt > 0:
+ window.ResetAt = time.Unix(w.ResetAt, 0).UTC().Format(time.RFC3339)
+ case w.ResetAfterSeconds >= 0:
+ window.ResetAt = time.Now().Add(time.Duration(w.ResetAfterSeconds) * time.Second).UTC().Format(time.RFC3339)
+ }
+ return window
+}
+
+func quotaFromWindows(primary, secondary *providers.QuotaWindow, planType string) *providers.QuotaInfo {
if primary == nil && secondary == nil {
return nil
}
@@ -26,16 +83,16 @@ func quotaFromHeaders(h http.Header) *providers.QuotaInfo {
if primary != nil {
windows = append(windows, *primary)
usedPct = primary.Percentage
- exceeded = exceeded || primary.Exceeded
+ exceeded = primary.Exceeded
}
if secondary != nil {
windows = append(windows, *secondary)
- // Route on the tighter window.
if secondary.Percentage > usedPct {
usedPct = secondary.Percentage
}
exceeded = exceeded || secondary.Exceeded
}
+ _ = planType
return &providers.QuotaInfo{
Used: usedPct,
Total: 100,
@@ -49,16 +106,16 @@ func quotaFromHeaders(h http.Header) *providers.QuotaInfo {
}
}
-func windowFromHeaders(h http.Header, prefix, label string) *providers.QuotaWindow {
+func windowFromHeaders(h http.Header, prefix, id, label string) *providers.QuotaWindow {
used := headerFloat(h, prefix+"Used-Percent")
resetSecs := headerFloat(h, prefix+"Reset-After-Seconds")
windowMinutes := headerFloat(h, prefix+"Window-Minutes")
- if used <= 0 && resetSecs <= 0 {
+ if used < 0 || (used == 0 && resetSecs <= 0 && windowMinutes <= 0) {
return nil
}
window := &providers.QuotaWindow{
- ID: label,
- Label: label + " window",
+ ID: id,
+ Label: label,
Used: used,
Total: 100,
Remaining: 100 - used,
diff --git a/internal/providers/codex/quota_test.go b/internal/providers/codex/quota_test.go
new file mode 100644
index 0000000..c575f65
--- /dev/null
+++ b/internal/providers/codex/quota_test.go
@@ -0,0 +1,46 @@
+package codex
+
+import (
+ "net/http"
+ "testing"
+)
+
+func TestQuotaFromUsageReadsRateLimitWindows(t *testing.T) {
+ body := []byte(`{
+ "plan_type":"plus",
+ "rate_limits":{
+ "primary":{"used_percent":12.5,"window_minutes":300,"reset_after_seconds":600},
+ "secondary":{"used_percent":40,"window_minutes":10080,"reset_at":1893456000}
+ }
+ }`)
+ info := quotaFromUsage(body)
+ if info == nil || len(info.Windows) != 2 {
+ t.Fatalf("windows = %+v", info)
+ }
+ if info.Windows[0].ID != "fiveHour" || info.Windows[0].Percentage != 12.5 || info.Windows[0].ResetAt == "" {
+ t.Fatalf("primary = %+v", info.Windows[0])
+ }
+ if info.Windows[1].ID != "weeklyLimit" || info.Windows[1].Percentage != 40 || info.Percentage != 40 {
+ t.Fatalf("secondary = %+v info=%+v", info.Windows[1], info)
+ }
+}
+
+func TestQuotaFromUsageRejectsIncompleteWindows(t *testing.T) {
+ if info := quotaFromUsage([]byte(`{"rate_limits":{"primary":{"used_percent":0}}}`)); info != nil {
+ t.Fatalf("incomplete window accepted: %+v", info)
+ }
+ if info := quotaFromUsage([]byte(`not json`)); info != nil {
+ t.Fatal("invalid json accepted")
+ }
+}
+
+func TestQuotaFromHeadersKeepsZeroUsedWindow(t *testing.T) {
+ header := http.Header{}
+ header.Set("X-Codex-Primary-Used-Percent", "0")
+ header.Set("X-Codex-Primary-Window-Minutes", "300")
+ header.Set("X-Codex-Primary-Reset-After-Seconds", "1000")
+ info := quotaFromHeaders(header)
+ if info == nil || len(info.Windows) != 1 || info.Windows[0].ID != "fiveHour" || info.Windows[0].Percentage != 0 {
+ t.Fatalf("zero usage dropped: %+v", info)
+ }
+}
diff --git a/internal/runtime/manager_quota.go b/internal/runtime/manager_quota.go
index 65ad48c..a965e50 100644
--- a/internal/runtime/manager_quota.go
+++ b/internal/runtime/manager_quota.go
@@ -19,7 +19,9 @@ func (m *Manager) fetchProviderQuota(ctx context.Context, accountID string, prob
return
}
info, err := prober.Quota(ctx, accountID)
- if err != nil || info == nil {
+ if err != nil || info == nil || !quotaInfoHasWindows(info) {
+ // An empty snapshot is "not fetched yet", not a zero balance. Saving it
+ // makes the account card render 0/0 and hides a later real reading.
return
}
unit := info.Unit
@@ -69,6 +71,16 @@ func quotaPackagesFromInfo(packages []providers.QuotaPackage) []accounts.QuotaPa
return out
}
+func quotaInfoHasWindows(info *providers.QuotaInfo) bool {
+ if info == nil {
+ return false
+ }
+ if len(info.Windows) > 0 || len(info.Packages) > 0 {
+ return true
+ }
+ return info.Total > 0 || info.Used > 0 || info.Remaining > 0 || info.Percentage > 0 || info.Exceeded
+}
+
func quotaWindowsFromInfo(windows []providers.QuotaWindow) []accounts.QuotaWindow {
if len(windows) == 0 {
return nil