Files
magpie/internal/provider/codex_switch_test.go
T
yetone 0b7781ba18 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.
2026-09-29 18:56:33 +08:00

170 lines
5.5 KiB
Go

package provider
import (
"context"
"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
// next account that is on and has room; while none has, it stays.
func TestCodexSwitchedWhenUsedUp(t *testing.T) {
home := signIn(t) // me@example.com, acct-1
rememberLogins(true)
codexSignIn(t, home, "spare@example.com", "r-spare")
rememberLogins(true)
codexSignIn(t, home, "work@example.com", "r-work")
rememberLogins(true)
used := map[string]float64{"acct-work@example.com": 100, "acct-1": 100, "acct-spare@example.com": 20}
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{"plan_type": "plus", "rate_limit": map[string]any{
"primary_window": map[string]any{"used_percent": used[r.Header.Get("chatgpt-account-id")], "limit_window_seconds": 18000}}})
}))
defer fake.Close()
old := CodexBase
CodexBase = fake.URL + "/backend-api/codex"
t.Cleanup(func() { CodexBase = old })
fresh := func() {
loginUsageCache.Lock()
loginUsageCache.m = nil
loginUsageCache.Unlock()
}
switched := func() string {
t.Helper()
fresh()
to, err := SwitchWhenSpent(context.Background(), "codex")
if err != nil {
t.Fatal(err)
}
return to
}
// no other account on: nothing to go to
if to := switched(); to != "" {
t.Fatalf("switched to %s with none on", to)
}
// the one on is out too
if err := SetLoginOn("codex", "me@example.com", true); err != nil {
t.Fatal(err)
}
if to := switched(); to != "" {
t.Fatalf("switched to %s, out as well", to)
}
// one on with room: the first of those, not one that is off
if err := SetLoginOn("codex", "spare@example.com", true); err != nil {
t.Fatal(err)
}
if to := switched(); to != "spare@example.com" {
t.Fatalf("switched to %q", to)
}
var live codexAuth
readJSON(filepath.Join(home, ".codex", "auth.json"), &live)
if live.Tokens.RefreshToken != "r-spare" {
t.Fatalf("Codex is signed in with %+v", live.Tokens)
}
for _, l := range Logins("codex") {
if l.User == "work@example.com" && (!l.On || l.Active) || l.User == "spare@example.com" && !l.Active {
t.Fatalf("after: %+v", l)
}
}
// on one with room it stays
if to := switched(); to != "" {
t.Fatalf("switched again, to %s", to)
}
// an allowance not known is not taken for used up
used["acct-spare@example.com"] = 100
used["acct-work@example.com"] = 0
CodexBase = "http://127.0.0.1:1/backend-api/codex"
if to := switched(); to != "" {
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
}