Files
magpie/internal/provider/codex_switch.go
T
yetone ed79a72927 provider: a subscription account can be capped at a share of its usage windows, and routing treats it as used up there (Discord: hold a new Claude account at 70% of its five hours and its week to keep its ban risk down, and let magpie go on to the other accounts) — an account's cap (1–99, keyed by the provider id and the account in lower case, AccountCaps in providers.json beside AccountModels and AccountProxies) holds it once any window that applies to the model is at or past the cap and hasn't reset (Allowance.CapHeld; Aside windows and windows kept for another model are left out). One share for every window rather than one per window: the windows an account has differ by vendor (five hours and week for Claude and Codex, a month or a day for others, per-model weeks), the request was one figure for both, and a lower share for one window would be set by lowering the cap. The gateway's perKeyBarred moves a capped account to the candidates left out (barredOf marks it Capped with its use and when it is back, so the routing trace says "held at its 70% usage cap"), so groups, fallback, Ordered and Spread routing and the Codex/Claude Code switch list (NextLogin: a capped account is moved off, not returned to, and isn't a spare) all go on to the next account; a pinned capped account and a draw over only capped accounts answer 429. When every account that serves the model is capped, the client is turned away as when all are used up — 429 rate_limit_error with Retry-After at the soonest reset — and told the cap was reached, which accounts, at what use and cap, and how to lift it. A cap never spends a Codex reset: weekUsedUp, usedUp, BackAt and spendExpiringNow still go by ChatGPT's own 100%, so an account held at 80% with auto reset on is held and nothing is spent; a lone Codex account with a cap goes through serve() instead of being relayed as it came (codexAccounts), so the cap holds it. The Providers page shows a cap pill on each account's row (on hover until set) that opens the app's own menu — No cap, 50/60/70/80/90%, Other… (typed inline, focused without scrolling) — posting provider/accountcap; each window's bar gets a tick at the cap (from the right when the bar shows what is left), and an account held by its cap says so and until when. zh strings in i18n.js. CLI: magpie provider account-cap <id> [account [percent|off]] shows or sets caps. Tests: TestCapHeld, TestCapReached, TestParseCap; TestCapNeverSpendsACodexReset (80% windows, 70% cap: not used up, no reset due, routing held); TestCodexSwitchedAtCap (the active account at 75% with a 70% cap is switched off to the one at 20%, not back while capped, back once lifted; 100 and an unknown account refused); gateway TestAccountCapMovesOn (first account at 75% capped at 70%: the second is tried and the trace says 75/70; lifted: the first), TestAccountCapEveryAccount (429 rate_limit_error naming both caps, nothing sent, logged 429) and TestAccountCapSpendsNoCodexReset (one account at an 80% week, auto reset on: 429, nothing tried or spent; lifted: 200, nothing spent) — the three gateway tests fail with the cap check taken out; TestAccountCapCmd; account-cap.test.cjs (webkit, en and zh: pill, menu, Other…, No cap, cap ticks, held note, no <select>, no border stripes, no scroll on click). account-models, click-scroll, keep-login, codex-auto-reset, account-return, routing-blocked, routing-account-order, routing-quota-left, routing-auto-reset, routing-manual, quota-pools, quota-families and usage-accounts GUI tests pass in webkit. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried with real accounts; a cap acts on usage as last read, so a burst between reads can run a little past it.
2026-10-04 10:18:50 +08:00

332 lines
11 KiB
Go

