From 0b7781ba18cd949bcda8f5afe9bc321cadcf80ee Mon Sep 17 00:00:00 2001 From: yetone Date: Tue, 29 Sep 2026 18:53:51 +0800 Subject: [PATCH] =?UTF-8?q?providers:=20Claude=20Code=20is=20signed=20in?= =?UTF-8?q?=20to=20its=20next=20account=20once=20the=20one=20it=20is=20on?= =?UTF-8?q?=20is=20spent,=20as=20Codex=20is,=20and=20an=20account=20keeps?= =?UTF-8?q?=20its=20own=20affinity,=20backoff=20and=20usage=20across=20a?= =?UTF-8?q?=20switch=20(#208=20Cyb3rN8:=20Claude=20Code=20=E7=94=A8?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E7=9A=84=E6=A8=A1=E5=9E=8B=E6=97=B6=EF=BC=8C?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E8=B4=A6=E5=8F=B7=E9=A2=9D=E5=BA=A6=E7=94=A8?= =?UTF-8?q?=E6=BB=A1=E4=B8=8D=E4=BC=9A=E8=87=AA=E5=8A=A8=E5=88=87=E6=8D=A2?= =?UTF-8?q?=20=C2=B7=20#209:=20Smart=20=E9=80=89=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=92=8C=20Claude=20Code=20=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E8=B4=A6=E5=8F=B7=E4=BA=92=E4=B8=8D=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E2=80=A6=E5=88=87=E6=8D=A2=E7=99=BB=E5=BD=95=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E8=BF=98=E4=BC=9A=E6=8A=8A=E6=8C=89=20id=20=E8=AE=B0=E7=9A=84?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=B2=E5=88=B0=E5=8F=A6=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E4=B8=8A)=20=E2=80=94=20Claude=20Code=20on?= =?UTF-8?q?=20its=20own=20model=20asks=20Anthropic=20as=20the=20account=20?= =?UTF-8?q?it=20is=20signed=20in=20to,=20never=20through=20magpie,=20so=20?= =?UTF-8?q?with=20A=20spent=20its=20sessions=20got=20"You've=20hit=20your?= =?UTF-8?q?=20session=20limit"=20while=20the=20gateway=20had=20moved=20on?= =?UTF-8?q?=20to=20B.=20The=20Codex=20watcher=20(KeepCodexOnAnAccountWithR?= =?UTF-8?q?oom,=20now=20KeepOnAnAccountWithRoom,=20same=20place:=20started?= =?UTF-8?q?=20by=20the=20magpie=20serving=20the=20gateway,=20a=20minute=20?= =?UTF-8?q?in=20and=20every=205=20minutes)=20now=20looks=20at=20Claude=20C?= =?UTF-8?q?ode=20too:=20when=20the=20account=20it=20is=20signed=20in=20to?= =?UTF-8?q?=20is=20spent=20and=20another=20ticked=20account's=20allowance?= =?UTF-8?q?=20is=20known=20and=20has=20room,=20it=20is=20signed=20in=20to?= =?UTF-8?q?=20that=20one=20through=20SwitchLogin=20(credentials=20and=20.c?= =?UTF-8?q?laude.json's=20oauthAccount),=20as=20Use=20on=20the=20Accounts?= =?UTF-8?q?=20page=20does;=20not=20when=20the=20Claude=20account=20was=20r?= =?UTF-8?q?emoved=20from=20magpie.=20The=20rule=20for=20#209:=20the=20sign?= =?UTF-8?q?-in=20follows=20Smart,=20not=20the=20other=20way=20round=20?= =?UTF-8?q?=E2=80=94=20it=20moves=20at=20the=20share=20Smart=20counts=20an?= =?UTF-8?q?=20account=20spent=20(provider.SpentShare=20=3D=2098,=20which?= =?UTF-8?q?=20the=20gateway's=20usedShare=20now=20is),=20for=20Codex=20too?= =?UTF-8?q?=20(it=20moved=20at=20100%).=20The=20Routing=20field=20of=20a?= =?UTF-8?q?=20Codex=20or=20Claude=20subscription=20with=20several=20accoun?= =?UTF-8?q?ts=20says=20so:=20routing=20picks=20the=20account=20for=20what?= =?UTF-8?q?=20comes=20through=20magpie;=20the=20agent=20on=20its=20own=20u?= =?UTF-8?q?ses=20the=20one=20it=20is=20signed=20in=20to,=20moved=20on=20at?= =?UTF-8?q?=2098%.=20Sessions=20already=20running=20keep=20the=20account?= =?UTF-8?q?=20they=20started=20with=20until=20restarted,=20as=20SwitchLogi?= =?UTF-8?q?n=20says;=20routing=20Claude=20Code's=20own=20model=20through?= =?UTF-8?q?=20the=20gateway=20(the=20codexFailover=20way)=20is=20not=20don?= =?UTF-8?q?e=20here.=20#209's=20second=20part=20was=20a=20real=20bug:=20th?= =?UTF-8?q?e=20account=20the=20agent=20is=20on=20is=20the=20candidate=20"c?= =?UTF-8?q?laude"=20and=20the=20others=20"claude@user",=20so=20a=20switch?= =?UTF-8?q?=20handed=20"claude"=20from=20A=20to=20B=20=E2=80=94=20a=20conv?= =?UTF-8?q?ersation=20A=20answered=20was=20kept=20on=20B=20and=20traced=20?= =?UTF-8?q?kept=3Dtrue,=20and=20the=20Least=20used=20tokens=20and=20the=20?= =?UTF-8?q?backoff=20count=20followed=20the=20id.=20who(),=20routed.used?= =?UTF-8?q?=20and=20routed.failures=20are=20now=20keyed=20by=20restKey=20(?= =?UTF-8?q?provider@user,=20as=20rests=20already=20were);=20rest=20itself?= =?UTF-8?q?=20(group=20members,=20the=20trace)=20is=20unchanged.=20Tested?= =?UTF-8?q?=20with=20new=20TestClaudeSwitchedWhenSpent=20(sandbox=20HOME,?= =?UTF-8?q?=20keychain=20off,=20a=20fake=20usage=20endpoint:=2097%=20stays?= =?UTF-8?q?,=2098%=20signs=20Claude=20Code=20in=20to=20b=20=E2=80=94=20cre?= =?UTF-8?q?dentials=20and=20.claude.json=20=E2=80=94=20a=20stays=20ticked;?= =?UTF-8?q?=20on=20b=20it=20stays;=20both=20spent=20it=20stays),=20TestCod?= =?UTF-8?q?exSwitchedWhenUsedUp=20still=20passing,=20new=20TestLoginSwitch?= =?UTF-8?q?KeepsEachAccountItself=20(fails=20on=20main:=20the=20conversati?= =?UTF-8?q?on=20went=20to=20b,=20kept)=20and=20new=20internal/gui/tests/ro?= =?UTF-8?q?uting-signin.test.cjs=20in=20Chromium=20and=20WebKit,=20en=20an?= =?UTF-8?q?d=20zh;=20login-import=20and=20routing-kind=20GUI=20tests=20pas?= =?UTF-8?q?s.=20go=20build=20-tags=20nogui=20(and=20GOOS=3Dlinux,=20GOOS?= =?UTF-8?q?=3Dwindows),=20go=20test=20-tags=20nogui=20./...=20pass=20(Test?= =?UTF-8?q?UserPath,=20TestClaudeAccountNamedAsItsLogin,=20TestClaudeIdent?= =?UTF-8?q?ityServedWhileAsked=20and=20TestCodexVersion=20timed=20out=20at?= =?UTF-8?q?=205s=20under=20the=20full=20run's=20load=20and=20pass=20on=20t?= =?UTF-8?q?heir=20own).=20Not=20tested=20against=20real=20Claude=20account?= =?UTF-8?q?s=20or=20a=20running=20Claude=20Code.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/codex_accounts_test.go | 2 +- internal/gateway/fallback.go | 5 +- internal/gateway/gateway.go | 5 +- internal/gateway/login_switch_test.go | 54 +++++++++++ internal/gateway/routing.go | 23 +++-- internal/gui/assets/app.js | 9 +- internal/gui/assets/i18n.js | 1 + internal/gui/tests/routing-signin.test.cjs | 65 +++++++++++++ internal/provider/codex_switch.go | 105 ++++++++++++++------- internal/provider/codex_switch_test.go | 87 ++++++++++++++++- 10 files changed, 304 insertions(+), 52 deletions(-) create mode 100644 internal/gateway/login_switch_test.go create mode 100644 internal/gui/tests/routing-signin.test.cjs diff --git a/internal/gateway/codex_accounts_test.go b/internal/gateway/codex_accounts_test.go index 13e1e1ce..e5fd10b8 100644 --- a/internal/gateway/codex_accounts_test.go +++ b/internal/gateway/codex_accounts_test.go @@ -105,7 +105,7 @@ func TestCodexOwnModelMovesToNextAccount(t *testing.T) { } // Codex signed in to the next account once the one it was on is out -// (provider.SwitchCodexWhenUsedUp): the one out still rests, now beside it, +// (provider.SwitchWhenSpent): the one out still rests, now beside it, // and the one it is on now doesn't take that rest over. func TestCodexSwitchedAccountRestsAsItself(t *testing.T) { codexSignedIn(t, "spare@example.com") diff --git a/internal/gateway/fallback.go b/internal/gateway/fallback.go index 694954ed..744f6aea 100644 --- a/internal/gateway/fallback.go +++ b/internal/gateway/fallback.go @@ -84,11 +84,14 @@ func (c candidate) restID() string { // who is the key or account itself, however many the provider has on: a // provider's one key rests as the provider, and as itself once another // is added, and a conversation it answered stays with it all the same. +// An account is its user, signed in to or not: rest names the one the +// agent is on by the provider's id alone, which a switch of the agent's +// account hands to another (#209). func (c candidate) who() string { if c.p.Account == nil && c.p.Key != "" { return c.p.ID + "#" + provider.KeyID(c.p.Key) } - return c.rest + return c.restKey() } // perKey is a provider once per key it has on, in order — or, for a diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 516b5074..e46417a0 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -263,8 +263,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error { // the magpie serving the gateway, and only it, keeps the saved accounts // signed in, so two never refresh one sign-in at once go provider.KeepLoginsAlive(ctx) - // and signs Codex in to its next account when the one it is on is out - go provider.KeepCodexOnAnAccountWithRoom(ctx) + // and signs Codex and Claude Code in to their next account when the + // one they are on is spent + go provider.KeepOnAnAccountWithRoom(ctx) // and, when settings say to, starts its accounts' next windows as the last reset go provider.KeepCodexWindowsWarm(ctx) go provider.KeepClaudeWindowsWarm(ctx, warmClaude) diff --git a/internal/gateway/login_switch_test.go b/internal/gateway/login_switch_test.go new file mode 100644 index 00000000..87c3dc8a --- /dev/null +++ b/internal/gateway/login_switch_test.go @@ -0,0 +1,54 @@ +package gateway + +import ( + "net/http" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// Claude Code signed in to a, with b on beside it; then signed in to b +// (magpie switching it, or the user). The account the agent is on is +// named by the provider's id alone, so the switch hands "claude" from a to +// b — but a conversation a answered stays with a, and what a served and +// how often it failed are a's, not b's (#209). +func TestLoginSwitchKeepsEachAccountItself(t *testing.T) { + acct := func(user, rest string) candidate { + return candidate{p: provider.Provider{ID: "claude", Routing: provider.LeastUsed, + Account: &provider.Account{Agent: "claude", User: user}}, model: "claude-opus-4-8", rest: rest} + } + t.Cleanup(func() { + routed.Lock() + for _, k := range []string{"claude", "claude@a@example.com", "claude@b@example.com"} { + delete(routed.used, k) + delete(routed.failures, k) + } + routed.Unlock() + }) + a, b := acct("a@example.com", "claude"), acct("b@example.com", "claude@b@example.com") + h := http.Header{} + h.Set("x-claude-code-session-id", "login-switch-209") + body := []byte(`{"messages":[{"role":"user","content":"hi"}]}`) + pl := planned{order: make([]Weighed, 2)} + _, _, _, key := affine("t", provider.AffinitySession, false, h, provider.Anthropic, body, []candidate{a, b}, pl) + answered(key, a, 1, 0) + servedCandidate(a, 5000) + + // signed in to b now + a2, b2 := acct("a@example.com", "claude@a@example.com"), acct("b@example.com", "claude") + cs, _, aff, _ := affine("t", provider.AffinitySession, false, h, provider.Anthropic, body, []candidate{b2, a2}, planned{order: make([]Weighed, 2)}) + if cs[0].p.Account.User != "a@example.com" || !aff.Kept { + t.Fatalf("the conversation went to %s (kept %v, %s)", cs[0].p.Account.User, aff.Kept, aff.Why) + } + // least used: b, which served nothing, before a + cs, _ = weigh(b2.p, []candidate{a2, b2}, "claude-opus-4-8", provider.Anthropic) + if cs[0].p.Account.User != "b@example.com" { + t.Fatalf("least used put %s first", cs[0].p.Account.User) + } + routed.Lock() + _, onB := routed.used["claude"] + routed.Unlock() + if onB { + t.Fatal("a's tokens counted by the id the signed-in account has") + } +} diff --git a/internal/gateway/routing.go b/internal/gateway/routing.go index 6088e894..572a035e 100644 --- a/internal/gateway/routing.go +++ b/internal/gateway/routing.go @@ -32,8 +32,8 @@ const usageHalfLife = time.Hour var routed = struct { sync.Mutex turn map[string]int // provider → requests routed in turn - used map[string]tokenUse // candidate rest id → tokens it served - failures map[string]int // candidate rest id → failures since it last answered + used map[string]tokenUse // candidate restKey → tokens it served + failures map[string]int // candidate restKey → failures since it last answered }{turn: map[string]int{}, used: map[string]tokenUse{}, failures: map[string]int{}} type tokenUse struct { @@ -45,8 +45,10 @@ func (u tokenUse) now(t time.Time) float64 { return u.n * math.Exp2(-t.Sub(u.at).Seconds()/usageHalfLife.Seconds()) } -// served counts what a candidate just answered against it; key is what it -// rests by (restKey). +// served counts what a candidate just answered against it, by who it is: +// rest for a key, an account by its user whichever the agent is signed in +// to (restKey, as key is), so a switch doesn't hand one account's count to +// another (#209). func served(rest, key string, tokens int) { if tokens <= 0 { tokens = 1 // it answered, whether or not it said how much @@ -62,7 +64,7 @@ func served(rest, key string, tokens int) { // servedCandidate records an answer and ends both the provider's rest and, // when it has one, the candidate's model-specific rest. func servedCandidate(c candidate, tokens int) { - served(c.rest, c.restKey(), tokens) + served(c.restKey(), c.restKey(), tokens) if id := c.restID(); id != c.restKey() { clearRest(id) } @@ -302,8 +304,8 @@ func (s *Server) restAfterMarked(c candidate, status int, header http.Header, bo r.Link, _ = provider.Verification([]byte(r.said)) default: routed.Lock() - routed.failures[c.rest]++ - n := routed.failures[c.rest] + routed.failures[c.restKey()]++ + n := routed.failures[c.restKey()] routed.Unlock() d, r.By, r.Failures = min(fallbackCooldown< id === routing); accountAction("provider/route", { id: p.id, routing }, t("{name}: {routing}", { name: p.name, routing: t(r[1]) })); }); - return field(t("Routing"), pick, t(cur[2])); + // what Codex or Claude Code sends past magpie goes to the account it is + // signed in to, which magpie moves on once Smart would count it spent + // (provider.KeepOnAnAccountWithRoom, #209) + const a = p.account; + const own = a && (a.agent === "codex" || a.agent === "claude") + ? " " + t("Routing picks the account for each request through magpie; {agent} on its own uses the one it is signed in to, which magpie moves to the next ticked account with room once it is 98% used.", { agent: a.agentName }) + : ""; + return field(t("Routing"), pick, t(cur[2]) + own); } // renderFallback: where requests go when this provider can't take them — diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 649ffd27..85fd840c 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -301,6 +301,7 @@ const I18N = { "Routing": "路由", "Tick every key to use; Routing says how requests spread over them.": "勾选的密钥会同时启用;请求怎么分给它们,由下面的「路由」决定。", "Tick every account to use; Routing says how requests spread over them.": "勾选的账号会同时启用;请求怎么分给它们,由下面的「路由」决定。", + "Routing picks the account for each request through magpie; {agent} on its own uses the one it is signed in to, which magpie moves to the next ticked account with room once it is 98% used.": "经过 magpie 的请求由路由挑选账号;{agent} 自己直连时用的是它登录的账号——这个账号用到 98% 时,magpie 会把 {agent} 登录到下一个已勾选且还有余量的账号。", "Smart": "智能", "The first takes requests while it has quota to spare; when it runs low, the one with the most left takes over. One out of credit sits out half an hour, one out of quota until it resets, one rate limited as long as the vendor asks, and one that fails a minute, longer each time it fails again.": "额度充足时请求都给第一个(保持缓存命中);额度快用完时,自动换到剩余额度最多的那个。欠费的暂停半小时,额度用完的暂停到额度重置,被限流的按厂商要求的时间暂停,出错的暂停一分钟、连续出错则越停越久——期间请求自动转给其他可用的。", "In order": "按顺序", diff --git a/internal/gui/tests/routing-signin.test.cjs b/internal/gui/tests/routing-signin.test.cjs new file mode 100644 index 00000000..f6343319 --- /dev/null +++ b/internal/gui/tests/routing-signin.test.cjs @@ -0,0 +1,65 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Smart routing and the account Claude Code (or Codex) is signed in to are +// two things (#209): the Routing field of a Claude subscription with two +// accounts on says the routing picks for what comes through magpie, and +// that the sign-in moves on to the next ticked account once it is 98% +// used; in English and Chinese. +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 claude = { + id: "claude", name: "Claude", icon: "anthropic", chat: "", responses: "", anthropic: "", catalog: "", + models: [{ id: "claude-opus-5-5", name: "Claude Opus 5.5", on: true }], agents: [], fallback: [], headers: {}, keyList: [], + account: { agent: "claude", agentName: "Claude Code", user: "a@example.com", plan: "max", logins: [ + { user: "a@example.com", plan: "max", active: true, on: true }, + { user: "b@example.com", plan: "max", on: true }, + ] }, +}; + +function serve(lang) { + const providers = { providers: [claude], presets: [], excluded: [], gateway: { running: true, window: true } }; + const state = { agents: [], profiles: [], settings: { lang, theme: "light" } }; + return async (route) => { + const url = new URL(route.request().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:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state); + if (url.pathname === "/api/providers") return json(providers); + if (url.pathname === "/api/groups") return json({ groups: [] }); + 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 want = { + en: /Claude Code on its own uses the one it is signed in to, which magpie moves to the next ticked account with room once it is 98% used/, + zh: /Claude Code 自己直连时用的是它登录的账号——这个账号用到 98% 时/, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: the Routing field says the sign-in follows Smart`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const context = await browser.newContext({ viewport: { width: 900, height: 760 }, reducedMotion: "reduce" }); + const page = await context.newPage(); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang)); + t.after(() => browser.close()); + await page.goto("http://magpie.test/?view=providers"); + await page.locator(".row.provider", { hasText: "Claude" }).click(); + const hint = page.locator(".editor .hint", { hasText: "98%" }); + await hint.waitFor(); + assert.match(await hint.textContent(), want[lang]); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/codex_switch.go b/internal/provider/codex_switch.go index dcbce4ff..4721b024 100644 --- a/internal/provider/codex_switch.go +++ b/internal/provider/codex_switch.go @@ -1,13 +1,22 @@ package provider -// Signing Codex 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. So whichever magpie runs the gateway signs Codex in to the -// next account that is on and has room, as switching it on the Accounts -// page would: a Codex started after that is on it from the first turn. +// 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" @@ -17,27 +26,48 @@ import ( "github.com/yetone/magpie/internal/catalog" ) -// codexSwitchEvery is how often the account Codex is on is looked at. -const codexSwitchEvery = 5 * time.Minute +// 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 >= 100 { + if !w.Aside && w.Model == "" && w.Used >= share { return true } } return false } -// NextCodexLogin is the account Codex should be signed in to instead of -// the one it is on, when that one has used its allowance up: the first of -// its other accounts that are on in magpie, in the order they were saved, -// whose allowance is known and has room. ok is false when Codex should stay. -func NextCodexLogin(ctx context.Context) (from, to string, ok bool) { +// 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("codex") { + for _, l := range Logins(agent) { switch { case l.Active: from = l.User @@ -48,38 +78,39 @@ func NextCodexLogin(ctx context.Context) (from, to string, ok bool) { if from == "" || len(spares) == 0 { return "", "", false } - u := LoginUsage(ctx, "codex") - if q, known := u[from]; !known || q.Error != "" || !usedUp(q) { + 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 == "" && !usedUp(q) { + if q, known := u[l.User]; known && q.Error == "" && !spent(q) { return from, l.User, true } } return "", "", false } -// SwitchCodexWhenUsedUp signs Codex in to the next of its accounts when the -// one it is on has used its allowance up (NextCodexLogin), and answers the -// account it signed it in to, "" when it stayed. -func SwitchCodexWhenUsedUp(ctx context.Context) (string, error) { - from, to, ok := NextCodexLogin(ctx) +// 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("codex", to); err != nil { + if err := SwitchLogin(agent, to); err != nil { return "", err } - log.Printf("codex: %s has used its allowance up; signed Codex in to %s", from, to) - // the models Codex is offered are the new account's plan's + 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 } -// KeepCodexOnAnAccountWithRoom runs SwitchCodexWhenUsedUp a minute after it -// starts and every codexSwitchEvery after that, until ctx ends. -func KeepCodexOnAnAccountWithRoom(ctx context.Context) { +// 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 { @@ -88,11 +119,13 @@ func KeepCodexOnAnAccountWithRoom(ctx context.Context) { return case <-t.C: } - c, cancel := context.WithTimeout(ctx, time.Minute) - if _, err := SwitchCodexWhenUsedUp(c); err != nil { - log.Printf("codex: switching to an account with room: %v", err) + 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() } - cancel() - t.Reset(codexSwitchEvery) + t.Reset(loginSwitchEvery) } } diff --git a/internal/provider/codex_switch_test.go b/internal/provider/codex_switch_test.go index eedbd5a5..4b31b763 100644 --- a/internal/provider/codex_switch_test.go +++ b/internal/provider/codex_switch_test.go @@ -5,8 +5,11 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" + "strings" "testing" + "time" ) // Codex signed in to an account out of its allowance is signed in to the @@ -35,7 +38,7 @@ func TestCodexSwitchedWhenUsedUp(t *testing.T) { switched := func() string { t.Helper() fresh() - to, err := SwitchCodexWhenUsedUp(context.Background()) + to, err := SwitchWhenSpent(context.Background(), "codex") if err != nil { t.Fatal(err) } @@ -82,3 +85,85 @@ func TestCodexSwitchedWhenUsedUp(t *testing.T) { t.Fatalf("switched to %s, not knowing", to) } } + +// Claude Code signed in to an account Smart counts spent (98%) is signed +// in to the next account that is on and has room — its credentials and +// .claude.json's account both — and not before (#208, #209). +func TestClaudeSwitchedWhenSpent(t *testing.T) { + home := claudeHome(t) + cred := claudeSignIn(t, home, time.Now().Add(time.Hour)) // sk-ant-oat01-old + profile := filepath.Join(home, ".claude.json") + writeFile(t, profile, map[string]any{"oauthAccount": map[string]any{"emailAddress": "a@example.com", "accountUuid": "u-a"}}) + rememberLogins(true) + loginsMu.Lock() + ls := upsertLogin(readLogins(), savedLogin{Agent: "claude", User: "b@example.com", Plan: "max", On: true, Seen: time.Now().UTC(), + Auth: mustJSONRaw(t, map[string]any{"claudeAiOauth": map[string]any{"accessToken": "sk-ant-oat01-b", "refreshToken": "sk-ant-ort01-b", + "expiresAt": time.Now().Add(time.Hour).UnixMilli(), "subscriptionType": "max", "scopes": []string{"user:inference", "user:profile"}}}), + Profile: mustJSONRaw(t, map[string]any{"emailAddress": "b@example.com", "accountUuid": "u-b"})}) + if err := writeLogins(ls); err != nil { + t.Fatal(err) + } + loginsMu.Unlock() + + used := map[string]float64{"Bearer sk-ant-oat01-old": 97, "Bearer sk-ant-oat01-b": 10} + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"five_hour": map[string]any{"utilization": used[r.Header.Get("Authorization")], + "resets_at": time.Now().Add(time.Hour).Format(time.RFC3339)}}) + })) + defer fake.Close() + claudeBase = fake.URL // isolate puts it back + switched := func() string { + t.Helper() + loginUsageCache.Lock() + loginUsageCache.m = nil + loginUsageCache.Unlock() + claudeUsage.Lock() + claudeUsage.m = nil + claudeUsage.Unlock() + to, err := SwitchWhenSpent(context.Background(), "claude") + if err != nil { + t.Fatal(err) + } + return to + } + + // 97%: Smart still counts it low, not spent + if to := switched(); to != "" { + t.Fatalf("switched to %s at 97%%", to) + } + used["Bearer sk-ant-oat01-old"] = 98 + if to := switched(); to != "b@example.com" { + t.Fatalf("switched to %q at 98%%", to) + } + var c map[string]any + readJSON(cred, &c) + if o, _ := c["claudeAiOauth"].(map[string]any); o["refreshToken"] != "sk-ant-ort01-b" { + t.Fatalf("Claude Code is signed in with %v", o) + } + b, _ := os.ReadFile(profile) + if !strings.Contains(string(b), "b@example.com") { + t.Fatalf(".claude.json: %s", b) + } + for _, l := range Logins("claude") { + if l.User == "a@example.com" && (!l.On || l.Active) || l.User == "b@example.com" && !l.Active { + t.Fatalf("after: %+v", l) + } + } + // on one with room it stays; with none left, too + if to := switched(); to != "" { + t.Fatalf("switched again, to %s", to) + } + used["Bearer sk-ant-oat01-b"] = 99 + if to := switched(); to != "" { + t.Fatalf("switched to %s, spent as well", to) + } +} + +func mustJSONRaw(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +}