usage: a Codex account's credits bought or given besides its plan show on the Usage page (#571: codex的套餐用完之后,可能会购买额外的信用点套餐…希望能做展示) — ChatGPT's /wham/usage answers, besides the 5-hour and weekly windows, the account's credits (credits: has_credits, unlimited, balance), which Codex spends once a window is used up; magpie read only the windows, so an account whose windows were spent showed spent though it still had credits to run on. codexWindows now reads them too, and a balance held (not unlimited, above 0) is the account's Balance, shown under its windows on the Usage page ("What is left on the account besides its windows") and in the panel card, for the account Codex is signed in to and for each saved one. TestCodexCreditsBalance (held, a few, none said, none held, spent, unlimited, unreadable). go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-02 22:44:03 +08:00
1 parent bb42b47663
commit 4007beb9c0
4 files changed
+73 -9

No files matched your search

+1 -1
View File
@@ -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
}
+49
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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())
+22 -7
View File
@@ -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 {