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-quota-usage-probe.md
Original file line number Diff line number Diff line change
@@ -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 拦截页)。上次快照显示「额度耗尽」的账号现在会重新拉取窗口数据,不再一直卡在过期数字上;探测失败且没有缓存窗口时会如实报错,而不是静默忽略。
34 changes: 23 additions & 11 deletions internal/providers/codex/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions internal/providers/codex/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
78 changes: 78 additions & 0 deletions internal/providers/codex/quota_test.go
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading