Files
magpie/internal/gateway/codex_autoreset.go
T
forever b37ac53bef 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 <account> 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.
2026-10-06 14:20:09 +08:00

99 lines
3.4 KiB
Go

package gateway
// A Codex rate-limit reset spent by itself: when everyone a request could
// go to is out of their allowance, a ChatGPT account the user lets spend
// its resets, its weekly window used up, spends one and is asked again
// (provider.AutoUseCodexReset has the rules).
import (
"context"
"log"
"slices"
"strings"
"time"
"github.com/yetone/magpie/internal/provider"
)
// autoResetTimeout bounds the look at an account's week and the reset.
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. 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
}
_, resting := restOf(x.restKey())
if !resting && x.restID() != x.restKey() {
_, resting = restOf(x.restID())
}
if !resting {
return candidate{}, provider.ResetOutcome{}, false
}
}
seen := map[string]bool{}
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
}
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
}
}
return candidate{}, provider.ResetOutcome{}, false
}
// autoResetSignedIn is autoReset for a request relayed with Codex's own
// sign-in, the only account there is.
func (s *Server) autoResetSignedIn(ctx context.Context) (string, provider.ResetOutcome, bool) {
who, ok := provider.CodexSignedIn()
if !ok || !provider.CodexAutoReset(who) {
return "", provider.ResetOutcome{}, false
}
out, ok := autoResetOf(ctx, "codex", who)
if ok {
s.Unrest("codex@" + strings.ToLower(who))
}
return who, out, ok
}
// autoResetOf spends one of agent's account user's resets if its rules
// let it, and says whether the windows started again.
func autoResetOf(ctx context.Context, agent, user string) (provider.ResetOutcome, bool) {
ctx, cancel := context.WithTimeout(ctx, autoResetTimeout)
defer cancel()
out, err := provider.AutoUseCodexReset(ctx, user)
switch {
case err != nil:
log.Printf("%s reset for %s not used: %v", agent, user, err)
case out.Code == "reset":
log.Printf("%s reset used for %s, its week used up: %s", agent, user, out.Text())
case out.Code != "":
log.Printf("%s reset for %s not used: %s", agent, user, out.Text())
}
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)
}