From 4007beb9c07bfdc18a070f566acafd7ba7ed4a99 Mon Sep 17 00:00:00 2001 From: yetone Date: Fri, 2 Oct 2026 22:44:03 +0800 Subject: [PATCH] =?UTF-8?q?usage:=20a=20Codex=20account's=20credits=20boug?= =?UTF-8?q?ht=20or=20given=20besides=20its=20plan=20show=20on=20the=20Usag?= =?UTF-8?q?e=20page=20(#571:=20codex=E7=9A=84=E5=A5=97=E9=A4=90=E7=94=A8?= =?UTF-8?q?=E5=AE=8C=E4=B9=8B=E5=90=8E=EF=BC=8C=E5=8F=AF=E8=83=BD=E4=BC=9A?= =?UTF-8?q?=E8=B4=AD=E4=B9=B0=E9=A2=9D=E5=A4=96=E7=9A=84=E4=BF=A1=E7=94=A8?= =?UTF-8?q?=E7=82=B9=E5=A5=97=E9=A4=90=E2=80=A6=E5=B8=8C=E6=9C=9B=E8=83=BD?= =?UTF-8?q?=E5=81=9A=E5=B1=95=E7=A4=BA)=20=E2=80=94=20ChatGPT's=20/wham/us?= =?UTF-8?q?age=20answers,=20besides=20the=205-hour=20and=20weekly=20window?= =?UTF-8?q?s,=20the=20account's=20credits=20(credits:=20has=5Fcredits,=20u?= =?UTF-8?q?nlimited,=20balance),=20which=20Codex=20spends=20once=20a=20win?= =?UTF-8?q?dow=20is=20used=20up;=20magpie=20read=20only=20the=20windows,?= =?UTF-8?q?=20so=20an=20account=20whose=20windows=20were=20spent=20showed?= =?UTF-8?q?=20spent=20though=20it=20still=20had=20credits=20to=20run=20on.?= =?UTF-8?q?=20codexWindows=20now=20reads=20them=20too,=20and=20a=20balance?= =?UTF-8?q?=20held=20(not=20unlimited,=20above=200)=20is=20the=20account's?= =?UTF-8?q?=20Balance,=20shown=20under=20its=20windows=20on=20the=20Usage?= =?UTF-8?q?=20page=20("What=20is=20left=20on=20the=20account=20besides=20i?= =?UTF-8?q?ts=20windows")=20and=20in=20the=20panel=20card,=20for=20the=20a?= =?UTF-8?q?ccount=20Codex=20is=20signed=20in=20to=20and=20for=20each=20sav?= =?UTF-8?q?ed=20one.=20TestCodexCreditsBalance=20(held,=20a=20few,=20none?= =?UTF-8?q?=20said,=20none=20held,=20spent,=20unlimited,=20unreadable).=20?= =?UTF-8?q?go=20vet,=20go=20build=20-tags=20nogui=20(darwin,=20GOOS=3Dlinu?= =?UTF-8?q?x),=20GOOS=3Dwindows=20go=20build,=20go=20test=20-tags=20nogui?= =?UTF-8?q?=20./...=20under=20a=20temp=20HOME=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/codex_autoreset.go | 2 +- internal/provider/codex_credits_test.go | 49 +++++++++++++++++++++++++ internal/provider/login_usage.go | 2 +- internal/provider/subscription_usage.go | 29 +++++++++++---- 4 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 internal/provider/codex_credits_test.go diff --git a/internal/provider/codex_autoreset.go b/internal/provider/codex_autoreset.go index 873c6215..65fb88ef 100644 --- a/internal/provider/codex_autoreset.go +++ b/internal/provider/codex_autoreset.go @@ -100,7 +100,7 @@ func AutoUseCodexReset(ctx context.Context, user string) (ResetOutcome, error) { if err != nil { return nil, false, err } - _, windows, resets, err := codexWindows(ViaLogin(ctx, "codex", who), tok, accountID) + _, windows, resets, _, err := codexWindows(ViaLogin(ctx, "codex", who), tok, accountID) if err != nil { return nil, false, err } diff --git a/internal/provider/codex_credits_test.go b/internal/provider/codex_credits_test.go new file mode 100644 index 00000000..d8b8f133 --- /dev/null +++ b/internal/provider/codex_credits_test.go @@ -0,0 +1,49 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// The credits a ChatGPT account holds besides its windows are its balance +// (#571), shown when it has some to spend and they are not unlimited. +func TestCodexCreditsBalance(t *testing.T) { + for _, c := range []struct { + name string + credits any + want string + }{ + {"none said", nil, ""}, + {"held", map[string]any{"has_credits": true, "unlimited": false, "balance": "1234.5"}, "1234.5 credits"}, + {"a few", map[string]any{"has_credits": true, "unlimited": false, "balance": "42"}, "42 credits"}, + {"none held", map[string]any{"has_credits": false, "unlimited": false, "balance": "0"}, ""}, + {"spent", map[string]any{"has_credits": true, "unlimited": false, "balance": "0"}, ""}, + {"unlimited", map[string]any{"has_credits": true, "unlimited": true, "balance": ""}, ""}, + {"unreadable", map[string]any{"has_credits": true, "balance": "lots"}, ""}, + } { + t.Run(c.name, func(t *testing.T) { + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := map[string]any{"plan_type": "plus", "rate_limit": map[string]any{ + "primary_window": map[string]any{"used_percent": 100, "limit_window_seconds": 18000}}} + if c.credits != nil { + body["credits"] = c.credits + } + json.NewEncoder(w).Encode(body) + })) + defer fake.Close() + old := CodexBase + CodexBase = fake.URL + "/backend-api/codex" + defer func() { CodexBase = old }() + plan, windows, _, credits, err := codexWindows(context.Background(), "tok", "acct-1") + if err != nil || plan != "plus" || len(windows) != 1 { + t.Fatalf("plan %q, windows %v, err %v", plan, windows, err) + } + if credits != c.want { + t.Errorf("credits %q, want %q", credits, c.want) + } + }) + } +} diff --git a/internal/provider/login_usage.go b/internal/provider/login_usage.go index 8c19034c..3fd5be98 100644 --- a/internal/provider/login_usage.go +++ b/internal/provider/login_usage.go @@ -210,7 +210,7 @@ func loginQuota(ctx context.Context, l Login) SubscriptionQuota { q.Windows, err = claudeWindows(ctx, l.User, true) } else { var plan string - if plan, q.Windows, q.Resets, err = codexWindows(ctx, tok, accountID); plan != "" { + if plan, q.Windows, q.Resets, q.Balance, err = codexWindows(ctx, tok, accountID); plan != "" { q.Plan = plan } q.Until = codexUntil(codexLoginAuth(l), time.Now()) diff --git a/internal/provider/subscription_usage.go b/internal/provider/subscription_usage.go index 9c7abf38..40628acf 100644 --- a/internal/provider/subscription_usage.go +++ b/internal/provider/subscription_usage.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "math" "math/rand/v2" "net/http" "os" @@ -641,7 +642,7 @@ func codexSubscriptionUsage(ctx context.Context, path string) SubscriptionQuota q.Error = err.Error() return q } - q.Plan, q.Windows, q.Resets, err = codexWindows(ctx, token, accountID) + q.Plan, q.Windows, q.Resets, q.Balance, err = codexWindows(ctx, token, accountID) if b, rerr := os.ReadFile(path); rerr == nil { q.Until = codexUntil(b, time.Now()) } @@ -667,11 +668,20 @@ func codexUntil(auth []byte, now time.Time) *time.Time { return &t } -// codexWindows is the plan, allowance and rate-limit resets of the -// ChatGPT account token signs in to. -func codexWindows(ctx context.Context, token, accountID string) (plan string, out []QuotaWindow, resets *ResetCredits, err error) { +// codexWindows is the plan, allowance, rate-limit resets and credits of +// the ChatGPT account token signs in to. credits is what is left of the +// credits the account bought or was given (#571), which Codex spends once +// a window is used up, "" when it holds none or they are unlimited. +func codexWindows(ctx context.Context, token, accountID string) (plan string, out []QuotaWindow, resets *ResetCredits, credits string, err error) { var data struct { - PlanType string `json:"plan_type"` + PlanType string `json:"plan_type"` + // as Codex's /status reads it: {"has_credits":true, + // "unlimited":false,"balance":"1234.5"} + Credits *struct { + Has bool `json:"has_credits"` + Unlimited bool `json:"unlimited"` + Balance string `json:"balance"` + } `json:"credits"` RateLimit struct { Primary *codexWindow `json:"primary_window"` Secondary *codexWindow `json:"secondary_window"` @@ -683,7 +693,12 @@ func codexWindows(ctx context.Context, token, accountID string) (plan string, ou base := strings.TrimSuffix(CodexBase, "/codex") out = []QuotaWindow{} if err = accountJSON(ctx, base+"/wham/usage", token, map[string]string{"chatgpt-account-id": accountID}, &data); err != nil { - return "", out, nil, err + return "", out, nil, "", err + } + if c := data.Credits; c != nil && c.Has && !c.Unlimited { + if n, perr := strconv.ParseFloat(strings.TrimSpace(c.Balance), 64); perr == nil && n > 0 { + credits = fmt.Sprintf("%s credits", compactNumber(math.Round(n*100)/100)) + } } if data.Resets != nil { resets = codexResets(ctx, base, token, accountID, data.Resets.Available) @@ -694,7 +709,7 @@ func codexWindows(ctx context.Context, token, accountID string) (plan string, ou if data.RateLimit.Secondary != nil { out = append(out, data.RateLimit.Secondary.window()) } - return data.PlanType, out, resets, nil + return data.PlanType, out, resets, credits, nil } type codexWindow struct {