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 <noreply@anthropic.com>
This commit is contained in:
尔立andClaude Code authored and GitHub committed 2026-10-03 00:44:43 +08:00
1 parent 4bc7d6c00d
commit 2c81af6983
2 files changed
+213 -6

No files matched your search

+61 -4
View File
@@ -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
+152 -2
View File
@@ -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)
}
}