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.

This commit is contained in:
yetone committed 2026-09-29 18:56:33 +08:00
1 parent 3676d2e713
commit 0b7781ba18
10 files changed
+304 -52

No files matched your search

+1 -1
View File
@@ -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")
+4 -1
View File
@@ -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
+3 -2
View File
@@ -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)
+54
View File
@@ -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")
}
}
+13 -10
View File
@@ -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<<min(n-1, 10), longestRetry), "backoff", n
// a subscription that failed with a window full is out of it,
@@ -412,10 +414,11 @@ func retryAfter(h http.Header, now time.Time) time.Duration {
var allowances = provider.Allowances
// Shares of an allowance past which an account is kept for when the others
// can't take a request: low, and all but used up.
// can't take a request: low, and all but used up — where the agent signed
// in to it is signed in to another, too (provider.SpentShare).
const (
lowShare = 90
usedShare = 98
usedShare = provider.SpentShare
)
// route orders one provider's candidates as its routing says.
@@ -549,7 +552,7 @@ func weigh(p provider.Provider, cs []candidate, model string, from provider.Prot
tokens := make([]float64, len(cs))
wg.tokens = map[string]float64{}
for i, c := range cs {
tokens[i] = routed.used[c.rest].now(now)
tokens[i] = routed.used[c.restKey()].now(now)
wg.tokens[c.rest] = tokens[i]
}
routed.Unlock()
+8 -1
View File
@@ -3751,7 +3751,14 @@ function renderRouting(p) {
const r = ROUTINGS.find(([id]) => 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 —
+1
View File
@@ -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": "按顺序",
@@ -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, []);
});
}
}
+69 -36
View File
@@ -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)
}
}
+86 -1
View File
@@ -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
}