Files
magpie/account_cap_cli_test.go
T
yetone 34a598372c providers, gateway, gui, cli: each usage window of an account can have a cap of its own beside the account's (willz on Discord)
willz asked for a cap per window: a friend's account should stop at 50%
of its five hours, while its week has its own 40%. Until now an account
had one usage cap, the same share of every window.

What the user sees: the account's pill still sets the cap for all its
windows and stays the default. Each window's meter on the account's row
is now a button: a click opens the app's own menu headed "Cap for
<window>" with the account's cap, No cap on this window, 50–90%, and
Other… typed in place. A window with its own cap says it beside its
figure ("Cap 50%", or "No cap"), its mark sits there, and the account's
pill says "Caps by window" when only windows have caps. The account is
held while any window is past its own cap, and the row's note, the
gateway's 429 and the Routing trace name that window's cap. CLI:
magpie provider account-cap <id> <account> --window "5 hours" 50
(none: no cap on that window; default: back to the account's), and
`account-cap <id>` lists the overrides.

How: Provider.AccountWindowCaps (providers.json, by account and the
window's capId, its name in lower case; 1–99, or 100 for none) beside
AccountCaps. provider.WindowCaps gives a window its share (its own, else
the account's); Allowance.CapHeld and capReached judge each window by
its own share and report the fullest holding window's cap.
provider.HoldShare becomes HoldCaps, used by gateway capHeld (plan and
group filtering, cappedError, trace) and codex_backend's route-or-relay
choice; codex_switch's NextLogin moves the agent off at per-window caps.
QuotaWindow carries capId; Limit.name is exported as Limit.Name. A
config with no window caps reads and routes exactly as before
(TestSetWindowCap reads an old one). The meter is a span with
role=button, Enter/Space: a <button> as a flex item of the wrapping
meter line kept WebKit's row at its one-line height until the next
layout, so the row grew under the pointer at the first click.

Verified:
- go test -tags nogui ./... in a sandbox HOME: all ok. New
  TestWindowCapHeld and TestSetWindowCap (provider), TestWindowCapsHoldTheAccount
  through the real gateway (5h 60%/cap 50 goes to the spare; 20/30 under
  50/40 is used; 20/45 with the spare past its own cap is a 429 naming
  "past its 40% cap"), TestAccountCapCmd --window cases.
- Break check: with WindowCaps.Of ignoring the window's own share,
  TestWindowCapHeld fails ("5 hours at 60% of 50%: <nil>") and
  TestWindowCapsHoldTheAccount fails (the request is answered 200 by the
  capped account).
- internal/gui/tests/window-cap.test.cjs: Chromium and WebKit, 420 and
  1100 wide, en, zh, zh-TW, ja, de (60 tests): held note per window,
  marks, menu from the meter with no native select, keyboard, posts of
  50, typed 45, 100 and 0, no page scroll on a click, no sideways scroll,
  no left border stripes, every new string translated. With
  windowOwnCapOf returning 0 it fails.
- account-cap, gui-zh-tw, gui-ja, gui-de, i18n-keys pass; full GUI suite
  in both engines (483 files, concurrency 6): 4315 of 4323
  pass; the 8 that failed (library-servers-bulk, purpose-filter,
  routing-proxy, routing-zcode-blocked) touch no account row and all 32
  of their tests pass when run alone
- gofmt -l clean; go vet -tags nogui ./... on darwin, linux and windows,
  and windows without the tag for ., internal/gui, internal/provider,
  internal/gateway; go build ./...
- Not tried in the real window with a live gateway or real accounts.

Docs: gateway-routing.md (HoldCaps, window caps, tests),
providers-accounts.md (AccountWindowCaps, tests), reference.md.
2026-10-09 12:00:26 +08:00

104 lines
4.0 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)
}
// a window's own (willz on Discord): the five hours at 50%, the week
// with none, then back to the account's
if err := providerCmd([]string{"provider", "account-cap", "codex", "me@example.com", "--window", "5 Hours", "50%"}); err != nil {
t.Fatal(err)
}
if err := providerCmd([]string{"provider", "account-cap", "codex", "me@example.com", "--window=Weekly", "none"}); err != nil {
t.Fatal(err)
}
c := provider.AccountCapsOf("codex", "me@example.com")
if five, _ := c.Of("5 hours"); five != 50 || c.Windows["weekly"] != 100 || c.All != 0 {
t.Fatalf("window caps %+v", c)
}
if err := providerCmd([]string{"provider", "account-cap", "codex", "me@example.com", "--window", "5 hours"}); err != nil {
t.Fatal(err) // shown
}
if err := providerCmd([]string{"provider", "account-cap", "codex", "me@example.com", "--window", "5 hours", "lots"}); err == nil {
t.Fatal("a window's share of lots taken")
}
if err := providerCmd([]string{"provider", "account-cap", "codex", "--window", "5 hours"}); err == nil {
t.Fatal("a window with no account taken")
}
for _, w := range []string{"5 hours", "weekly"} {
if err := providerCmd([]string{"provider", "account-cap", "codex", "me@example.com", "--window", w, "default"}); err != nil {
t.Fatal(err)
}
}
if c := provider.AccountCapsOf("codex", "me@example.com"); c.Windows != nil {
t.Fatalf("default left %+v", 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")
}
}