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:
1 parent
4bc7d6c00d
commit
2c81af6983
2 files changed
+213
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user