Files
magpie/internal/provider/codex_switch.go
T
yetone 0b7781ba18 providers: Claude Code is signed in to its next account once the one it is on is spent, as Codex is, and an account keeps its own affinity, backoff and usage across a switch (#208 Cyb3rN8: Claude Code 用自己的模型时,登录账号额度用满不会自动切换 · #209: Smart 选中的账号和 Claude Code 登录账号互不同步…切换登录账号还会把按 id 记的状态串到另一个账号上) — Claude Code on its own model asks Anthropic as the account it is signed in to, never through magpie, so with A spent its sessions got "You've hit your session limit" while the gateway had moved on to B. The Codex watcher (KeepCodexOnAnAccountWithRoom, now KeepOnAnAccountWithRoom, same place: started by the magpie serving the gateway, a minute in and every 5 minutes) now looks at Claude Code too: when the account it is signed in to is spent and another ticked account's allowance is known and has room, it is signed in to that one through SwitchLogin (credentials and .claude.json's oauthAccount), as Use on the Accounts page does; not when the Claude account was removed from magpie. The rule for #209: the sign-in follows Smart, not the other way round — it moves at the share Smart counts an account spent (provider.SpentShare = 98, which the gateway's usedShare now is), for Codex too (it moved at 100%). The Routing field of a Codex or Claude subscription with several accounts says so: routing picks the account for what comes through magpie; the agent on its own uses the one it is signed in to, moved on at 98%. Sessions already running keep the account they started with until restarted, as SwitchLogin says; routing Claude Code's own model through the gateway (the codexFailover way) is not done here. #209's second part was a real bug: the account the agent is on is the candidate "claude" and the others "claude@user", so a switch handed "claude" from A to B — a conversation A answered was kept on B and traced kept=true, and the Least used tokens and the backoff count followed the id. who(), routed.used and routed.failures are now keyed by restKey (provider@user, as rests already were); rest itself (group members, the trace) is unchanged. Tested with new TestClaudeSwitchedWhenSpent (sandbox HOME, keychain off, a fake usage endpoint: 97% stays, 98% signs Claude Code in to b — credentials and .claude.json — a stays ticked; on b it stays; both spent it stays), TestCodexSwitchedWhenUsedUp still passing, new TestLoginSwitchKeepsEachAccountItself (fails on main: the conversation went to b, kept) and new internal/gui/tests/routing-signin.test.cjs in Chromium and WebKit, en and zh; login-import and routing-kind GUI tests pass. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./... pass (TestUserPath, TestClaudeAccountNamedAsItsLogin, TestClaudeIdentityServedWhileAsked and TestCodexVersion timed out at 5s under the full run's load and pass on their own). Not tested against real Claude accounts or a running Claude Code.
2026-09-29 18:56:33 +08:00

132 lines
4.3 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.
import (
"context"
"log"
"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
// 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)
}
// spent reports whether an account's allowance is all but used up, as
// Smart routing counts it: a window for every model at SpentShare.
func spent(q SubscriptionQuota) bool {
return usedPast(q, SpentShare)
}
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, when that one 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, ok bool) {
if accountRemoved(agent) {
return "", "", false // magpie has no say in its sign-in
}
var spares []Login
for _, l := range Logins(agent) {
switch {
case l.Active:
from = l.User
case l.On && l.Lapsed == "":
spares = append(spares, l)
}
}
if from == "" || len(spares) == 0 {
return "", "", false
}
u := LoginUsage(ctx, agent)
if q, known := u[from]; !known || q.Error != "" || !spent(q) {
return "", "", false
}
for _, l := range spares {
if q, known := u[l.User]; known && q.Error == "" && !spent(q) {
return from, l.User, true
}
}
return "", "", false
}
// SwitchWhenSpent signs agent in to the next of its accounts when the one
// it is on is spent (NextLogin), and answers the account it signed it in
// to, "" when it stayed.
func SwitchWhenSpent(ctx context.Context, agent string) (string, error) {
from, to, ok := NextLogin(ctx, agent)
if !ok {
return "", nil
}
if err := SwitchLogin(agent, to); err != nil {
return "", err
}
log.Printf("%s: %s has used %d%% or more of its allowance; signed it in to %s", agent, from, SpentShare, to)
// the models the agent is offered are the new account's plan's
catalog.Touched()
return to, nil
}
// 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()
}
t.Reset(loginSwitchEvery)
}
}