Files
magpie/account_cap_cli_test.go
T
yetone ed79a72927 provider: a subscription account can be capped at a share of its usage windows, and routing treats it as used up there (Discord: hold a new Claude account at 70% of its five hours and its week to keep its ban risk down, and let magpie go on to the other accounts) — an account's cap (1–99, keyed by the provider id and the account in lower case, AccountCaps in providers.json beside AccountModels and AccountProxies) holds it once any window that applies to the model is at or past the cap and hasn't reset (Allowance.CapHeld; Aside windows and windows kept for another model are left out). One share for every window rather than one per window: the windows an account has differ by vendor (five hours and week for Claude and Codex, a month or a day for others, per-model weeks), the request was one figure for both, and a lower share for one window would be set by lowering the cap. The gateway's perKeyBarred moves a capped account to the candidates left out (barredOf marks it Capped with its use and when it is back, so the routing trace says "held at its 70% usage cap"), so groups, fallback, Ordered and Spread routing and the Codex/Claude Code switch list (NextLogin: a capped account is moved off, not returned to, and isn't a spare) all go on to the next account; a pinned capped account and a draw over only capped accounts answer 429. When every account that serves the model is capped, the client is turned away as when all are used up — 429 rate_limit_error with Retry-After at the soonest reset — and told the cap was reached, which accounts, at what use and cap, and how to lift it. A cap never spends a Codex reset: weekUsedUp, usedUp, BackAt and spendExpiringNow still go by ChatGPT's own 100%, so an account held at 80% with auto reset on is held and nothing is spent; a lone Codex account with a cap goes through serve() instead of being relayed as it came (codexAccounts), so the cap holds it. The Providers page shows a cap pill on each account's row (on hover until set) that opens the app's own menu — No cap, 50/60/70/80/90%, Other… (typed inline, focused without scrolling) — posting provider/accountcap; each window's bar gets a tick at the cap (from the right when the bar shows what is left), and an account held by its cap says so and until when. zh strings in i18n.js. CLI: magpie provider account-cap <id> [account [percent|off]] shows or sets caps. Tests: TestCapHeld, TestCapReached, TestParseCap; TestCapNeverSpendsACodexReset (80% windows, 70% cap: not used up, no reset due, routing held); TestCodexSwitchedAtCap (the active account at 75% with a 70% cap is switched off to the one at 20%, not back while capped, back once lifted; 100 and an unknown account refused); gateway TestAccountCapMovesOn (first account at 75% capped at 70%: the second is tried and the trace says 75/70; lifted: the first), TestAccountCapEveryAccount (429 rate_limit_error naming both caps, nothing sent, logged 429) and TestAccountCapSpendsNoCodexReset (one account at an 80% week, auto reset on: 429, nothing tried or spent; lifted: 200, nothing spent) — the three gateway tests fail with the cap check taken out; TestAccountCapCmd; account-cap.test.cjs (webkit, en and zh: pill, menu, Other…, No cap, cap ticks, held note, no <select>, no border stripes, no scroll on click). account-models, click-scroll, keep-login, codex-auto-reset, account-return, routing-blocked, routing-account-order, routing-quota-left, routing-auto-reset, routing-manual, quota-pools, quota-families and usage-accounts GUI tests pass in webkit. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried with real accounts; a cap acts on usage as last read, so a burst between reads can run a little past it.
2026-10-04 10:18:50 +08:00

75 lines
2.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
// magpie provider account-cap caps a subscription account at a share of
// each usage window (70, 70%), off lifts it; a share out of 1–99, an
// account the subscription hasn't, or a provider with keys is an error.
func TestAccountCapCmd(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("DSH_HOME", "")
jwt := func(m map[string]any) string {
b, _ := json.Marshal(m)
return "h." + base64.RawURLEncoding.EncodeToString(b) + ".s"
}
auth, _ := json.Marshal(map[string]any{"auth_mode": "chatgpt", "tokens": map[string]any{
"id_token": jwt(map[string]any{"email": "me@example.com"}),
"access_token": jwt(map[string]any{"exp": time.Now().Add(time.Hour).Unix()}),
"refresh_token": "r", "account_id": "acct-1"}})
os.MkdirAll(filepath.Join(home, ".codex"), 0o755)
if err := os.WriteFile(filepath.Join(home, ".codex", "auth.json"), auth, 0o600); err != nil {
t.Fatal(err)
}
provider.ForgetAccounts()
t.Cleanup(provider.ForgetAccounts)
if err := providerCmd([]string{"provider", "account-cap", "codex", "Me@Example.com", "70%"}); err != nil {
t.Fatal(err)
}
if c := provider.AccountCapOf("codex", "me@example.com"); c != 70 {
t.Fatalf("cap %d", c)
}
// shown, for one account or all
for _, args := range [][]string{{"provider", "account-cap", "codex"}, {"provider", "account-cap", "codex", "me@example.com"}} {
if err := providerCmd(args); err != nil {
t.Fatal(err)
}
}
for _, bad := range []string{"0.5", "150", "lots"} {
if err := providerCmd([]string{"provider", "account-cap", "codex", "me@example.com", bad}); err == nil {
t.Fatalf("%s taken", bad)
}
}
if c := provider.AccountCapOf("codex", "me@example.com"); c != 70 {
t.Fatalf("a refused share changed the cap to %d", c)
}
if err := providerCmd([]string{"provider", "account-cap", "codex", "nobody@example.com", "50"}); err == nil {
t.Fatal("an account it hasn't was capped")
}
if err := providerCmd([]string{"provider", "account-cap", "codex", "me@example.com", "off"}); err != nil {
t.Fatal(err)
}
if c := provider.AccountCapOf("codex", "me@example.com"); c != 0 {
t.Fatalf("off left %d", c)
}
if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k1", Chat: "http://127.0.0.1:1/v1"}); err != nil {
t.Fatal(err)
}
if err := providerCmd([]string{"provider", "account-cap", "relay", "k1", "50"}); err == nil {
t.Fatal("a key provider was capped")
}
}