From 376c6467404afdb1870ab4601b0dc17265f885cc Mon Sep 17 00:00:00 2001 From: yoruuuchan <1587761204@qq.com> Date: Wed, 4 Nov 2026 15:05:45 +0900 Subject: [PATCH] fix(codex): probe quota from backend-api /wham/usage The usage probe was built on ChatBase, which carries the /codex chat prefix: /backend-api/codex/wham/usage answers with the Cloudflare 403 interstitial, so every probe failed. Quota() swallowed that failure and returned the in-memory cache, which is only filled from chat response headers. An account already marked exhausted never runs a chat, so the stale snapshot stayed in place forever and SaveQuota kept the account at quota_exhausted. Probe /backend-api/wham/usage instead, and report a probe failure when no windows are cached so the sweep surfaces the real error. --- .../unreleased/codex-quota-usage-probe.md | 7 ++ internal/providers/codex/client.go | 34 +++++--- internal/providers/codex/credential.go | 5 ++ internal/providers/codex/quota_test.go | 78 +++++++++++++++++++ 4 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 changelog/unreleased/codex-quota-usage-probe.md diff --git a/changelog/unreleased/codex-quota-usage-probe.md b/changelog/unreleased/codex-quota-usage-probe.md new file mode 100644 index 00000000..bf29710c --- /dev/null +++ b/changelog/unreleased/codex-quota-usage-probe.md @@ -0,0 +1,7 @@ +### English + +- Fix Codex quota refresh: the usage probe now calls the backend-api `/wham/usage` endpoint instead of the chat-prefixed `/codex/wham/usage` path, which answers with the Cloudflare 403 interstitial. An account whose last snapshot says "exhausted" refreshes its windows again instead of staying quota-blocked on stale numbers, and a probe failure with no cached windows is reported instead of silently ignored. + +### 中文 + +- 修复 Codex 额度刷新:用量探测改走 backend-api 的 `/wham/usage`,不再请求聊天前缀下的 `/codex/wham/usage`(那里返回的是 Cloudflare 403 拦截页)。上次快照显示「额度耗尽」的账号现在会重新拉取窗口数据,不再一直卡在过期数字上;探测失败且没有缓存窗口时会如实报错,而不是静默忽略。 diff --git a/internal/providers/codex/client.go b/internal/providers/codex/client.go index a68bdadd..ff1cc5bf 100644 --- a/internal/providers/codex/client.go +++ b/internal/providers/codex/client.go @@ -881,26 +881,38 @@ func (c *Client) Quota(ctx context.Context, accountID string) (*providers.QuotaI return nil, err } if credential.UsesChatGPT() { - // Sign in with ChatGPT talks to api.openai.com, not the Codex - // backend /wham/usage probe. Keep the last windows observed from - // a chat response rather than calling an endpoint that 404s. + // Sign in with ChatGPT tokens address api.openai.com and the + // backend-api probe rejects them. Keep the last windows observed + // from a chat response instead. return c.cachedQuota(accountID), nil } - body, status, err := c.do(ctx, accountID, http.MethodGet, ChatBase+pathWhamUsage, nil, func(header http.Header) { + body, status, err := c.do(ctx, accountID, http.MethodGet, UsageBase+pathWhamUsage, nil, func(header http.Header) { SetChatHeaders(header, credential, "", false) }) if err != nil { - return c.cachedQuota(accountID), nil + return c.quotaFallback(accountID, err) } - if status < 300 { - if info := quotaFromUsage(body); info != nil { - c.rememberQuota(accountID, info) - return info, nil - } + if status >= 300 { + return c.quotaFallback(accountID, fmt.Errorf("codex usage probe: http %d", status)) + } + 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 + return c.quotaFallback(accountID, fmt.Errorf("codex usage probe returned no rate-limit windows")) +} + +// quotaFallback returns the last windows observed from a chat response when the +// probe fails, and reports the probe failure when nothing is cached. Swallowing +// the error instead leaves a stale exhausted snapshot in place: the account +// stays quota-blocked, so no chat runs to refresh its headers, forever. +func (c *Client) quotaFallback(accountID string, probeErr error) (*providers.QuotaInfo, error) { + if cached := c.cachedQuota(accountID); cached != nil { + return cached, nil + } + return nil, probeErr } func (c *Client) rememberQuota(accountID string, info *providers.QuotaInfo) { diff --git a/internal/providers/codex/credential.go b/internal/providers/codex/credential.go index 99f30744..e9872711 100644 --- a/internal/providers/codex/credential.go +++ b/internal/providers/codex/credential.go @@ -47,6 +47,11 @@ const ( ChatBase = "https://chatgpt.com/backend-api/codex" pathResponses = "/responses" + // UsageBase is the backend-api root that serves the account usage probe. + // The probe is not under ChatBase: /backend-api/codex/wham/usage answers + // with the Cloudflare 403 interstitial, while /backend-api/wham/usage + // returns the rate_limit windows as JSON. + UsageBase = "https://chatgpt.com/backend-api" // 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. diff --git a/internal/providers/codex/quota_test.go b/internal/providers/codex/quota_test.go index d4883ec2..f02efcd1 100644 --- a/internal/providers/codex/quota_test.go +++ b/internal/providers/codex/quota_test.go @@ -1,10 +1,88 @@ package codex import ( + "context" + "io" "net/http" + "strings" "testing" ) +// roundTripFunc serves Quota's probe request from a test closure without a +// network round-trip; the client keeps its real base URLs, so the test also +// pins the endpoint the probe calls. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func quotaProbeClient(t *testing.T, transport roundTripFunc) *Client { + t.Helper() + client := NewClient(testStore{items: map[string][]byte{"a1": credentialPayload()}}) + client.http.Transport = transport + return client +} + +func TestQuotaProbesBackendApiUsage(t *testing.T) { + var gotURL, gotAuth, gotOriginator string + client := quotaProbeClient(t, func(r *http.Request) (*http.Response, error) { + gotURL = r.URL.String() + gotAuth = r.Header.Get("Authorization") + gotOriginator = r.Header.Get("Originator") + body := `{"plan_type":"plus","rate_limit":{ + "primary_window":{"used_percent":0,"limit_window_seconds":18000,"reset_after_seconds":900}, + "secondary_window":{"used_percent":54,"limit_window_seconds":604800,"reset_after_seconds":2263}}}` + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}}, nil + }) + info, err := client.Quota(context.Background(), "a1") + if err != nil { + t.Fatalf("quota: %v", err) + } + // The probe lives on the backend-api root, not under the /codex chat + // prefix: the /codex path answers with the Cloudflare 403 interstitial. + if gotURL != "https://chatgpt.com/backend-api/wham/usage" { + t.Fatalf("probe url = %q", gotURL) + } + if strings.Contains(gotURL, "/codex/") { + t.Fatalf("probe url must not use the chat prefix: %q", gotURL) + } + if gotAuth != "Bearer at" || gotOriginator != Originator { + t.Fatalf("probe headers auth=%q originator=%q", gotAuth, gotOriginator) + } + if info == nil || len(info.Windows) != 2 || info.Percentage != 54 || info.Exceeded { + t.Fatalf("quota info = %+v", info) + } +} + +func TestQuotaReportsProbeFailureWithoutCachedWindows(t *testing.T) { + client := quotaProbeClient(t, func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusForbidden, Body: io.NopCloser(strings.NewReader("blocked")), Header: http.Header{}}, nil + }) + info, err := client.Quota(context.Background(), "a1") + if info != nil || err == nil { + t.Fatalf("expected probe failure, got info=%+v err=%v", info, err) + } + if !strings.Contains(err.Error(), "403") { + t.Fatalf("error = %v", err) + } +} + +func TestQuotaKeepsObservedWindowsWhenProbeFails(t *testing.T) { + client := quotaProbeClient(t, func(*http.Request) (*http.Response, error) { + return nil, io.ErrUnexpectedEOF + }) + header := http.Header{} + header.Set("X-Codex-Primary-Used-Percent", "12") + header.Set("X-Codex-Primary-Reset-After-Seconds", "600") + client.observeQuotaHeaders("a1", header) + info, err := client.Quota(context.Background(), "a1") + if err != nil { + t.Fatalf("quota: %v", err) + } + if info == nil || len(info.Windows) != 1 || info.Windows[0].Percentage != 12 { + t.Fatalf("cached windows lost: %+v", info) + } +} + func TestQuotaFromUsageReadsCurrentWindows(t *testing.T) { body := []byte(`{ "plan_type":"plus",