Skip to content
Merged
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
7 changes: 7 additions & 0 deletions changelog/unreleased/codex-account-quota.md
Original file line number Diff line number Diff line change
@@ -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。
2 changes: 1 addition & 1 deletion frontend/src/components/account/AccountCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ export function AccountCard({
exceededLabel={t('quotaExceeded')}
provider={account.provider}
/>
) : <span className="text-[11px] text-foreground/65">{state === 'loading' ? t('quotaLoading') : t('quotaUnavailable')}</span>}
) : <span className="text-[11px] text-foreground/65">{state === 'hot' || state === 'ready' || state === 'loading' || state === 'starting' ? t('quotaLoading') : t('quotaUnavailable')}</span>}

{lastError ? (
<Tooltip>
Expand Down
47 changes: 29 additions & 18 deletions internal/providers/codex/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions internal/providers/codex/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
73 changes: 65 additions & 8 deletions internal/providers/codex/quota.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package codex

import (
"encoding/json"
"net/http"
"strconv"
"strings"
Expand All @@ -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
}
Expand All @@ -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,
Expand All @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions internal/providers/codex/quota_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
14 changes: 13 additions & 1 deletion internal/runtime/manager_quota.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading