From 2c81af6983d87f044953b48c4b3a47b8aff78574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=94=E7=AB=8B?= Date: Sat, 3 Oct 2026 00:44:43 +0800 Subject: [PATCH] fix(provider): InUseLogin follows the gateway when the signed-in account is spent (#587) The menu bar's account-in-use card (trayInUseCard via InUseLogin) picked the account by sign-in state alone: Active or first, unless paused. The gateway moves its requests as soon as an account's allowance is spent (Allowances, SpentShare), but the sign-in follows only every few minutes (SwitchWhenSpent, codex/claude only). Between those - and for every other multi-account provider - the card showed the exhausted account while traffic flowed through another. InUseLogin now weighs the leading account against the same allowances the gateway routes by (Allowances + loginSwitching's SpentShareOf). When it is known spent and another account is on with room, that one is reported instead. An allowance not known is never taken for spent, so a single account, an unread one, or a failed read keeps the signed-in account as before. Tests: the existing inUseOf table gains a room parameter (nil-safe); new cases pin the follow-the-gateway behaviour and its guards; TestLoginRoom verifies the wiring against injected allowances. Co-authored-by: Claude Code --- internal/provider/logins.go | 65 ++++++++++- internal/provider/logins_inuse_test.go | 154 ++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 6 deletions(-) diff --git a/internal/provider/logins.go b/internal/provider/logins.go index 97e1c02b..defbfe60 100644 --- a/internal/provider/logins.go +++ b/internal/provider/logins.go @@ -608,8 +608,9 @@ func Logins(agent string) []Login { } // InUseLogin is the account of an agent's the gateway goes to first: the -// one the agent is signed in to, unless it is paused, else the first other -// one on; "" when the agent has none. +// one the agent is signed in to, unless it is paused or has used its +// allowance up — then the next one on that still has room — else the first +// other one on; "" when the agent has none. // Kept signed in to one of the user's choosing, it is the first in the // order that is in use. func InUseLogin(agent string) string { @@ -621,12 +622,29 @@ func InUseLogin(agent string) string { } } } - return inUseOf(ls) + return inUseOf(ls, loginRoom(agent)) } -func inUseOf(ls []Login) string { +func inUseOf(ls []Login, room func(user string) (known, spent bool)) string { for _, l := range ls { if (l.Active || l.first) && !l.Paused { + // The account the agent is signed in to leads, but magpie moves + // the sign-in only every few minutes (SwitchWhenSpent, and for + // the agents it signs in at all), while the gateway moves its + // requests as soon as an account is spent. So the one in use can + // still be spent here; the menu bar watching it would show an + // allowance already gone. When the account the agent is on has + // used up, and another is on with room, the gateway goes to that + // one, so report it. An allowance not known is never taken for + // spent, so a single account or a read that failed is left as it + // was. room is nil where the caller has no allowances to weigh. + if room != nil { + if known, spent := room(l.User); known && spent { + if next, ok := nextWithRoom(ls, l.User, room); ok { + return next + } + } + } return l.User } } @@ -638,6 +656,45 @@ func inUseOf(ls []Login) string { return "" } +// nextWithRoom is the account inUseOf moves to when the one in use is spent: +// the first other one the gateway could take a request to — on, not paused, +// not lapsed — whose allowance is known and not spent, the spares NextLogin +// picks among. ok is false when none has room, and the account in use is +// kept. +func nextWithRoom(ls []Login, spent string, room func(user string) (known, spent bool)) (string, bool) { + for _, l := range ls { + if !l.On || l.Paused || l.Lapsed != "" || strings.EqualFold(l.User, spent) { + continue + } + if known, used := room(l.User); known && !used { + return l.User, true + } + } + return "", false +} + +// loginRoom weighs an account against the share the gateway counts it spent +// at (loginSwitching, the same SpentShareOf its routing). It reads the +// allowances the gateway routes by, so the account reported as in use and +// the one the gateway sends to agree on which is out (#209), and the menu +// bar's "account in use" card follows the gateway rather than the sign-in +// that lags behind it. +func loginRoom(agent string) func(user string) (known, spent bool) { + al := Allowances(agent) + share, _ := loginSwitching(agent) + now := time.Now() + return func(user string) (bool, bool) { + a, ok := al[user] + if !ok { + return false, false // not read yet: unknown, never taken for spent + } + // The account-wide windows (For's "" model), as the gateway's + // usedPast does; a window whose reset passed is empty again there. + used, _ := a.For("", now) + return true, used >= share + } +} + // SwitchLogin signs an agent in to a remembered account. Sessions of the // agent that are already running keep the account they started with until // they restart; so does Codex's background app-server, which new Codex diff --git a/internal/provider/logins_inuse_test.go b/internal/provider/logins_inuse_test.go index 44fe317f..9ef28bf2 100644 --- a/internal/provider/logins_inuse_test.go +++ b/internal/provider/logins_inuse_test.go @@ -1,6 +1,14 @@ package provider -import "testing" +import ( + "testing" + "time" +) + +// noRoom is inUseOf's room for a caller with no allowances to weigh: the +// account in use stays whichever it has left, as it did before the menu +// bar's card was made to follow the gateway. +func noRoom(string) (bool, bool) { return false, false } // The account in use, as the menu bar's "account in use" card follows it: // the one the agent is signed in to, unless paused, else the first on. @@ -15,8 +23,150 @@ func TestInUseOf(t *testing.T) { {[]Login{{User: "c"}}, ""}, {nil, ""}, } { - if got := inUseOf(c.ls); got != c.want { + if got := inUseOf(c.ls, noRoom); got != c.want { t.Errorf("%+v: %q, want %q", c.ls, got, c.want) } } } + +// A provider's account A is spent and the gateway has moved its requests to +// B, but magpie has not switched the sign-in yet (it does that only every +// few minutes). The card must show B's remaining allowance, not A's already +// gone (a user report: the menu bar kept showing the exhausted account). So +// the account in use follows the gateway to the next one on with room — +// but only where the allowance is actually known to be spent: a single +// account, an unknown allowance, or a failed read must still show the one +// the agent is signed in to, never another. +func TestInUseOfFollowsGatewayWhenSpent(t *testing.T) { + // room reports which accounts have used up their allowance. + spend := func(spent ...string) func(string) (bool, bool) { + return func(user string) (bool, bool) { + for _, s := range spent { + if s == user { + return true, true // known and spent + } + } + return true, false // known, has room + } + } + unknown := func(string) (bool, bool) { return false, false } + + t.Run("signed-in account spent, another on has room", func(t *testing.T) { + ls := []Login{ + {User: "a", Active: true, On: true}, + {User: "b", On: true}, + } + if got := inUseOf(ls, spend("a")); got != "b" { + t.Errorf("got %q, want b: the gateway goes to b, the card should follow", got) + } + }) + + t.Run("signed-in account spent, none has room", func(t *testing.T) { + ls := []Login{ + {User: "a", Active: true, On: true}, + {User: "b", On: true}, + } + if got := inUseOf(ls, spend("a", "b")); got != "a" { + t.Errorf("got %q, want a: with no account left to show, keep the one signed in", got) + } + }) + + t.Run("allowance unknown, keep the signed-in account", func(t *testing.T) { + // This is the "read failed / not read yet" path: a card that fell + // back to another account would show an allowance the user is not + // actually on. + ls := []Login{ + {User: "a", Active: true, On: true}, + {User: "b", On: true}, + } + if got := inUseOf(ls, unknown); got != "a" { + t.Errorf("got %q, want a: an unknown allowance is never taken for spent", got) + } + }) + + t.Run("single account spent, still shows it", func(t *testing.T) { + // The only account, however drained, is the one the agent is on: + // there is no other to follow, and the card would be wrong to jump. + ls := []Login{{User: "a", Active: true, On: true}} + if got := inUseOf(ls, spend("a")); got != "a" { + t.Errorf("got %q, want a: a single account is never swapped out", got) + } + }) + + t.Run("paused account not rescued by room", func(t *testing.T) { + // The first loop only considers Active/first and not paused; a + // paused signed-in account already falls through to the On pass, + // which room does not reorder. + ls := []Login{ + {User: "a", Active: true, On: true, Paused: true}, + {User: "b", On: true}, + } + if got := inUseOf(ls, spend("b")); got != "b" { + t.Errorf("got %q, want b", got) + } + }) + + t.Run("a lapsed spare is not taken for room", func(t *testing.T) { + // A signed-out account can take no request (NextLogin skips one + // lapsed), so the card must not follow it either. + ls := []Login{ + {User: "a", Active: true, On: true}, + {User: "b", On: true, Lapsed: "signed out"}, + } + if got := inUseOf(ls, spend("a")); got != "a" { + t.Errorf("got %q, want a: the lapsed spare has no room to follow", got) + } + }) + + t.Run("a spare that is itself the signed-in one is not chosen twice", func(t *testing.T) { + // Only other accounts can take over: skip the spent user by name, so + // a same account marked twice doesn't loop back to itself. + ls := []Login{ + {User: "a", Active: true, On: true}, + {User: "b", On: true}, + } + if got := inUseOf(ls, spend("a")); got != "b" { + t.Errorf("got %q, want b", got) + } + }) +} + +// loginRoom weighs an account against the share the gateway's routing counts +// it spent at, reading the allowances the gateway routes by. inUseOf relies +// on it to decide whether the account in use still has room, so it must +// agree with the gateway on which accounts are out: a full one is spent, one +// with room is not, and one never read is unknown — never taken for spent, so +// the card never jumps off an account whose allowance it hasn't seen. +func TestLoginRoom(t *testing.T) { + signIn(t) + // Inject the allowances as the gateway would see them: Codex's signed-in + // account at the vendor's edge, a spare with room, and an account magpie + // has no reading for. Marked fresh so Allowances answers from them rather + // than refetching over a test's fake home. + usedCache.Lock() + if usedCache.m == nil { + usedCache.m, usedCache.at, usedCache.loading = map[string]map[string]Allowance{}, map[string]time.Time{}, map[string]chan struct{}{} + } + usedCache.m["codex"] = map[string]Allowance{ + "me@example.com": {{Used: 100, Span: 5 * time.Hour}}, + "spare@example.com": {{Used: 10, Span: 5 * time.Hour}}, + } + usedCache.at["codex"] = time.Now() + usedCache.Unlock() + t.Cleanup(func() { + usedCache.Lock() + usedCache.m, usedCache.at, usedCache.loading = map[string]map[string]Allowance{}, map[string]time.Time{}, map[string]chan struct{}{} + usedCache.Unlock() + }) + + room := loginRoom("codex") + if known, spent := room("me@example.com"); !known || !spent { + t.Errorf("me: known=%v spent=%v, want both: a full account is spent", known, spent) + } + if known, spent := room("spare@example.com"); !known || spent { + t.Errorf("spare: known=%v spent=%v, want known and not spent", known, spent) + } + if known, spent := room("nobody@example.com"); known || spent { + t.Errorf("nobody: known=%v spent=%v, want unknown: an unread allowance is never spent", known, spent) + } +}