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 {