package provider
// Signing Codex or Claude Code in to the next of its accounts when the one
// it is on has used its allowance up. Codex's requests on its own models go
// through magpie while more of its accounts are on there, and move on to
// the next account when one is out — but the Codex app, once it knows the
// account it is signed in to is out, may not send at all, so no request
// reaches magpie to move on. Claude Code on its own models never comes
// through magpie: it asks Anthropic as the account it is signed in to, and
// that account being out ends its turns with "You've hit your session
// limit" while the gateway has long moved on to another (#208). So
// whichever magpie runs the gateway signs the agent in to the next account
// that is on and has room, as switching it on the Accounts page would: a
// session started after that is on it from the first turn.
//
// It moves at the share Smart routing counts an account spent at, so the
// account the agent is signed in to and the one the gateway goes to agree
// on which is out (#209): the sign-in follows Smart; Smart doesn't follow
// the sign-in. In order, the gateway goes to the first until it is used
// up or refused, so the sign-in moves only once it is used up (#530).
//
// The account it moved from is the one the user made first, and it stays
// that: once it is no longer low (backShare) the agent is signed back in
// to it, as Smart gives it requests again then (#408). A switch the user
// makes meanwhile ends that. With KeepLogin on the subscription, the agent
// stays signed in to the first, whatever it has left (#524); with
// KeepLoginAs, to that account, wherever it stands in the order.
import (
"context"
"encoding/json"
"log"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/yetone/magpie/internal/catalog"
)
// loginSwitchEvery is how often the account each agent is on is looked at.
const loginSwitchEvery = 5 * time.Minute
// SpentShare is the share of an allowance past which an account is all
// but used up: Smart routing keeps it for when no other can take a
// request, and the agent signed in to it is signed in to another.
const SpentShare = 98
// SpentShareOf is the share past which routing counts an account spent:
// SpentShare, but In order sends requests to the first until a window of it
// is used up or the vendor refuses it, so there an account at 98% is still
// tried in its turn (#530).
func SpentShareOf(routing string) float64 {
if routing == Ordered {
return 100
}
return SpentShare
}
// loginSwitching is how magpie moves agent's sign-in, as its subscription
// says: the share the account it is on is moved off at, and whether it is
// kept on the first instead (KeepLogin).
func loginSwitching(agent string) (share float64, keep bool) {
for _, p := range load().Providers {
if p.ID == agent {
return SpentShareOf(p.Routing), p.KeepLogin
}
}
return SpentShare, false
}
// backShare is the share below which the account magpie moved the agent
// off is signed back in to: no longer low, as Smart counts it (the
// gateway's lowShare), so it doesn't go back and forth at the edge.
const backShare = 90
// switchedAgents are the agents whose account magpie moves on.
var switchedAgents = []string{"codex", "claude"}
// usedUp reports whether an account's allowance is used up for now: a
// window that stops the account, for every model, at 100%.
func usedUp(q SubscriptionQuota) bool {
return usedPast(q, 100)
}
// UsedUp is usedUp for the CLI's magpie quota wait, so what it waits out
// is what stops an account here.
func UsedUp(q SubscriptionQuota) bool { return usedUp(q) }
// BackAt is when a used-up account has room again: when the last of the
// windows that stop it (usedUp's) starts again, a window's seconds to go
// counted from now. Zero when one of them doesn't say.
func BackAt(q SubscriptionQuota, now time.Time) time.Time {
var back time.Time
for _, w := range q.Windows {
if w.Aside || w.Model != "" || w.Used < 100 {
continue
}
at := time.Time{}
switch {
case w.ResetsAt != nil:
at = *w.ResetsAt
case w.ResetSecs > 0:
at = now.Add(time.Duration(w.ResetSecs) * time.Second)
default:
return time.Time{}
}
if at.After(back) {
back = at
}
}
return back
}
func usedPast(q SubscriptionQuota, share float64) bool {
for _, w := range q.Windows {
if !w.Aside && w.Model == "" && w.Used >= share {
return true
}
}
return false
}
// NextLogin is the account agent should be signed in to instead of the
// one it is on: the account magpie moved it off, once that has room again
// (back); else, when the one it is on is spent, the first of its other
// accounts that are on in magpie, in the order they were saved, whose
// allowance is known and not spent. ok is false when the agent should stay.
func NextLogin(ctx context.Context, agent string) (from, to string, back, ok bool) {
if accountRemoved(agent) {
return "", "", false, false // magpie has no say in its sign-in
}
var spares []Login
var first *Login
ls := Logins(agent)
for i, l := range ls {
switch {
case l.Active:
from = l.User
case l.Returns:
first = &ls[i]
}
if !l.Active && l.On && l.Lapsed == "" {
spares = append(spares, l)
}
}
// kept on an account of the user's choosing, the agent goes back to
// it whenever it is on another, and never moves off it (#524)
if as := keptAs(agent); as != "" {
i := slices.IndexFunc(ls, func(l Login) bool { return strings.EqualFold(l.User, as) })
if from == "" || strings.EqualFold(from, as) || i < 0 || ls[i].Lapsed != "" {
return "", "", false, false
}
return from, ls[i].User, true, true
}
if from == "" || len(spares) == 0 {
return "", "", false, false
}
// kept on the first, the agent goes back to it at once, however little
// that has left (#524)
share, keep := loginSwitching(agent)
if keep && first != nil && first.On && first.Lapsed == "" {
return from, first.User, true, true
}
u := LoginUsage(ctx, agent)
now := time.Now()
// an account at its usage cap is spent as one at the share below
// (account_caps.go): it is moved off, and none is moved to or back to
// till the window it filled renews
capped := func(user string, q SubscriptionQuota) bool {
return capReached(q, AccountCapOf(agent, user), now)
}
if first != nil && first.On && first.Lapsed == "" {
if q, known := u[first.User]; known && q.Error == "" && !usedPast(q, backShare) && !capped(first.User, q) {
return from, first.User, true, true
}
}
q, known := u[from]
if q.Provider == "claude" && q.AsOf != nil {
// An expired cached window cannot say whether this account is spent
// now. Keep other windows and the stored historical reading intact.
q.Windows = slices.DeleteFunc(slices.Clone(q.Windows), func(w QuotaWindow) bool {
return w.ResetsAt != nil && !w.ResetsAt.After(now)
})
}
// kept on the first, it stays there however little that has left; and
// it moves on at the share its routing counts the account spent at
if keep || !known || q.Error != "" || !usedPast(q, share) && !capped(from, q) {
return "", "", false, false
}
for _, l := range spares {
if q, known := u[l.User]; known && q.Error == "" && !usedPast(q, share) && !capped(l.User, q) {
return from, l.User, false, true
}
}
return "", "", false, false
}
// SwitchWhenSpent signs agent in to the next of its accounts when the one
// it is on is spent, or back to the one it moved off once that has room
// (NextLogin), and answers the account it signed it in to, "" when it
// stayed.
func SwitchWhenSpent(ctx context.Context, agent string) (string, error) {
from, to, back, ok := NextLogin(ctx, agent)
if !ok {
return "", nil
}
// the first moved off, not one moved to on the way
r, _ := loginReturnOf(agent, from)
if err := switchLogin(agent, to); err != nil {
return "", err
}
if back {
setLoginReturn(agent, loginReturn{})
if keptAs(agent) != "" {
log.Printf("%s: kept signed in to %s; signed it back in to it from %s", agent, to, from)
} else {
log.Printf("%s: %s has room again; signed it back in to it", agent, to)
}
} else {
if r.Back == "" {
r.Back = from
}
r.To = to
setLoginReturn(agent, r)
share, _ := loginSwitching(agent)
log.Printf("%s: %s has used %g%% or more of its allowance; signed it in to %s", agent, from, share, to)
}
// the models the agent is offered are the new account's plan's
catalog.Touched()
return to, nil
}
// loginReturn is the account magpie signed an agent out of when it was
// spent (Back), and the one it signed it in to (To): while the agent is
// still on To, Back is signed in again once it has room.
type loginReturn struct {
Back string `json:"back"`
To string `json:"to"`
}
var loginReturnsMu sync.Mutex
func loginReturnsPath() string { return filepath.Join(filepath.Dir(Path()), "login-returns.json") }
func readLoginReturns() map[string]loginReturn {
m := map[string]loginReturn{}
if b, err := os.ReadFile(loginReturnsPath()); err == nil {
_ = json.Unmarshal(b, &m)
}
return m
}
// loginReturnOf is the account to sign agent back in to, when it is still
// on the one magpie moved it to (active): one signed in otherwise since,
// by the user or the agent itself, is the user's choice.
func loginReturnOf(agent, active string) (loginReturn, bool) {
loginReturnsMu.Lock()
defer loginReturnsMu.Unlock()
r, ok := readLoginReturns()[agent]
if !ok || r.Back == "" || !strings.EqualFold(r.To, active) || strings.EqualFold(r.Back, active) {
return loginReturn{}, false
}
return r, true
}
// setLoginReturn keeps r for agent; an empty one forgets it.
func setLoginReturn(agent string, r loginReturn) {
loginReturnsMu.Lock()
defer loginReturnsMu.Unlock()
m := readLoginReturns()
if _, had := m[agent]; !had && r.Back == "" {
return
}
if r.Back == "" {
delete(m, agent)
} else {
m[agent] = r
}
b, err := json.MarshalIndent(m, "", " ")
if err == nil {
err = writePrivate(loginReturnsPath(), append(b, '\n'))
}
if err != nil {
log.Printf("%s: keeping the account to go back to: %v", agent, err)
}
}
// codexWasUsedUp is whether the account Codex is signed in to was out of
// its allowance at the last look.
var codexWasUsedUp atomic.Bool
// noteCodexUsedUp tells the agents' files when the account Codex is signed
// in to runs out of its allowance, or has it back: the Codex app sends
// nothing at all for an account that is out, a magpie model's turn
// included, so the agent package then makes magpie Codex's provider, and
// puts it back beside the sign-in after (#540). Only a change is told.
func noteCodexUsedUp(now bool) {
if codexWasUsedUp.Swap(now) != now {
catalog.Touched()
}
}
// KeepOnAnAccountWithRoom runs SwitchWhenSpent for Codex and Claude Code a
// minute after it starts and every loginSwitchEvery after that, until ctx
// ends.
func KeepOnAnAccountWithRoom(ctx context.Context) {
t := time.NewTimer(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
for _, agent := range switchedAgents {
c, cancel := context.WithTimeout(ctx, time.Minute)
if _, err := SwitchWhenSpent(c, agent); err != nil {
log.Printf("%s: switching to an account with room: %v", agent, err)
}
cancel()
}
c, cancel := context.WithTimeout(ctx, time.Minute)
noteCodexUsedUp(CodexUsedUp(c))
cancel()
t.Reset(loginSwitchEvery)
}
}