From b37ac53befb52686ebc8ddcf0b01e74555c97ff8 Mon Sep 17 00:00:00 2001 From: forever Date: Mon, 5 Oct 2026 23:20:09 -0700 Subject: [PATCH] codex: a switch for whether an account spends its credits (#974) * tui: show a ChatGPT account's credits beside its windows The Usage page printed only the balance for an account that had one, so a ChatGPT account holding credits lost its 5-hour and weekly windows there. The balance now follows the windows, as on the app's cards and in magpie quota. * accounts: tell a ChatGPT account's credits magpie accounts left out the credits an account holds, which magpie quota and the Usage page show. Its rows now carry them, in the line and as balance in --json. * codex: a switch for whether an account spends its credits A ChatGPT account holding credits keeps answering once a usage window is used up: the vendor spends the credits, and a task goes on. That stays the default. Each Codex account now has a say in it (Usage card and menu bar panel: Use credits; magpie quota credits on|off; settings' codexNoCredits). Turned off, the gateway holds the account at 100% of a window, as a usage cap holds it at its share, so the other accounts, groups and fallbacks take the request and no credits are spent. With none left the request gets a 429 naming what holds each account and how to go on, unless the account spends its resets by itself and its week is used up: then a reset is spent first, whether every candidate was held or the last one tried was refused. A cap set on the account takes precedence. A reset made now forgets the account's cached allowance, so neither a cap nor this hold outlives the windows it started again. The switch is routing's only: which account Codex is signed in to is unchanged. The Routing page tells why such an account was left out, and magpie quota, magpie accounts and the TUI mark credits not spent. * usage: an auto-reset switch keeps by its first line The auto-reset row centred its switch on its words, so the switch moved when they rewrapped on a click, and it sat lower than the Use credits switch beneath it. Both now keep the switch by the first line. * gui tests: quota-curve expects the Use credits row to close a Codex account's section The credits switch adds a row after the Auto-use one, so the account after a Codex account follows the curve, the Auto-use row and then the Use credits row. --- accounts_cli.go | 8 +- accounts_cli_test.go | 17 ++ docs/reference.md | 14 ++ docs/subsystems/gateway-routing.md | 7 +- docs/subsystems/providers-accounts.md | 1 + internal/gateway/codex_autoreset.go | 24 ++- internal/gateway/codex_backend.go | 12 +- internal/gateway/codex_credits_test.go | 153 +++++++++++++++ internal/gateway/fallback.go | 61 ++++-- internal/gateway/gateway.go | 30 ++- internal/gateway/key_models.go | 5 + internal/gateway/trace.go | 6 + internal/gui/api.go | 23 +++ internal/gui/assets/app.css | 25 ++- internal/gui/assets/app.js | 63 +++++++ internal/gui/assets/i18n.js | 30 +++ internal/gui/assets/routing.js | 4 +- internal/gui/settings_keep_test.go | 1 + internal/gui/tests/codex-credits.test.cjs | 218 ++++++++++++++++++++++ internal/gui/tests/quota-curve.test.cjs | 5 +- internal/provider/codex_credits.go | 56 ++++++ internal/provider/codex_credits_test.go | 80 ++++++++ internal/provider/routing.go | 49 ++++- internal/settings/settings.go | 10 + internal/tui/pages.go | 12 +- internal/tui/usage_test.go | 24 +++ quota_cli.go | 105 +++++++++-- 27 files changed, 987 insertions(+), 56 deletions(-) create mode 100644 internal/gateway/codex_credits_test.go create mode 100644 internal/gui/tests/codex-credits.test.cjs create mode 100644 internal/provider/codex_credits.go diff --git a/accounts_cli.go b/accounts_cli.go index c28e8777..6944dfd7 100644 --- a/accounts_cli.go +++ b/accounts_cli.go @@ -185,6 +185,9 @@ func accountsCmd(args []string) error { for _, w := range r.Windows { line += " " + quotaCell(w) } + if r.Balance != "" { + line += " " + balanceCell(r.Balance, r.Agent, r.User) + } if r.Resets != nil { line += " " + resetsCell(r.Resets, provider.AutoResets(r.Agent, r.User)) } @@ -214,6 +217,9 @@ type accountRow struct { ReadAt *time.Time `json:"readAt,omitempty"` // when what is shown was read, when known // Resets are a Codex account's rate-limit resets, when it holds any. Resets *provider.ResetCredits `json:"resets,omitempty"` + // Balance is what the account holds beside its windows, a ChatGPT + // account's credits, when the vendor tells it. + Balance string `json:"balance,omitempty"` } type quotaSpan = provider.QuotaSpan @@ -253,7 +259,7 @@ func accountRows(ls []provider.Login, now time.Time) []accountRow { if r.Plan == "" { r.Plan = q.Plan } - r.Error, r.Resets, r.AsOf, r.ReadAt = q.Error, q.Resets, q.AsOf, q.ReadAt + r.Error, r.Resets, r.AsOf, r.ReadAt, r.Balance = q.Error, q.Resets, q.AsOf, q.ReadAt, q.Balance // a pool's own windows stand in for the models' drawing on it, // as the usage page shows them for _, w := range provider.PooledWindows(q.Windows) { diff --git a/accounts_cli_test.go b/accounts_cli_test.go index 93f91844..fb03a2e8 100644 --- a/accounts_cli_test.go +++ b/accounts_cli_test.go @@ -168,3 +168,20 @@ func TestAccountRowsPoolsAntigravity(t *testing.T) { t.Fatalf("windows %v", names) } } + +// A ChatGPT account's credits are in its row beside its windows, as on the +// usage page and in magpie quota. +func TestAccountRowsTellCredits(t *testing.T) { + now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC) + provider.LoginUsageVia(func(context.Context, string) map[string]provider.SubscriptionQuota { + return map[string]provider.SubscriptionQuota{"me@example.com": { + Provider: "codex", Name: "Codex", User: "me@example.com", Balance: "1.2K credits", + Windows: []provider.QuotaWindow{{Name: "5 hours", Used: 100}, {Name: "7 days", Used: 40}}, + }} + }) + t.Cleanup(func() { provider.LoginUsageVia(nil) }) + rows := accountRows([]provider.Login{{Agent: "codex", User: "Me@example.com", Active: true, On: true}}, now) + if len(rows) != 1 || rows[0].Balance != "1.2K credits" || len(rows[0].Windows) != 2 { + t.Fatalf("%+v", rows) + } +} diff --git a/docs/reference.md b/docs/reference.md index cd690689..0c822e93 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -836,6 +836,19 @@ login, or as `/` when two subscriptions share it. `--timeout 6h` exits 1 if it passes first, `--quiet` says nothing; an unknown name exits 2 and Ctrl+C 130. +A ChatGPT account that holds credits keeps answering once a usage window is +used up: the vendor spends the credits, so a task goes on. That is the +default. `magpie quota credits off` (or *Use credits* on the +account's Usage card) turns it off for that account: the gateway then holds +it as used up till the window renews, and requests go to the other +accounts, groups and fallbacks; with none left, the request gets a 429 +saying why, unless the account spends its resets by itself and its week is +used up, when a reset is spent first. `magpie quota credits` lists the +accounts set not to spend them, `magpie quota credits ` says one's. +It changes the gateway's routing only, never the account Codex is signed in +to. The credits an account holds show beside its windows in `magpie quota`, +`magpie accounts`, the Usage page and the menu bar panel. + The *Gateway* tab in the app has this as copy buttons and ready-made snippets (shell, curl, Python, Node) for each API, the list of model ids, and the recent calls; `MAGPIE_DEBUG=1` logs every call to the terminal. @@ -1121,6 +1134,7 @@ magpie sync # refresh the models.dev catalog and every live magpie quota # what is left of every subscription, plan and key balance magpie quota wait codex # block until a Codex account has allowance again +magpie quota credits me@example.com off # hold a ChatGPT account at its limit, not spending credits ``` In the app, click any value to open a filtered list; type to search or to diff --git a/docs/subsystems/gateway-routing.md b/docs/subsystems/gateway-routing.md index fca718cd..52c416ce 100644 --- a/docs/subsystems/gateway-routing.md +++ b/docs/subsystems/gateway-routing.md @@ -33,7 +33,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md). - A gateway key held to some models (`keyHolds`, #882) gets a 403 for any other model. - Sealed tasks (#619) are checked here. - Rules and image inputs are handled here too. -3. Candidates come from `planGroup` for a group or `plan` for a provider. They are filtered by `sealedReaders` and `allowedCandidates`. A capped or barred set returns `cappedError` or `barredError`. Then `affine` moves the conversation's last answerer first, unless it is resting or nearly used up. An `X-Magpie-Account` header (`AccountHeader`) pins the request to one account: only that account is tried, and when it can't answer the caller is told why. +3. Candidates come from `planGroup` for a group or `plan` for a provider. They are filtered by `sealedReaders` and `allowedCandidates`. An account past its share is held out of the candidates (`capHeld`): a usage cap's share, or 100% of a window for a Codex account set not to spend its credits (`provider.HoldShare`, `CodexNoCredits`). When every candidate is held and one held for its credits spends its resets by itself, `autoReset` spends a reset on it and it is tried (`planned.held`); otherwise a capped or barred set returns `cappedError` or `barredError`. Then `affine` moves the conversation's last answerer first, unless it is resting or nearly used up. An `X-Magpie-Account` header (`AccountHeader`) pins the request to one account: only that account is tried, and when it can't answer the caller is told why. 4. The candidate loop calls `attempt` on each candidate in turn, through a `holdWriter`. The writer holds the reply until it is clearly passing. A candidate that fails before anything is sent rests (`failure`): out of credit for 30 minutes, out of quota until reset, rate limited until retry-after, otherwise a minute and longer on repeats. The next candidate is then tried. A failed provider waits `fallbackCooldown` (1 minute) at the back. 5. The last candidate's error reaches the agent. A try that answered is recorded (`served`, `answered`) for routing, affinity and usage. @@ -44,6 +44,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md). - A resting candidate is tried last, never dropped (`restLast`). A lone candidate is tried even while it rests, because there is no other. - Routing decides who goes first only when nobody has answered the conversation yet. Affinity keeps the answerer while its cache is worth it. Auto mode keeps it across turns while at least `cacheWorth` (1024) tokens were read from the cache and the cache isn't cold (`cacheCold`, 5 minutes). - In turn (`rotate`), a conversation nobody has answered yet goes to the key, account or model the fewest other conversations of the provider's model or the group answered in the last `heldFor` (30 minutes) are on (`leastHeld`, #946). Ties, resting ones and a group with a group in it keep routing's order. With session affinity, two sessions of one group so stay on different models. +- A ChatGPT account that holds credits answers past its windows on them; the vendor spends them. That is the default. Set not to spend them (Usage card's *Use credits*, `magpie quota credits off`), the account is held at 100% of a window till it renews, so the other accounts, groups and fallbacks take the request. A cap set on the account (1–99%) takes precedence. Unlike a cap, the hold is at the vendor's own 100%, so an account allowed to spend its resets spends one there, both when every candidate is held and after the last one is refused. A reset made forgets the account's cached allowance (`renewedNow`), so neither hold outlives the windows it started again. - Different failures are classified separately: out of credit, out of quota, rate limit and other errors rest for different lengths. A fix must not merge them into one bucket (see #873). - Codex offers its image tool (the `image_gen` namespace, #870) to every magpie provider. When a provider other than a ChatGPT sign-in or openai.com answers a bare 400, `attempt` asks it once more without the tool (`image_tool.go`). This happens both passed through and translated. If that works, the provider is marked unfit for the tool (`tool:image_gen`) and isn't offered it again. If it doesn't, the tool is restored and the 400 stands (#949). - Group members are `provider/model[:effort]`. A member with a fixed effort is asked for that effort, bounded by the group's levels (#671). @@ -51,8 +52,8 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md). ## Verification ```sh -go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool' -go test -tags nogui ./internal/provider -run 'TestGroup' +go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits' +go test -tags nogui ./internal/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance' ``` The gateway's `TestMain` gives the package a home of its own, so these tests diff --git a/docs/subsystems/providers-accounts.md b/docs/subsystems/providers-accounts.md index 151383ff..ecb5b59c 100644 --- a/docs/subsystems/providers-accounts.md +++ b/docs/subsystems/providers-accounts.md @@ -47,6 +47,7 @@ keys and accounts per request is in [Gateway routing and fallback](gateway-routi - A switched-off provider (`Off`, #163) stays saved. The gateway says it is off instead of saying the model is unknown. - A provider removed or switched off takes its models out of the catalog. [Agent wiring](agent-wiring.md)'s `Reseat` moves agents off them. - A gateway key held to some accounts (#905) is held to those of the providers its list names: a provider it names no account of, the key uses as it always did. The list keeps an account by its stable id — `logins.json`'s `id`, set once written and kept through renames; the agents that keep no logins (and plugins) are known by a hash of the account's name, so an account renamed there drops out of a key's list, holding it closer, never wider. Storing an id for every agent is a larger change, not this one's. +- Whether a Codex account spends its credits (`CodexNoCredits` in settings, opt-out, by lower-cased email) is the gateway's routing only. It never changes which account Codex is signed in to: `NextLogin` moves off an account at its routing's spent share, 100% at most, credits or not. ## Verification diff --git a/internal/gateway/codex_autoreset.go b/internal/gateway/codex_autoreset.go index 1b7ad15e..a99dac21 100644 --- a/internal/gateway/codex_autoreset.go +++ b/internal/gateway/codex_autoreset.go @@ -8,6 +8,7 @@ package gateway import ( "context" "log" + "slices" "strings" "time" @@ -19,8 +20,11 @@ var autoResetTimeout = 20 * time.Second // autoReset spends a reset of one of cands' Codex accounts, c — the last // one, just out of its allowance — first: only when every other one of -// them sits out too. It says which account it was and what spending did. -func (s *Server) autoReset(ctx context.Context, cands []candidate, c candidate) (candidate, provider.ResetOutcome, bool) { +// them sits out too. held are accounts left out as they won't spend their +// credits (planned.held): they sit out, and are out of their allowance as +// c is, so one of them may spend one after cands'. It says which account it +// was and what spending did. +func (s *Server) autoReset(ctx context.Context, cands []candidate, c candidate, held []candidate) (candidate, provider.ResetOutcome, bool) { for _, x := range cands { if x.restKey() == c.restKey() { continue @@ -34,7 +38,7 @@ func (s *Server) autoReset(ctx context.Context, cands []candidate, c candidate) } } seen := map[string]bool{} - for _, x := range append([]candidate{c}, cands...) { + for _, x := range append(append([]candidate{c}, cands...), held...) { a := x.p.Account if a == nil || a.User == "" || seen[x.restKey()] || !provider.AutoResets(a.Agent, a.User) { continue @@ -42,6 +46,7 @@ func (s *Server) autoReset(ctx context.Context, cands []candidate, c candidate) seen[x.restKey()] = true if out, ok := autoResetOf(ctx, a.Agent, a.User); ok { s.Unrest(x.restKey()) + x.capped = nil // its windows started again: held no more return x, out, true } } @@ -78,3 +83,16 @@ func autoResetOf(ctx context.Context, agent, user string) (provider.ResetOutcome } return out, err == nil && out.Code == "reset" } + +// unhold moves the account c of a plan's left, held as it won't spend its +// credits, to its order: a reset started its windows again, and it is +// tried. +func unhold(order, left []Weighed, c candidate) ([]Weighed, []Weighed) { + i := slices.IndexFunc(left, func(w Weighed) bool { return w.ID == c.rest && w.NoCredits }) + if i < 0 { + return order, left + } + w := left[i] + w.Unlisted, w.Capped, w.CapBack, w.NoCredits = false, 0, nil, false + return append(slices.Clip(order), w), slices.Delete(slices.Clone(left), i, i+1) +} diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go index c450438f..a80162f8 100644 --- a/internal/gateway/codex_backend.go +++ b/internal/gateway/codex_backend.go @@ -152,6 +152,7 @@ func sealedReaders(cands []candidate, pl planned) ([]candidate, planned) { } } cands, pl.order = kept, order + pl.held = slices.DeleteFunc(slices.Clone(pl.held), func(c candidate) bool { return !sealedReader(c.p) }) return cands, pl } @@ -264,10 +265,13 @@ func codexAccounts(r *http.Request, model string) (string, bool) { p, _, ok := provider.Resolve(id) // one account named is found among them however many are on pinned := h.Get(AccountHeader) != "" - // an account with a usage cap goes through routing, which holds it - // there, even alone: relayed as it came, nothing would - if ok && p.Account != nil && p.Account.Agent == "codex" && p.AccountCap(p.Account.User) > 0 { - return id, true + // an account with a usage cap, or set not to spend its credits, goes + // through routing, which holds it there, even alone: relayed as it + // came, nothing would + if ok && p.Account != nil && p.Account.Agent == "codex" { + if share, _ := provider.HoldShare(p, "codex", p.Account.User); share > 0 { + return id, true + } } // the key's holds are served, not relayed past: a key held to some // models or accounts goes through routing, which holds it to them, diff --git a/internal/gateway/codex_credits_test.go b/internal/gateway/codex_credits_test.go new file mode 100644 index 00000000..1c4bf3d0 --- /dev/null +++ b/internal/gateway/codex_credits_test.go @@ -0,0 +1,153 @@ +package gateway + +import ( + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// A ChatGPT account at 100% of its five hours that holds credits answers +// on them (the backend answers it): by default it is asked first as the +// order says and spends them; set not to spend its credits, it is held as +// used up and the next account takes the request, the trace saying why. +func TestCodexNoCreditsMovesOn(t *testing.T) { + codexSignedIn(t, "spare@example.com") + b := newResetBackend(t) // every account answers: the first on its credits + capUsage(t, map[string]float64{"me@example.com": 100, "spare@example.com": 20}) + if err := provider.SetRouting("codex", provider.Ordered); err != nil { + t.Fatal(err) + } + code, body := resetPost(t, New()) + if tried, _ := b.seen(); code != 200 || !strings.Contains(body, "pong") || tried != "acct-1" { + t.Fatalf("credits spent: %d %s tried %s", code, body, tried) + } + + if err := provider.SetCodexCredits("Me@Example.com", false); err != nil { + t.Fatal(err) + } + b.mu.Lock() + b.tried = nil + b.mu.Unlock() + srv := New() + code, body = resetPost(t, srv) + if tried, _ := b.seen(); code != 200 || !strings.Contains(body, "pong") || tried != "acct-2" { + t.Fatalf("credits not spent: %d %s tried %s", code, body, tried) + } + var left []string + for _, r := range srv.Trace(t.Context(), 0, 0).Routes { + for _, w := range r.Left { + if w.NoCredits && w.Capped == 100 && w.CapBack != nil { + left = append(left, w.Who) + } + } + } + if len(left) != 1 || left[0] != "me@example.com" { + t.Fatalf("traced as held for its credits: %v", left) + } +} + +// Every account held, as none may spend its credits or one is at its cap: +// the client is refused as when all are used up (429), told why in the +// words of what holds each, and how to go on; nothing reaches the vendor. +func TestCodexNoCreditsEveryAccount(t *testing.T) { + codexSignedIn(t, "spare@example.com") + b := newResetBackend(t) + capUsage(t, map[string]float64{"me@example.com": 100, "spare@example.com": 100}) + for _, u := range []string{"me@example.com", "spare@example.com"} { + if err := provider.SetCodexCredits(u, false); err != nil { + t.Fatal(err) + } + } + srv := New() + code, body := resetPost(t, srv) + if tried, _ := b.seen(); code != 429 || tried != "" { + t.Fatalf("%d tried %q: %s", code, tried, body) + } + for _, want := range []string{"rate_limit_error", "set in magpie not to spend its credits", "me@example.com", "spare@example.com", "quota credits"} { + if !strings.Contains(body, want) { + t.Errorf("no %q in %s", want, body) + } + } + if strings.Contains(body, "usage cap reached") { + t.Errorf("told as a cap: %s", body) + } + + // one held at its cap, the other for its credits: both told + if err := provider.SetAccountCap("codex", "me@example.com", 70); err != nil { + t.Fatal(err) + } + code, body = resetPost(t, srv) + if tried, _ := b.seen(); code != 429 || tried != "" { + t.Fatalf("%d tried %q: %s", code, tried, body) + } + for _, want := range []string{"held by magpie", "past its 70% cap", "(spare@example.com) has used up a usage window and is set not to spend its credits", "account-cap", "quota credits"} { + if !strings.Contains(body, want) { + t.Errorf("no %q in %s", want, body) + } + } +} + +// Codex's one account, its week used up and credits left, set not to +// spend them and to spend its resets by itself: relayed as it came it +// would answer on its credits; routed, it is held, and with nobody else +// left a reset is spent first and the turn goes through on the windows +// started again — the trace says so. Without leave to spend resets, it is +// refused, and nothing is spent. +func TestCodexNoCreditsSpendsAReset(t *testing.T) { + codexSignedIn(t) + b := newResetBackend(t) + b.week["acct-1"], b.held["acct-1"] = 100, 1 // answers all the same: credits + capUsage(t, map[string]float64{"me@example.com": 100}) + if err := provider.SetCodexCredits("me@example.com", false); err != nil { + t.Fatal(err) + } + srv := New() + code, body := resetPost(t, srv) + if tried, spent := b.seen(); code != 429 || !strings.Contains(body, "not to spend its credits") || tried != "" || spent != "" { + t.Fatalf("no auto-reset: %d %s tried %q spent %q", code, body, tried, spent) + } + + if err := provider.SetCodexAutoReset("me@example.com", true); err != nil { + t.Fatal(err) + } + code, body = resetPost(t, srv) + if tried, spent := b.seen(); code != 200 || !strings.Contains(body, "pong") || tried != "acct-1" || spent != "acct-1" { + t.Fatalf("auto-reset: %d %s tried %q spent %q", code, body, tried, spent) + } + if got := resetsTraced(srv); len(got) != 1 || got[0] != "me@example.com: 2 windows started again" { + t.Fatalf("traced %v", got) + } + // the account it was spent on is in the trace's order, tried, not + // left out + r := srv.Trace(t.Context(), 0, 0).Routes[0] + if len(r.Order) != 1 || r.Order[0].Who != "me@example.com" || r.Order[0].Capped != 0 || len(r.Left) != 0 { + t.Fatalf("order %+v left %+v", r.Order, r.Left) + } +} + +// The account Codex is signed in to is out of its allowance, its credits +// spent or none, and the only other is held as it won't spend its credits, +// its week used up, with leave to spend its resets: once the first is +// refused, the held one spends a reset and answers. +func TestCodexNoCreditsResetAfterTheLastRefusal(t *testing.T) { + codexSignedIn(t, "spare@example.com") + b := newResetBackend(t, "acct-1") + b.held["acct-1"] = 0 + b.week["acct-2"], b.held["acct-2"] = 100, 1 + capUsage(t, map[string]float64{"me@example.com": 60, "spare@example.com": 100}) + if err := provider.SetCodexCredits("spare@example.com", false); err != nil { + t.Fatal(err) + } + if err := provider.SetCodexAutoReset("spare@example.com", true); err != nil { + t.Fatal(err) + } + srv := New() + code, body := resetPost(t, srv) + if tried, spent := b.seen(); code != 200 || !strings.Contains(body, "pong") || tried != "acct-1,acct-2" || spent != "acct-2" { + t.Fatalf("%d %s tried %q spent %q", code, body, tried, spent) + } + if got := resetsTraced(srv); len(got) != 1 || got[0] != "spare@example.com: 2 windows started again" { + t.Fatalf("traced %v", got) + } +} diff --git a/internal/gateway/fallback.go b/internal/gateway/fallback.go index bbc04a05..1716cadc 100644 --- a/internal/gateway/fallback.go +++ b/internal/gateway/fallback.go @@ -52,11 +52,13 @@ type candidate struct { // capHold is how an account is held at its usage cap: the cap, the share // of the fullest window at or past it, and when the last such window -// renews (zero when one doesn't say). +// renews (zero when one doesn't say). noCredits: it is a Codex account +// held at 100% as it is set not to spend its credits (cap is 100 then). type capHold struct { - cap int - used float64 - back time.Time + cap int + used float64 + back time.Time + noCredits bool } // label names a candidate in a call's record: the provider, and the key @@ -188,7 +190,9 @@ func perKeyBarred(p provider.Provider, model string, from provider.Protocol) (ou }) // an account at its usage cap is used up for routing until the // window it filled renews: never tried, so groups, fallbacks and - // the other accounts take the request (provider/account_caps.go) + // the other accounts take the request (provider/account_caps.go); + // so is a Codex account at 100% set not to spend its credits + // (provider/codex_credits.go) all = slices.DeleteFunc(all, func(c candidate) bool { h := capHeld(p, c.p, model, time.Now()) if h == nil { @@ -310,6 +314,7 @@ func (s *Server) plan(p provider.Provider, model string, from provider.Protocol) add := func(q provider.Provider, m string, fallback bool) []candidate { cs, aside, left, barred := perKeyBarred(q, m, from) pl.left = append(pl.left, barredOf(barred, q, fallback, from, nil)...) + pl.held = append(pl.held, creditsHeld(barred)...) cs, wg := weigh(q, cs, m, from) for i, c := range cs { w := weighed(c, q, wg, fallback, from) @@ -369,6 +374,7 @@ func planLevel(g provider.Group, ms []provider.Member, depth int, from provider. keys := func(m provider.Member) []candidate { cs, aside, left, barred := perKeyBarred(m.Provider, m.Model, from) pl.left = append(pl.left, barredOf(barred, m.Provider, false, from, m.Groups())...) + pl.held = append(pl.held, creditsHeld(barred)...) // the effort the member is fixed at goes with each of its keys: // the same model at another effort is another member's for _, l := range [][]candidate{cs, aside, left} { @@ -486,21 +492,23 @@ func planLevel(g provider.Group, ms []provider.Member, depth int, from provider. } // capHeld is how the account acct of p is held at its usage cap for -// model at now, nil when it has no cap or is below it. Its windows are -// those last read (allowances), as smart routing weighs them. +// model at now, or at 100% as a Codex account set not to spend its +// credits; nil when nothing holds it or it is below the share. Its windows +// are those last read (allowances), as smart routing weighs them. func capHeld(p, acct provider.Provider, model string, now time.Time) *capHold { if acct.Account == nil { return nil } - cap := p.AccountCap(acct.Account.User) - if cap <= 0 { + a := acct.Account + share, noCredits := provider.HoldShare(p, a.Agent, a.User) + if share <= 0 { return nil } - held, used, back := allowances(acct.Account.UsageAgent())[acct.Account.User].CapHeld(model, cap, now) + held, used, back := allowances(a.UsageAgent())[a.User].CapHeld(model, share, now) if !held { return nil } - return &capHold{cap: cap, used: used, back: back} + return &capHold{cap: share, used: used, back: back, noCredits: noCredits} } // cappedError says why a request for model went nowhere when every account @@ -509,11 +517,18 @@ func capHeld(p, acct provider.Provider, model string, now time.Time) *capHold { func cappedError(model string, ws []Weighed, now time.Time) (string, time.Time) { var held []string var soonest time.Time + capped, noCredits := false, false for _, w := range ws { if w.Capped == 0 { continue } s := fmt.Sprintf("%s (%s) is at %.0f%% of a usage window, past its %d%% cap", w.Name, w.Who, w.Used, w.Capped) + if w.NoCredits { + s = fmt.Sprintf("%s (%s) has used up a usage window and is set not to spend its credits", w.Name, w.Who) + noCredits = true + } else { + capped = true + } if w.CapBack != nil { s += ", until " + w.CapBack.Local().Format("Jan 2 15:04") if soonest.IsZero() || w.CapBack.Before(soonest) { @@ -522,7 +537,27 @@ func cappedError(model string, ws []Weighed, now time.Time) (string, time.Time) } held = append(held, s) } - return fmt.Sprintf("usage cap reached: every account that serves %q is held at the usage cap set on it in magpie — %s. magpie uses it again once that window renews; raise or lift the cap in magpie (Providers → the account's cap, or magpie provider account-cap)", model, strings.Join(held, "; ")), soonest + switch { + case !noCredits: + return fmt.Sprintf("usage cap reached: every account that serves %q is held at the usage cap set on it in magpie — %s. magpie uses it again once that window renews; raise or lift the cap in magpie (Providers → the account's cap, or magpie provider account-cap)", model, strings.Join(held, "; ")), soonest + case !capped: + return fmt.Sprintf("usage limit reached: every account that serves %q has used up its allowance and is set in magpie not to spend its credits — %s. magpie uses it again once that window renews; to go on now, let an account spend its credits (Usage → the account's Use credits, or magpie quota credits on)", model, strings.Join(held, "; ")), soonest + } + return fmt.Sprintf("usage limit reached: every account that serves %q is held by magpie — %s. magpie uses it again once that window renews; to go on now, raise or lift a cap (Providers → the account's cap, or magpie provider account-cap) or let an account spend its credits (Usage → the account's Use credits, or magpie quota credits on)", model, strings.Join(held, "; ")), soonest +} + +// creditsHeld are those of cs held only as they won't spend their +// credits: out of their allowance at the vendor's own 100%, where one +// that spends its resets by itself spends one (codex_autoreset.go). One +// held at the user's cap never does. +func creditsHeld(cs []candidate) []candidate { + var out []candidate + for _, c := range cs { + if c.capped != nil && c.capped.noCredits { + out = append(out, c) + } + } + return out } // barredOf is how the trace tells the accounts or keys the user set not @@ -533,7 +568,7 @@ func barredOf(cs []candidate, q provider.Provider, fallback bool, from provider. w := weighed(c, q, weighing{}, fallback, from) w.Unlisted, w.Via = true, via if h := c.capped; h != nil { - w.Capped, w.Used = h.cap, h.used + w.Capped, w.Used, w.NoCredits = h.cap, h.used, h.noCredits if !h.back.IsZero() { w.CapBack = &h.back } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 1815b51c..3832e139 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -1422,6 +1422,21 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro } cands, pl, accountHeld = allowedCandidates(who, cands, pl, members) } + // a Codex reset spent before any try, on an account held as it won't + // spend its credits + var resetFirst *AutoReset + if len(cands) == 0 && len(pl.held) > 0 && strings.TrimSpace(r.Header.Get(AccountHeader)) == "" && + !slices.ContainsFunc(pl.left, func(w Weighed) bool { return !w.Barred && !w.Held && w.Capped == 0 }) { + // every account there is is held, and one held only as it won't + // spend its credits is out of its allowance at the vendor's own + // 100%: one that spends its resets by itself, its week used up, + // spends one and is tried, as one the vendor refused would be + if pick, out, ok := s.autoReset(r.Context(), nil, pl.held[0], pl.held); ok { + cands = []candidate{pick} + pl.order, pl.left = unhold(pl.order, pl.left, pick) + resetFirst = &AutoReset{Who: pick.p.Account.User, Text: out.Text(), Agent: pick.p.Account.Agent} + } + } if len(cands) == 0 && slices.ContainsFunc(pl.left, func(w Weighed) bool { return w.Capped > 0 }) && !slices.ContainsFunc(pl.left, func(w Weighed) bool { return !w.Barred && !w.Held && w.Capped == 0 }) { // every account there is is held at its usage cap: used up, as far @@ -1577,8 +1592,8 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro plainFor := "" // the account and model asked again without effort updates (#617) // the key or subscription account the last try went to (#557) providerKeyID, providerKeyName, providerAccount := "", "", "" - var other *Try // the first failure that wasn't an allowance run out - autoReset := false // a Codex or Claude reset looked at, once a request + var other *Try // the first failure that wasn't an allowance run out + autoReset := resetFirst != nil // a Codex or Claude reset looked at, once a request // what the tries' held streams sent the agent ahead of a reply (#751) kept := &keptAlive{proto: from} var sentMs int64 // ms from the request to its answering try going to the vendor @@ -1828,6 +1843,10 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro } try := Try{ID: c.rest, Model: c.model, Effort: sent, Picked: picked, Fixed: fixed, Fast: fast, Start: began, Done: true, Status: call.Status, Millis: time.Since(began).Milliseconds(), Error: call.Error, Queued: queued, Served: call.Usage.Served, Upstream: call.Usage.Upstream, Auto: autoPicked()} + if resetFirst != nil { + // the reset spent for it before it was asked + try.Reset, resetFirst = resetFirst, nil + } asName := provider.SentNameOnIn(wiresOf(r.Context()), c.p.ID, accountAgent(c.p), c.model, sent) try.Swapped, try.Routed = swapped(asName, call.Usage.Served), usage.GroupRouted(asName, call.Usage.Served) try.TTFT, try.FirstText = hw.first.ms() @@ -2145,10 +2164,13 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro // the user lets spend its resets by itself, its week used up, // spends one and is asked again autoReset = true - if pick, out, ok := s.autoReset(r.Context(), cands, c); ok { + if pick, out, ok := s.autoReset(r.Context(), cands, c, pl.held); ok { try.Fail = failQuota try.Reset = &AutoReset{Who: pick.p.Account.User, Text: out.Text(), Agent: pick.p.Account.Agent} - s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try }) + s.trace.update(tr, func(t *Route) { + t.Tries[len(t.Tries)-1] = try + t.Order, t.Left = unhold(t.Order, t.Left, pick) + }) skipped = append(skipped, c.label()+": "+call.Error, pick.label()+": used one of its resets by itself ("+out.Text()+")") cands = append(cands[:len(cands):len(cands)], pick) continue diff --git a/internal/gateway/key_models.go b/internal/gateway/key_models.go index f1e2d278..b3b627a8 100644 --- a/internal/gateway/key_models.go +++ b/internal/gateway/key_models.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "slices" "strings" "github.com/yetone/magpie/internal/access" @@ -202,6 +203,10 @@ func allowedCandidates(who access.Identity, cs []candidate, pl planned, members } } pl.order = order + // nor is one held for its credits there for a reset to bring back + pl.held = slices.DeleteFunc(slices.Clone(pl.held), func(c candidate) bool { + return !members[c.p.ID+"/"+c.model] && !modelAllowed(who, c.p, c.model) || !accountAllowed(who, c) + }) return out, pl, held && len(out) == 0 } diff --git a/internal/gateway/trace.go b/internal/gateway/trace.go index edd64f05..7d2503c1 100644 --- a/internal/gateway/trace.go +++ b/internal/gateway/trace.go @@ -202,6 +202,9 @@ type Weighed struct { // share, CapBack when the last window at or past it renews Capped int `json:"capped,omitempty"` CapBack *time.Time `json:"capBack,omitempty"` + // NoCredits: held at 100% (Capped), a Codex account the user set not + // to spend its credits once its allowance is used up + NoCredits bool `json:"noCredits,omitempty"` // Rank: its place in its provider's own list of accounts or keys, the // order the provider's page shows and a drag sets (#217); routing may // weigh them in another @@ -271,6 +274,9 @@ type AutoReset struct { type planned struct { order, left []Weighed + // held: the accounts left out (in left) as they won't spend their + // credits, for one that spends its resets by itself to spend one + held []candidate } func weighed(c candidate, p provider.Provider, wg weighing, fallback bool, from provider.Protocol) Weighed { diff --git a/internal/gui/api.go b/internal/gui/api.go index 54857c8c..227197e6 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -982,6 +982,8 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { in.ChinaMirror = cur.ChinaMirror // the Plugins page's, set on its own // which Codex accounts spend a reset by themselves, set on the Usage card in.CodexAutoReset = cur.CodexAutoReset + // and which of them spend their credits, set there too + in.CodexNoCredits = cur.CodexNoCredits // and the text size, which the keyboard changes too (text-size below) in.TextSize = cur.TextSize // the version the Update pill was hidden for, set from the pill @@ -1211,6 +1213,27 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { } writeJSON(rw, settingsState()) }) + // whether a Codex account spends its credits once its allowance is + // used up, the Usage card's toggle, set on its own + mux.HandleFunc("POST /api/settings/codex-credits", func(rw http.ResponseWriter, r *http.Request) { + var in struct { + User string + On bool + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(rw, err) + return + } + if strings.TrimSpace(in.User) == "" { + fail(rw, fmt.Errorf("which Codex account?")) + return + } + if err := provider.SetCodexCredits(in.User, in.On); err != nil { + fail(rw, err) + return + } + writeJSON(rw, settingsState()) + }) // whether WorkBuddy's daily check-in is pressed each day, the Usage // card's toggle (#694), set on its own as Settings' is mux.HandleFunc("POST /api/settings/workbuddy-checkin", func(rw http.ResponseWriter, r *http.Request) { diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 107e0774..db2323e0 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -1330,18 +1330,23 @@ body.panel .profiles #save { height: 25px; } /* the field's: the line keeps its .pq-use:hover { background: color-mix(in srgb, var(--fg) 12%, transparent); color: var(--fg); } /* a Codex account spending its resets by itself (#719): a standing say of the account's, held or not, its two times in words beside a switch */ -.quota-autoreset, .pq-autoreset { display: flex; align-items: center; gap: 8px; color: var(--muted); } -.quota-autoreset { margin-top: 8px; font-size: 10.5px; } -.quota-resets + .quota-autoreset { margin-top: 6px; } -.subscription-card.several .quota-autoreset + .subscription-account { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); } -.pq-autoreset { grid-column: 1 / -1; margin-top: 5px; padding-top: 7px; border-top: 1px solid var(--line-2); font-size: 11px; } -.pq-resets + .pq-autoreset { margin-top: 5px; padding-top: 0; border-top: 0; } +.quota-autoreset, .pq-autoreset, .quota-credits, .pq-credits { display: flex; align-items: center; gap: 8px; color: var(--muted); } +/* the switch keeps by the first line, so one whose words rewrap when it + turns stays under the pointer */ +.quota-autoreset, .pq-autoreset, .quota-credits, .pq-credits { align-items: flex-start; } +.quota-autoreset .ar-say, .pq-autoreset .ar-say, .quota-credits .ar-say, .pq-credits .ar-say { line-height: 16px; } +.quota-autoreset, .quota-credits { margin-top: 8px; font-size: 10.5px; } +.quota-resets + .quota-autoreset, .quota-autoreset + .quota-credits { margin-top: 6px; } +.subscription-card.several .quota-autoreset + .subscription-account, +.subscription-card.several .quota-credits + .subscription-account { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); } +.pq-autoreset, .pq-credits { grid-column: 1 / -1; margin-top: 5px; padding-top: 7px; border-top: 1px solid var(--line-2); font-size: 11px; } +.pq-resets + .pq-autoreset, .pq-autoreset + .pq-credits { margin-top: 5px; padding-top: 0; border-top: 0; } .ar-say { flex: 1; min-width: 0; line-height: 1.35; } .ar-say b { font-weight: 500; color: var(--fg-2); } -.lib-switch.auto-reset { width: 28px; height: 16px; padding: 0; border: 0; cursor: pointer; } -.lib-switch.auto-reset i { width: 12px; height: 12px; } -.lib-switch.auto-reset.on i { transform: translateX(12px); } -.lib-switch.auto-reset:disabled { opacity: .6; cursor: default; } +.lib-switch.auto-reset, .lib-switch.use-credits { width: 28px; height: 16px; padding: 0; border: 0; cursor: pointer; } +.lib-switch.auto-reset i, .lib-switch.use-credits i { width: 12px; height: 12px; } +.lib-switch.auto-reset.on i, .lib-switch.use-credits.on i { transform: translateX(12px); } +.lib-switch.auto-reset:disabled, .lib-switch.use-credits:disabled { opacity: .6; cursor: default; } .resets-kept { color: var(--faint); } .resets-kept.on { color: var(--accent); } /* in brief: the resets held and the say, beside the account's name */ diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 419142d7..1aaf4e32 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -12283,6 +12283,9 @@ function renderQuotas() { // the account's standing say on its resets, held or not (#719) const policy = !brief && autoResetRow(sub, "quota-autoreset"); if (policy) card.append(policy); + // and on its credits, when its windows run out + const credits = !brief && creditsRow(sub, "quota-credits"); + if (credits) card.append(credits); } // in the card's head, beside its name: no line of its own // (of the keys in sight: the ones folded away are the foot's button's) @@ -13406,6 +13409,8 @@ function panelQuotaCard(q) { } const policy = autoResetRow(q, "pq-autoreset"); if (policy) card.append(policy); + const credits = creditsRow(q, "pq-credits"); + if (credits) card.append(credits); return card; } @@ -13542,6 +13547,64 @@ function autoResetButton(q) { return b; } +// Whether a Codex account spends its credits once one of its windows is +// used up, a standing say of the account's kept by its name (settings' +// codexNoCredits, the CLI's quota credits): on unless the user turns it +// off. Off, routing holds the account as used up till the window renews, +// as a usage cap does at its share. creditsKept: the accounts that can +// have it. +function creditsKept(q) { + return !!(q.user && q.provider === "codex"); +} +function creditsOn(q) { + return creditsKept(q) && !(state.settings?.codexNoCredits || []).includes(q.user.toLowerCase()); +} + +// creditsRow is that say on an account's card, credits held or not, so it +// can be set ahead: what it does, in words, and the switch. +function creditsRow(q, cls) { + if (!creditsKept(q)) return null; + const on = creditsOn(q); + const row = el("div", cls + (on ? " on" : "")); + const say = el("span", "ar-say"); + say.append(el("b", "", t("Use credits:")), document.createTextNode(" " + t(on ? "when its windows run out, so a task goes on" : "no — held when a window runs out"))); + say.title = creditsTitle(on); + row.dataset.user = q.user; + row.append(say, creditsButton(q)); + return row; +} +function creditsTitle(on) { + return t(on ? "On: once one of this account's windows is used up, ChatGPT answers on the account's credits, if it holds any, so a task goes on. Click to turn it off." + : "Off: once one of this account's windows is used up, magpie holds it till the window renews, and requests go to your other accounts, groups and fallbacks, so its credits aren't spent. With none of them left, a request is refused with why, unless Auto-use resets is on and its week is used up: then a reset is used first."); +} + +// creditsButton turns that on or off: a switch, its words beside it. +function creditsButton(q) { + const on = creditsOn(q); + const b = el("button", "lib-switch use-credits" + (on ? " on" : "")); + b.type = "button"; + b.setAttribute("role", "switch"); + b.setAttribute("aria-checked", String(on)); + b.setAttribute("aria-label", t("Use credits")); + b.title = creditsTitle(on); + b.append(el("i")); + b.onclick = async (e) => { + e.stopPropagation(); + if (b.disabled) return; + b.disabled = true; + try { + prefs = await writingPrefs(api("settings/codex-credits", { user: q.user, on: !on })); + state.settings = prefs; + status(t(on ? "{who} no longer spends its credits: held when a window runs out" : "{who} spends its credits when its windows run out", { who: q.user }), "ok"); + renderQuotas(); + } catch (err) { + b.disabled = false; + status(err.message, "err"); + } + }; + return b; +} + // checkinRow is a WorkBuddy (China), Trae CN or MiniMax Code account's daily check-in // on its Usage card: today's (a Beijing day) done, with the credits and // the streak, or why not, or the last day it was; the vendor's first such diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 77c8d4fa..870e44b2 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1244,6 +1244,15 @@ const I18N = { "{who} no longer uses a reset by itself": "{who} 不再自动使用重置卡", "Auto-use resets": "自动使用重置卡", "Auto-use resets:": "自动使用重置卡:", + "Use credits:": "使用积分:", + "when its windows run out, so a task goes on": "额度用尽时继续用积分,任务不中断", + "no — held when a window runs out": "否,额度用尽即暂停使用", + "On: once one of this account's windows is used up, ChatGPT answers on the account's credits, if it holds any, so a task goes on. Click to turn it off.": "开启:此账号任一额度窗口用尽后,ChatGPT 会用账号的积分(如有)继续回答,任务不中断。点击关闭。", + "Off: once one of this account's windows is used up, magpie holds it till the window renews, and requests go to your other accounts, groups and fallbacks, so its credits aren't spent. With none of them left, a request is refused with why, unless Auto-use resets is on and its week is used up: then a reset is used first.": "关闭:此账号任一额度窗口用尽后,magpie 会暂停使用它,直到该窗口重置;请求改由你的其他账号、组合和回退接手,不消耗它的积分。都无法接手时,请求会被拒绝并说明原因;若开启了自动使用重置卡且本周额度已用完,会先使用一张重置卡。", + "Use credits": "使用积分", + "{who} no longer spends its credits: held when a window runs out": "{who} 不再使用积分:额度用尽即暂停使用", + "{who} spends its credits when its windows run out": "{who} 额度用尽时将使用积分", + "held: its allowance used up, set not to spend credits": "额度已用尽,且设为不使用积分", "when the week runs out, or before one expires": "周额度用完时 · 重置卡到期前", "Auto-use": "自动用卡", "Auto-use: on": "自动用卡:开", @@ -2797,6 +2806,7 @@ const I18N = { "held at its {cap}% usage cap": "已达 {cap}% 用量上限", "not an account the gateway key may use": "不是该网关密钥可用的账号", "{who} is left out: the gateway key asking may not use its account.": "{who} 被排除:发起请求的网关密钥不能使用它的账号。", + "{who} is left out: a usage window is used up, and the account is set not to spend its credits, so it counts as used up until that window renews.": "{who} 不参与:某个用量窗口已用完,且该账号设为不使用积分,窗口重置前视为已用完。", "{who} is left out: a usage window is at {n}, past the {cap}% cap set on the account, so it counts as used up until that window renews.": "{who} 不参与:某个用量窗口已用 {n},超过该账号的 {cap}% 上限,窗口重置前视为已用完。", "No cap": "不设上限", "Cap {n}%": "上限 {n}%", @@ -4687,6 +4697,15 @@ const I18N = { "{who} no longer uses a reset by itself": "{who} はリセットを自動で使用しなくなりました", "Auto-use resets": "リセットを自動使用", "Auto-use resets:": "リセットを自動使用:", + "Use credits:": "クレジットを使用:", + "when its windows run out, so a task goes on": "枠を使い切ったら使用し、タスクを続行", + "no — held when a window runs out": "しない — 枠を使い切ったら停止", + "On: once one of this account's windows is used up, ChatGPT answers on the account's credits, if it holds any, so a task goes on. Click to turn it off.": "オン:このアカウントの枠のいずれかを使い切ると、ChatGPT はアカウントのクレジット(あれば)で応答を続け、タスクは止まりません。クリックでオフにします。", + "Off: once one of this account's windows is used up, magpie holds it till the window renews, and requests go to your other accounts, groups and fallbacks, so its credits aren't spent. With none of them left, a request is refused with why, unless Auto-use resets is on and its week is used up: then a reset is used first.": "オフ:このアカウントの枠のいずれかを使い切ると、magpie はその枠がリセットされるまで使用を止め、リクエストは他のアカウント・グループ・フォールバックに回るため、クレジットは消費されません。どれも使えない場合はリクエストを理由付きで拒否します。ただし「リセットを自動使用」がオンで週の枠を使い切っている場合は、先にリセットを 1 回使います。", + "Use credits": "クレジットを使用", + "{who} no longer spends its credits: held when a window runs out": "{who} はクレジットを使用しなくなりました:枠を使い切ったら停止します", + "{who} spends its credits when its windows run out": "{who} は枠を使い切るとクレジットを使用します", + "held: its allowance used up, set not to spend credits": "枠を使い切り、クレジット不使用のため停止中", "when the week runs out, or before one expires": "週の枠を使い切ったとき、または期限切れの前", "Auto-use": "自動使用", "Auto-use: on": "自動使用:オン", @@ -6243,6 +6262,7 @@ const I18N = { "held at its {cap}% usage cap": "使用量上限 {cap}% で停止中", "not an account the gateway key may use": "ゲートウェイキーが使えるアカウントではありません", "{who} is left out: the gateway key asking may not use its account.": "{who} は外されています:リクエストしたゲートウェイキーはそのアカウントを使えません。", + "{who} is left out: a usage window is used up, and the account is set not to spend its credits, so it counts as used up until that window renews.": "{who} は除外:使用量ウィンドウの 1 つを使い切り、アカウントはクレジットを使用しない設定のため、そのウィンドウが更新されるまで使い切ったものとみなします。", "{who} is left out: a usage window is at {n}, past the {cap}% cap set on the account, so it counts as used up until that window renews.": "{who} は除外:使用量ウィンドウの 1 つが {n} で、アカウントに設定された上限 {cap}% を超えているため、そのウィンドウが更新されるまで使い切ったものとみなします。", "No cap": "上限なし", "Cap {n}%": "上限 {n}%", @@ -8140,6 +8160,15 @@ const I18N = { "{who} no longer uses a reset by itself": "{who} löst keinen Reset mehr automatisch ein", "Auto-use resets": "Resets automatisch einlösen", "Auto-use resets:": "Resets automatisch einlösen:", + "Use credits:": "Credits nutzen:", + "when its windows run out, so a task goes on": "wenn die Kontingente aufgebraucht sind, damit eine Aufgabe weiterläuft", + "no — held when a window runs out": "nein – angehalten, wenn ein Kontingent aufgebraucht ist", + "On: once one of this account's windows is used up, ChatGPT answers on the account's credits, if it holds any, so a task goes on. Click to turn it off.": "An: Ist ein Kontingent dieses Kontos aufgebraucht, antwortet ChatGPT mit den Credits des Kontos, sofern vorhanden, und eine Aufgabe läuft weiter. Klicken zum Ausschalten.", + "Off: once one of this account's windows is used up, magpie holds it till the window renews, and requests go to your other accounts, groups and fallbacks, so its credits aren't spent. With none of them left, a request is refused with why, unless Auto-use resets is on and its week is used up: then a reset is used first.": "Aus: Ist ein Kontingent dieses Kontos aufgebraucht, hält magpie es an, bis sich das Kontingent erneuert, und Anfragen gehen an deine anderen Konten, Gruppen und Fallbacks; seine Credits werden nicht verbraucht. Ist keines davon übrig, wird eine Anfrage mit Begründung abgelehnt – außer „Resets automatisch einlösen“ ist an und die Woche aufgebraucht: dann wird zuerst ein Reset eingelöst.", + "Use credits": "Credits nutzen", + "{who} no longer spends its credits: held when a window runs out": "{who} verbraucht keine Credits mehr: angehalten, wenn ein Kontingent aufgebraucht ist", + "{who} spends its credits when its windows run out": "{who} verbraucht Credits, wenn die Kontingente aufgebraucht sind", + "held: its allowance used up, set not to spend credits": "angehalten: Kontingent aufgebraucht, keine Credits freigegeben", "when the week runs out, or before one expires": "wenn die Woche aufgebraucht ist oder bevor einer abläuft", "Auto-use": "Automatisch", "Auto-use: on": "Automatisch: an", @@ -9696,6 +9725,7 @@ const I18N = { "held at its {cap}% usage cap": "am Nutzungslimit von {cap} % angehalten", "not an account the gateway key may use": "kein Konto, das dieser Zugangsschlüssel nutzen darf", "{who} is left out: the gateway key asking may not use its account.": "{who} wird übergangen: der fragende Zugangsschlüssel darf sein Konto nicht nutzen.", + "{who} is left out: a usage window is used up, and the account is set not to spend its credits, so it counts as used up until that window renews.": "{who} wird übergangen: Ein Zeitfenster ist aufgebraucht, und das Konto soll keine Credits verbrauchen, daher gilt es bis zur Erneuerung des Zeitfensters als aufgebraucht.", "{who} is left out: a usage window is at {n}, past the {cap}% cap set on the account, so it counts as used up until that window renews.": "{who} wird übergangen: Ein Zeitfenster steht bei {n}, über dem für das Konto gesetzten Limit von {cap} %, und gilt daher bis zu seiner Erneuerung als aufgebraucht.", "No cap": "Kein Limit", "Cap {n}%": "Limit {n} %", diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index cdcc6d41..1e2e4812 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -336,7 +336,7 @@ // from its vendor does — relays list each key its own group's models // or the user set the account or key to serve other models only (#474) // or it is held at the usage cap the user set on the account - const unlistedWord = (w) => w.held ? t("not an account the gateway key may use") : w.capped ? t("held at its {cap}% usage cap", { cap: w.capped }) : w.barred ? t("set to serve other models, not {model}", { model: w.model }) : w.kind === "key" ? t("{name}'s list for this key has no {model}", { name: w.name, model: w.model }) : t("its plan doesn't list {model}", { model: w.model }); + const unlistedWord = (w) => w.held ? t("not an account the gateway key may use") : w.noCredits ? t("held: its allowance used up, set not to spend credits") : w.capped ? t("held at its {cap}% usage cap", { cap: w.capped }) : w.barred ? t("set to serve other models, not {model}", { model: w.model }) : w.kind === "key" ? t("{name}'s list for this key has no {model}", { name: w.name, model: w.model }) : t("its plan doesn't list {model}", { model: w.model }); const group = (w) => w.used >= 98 ? "spent" : w.used >= 90 ? "low" : "fine"; const renews = (w) => (w.renews || []).map((s) => known0(s) ? at(s) : 0); // renewsBy is renews as Smart ranks them: an auto-used Codex reset that @@ -475,6 +475,8 @@ for (const x of r.order.filter((x) => x.aside)) out.push(t("{who} is made for {api}, not {other} as the keys routed over are, so it isn't one of them: it's tried after them.", { who: who(x), api: API[x.speaks] || x.speaks || t("any API"), other: API[pooled?.speaks] || pooled?.speaks || t("any API") })); for (const x of r.left || []) out.push(x.held ? t("{who} is left out: the gateway key asking may not use its account.", { who: who(x) }) + : x.noCredits + ? t("{who} is left out: a usage window is used up, and the account is set not to spend its credits, so it counts as used up until that window renews.", { who: who(x) }) : x.capped ? t("{who} is left out: a usage window is at {n}, past the {cap}% cap set on the account, so it counts as used up until that window renews.", { who: who(x), n: pct(x.used), cap: x.capped }) : x.barred diff --git a/internal/gui/settings_keep_test.go b/internal/gui/settings_keep_test.go index a1d05fc1..9f21b3bc 100644 --- a/internal/gui/settings_keep_test.go +++ b/internal/gui/settings_keep_test.go @@ -141,6 +141,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) { CodexAutoReview: "fake/m1", ChinaMirror: true, CodexAutoReset: []string{"me@example.com"}, + CodexNoCredits: []string{"me@example.com"}, TextSize: 125, UpdateSkip: "0.1.500", UpdateMirror: "https://mirror.example/", diff --git a/internal/gui/tests/codex-credits.test.cjs b/internal/gui/tests/codex-credits.test.cjs new file mode 100644 index 00000000..41fff771 --- /dev/null +++ b/internal/gui/tests/codex-credits.test.cjs @@ -0,0 +1,218 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Whether a Codex account spends its credits once its windows run out, a +// standing say of the account's: every named Codex account's card in full +// has a row saying what it does, with a switch, on until turned off, that +// posts settings/codex-credits for that account; an account holding no +// credits has it too, so it can be set ahead. The credits held stay told +// beside it. A GLM team's and a plugin's accounts get none. Off, the row +// says the account is held when a window runs out, and the Routing page +// says why such an account was left out. On the Usage page and the menu +// bar panel, at narrow widths too, nothing of the row is cut off or wider +// than its card, and a click moves nothing. English, Chinese, Japanese and +// German, Chromium and WebKit; no backend, the API is faked here. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const later = new Date(Date.now() + 3 * 864e5).toISOString(); +const now = new Date(); +const quotas = [ + { provider: "codex", name: "Codex", icon: "codex-color", user: "Me@example.com", plan: "Plus", lastServedAt: now.toISOString(), + windows: [{ name: "5 hours", used: 100, resetsAt: later }, { name: "7 days", used: 40, resetsAt: later }], + balance: "1.2K credits" }, + // a second account, holding no credits: its say is set ahead of any + { provider: "codex", name: "Codex", icon: "codex-color", user: "two@example.com", plan: "Plus", + windows: [{ name: "5 hours", used: 10, resetsAt: later }, { name: "7 days", used: 20, resetsAt: later }] }, + { provider: "zhipu", name: "GLM Coding", icon: "zhipu-color", user: "team@example.com", plan: "Team", + windows: [{ name: "5 hours", used: 30 }] }, + { provider: "codex-plugin", name: "Codex (plugin)", user: "Me@example.com", plan: "Plus", + windows: [{ name: "7 days", used: 100, resetsAt: later }] }, +]; +const day = [now.getFullYear(), now.getMonth() + 1, now.getDate()].map((n) => String(n).padStart(2, "0")).join("-"); +const at = new Date(now.getTime() - 60e3).toISOString(); +const spare = { id: "codex@spare@example.com", provider: "codex", name: "Codex", kind: "account", who: "spare@example.com", model: "gpt-5.5" }; +const held = { id: "codex", provider: "codex", name: "Codex", kind: "account", who: "Me@example.com", model: "gpt-5.5", + unlisted: true, capped: 100, used: 100, capBack: later, noCredits: true }; +const routes = [{ id: 7, seq: 7, time: at, agent: "codex", model: "gpt-5.5", provider: "codex", order: [spare], left: [held], + tries: [{ id: spare.id, model: "gpt-5.5", start: at, done: true, status: 200, ms: 400 }], done: true, status: 200, ms: 400 }]; + +function serve(lang, posts) { + let off = []; + const settings = () => ({ theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd", trayUsages: ["codex", "zhipu", "codex-plugin"], trayUsageEvery: 3, codexNoCredits: off }); + return async (route) => { + const req = route.request(), url = new URL(req.url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [{ id: "codex", name: "Codex", path: "/test/config.toml", fields: [] }], profiles: [], settings: settings() }); + if (url.pathname === "/api/settings") return json(settings()); + if (url.pathname === "/api/settings/codex-credits") { + const body = req.postDataJSON(); + posts.push(body); + const who = body.user.toLowerCase(); + off = off.filter((u) => u !== who); + if (!body.on) off.push(who); + return json(settings()); + } + if (url.pathname === "/api/usage/quotas") return json(quotas); + if (url.pathname === "/api/gateway/trace") { + if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3)); + return json({ mine: true, now: now.toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] }); + } + if (url.pathname === "/api/gateway/history") { + const d = url.searchParams.get("day"); + return json({ cut: false, days: [{ day, requests: routes.length }], routes: d ? routes : [] }); + } + if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true, window: true } }); + if (url.pathname === "/api/groups") return json({ groups: [], models: [] }); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +const words = { + en: { + on: "Use credits: when its windows run out, so a task goes on", off: "Use credits: no — held when a window runs out", label: "Use credits", + turnedOff: (who) => `${who} no longer spends its credits: held when a window runs out`, turnedOn: (who) => `${who} spends its credits when its windows run out`, + left: "is left out: a usage window is used up, and the account is set not to spend its credits, so it counts as used up until that window renews.", + }, + zh: { + on: "使用积分: 额度用尽时继续用积分,任务不中断", off: "使用积分: 否,额度用尽即暂停使用", label: "使用积分", + turnedOff: (who) => `${who} 不再使用积分:额度用尽即暂停使用`, turnedOn: (who) => `${who} 额度用尽时将使用积分`, + left: "不参与:某个用量窗口已用完,且该账号设为不使用积分,窗口重置前视为已用完。", + }, + ja: { + on: "クレジットを使用: 枠を使い切ったら使用し、タスクを続行", off: "クレジットを使用: しない — 枠を使い切ったら停止", label: "クレジットを使用", + turnedOff: (who) => `${who} はクレジットを使用しなくなりました:枠を使い切ったら停止します`, turnedOn: (who) => `${who} は枠を使い切るとクレジットを使用します`, + left: "は除外:使用量ウィンドウの 1 つを使い切り、アカウントはクレジットを使用しない設定のため、そのウィンドウが更新されるまで使い切ったものとみなします。", + }, + de: { + on: "Credits nutzen: wenn die Kontingente aufgebraucht sind, damit eine Aufgabe weiterläuft", off: "Credits nutzen: nein – angehalten, wenn ein Kontingent aufgebraucht ist", label: "Credits nutzen", + turnedOff: (who) => `${who} verbraucht keine Credits mehr: angehalten, wenn ein Kontingent aufgebraucht ist`, turnedOn: (who) => `${who} verbraucht Credits, wenn die Kontingente aufgebraucht sind`, + left: "wird übergangen: Ein Zeitfenster ist aufgebraucht, und das Konto soll keine Credits verbrauchen, daher gilt es bis zur Erneuerung des Zeitfensters als aufgebraucht.", + }, +}; + +const keys = [ + "Use credits", "Use credits:", "when its windows run out, so a task goes on", "no — held when a window runs out", + "On: once one of this account's windows is used up, ChatGPT answers on the account's credits, if it holds any, so a task goes on. Click to turn it off.", + "Off: once one of this account's windows is used up, magpie holds it till the window renews, and requests go to your other accounts, groups and fallbacks, so its credits aren't spent. With none of them left, a request is refused with why, unless Auto-use resets is on and its week is used up: then a reset is used first.", + "{who} no longer spends its credits: held when a window runs out", "{who} spends its credits when its windows run out", + "held: its allowance used up, set not to spend credits", + "{who} is left out: a usage window is used up, and the account is set not to spend its credits, so it counts as used up until that window renews.", +]; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh", "ja", "de"]) { + const w = words[lang]; + test(`${engine} ${lang}: a Codex account spends its credits or is held, a standing say on its card`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const pages = []; + t.after(async () => { + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + for (const [i, p] of pages.entries()) await p.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-codex-credits-${i}.png`) }); + } + await browser.close(); + }); + const errors = []; + const open = async (url, viewport, posts) => { + const page = await (await browser.newContext({ viewport, reducedMotion: "reduce" })).newPage(); + pages.push(page); + page.setDefaultTimeout(5000); + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, posts)); + await page.goto(url); + return page; + }; + const wait = async (posts, n) => { for (let i = 0; i < 60 && posts.length < n; i++) await new Promise((r) => setTimeout(r, 50)); }; + const text = (loc) => loc.evaluate((e) => e.textContent.replace(/\s+/g, " ").trim()); + // a switch's click: it posts, turns, says so, and moves nothing + const flip = async (page, row, posts, who, want) => { + const sel = row + " .use-credits"; + const b = page.locator(sel); + const spot = () => b.evaluate((e) => [e.getBoundingClientRect().top, document.scrollingElement.scrollTop, ...[...document.querySelectorAll(".view")].map((v) => v.scrollTop)]); + const was = await spot(); + const n = posts.length; + await b.click(); + await wait(posts, n + 1); + assert.deepEqual(posts.at(-1), { user: who, on: want }); + await page.locator(sel + `[aria-checked="${want}"]`).waitFor(); + assert.equal(await b.evaluate((e) => e.classList.contains("on")), want); + assert.equal(await page.locator("#status").textContent(), want ? w.turnedOn(who) : w.turnedOff(who)); + assert.equal(await text(page.locator(row + " .ar-say")), want ? w.on : w.off); + await page.waitForTimeout(200); + assert.deepEqual(await spot(), was, "the click moved the page"); + }; + // the row fits its card: the switch whole and inside it, the words + // wrapping rather than cut off + const fits = (page, row, card) => page.locator(row).evaluate((r, card) => { + const c = r.closest(card).getBoundingClientRect(), b = r.getBoundingClientRect(), s = r.querySelector(".use-credits").getBoundingClientRect(); + const say = r.querySelector(".ar-say"); + return b.right <= c.right + 0.5 && s.right <= c.right + 0.5 && s.width >= 27 && s.height >= 15 && say.scrollWidth <= say.clientWidth + 1; + }, card); + + for (const width of [900, 440]) { + const posts = []; + const page = await open("http://magpie.test/?view=usage", { width, height: 760 }, posts); + const me = '.quota-credits[data-user="Me@example.com"]'; + await page.locator(me).waitFor(); + assert.equal(await page.locator(".quota-credits").count(), 1, "only Codex's own named accounts in full have the say; the GLM team's and the plugin's don't"); + // the credits held are told on its card, beside the say + assert.match(await text(page.locator('.subscription-card .quota-balance').first()), /1\.2K credits/); + assert.equal(await text(page.locator(me + " .ar-say")), w.on); + const sw = page.locator(me + " .use-credits"); + assert.equal(await sw.getAttribute("role"), "switch"); + assert.equal(await sw.getAttribute("aria-checked"), "true", "on until turned off"); + assert.equal(await sw.getAttribute("aria-label"), w.label); + assert(await sw.getAttribute("title")); + assert(await fits(page, me, ".subscription-card"), `the say fits the card at ${width}px`); + await flip(page, me, posts, "Me@example.com", false); + assert(await fits(page, me, ".subscription-card"), `the say, off, fits the card at ${width}px`); + // the account holding none has it too, once in full + await page.locator('.subscription-account.brief[data-card="codex|two@example.com"] .quota-acct-fold').click(); + const two = '.quota-credits[data-user="two@example.com"]'; + await page.locator(two).waitFor(); + assert.equal(await page.locator(two + " .use-credits").getAttribute("aria-checked"), "true"); + await flip(page, two, posts, "two@example.com", false); + await flip(page, two, posts, "two@example.com", true); + const border = await page.evaluate(() => [...document.querySelectorAll(".quota-credits, .quota-credits *")].map((e) => getComputedStyle(e).borderLeftWidth).filter((b) => parseFloat(b) > 1)); + assert.deepEqual(border, [], "no left-border accent"); + } + + // the menu bar panel's card, at its own width and narrower + for (const width of [440, 360]) { + const posts = []; + const panel = await open("http://magpie.test/?mode=panel", { width, height: 640 }, posts); + const psel = '.pq-credits[data-user="Me@example.com"]'; + await panel.locator('#ptabs [data-ptab="usage"]').click(); + await panel.locator(psel).waitFor(); + assert.equal(await panel.locator(".pq-credits").count(), 1, "only the Codex account in sight has it"); + assert.equal(await text(panel.locator(psel + " .ar-say")), w.on); + assert(await fits(panel, psel, ".pq-card"), `the say fits the panel's card at ${width}px`); + await flip(panel, psel, posts, "Me@example.com", false); + assert(await fits(panel, psel, ".pq-card"), `the say, off, fits the panel's card at ${width}px`); + } + + // the Routing page says why an account held for its credits was left out + const routing = await open("http://magpie.test/?view=routing", { width: 1100, height: 760 }, []); + await routing.locator(".rt-days .rt-day").nth(1).click(); + await routing.locator(".rt-req").first().click(); + await routing.waitForTimeout(300); + const steps = await routing.locator(".rt-steps li").evaluateAll((ls) => ls.map((l) => l.textContent)); + assert(steps.some((s) => s.includes(w.left)), JSON.stringify(steps)); + + if (lang !== "en") { + const missing = await routing.evaluate(([keys, lang]) => keys.filter((k) => !I18N[lang][k]), [keys, lang]); + assert.deepEqual(missing, [], `every string has its ${lang}`); + } + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/gui/tests/quota-curve.test.cjs b/internal/gui/tests/quota-curve.test.cjs index e731bf1e..c04f801a 100644 --- a/internal/gui/tests/quota-curve.test.cjs +++ b/internal/gui/tests/quota-curve.test.cjs @@ -133,8 +133,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", // several accounts: the next one is set apart from a curve's legend await card.locator(".quota-more").click(); const second = card.locator(".subscription-account", { hasText: "b@x.com" }); - // (a Codex account's Auto-use row, #719, closes its section after the curve) - assert.deepEqual(await second.evaluate((e) => [e.previousElementSibling.previousElementSibling.className, e.previousElementSibling.className]), ["quota-curve", "quota-autoreset"]); + // (after the curve, a Codex account's Auto-use row, #719, then its + // Use credits row, which closes its section) + assert.deepEqual(await second.evaluate((e) => [e.previousElementSibling.previousElementSibling.previousElementSibling.className, e.previousElementSibling.previousElementSibling.className, e.previousElementSibling.className]), ["quota-curve", "quota-autoreset", "quota-credits on"]); assert.notEqual(await second.evaluate((e) => getComputedStyle(e).borderTopStyle), "none", "a rule between the accounts"); const choose = async (name) => { await pick.click(); diff --git a/internal/provider/codex_credits.go b/internal/provider/codex_credits.go new file mode 100644 index 00000000..602689bc --- /dev/null +++ b/internal/provider/codex_credits.go @@ -0,0 +1,56 @@ +package provider + +// Whether a Codex account spends its credits. A ChatGPT account that holds +// credits (#571) keeps answering once a usage window is used up: the vendor +// spends the credits instead, and a request routed to it goes through. That +// is the default, and it carries a task on when the allowance runs out. +// Turned off for an account, routing holds it as used up at 100% of a +// window, as a usage cap holds it at its share (account_caps.go): never +// tried till the window renews, so the user's other accounts, groups and +// fallbacks take the request, and none spends the credits. An account with +// no credits is refused by the vendor at 100% either way; the switch only +// spares it the request. +// +// It doesn't change which account Codex is signed in to: NextLogin moves +// off an account at its routing's spent share (100% at most) and onto none +// past it, credits or not. Unlike the cap, it holds at the vendor's own +// 100%, so an account that spends its resets by itself spends one there +// (the gateway's autoReset) where a cap never does. + +import ( + "slices" + + "github.com/yetone/magpie/internal/settings" +) + +// CodexCredits says whether the Codex account user spends its credits +// once a usage window is used up: true unless the user turned it off. +func CodexCredits(user string) bool { + return !slices.Contains(settings.Load().CodexNoCredits, accountKey(user)) +} + +// SetCodexCredits turns that on or off for user. +func SetCodexCredits(user string, on bool) error { + user = accountKey(user) + s := settings.Load() + s.CodexNoCredits = slices.DeleteFunc(s.CodexNoCredits, func(u string) bool { return u == user }) + if !on && user != "" { + s.CodexNoCredits = append(s.CodexNoCredits, user) + } + return settings.Save(s) +} + +// HoldShare is the share of its windows (in percent) at which routing +// holds the account user of p, of agent, as used up: its usage cap when it +// has one, 100 for a Codex account set not to spend its credits, 0 when +// nothing holds it before the vendor does. noCredits says it is the credits +// that hold it. +func HoldShare(p Provider, agent, user string) (share int, noCredits bool) { + if c := p.AccountCap(user); c > 0 { + return c, false + } + if agent == "codex" && user != "" && !CodexCredits(user) { + return 100, true + } + return 0, false +} diff --git a/internal/provider/codex_credits_test.go b/internal/provider/codex_credits_test.go index d8b8f133..9608032d 100644 --- a/internal/provider/codex_credits_test.go +++ b/internal/provider/codex_credits_test.go @@ -5,7 +5,11 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "slices" "testing" + "time" + + "github.com/yetone/magpie/internal/settings" ) // The credits a ChatGPT account holds besides its windows are its balance @@ -47,3 +51,79 @@ func TestCodexCreditsBalance(t *testing.T) { }) } } + +// Whether an account spends its credits is kept by account, whatever its +// case: on unless turned off. Turned off, a Codex account is held at 100% +// of its windows; a cap holds it sooner, and nothing holds another +// vendor's account for its credits. +func TestCodexCreditsSwitch(t *testing.T) { + signIn(t) + if !CodexCredits("me@example.com") { + t.Fatal("off before it was turned off") + } + if err := SetCodexCredits(" Me@Example.com ", false); err != nil { + t.Fatal(err) + } + if got := settings.Load().CodexNoCredits; !slices.Equal(got, []string{"me@example.com"}) { + t.Fatalf("kept %v", got) + } + if CodexCredits("ME@example.com") || !CodexCredits("other@example.com") { + t.Fatal("read back wrong") + } + p := Provider{ID: "codex", AccountCaps: map[string]int{"capped@example.com": 70}} + for _, c := range []struct { + agent, user string + share int + noCredits bool + }{ + {"codex", "me@example.com", 100, true}, + {"codex", "other@example.com", 0, false}, + {"claude", "me@example.com", 0, false}, + {"codex", "capped@example.com", 70, false}, + } { + if share, no := HoldShare(p, c.agent, c.user); share != c.share || no != c.noCredits { + t.Errorf("%s %s: held at %d (credits %v)", c.agent, c.user, share, no) + } + } + if err := SetCodexCredits("me@example.com", true); err != nil { + t.Fatal(err) + } + if !CodexCredits("me@example.com") || len(settings.Load().CodexNoCredits) != 0 { + t.Fatal("still off") + } +} + +// A reset spent starts an account's windows again: what was read of them +// before is forgotten, so what holds the account at a share of them (its +// cap, its credits not spent) holds it no more, and they are read again +// at once; the other accounts' readings stay. +func TestRenewedAccountForgetsItsAllowance(t *testing.T) { + signIn(t) + usedCache.Lock() + usedCache.m, usedCache.at, usedCache.loading = map[string]map[string]Allowance{}, map[string]time.Time{}, map[string]chan struct{}{} + usedCache.m["codex"] = map[string]Allowance{ + "me@example.com": {{Used: 100, Span: 5 * time.Hour}}, + "spare@example.com": {{Used: 10, Span: 5 * time.Hour}}, + } + usedCache.at["codex"] = time.Now() + handed := usedCache.m["codex"] + usedCache.Unlock() + t.Cleanup(func() { + usedCache.Lock() + usedCache.m, usedCache.at, usedCache.loading = map[string]map[string]Allowance{}, map[string]time.Time{}, map[string]chan struct{}{} + usedCache.Unlock() + }) + renewedNow("codex", "Me@Example.com") + usedCache.Lock() + m, at := usedCache.m["codex"], usedCache.at["codex"] + usedCache.Unlock() + if _, ok := m["me@example.com"]; ok { + t.Fatalf("still read as before: %v", m["me@example.com"]) + } + if _, ok := m["spare@example.com"]; !ok || !at.IsZero() { + t.Fatalf("spare kept %v, read again at once %v", ok, at.IsZero()) + } + if _, ok := handed["me@example.com"]; !ok { + t.Fatal("the map handed out before was changed") + } +} diff --git a/internal/provider/routing.go b/internal/provider/routing.go index 64d58db1..c8500dbd 100644 --- a/internal/provider/routing.go +++ b/internal/provider/routing.go @@ -91,6 +91,10 @@ var usedCache struct { m map[string]map[string]Allowance // agent → user → allowance at map[string]time.Time loading map[string]chan struct{} // closed when the fetch in flight is done + // renewed: when an account's windows were last started again (a + // Codex reset spent), by agent/user: a reading begun before says + // nothing of them + renewed map[string]time.Time } // firstWait is how long a request waits for an agent's allowances the @@ -400,6 +404,7 @@ func Allowances(agent string) map[string]Allowance { go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + began := time.Now() all := map[string]Allowance{} for user, q := range LoginUsage(ctx, agent) { if q.Error != "" || len(q.Windows) == 0 { @@ -408,7 +413,16 @@ func Allowances(agent string) map[string]Allowance { all[user] = allowanceOf(q.Windows, time.Now()).restartedBy(resetRunsOut(agent, user, q.Windows, q.Resets)) } c.Lock() - c.m[agent], c.at[agent] = all, time.Now() + at := time.Now() + for user := range all { + if c.renewed[agent+"/"+strings.ToLower(user)].After(began) { + // started again while this was read: not known till + // it is read again, at once + delete(all, user) + at = time.Time{} + } + } + c.m[agent], c.at[agent] = all, at delete(c.loading, agent) c.Unlock() close(done) @@ -445,8 +459,12 @@ var renewedHooks struct { fs []func(agent, user string) } -// renewedNow tells those OnRenewed asked. +// renewedNow tells those OnRenewed asked, and forgets the account's +// allowance as last read: its windows are not known till read again, and +// what holds an account at a share of them (a cap, credits not spent) +// holds it no more. func renewedNow(agent, user string) { + forgetAllowance(agent, user) renewedHooks.Lock() fs := renewedHooks.fs renewedHooks.Unlock() @@ -455,6 +473,33 @@ func renewedNow(agent, user string) { } } +// forgetAllowance leaves agent's account user out of the allowances last +// read, and has the next Allowances read them again. +func forgetAllowance(agent, user string) { + c := &usedCache + c.Lock() + defer c.Unlock() + if c.renewed == nil { + c.renewed = map[string]time.Time{} + } + c.renewed[agent+"/"+strings.ToLower(user)] = time.Now() + if c.at != nil { + c.at[agent] = time.Time{} + } + m, ok := c.m[agent] + if !ok { + return + } + // a new map: the one handed out is read without the lock + kept := make(map[string]Allowance, len(m)) + for u, a := range m { + if !strings.EqualFold(u, user) { + kept[u] = a + } + } + c.m[agent] = kept +} + // StaleAllowance makes the next Allowances ask the vendor again for user's // allowance rather than trust what it last said: the account just // answered that it has run out. diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 8beff12c..91835e41 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -119,6 +119,12 @@ type Settings struct { // unused half an hour before it does, or at once when the account is // held up past then (provider.SpendExpiringCodexResets). CodexAutoReset []string `json:"codexAutoReset,omitempty"` + // CodexNoCredits are the ChatGPT accounts (lower-case) that don't + // spend their credits: once a usage window is used up, routing holds + // the account as used up and goes on to the user's other accounts, + // groups and fallbacks, where by default the vendor answers on its + // credits (see provider.CodexCredits). + CodexNoCredits []string `json:"codexNoCredits,omitempty"` // WorkBuddyCheckin presses WorkBuddy's daily check-in (签到) for each // signed-in WorkBuddy (China) account once a Beijing day, claiming the // credits it gives while its event runs. @@ -889,6 +895,10 @@ func Save(s Settings) error { s.CodexAutoReset[i] = strings.ToLower(u) } s.CodexAutoReset = ids(s.CodexAutoReset) + for i, u := range s.CodexNoCredits { + s.CodexNoCredits[i] = strings.ToLower(u) + } + s.CodexNoCredits = ids(s.CodexNoCredits) s.TrayUsage = "" if len(s.TrayUsages) > 0 { s.TrayUsage = s.TrayUsages[0] diff --git a/internal/tui/pages.go b/internal/tui/pages.go index c1c2feb3..1ec9ff90 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -990,7 +990,7 @@ func quotaLines(qs []provider.SubscriptionQuota, asked, left bool, width int, no ci = " " + ci } switch { - case q.Balance != "": + case q.Balance != "" && len(q.Windows) == 0: out = append(out, line+" "+sText.Render(q.Balance)+sMuted.Render(" left")+ci) continue case q.Error != "": @@ -1001,11 +1001,19 @@ func quotaLines(qs []provider.SubscriptionQuota, asked, left bool, width int, no continue } // the windows follow the name, those that don't fit on lines below - // it, and a Codex account's resets after them + // it, then the credits a ChatGPT account holds beside them and a + // Codex account's resets var cells []string for _, w := range provider.PooledWindows(q.Windows) { cells = append(cells, quotaCell(w, left, now)) } + if q.Balance != "" { + c := sText.Render(q.Balance) + sMuted.Render(" left") + if q.Provider == "codex" && q.User != "" && !provider.CodexCredits(q.User) { + c += sFaint.Render(" · not spent") // held once a window is used up + } + cells = append(cells, c) + } if ci != "" { cells = append(cells, ci[3:]) } diff --git a/internal/tui/usage_test.go b/internal/tui/usage_test.go index f52bc4fc..4c7eb91a 100644 --- a/internal/tui/usage_test.go +++ b/internal/tui/usage_test.go @@ -58,6 +58,13 @@ func TestQuotaLines(t *testing.T) { if !strings.HasSuffix(got[1], "↺ 2 resets until "+provider.ResetClock(expires, now)) || !strings.HasSuffix(got[2], "↺ 1 reset") { t.Errorf("resets:\n%s", plain(got)) } + // a ChatGPT account's credits are told beside its windows, not in + // their place + credits := []provider.SubscriptionQuota{qs[0]} + credits[0].Balance = "1.2K credits" + if got := plain(quotaLines(credits, true, false, 200, now)); !strings.Contains(got, "64% used") || !strings.Contains(got, "99% used") || !strings.Contains(got, "1.2K credits left") { + t.Errorf("credits:\n%s", got) + } if quotaLines(nil, false, false, 80, now) != nil { t.Error("not asked yet: want nothing") } @@ -134,3 +141,20 @@ func TestUsagePageShowsCallsNotThroughMagpie(t *testing.T) { t.Fatalf("the unknown provider's id is shown:\n%s", own) } } + +// A Codex account set not to spend its credits says so beside them. +func TestQuotaLinesCreditsNotSpent(t *testing.T) { + home(t) + now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) + qs := []provider.SubscriptionQuota{{Provider: "codex", Name: "Codex", User: "me@example.com", Balance: "1.2K credits", + Windows: []provider.QuotaWindow{{Name: "5 hours", Used: 100}}}} + if got := strings.Join(quotaLines(qs, true, false, 200, now), "\n"); strings.Contains(got, "not spent") { + t.Errorf("spends them by default:\n%s", got) + } + if err := provider.SetCodexCredits("Me@example.com", false); err != nil { + t.Fatal(err) + } + if got := strings.Join(quotaLines(qs, true, false, 200, now), "\n"); !strings.Contains(got, "1.2K credits left · not spent") { + t.Errorf("set not to:\n%s", got) + } +} diff --git a/quota_cli.go b/quota_cli.go index 55af79ca..2fc93bfd 100644 --- a/quota_cli.go +++ b/quota_cli.go @@ -17,6 +17,7 @@ import ( const quotaUsage = `usage: magpie quota […] [--json] magpie quota reset [] [--yes] magpie quota auto-reset [] [on|off] + magpie quota credits [] [on|off] magpie quota alert [|off] [--balance |off] magpie quota wait [--timeout ] [--quiet] magpie quota history […] [--days ] [--json] @@ -36,6 +37,12 @@ const quotaUsage = `usage: magpie quota […] [--json] then and it isn't lost; at once when the account is held up until after then. It is the account's standing setting, held resets or not; off stops it, and alone it says which accounts have it on. It is off until turned on. + quota credits off keeps a Codex account from spending its credits: once one of + its windows is used up, routing holds it as used up until that window renews and + the request goes to the other accounts, groups and fallbacks, or is refused with + why (with auto-reset on, its week used up and no one else left, it spends a reset + first). on, the default, lets the vendor answer on its credits then, so the task + goes on; alone it says which accounts don't spend theirs. quota alert 80 has the magpie app notify when any window of a subscription or plan reaches 80% used, once each time the window runs (not windows set aside, such as on-demand spending); --balance 5 when a balance falls to 5 or under, in its own @@ -65,6 +72,9 @@ func quotaCmd(args []string) error { if len(args) > 1 && args[1] == "auto-reset" { return quotaAutoResetCmd(args[2:]) } + if len(args) > 1 && args[1] == "credits" { + return quotaCreditsCmd(args[2:]) + } if len(args) > 1 && args[1] == "alert" { return quotaAlertCmd(args[2:]) } @@ -122,7 +132,7 @@ func quotaCmd(args []string) error { line += " " + quotaCell(w) } if q.Balance != "" { - line += " " + bold.Render(q.Balance) + muted.Render(" left") + line += " " + balanceCell(q.Balance, q.Provider, q.User) } if q.Resets != nil { line += " " + resetsCell(q.Resets, provider.AutoResets(q.Provider, q.User)) @@ -137,6 +147,16 @@ func quotaCmd(args []string) error { return nil } +// balanceCell is what an account holds in a line: "1.2K credits left", +// and "· not spent" for a Codex account set not to spend its credits. +func balanceCell(balance, id, user string) string { + cell := bold.Render(balance) + muted.Render(" left") + if id == "codex" && user != "" && !provider.CodexCredits(user) { + cell += muted.Render(" · not spent") + } + return cell +} + // resetsCell is a Codex account's rate-limit resets in a line: "↺ 2 // resets until Oct 3 14:30", no date when they don't run out, and "· auto" // when one is spent by itself once the week is used up. @@ -243,16 +263,9 @@ func quotaAutoResetCmd(args []string) error { } return nil } - if user == "" { - live, ok := provider.CodexSignedIn() - if !ok { - return fmt.Errorf("Codex isn't signed in: name the account") - } - user = live - } else if !slices.ContainsFunc(provider.Logins("codex"), func(l provider.Login) bool { return strings.EqualFold(l.User, user) }) { - if live, ok := provider.CodexSignedIn(); !ok || !strings.EqualFold(live, user) { - return fmt.Errorf("no Codex account %s", user) - } + user, err := codexAccountNamed(user) + if err != nil { + return err } if !set { if provider.CodexAutoReset(user) { @@ -273,6 +286,76 @@ func quotaAutoResetCmd(args []string) error { return nil } +// quotaCreditsCmd: magpie quota credits [] [on|off] — +// whether the account spends its credits once its allowance is used up. +func quotaCreditsCmd(args []string) error { + user, set, on := "", false, false + for _, a := range args { + switch strings.ToLower(a) { + case "on", "off": + set, on = true, strings.EqualFold(a, "on") + case "help", "-h", "--help": + fmt.Println(quotaUsage) + return nil + default: + if user != "" { + return fmt.Errorf("one account at a time: %s or %s", user, a) + } + user = a + } + } + if !set && user == "" { + off := settings.Load().CodexNoCredits + if len(off) == 0 { + fmt.Println(muted.Render("every Codex account spends its credits once its allowance is used up ·"), "magpie quota credits off") + return nil + } + for _, u := range off { + fmt.Println(muted.Render("○"), u, muted.Render("doesn't spend its credits: held once a window is used up")) + } + return nil + } + user, err := codexAccountNamed(user) + if err != nil { + return err + } + if !set { + if provider.CodexCredits(user) { + fmt.Println(user, "spends its credits once its allowance is used up") + } else { + fmt.Println(user, "doesn't spend its credits: once a window is used up it is held till it renews") + } + return nil + } + if err := provider.SetCodexCredits(user, on); err != nil { + return err + } + if on { + fmt.Println(green.Render("✓"), user+":", "spends its credits once its allowance is used up, so a task goes on") + } else { + fmt.Println(green.Render("✓"), user+":", "no longer spends its credits: once a window is used up, requests go to the other accounts till it renews") + } + return nil +} + +// codexAccountNamed is the Codex account user names, the one Codex is +// signed in to when "". +func codexAccountNamed(user string) (string, error) { + if user == "" { + live, ok := provider.CodexSignedIn() + if !ok { + return "", fmt.Errorf("Codex isn't signed in: name the account") + } + return live, nil + } + if !slices.ContainsFunc(provider.Logins("codex"), func(l provider.Login) bool { return strings.EqualFold(l.User, user) }) { + if live, ok := provider.CodexSignedIn(); !ok || !strings.EqualFold(live, user) { + return "", fmt.Errorf("no Codex account %s", user) + } + } + return user, nil +} + // quotaAlertCmd: magpie quota alert [|off] [--balance |off] // — the usage alerts Settings sets (#368), the notifications the app shows. func quotaAlertCmd(args []string) error {