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