diff --git a/accounts_cli.go b/accounts_cli.go
index 055f48ef..4a485d0f 100644
--- a/accounts_cli.go
+++ b/accounts_cli.go
@@ -196,6 +196,7 @@ func accountRows(ls []provider.Login, now time.Time) []accountRow {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
usage := map[string]map[string]provider.SubscriptionQuota{}
+ provider.AskClaudeUsage()
var mu sync.Mutex
var wg sync.WaitGroup
for _, l := range ls {
@@ -267,8 +268,8 @@ func untilShort(d time.Duration) string {
// addAccount signs in to one more subscription in the browser, the way the
// window's "Add account" does.
func addAccount(agentID string) error {
- if agentID == "antigravity" {
- fmt.Println(bold.Render("!"), provider.AntigravityRisk)
+ if risk := map[string]string{"antigravity": provider.AntigravityRisk, "claude": provider.ClaudeRisk}[agentID]; risk != "" {
+ fmt.Println(bold.Render("!"), risk)
fmt.Print("Sign in anyway? [y/N] ")
var yes string
fmt.Scanln(&yes)
diff --git a/internal/gateway/claude_autoreset_test.go b/internal/gateway/claude_autoreset_test.go
deleted file mode 100644
index 90eedf23..00000000
--- a/internal/gateway/claude_autoreset_test.go
+++ /dev/null
@@ -1,146 +0,0 @@
-package gateway
-
-import (
- "encoding/json"
- "io"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "strings"
- "sync"
- "testing"
- "time"
-
- "github.com/yetone/magpie/internal/provider"
-)
-
-// claudeResetAccounts signs Claude Code in to me@example.com and keeps
-// spare@example.com too, both out of their allowance, with a claude CLI
-// that answers me's turns again once Anthropic's fake (which it returns)
-// took one of me's resets.
-func claudeResetAccounts(t *testing.T) (claims func() []map[string]string, log string) {
- t.Helper()
- home := t.TempDir()
- t.Setenv("HOME", home)
- t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
- t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
- restingUntil.Lock()
- restingUntil.m = map[string]time.Time{}
- restingUntil.Unlock()
- far := time.Now().Add(24 * time.Hour).UnixMilli()
- oauth := func(tok string) map[string]any {
- return map[string]any{"claudeAiOauth": map[string]any{"accessToken": tok, "refreshToken": "r-" + tok, "expiresAt": far, "subscriptionType": "max"}}
- }
- os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
- os.WriteFile(filepath.Join(home, ".claude", ".credentials.json"), mustJSON(oauth("tok-me")), 0o600)
- os.WriteFile(filepath.Join(home, ".claude.json"), mustJSON(map[string]any{
- "oauthAccount": map[string]any{"emailAddress": "me@example.com", "accountUuid": "u-me"}}), 0o600)
- os.MkdirAll(filepath.Dir(provider.Path()), 0o755)
- os.WriteFile(filepath.Join(filepath.Dir(provider.Path()), "logins.json"), mustJSON([]map[string]any{
- {"agent": "claude", "user": "spare@example.com", "plan": "max", "on": true, "seen": time.Now(), "auth": oauth("tok-spare")},
- }), 0o600)
-
- dir := t.TempDir()
- marker := filepath.Join(dir, "reset")
- log = filepath.Join(dir, "log")
- var mu sync.Mutex
- var got []map[string]string
- api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case r.URL.Path == "/api/oauth/usage":
- week := 100
- if _, err := os.Stat(marker); err == nil {
- week = 0
- }
- later := func(d time.Duration) string { return time.Now().Add(d).UTC().Format(time.RFC3339) }
- json.NewEncoder(w).Encode(map[string]any{
- "five_hour": map[string]any{"utilization": week, "resets_at": later(2 * time.Hour)},
- "seven_day": map[string]any{"utilization": week, "resets_at": later(48 * time.Hour)},
- "cedar_ember": map[string]any{"eligible": true, "next_grant_id": "opus55-launch-promax-20260921",
- "grants": []any{map[string]any{"id": "opus55-launch-promax-20260921", "resets_left": 1, "usable_now": true, "paused": false,
- "ends_at": later(20 * 24 * time.Hour), "clears": []string{"five_hour", "seven_day", "seven_day_overage_included"}}}}})
- case r.URL.Path == "/api/oauth/profile":
- io.WriteString(w, `{"organization":{"uuid":"org-me"}}`)
- case r.Method == http.MethodPost && r.URL.Path == "/api/organizations/org-me/reset_rate_limits":
- var in map[string]string
- json.NewDecoder(r.Body).Decode(&in)
- mu.Lock()
- got = append(got, in)
- mu.Unlock()
- os.WriteFile(marker, nil, 0o644)
- io.WriteString(w, `{"result":"reset","resets_left":0,"cleared":["five_hour","seven_day","seven_day_overage_included"]}`)
- default:
- w.WriteHeader(404)
- }
- }))
- t.Cleanup(api.Close)
- t.Cleanup(provider.PointClaudeAt(api.URL))
-
- script := `#!/bin/sh
-if [ "$1" = auth ]; then echo '{"loggedIn":true,"authMethod":"oauth_token","apiProvider":"firstParty"}'; exit 0; fi
-echo "${CLAUDE_CODE_OAUTH_TOKEN:-own}" >> ` + log + `
-while read -r line; do
- if [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ] && [ -f ` + marker + ` ]; then
- echo '{"type":"stream_event","event":{"type":"message_start","message":{"id":"m","model":"claude-sonnet-5","usage":{"input_tokens":1}}}}'
- echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}}'
- echo '{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}}'
- echo '{"type":"stream_event","event":{"type":"message_stop"}}'
- echo '{"type":"result","subtype":"success","is_error":false,"result":"pong"}'
- else
- echo '{"type":"result","subtype":"success","is_error":true,"result":"You'"'"'ve hit your limit · resets 3am"}'
- fi
-done
-`
- os.WriteFile(filepath.Join(dir, "claude"), []byte(script), 0o755)
- t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
- provider.ForgetAccounts()
- t.Cleanup(provider.ForgetAccounts)
- return func() []map[string]string {
- mu.Lock()
- defer mu.Unlock()
- return append([]map[string]string(nil), got...)
- }, log
-}
-
-const claudeResetAsk = `{"model":"claude/claude-sonnet-5","max_tokens":50,"stream":true,"messages":[{"role":"user","content":"ping"}]}`
-
-// Both Claude accounts out of their allowance, the one Claude Code is
-// signed in to spending its resets by itself (a Max subscriber on
-// Discord, holding Opus 5.5's launch reset): the reset Anthropic names
-// next is taken, and the turn goes through on it.
-func TestClaudeAutoReset(t *testing.T) {
- claims, log := claudeResetAccounts(t)
- if err := provider.SetClaudeAutoReset("Me@example.com", true); err != nil {
- t.Fatal(err)
- }
- s := New()
- code, body := sendTo(s, "/v1/messages", claudeResetAsk)
- b, _ := os.ReadFile(log)
- runs := strings.Join(strings.Fields(string(b)), ",")
- if code != 200 || !strings.Contains(body, "pong") || !strings.HasSuffix(runs, ",own") || !strings.Contains(runs, "tok-spare") {
- t.Fatalf("status %d: %s (runs %s)", code, body, runs)
- }
- if c := claims(); len(c) != 1 || c[0]["program"] != "cedar_ember" || c[0]["grant_id"] != "opus55-launch-promax-20260921" || c[0]["request_id"] == "" {
- t.Fatalf("claims %v", c)
- }
- if got := resetsTraced(s); len(got) != 1 || got[0] != "me@example.com: 3 windows started again" {
- t.Fatalf("traced %v", got)
- }
- // the Routing page tells it as a Claude reset
- for _, try := range lastRoute(s).Tries {
- if try.Reset != nil && try.Reset.Agent != "claude" {
- t.Fatalf("reset traced as %q's", try.Reset.Agent)
- }
- }
-}
-
-// Without the user's leave, no reset is spent: the limit stands.
-func TestClaudeAutoResetNot(t *testing.T) {
- claims, _ := claudeResetAccounts(t)
- s := New()
- code, body := sendTo(s, "/v1/messages", claudeResetAsk)
- if code != 429 || len(claims()) != 0 || len(resetsTraced(s)) != 0 {
- t.Fatalf("status %d: %s, claims %v", code, body, claims())
- }
-}
diff --git a/internal/gateway/claude_warmup.go b/internal/gateway/claude_warmup.go
index 974ecbdd..89bb7ea6 100644
--- a/internal/gateway/claude_warmup.go
+++ b/internal/gateway/claude_warmup.go
@@ -73,7 +73,55 @@ func askClaude(ctx context.Context, oauth, model string) error {
return nil
}
-func init() { provider.ProbeClaudeVia(askClaude) }
+func init() {
+ provider.ProbeClaudeVia(askClaude)
+ provider.UsageClaudeVia(claudeUsage)
+}
+
+// claudeUsage is what Claude Code's /usage prints for the account it is
+// signed in to: a command it answers itself, asking no model, run as
+// askClaude runs it, with nothing of the user's settings and nothing kept.
+func claudeUsage(ctx context.Context) (string, error) {
+ binary, err := claudeBinary()
+ if err != nil {
+ return "", err
+ }
+ tmp, err := os.MkdirTemp("", "magpie-claude-")
+ if err != nil {
+ return "", err
+ }
+ defer os.RemoveAll(tmp)
+ cmd := proc.CommandContext(ctx, binary, claudeUsageArgs()...)
+ cmd.Dir = tmp
+ cmd.Stdin = strings.NewReader("")
+ cmd.Env = netproxy.EnvWith(claudeProxy(ctx), cleanClaudeEnv(os.Environ()))
+ var out, stderr bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &out, &stderr
+ runErr := cmd.Run()
+ var res struct {
+ IsError bool `json:"is_error"`
+ Result string `json:"result"`
+ }
+ if err := json.Unmarshal(out.Bytes(), &res); err == nil {
+ if res.IsError {
+ return "", errors.New("Claude Code: " + clip(res.Result))
+ }
+ return res.Result, nil
+ }
+ msg := strings.TrimSpace(stderr.String())
+ if msg == "" {
+ msg = strings.TrimSpace(out.String())
+ }
+ if msg == "" && runErr != nil {
+ return "", runErr
+ }
+ return "", errors.New("Claude Code: " + clip(msg))
+}
+
+func claudeUsageArgs() []string {
+ return []string{"-p", "/usage", "--output-format", "json",
+ "--tools", "", "--strict-mcp-config", "--setting-sources", "", "--no-session-persistence"}
+}
func claudeWarmArgs(model string) []string {
return []string{"-p", "--output-format", "json", "--model", model,
diff --git a/internal/gateway/claude_warmup_test.go b/internal/gateway/claude_warmup_test.go
index 27586f47..1857da52 100644
--- a/internal/gateway/claude_warmup_test.go
+++ b/internal/gateway/claude_warmup_test.go
@@ -2,6 +2,7 @@ package gateway
import (
"context"
+ "encoding/json"
"os"
"path/filepath"
"strings"
@@ -83,3 +84,32 @@ func TestClaudeTestRunsClaudeCode(t *testing.T) {
t.Fatalf("run:\n%s", got)
}
}
+
+// Claude's usage is Claude Code's own /usage, run with nothing of the
+// user's settings and nothing kept, as the account it is signed in to.
+func TestClaudeUsageRunsClaudeCode(t *testing.T) {
+ dir := t.TempDir()
+ log := filepath.Join(dir, "log")
+ out := filepath.Join(dir, "out")
+ text := "You are currently using your subscription\n\nCurrent session: 13% used · resets Oct 1 at 3:30pm (Asia/Shanghai)\n"
+ b, _ := json.Marshal(map[string]any{"type": "result", "is_error": false, "num_turns": 0, "result": text})
+ os.WriteFile(out, b, 0o644)
+ script := "#!/bin/sh\n" +
+ "{ printf 'args:'; for a in \"$@\"; do printf '[%s]' \"$a\"; done; echo; echo \"token:$CLAUDE_CODE_OAUTH_TOKEN\"; } > " + log + "\n" +
+ "cat " + out + "\n"
+ if err := os.WriteFile(filepath.Join(dir, "claude"), []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
+ t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "leaked")
+ got, err := claudeUsage(context.Background())
+ if err != nil || got != text {
+ t.Fatalf("usage %q %v", got, err)
+ }
+ b, _ = os.ReadFile(log)
+ for _, want := range []string{"[-p][/usage]", "[--setting-sources][]", "[--strict-mcp-config]", "[--no-session-persistence]", "token:\n"} {
+ if !strings.Contains(string(b), want) {
+ t.Errorf("run lacks %q:\n%s", want, b)
+ }
+ }
+}
diff --git a/internal/gateway/codex_autoreset.go b/internal/gateway/codex_autoreset.go
index 13405e85..1b7ad15e 100644
--- a/internal/gateway/codex_autoreset.go
+++ b/internal/gateway/codex_autoreset.go
@@ -1,10 +1,9 @@
package gateway
-// A Codex rate-limit reset, or a Claude usage-limit reset, spent by
-// itself: when everyone a request could go to is out of their allowance,
-// a ChatGPT or Claude account the user lets spend its resets, its weekly
-// window used up, spends one and is asked again
-// (provider.AutoUseCodexReset and AutoUseClaudeReset have the rules).
+// A Codex rate-limit reset spent by itself: when everyone a request could
+// go to is out of their allowance, a ChatGPT account the user lets spend
+// its resets, its weekly window used up, spends one and is asked again
+// (provider.AutoUseCodexReset has the rules).
import (
"context"
@@ -18,7 +17,7 @@ import (
// autoResetTimeout bounds the look at an account's week and the reset.
var autoResetTimeout = 20 * time.Second
-// autoReset spends a reset of one of cands' Codex or Claude accounts, c — the last
+// autoReset spends a reset of one of cands' Codex accounts, c — the last
// one, just out of its allowance — first: only when every other one of
// them sits out too. It says which account it was and what spending did.
func (s *Server) autoReset(ctx context.Context, cands []candidate, c candidate) (candidate, provider.ResetOutcome, bool) {
@@ -68,11 +67,7 @@ func (s *Server) autoResetSignedIn(ctx context.Context) (string, provider.ResetO
func autoResetOf(ctx context.Context, agent, user string) (provider.ResetOutcome, bool) {
ctx, cancel := context.WithTimeout(ctx, autoResetTimeout)
defer cancel()
- use := provider.AutoUseCodexReset
- if agent == "claude" {
- use = provider.AutoUseClaudeReset
- }
- out, err := use(ctx, user)
+ out, err := provider.AutoUseCodexReset(ctx, user)
switch {
case err != nil:
log.Printf("%s reset for %s not used: %v", agent, user, err)
diff --git a/internal/gui/api.go b/internal/gui/api.go
index 1dbac338..07d4453c 100644
--- a/internal/gui/api.go
+++ b/internal/gui/api.go
@@ -485,8 +485,8 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
in.QuotaLeft = cur.QuotaLeft
// how agents' lists name models, set on its own for the agents to be told
in.PlainNames = cur.PlainNames
- // which Codex and Claude accounts spend a reset by themselves, set on the Usage card
- in.CodexAutoReset, in.ClaudeAutoReset = cur.CodexAutoReset, cur.ClaudeAutoReset
+ // which Codex accounts spend a reset by themselves, set on the Usage card
+ in.CodexAutoReset = cur.CodexAutoReset
// and the text size, which the keyboard changes too (text-size below)
in.TextSize = cur.TextSize
// the version the Update pill was hidden for, set from the pill
@@ -606,26 +606,6 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
}
writeJSON(rw, settingsState())
})
- // and a Claude account its usage-limit resets, the same toggle
- mux.HandleFunc("POST /api/settings/claude-auto-reset", func(rw http.ResponseWriter, r *http.Request) {
- var in struct {
- User string
- On bool
- }
- if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
- fail(rw, err)
- return
- }
- if strings.TrimSpace(in.User) == "" {
- fail(rw, fmt.Errorf("which Claude account?"))
- return
- }
- if err := provider.SetClaudeAutoReset(in.User, in.On); err != nil {
- fail(rw, err)
- return
- }
- writeJSON(rw, settingsState())
- })
// how large the window and the panel are drawn: Settings' choice and
// Ctrl/Cmd +, − and 0 in either, set on its own so a key pressed while
// the Settings page saves something else is never undone by it
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index f00affc9..67571abb 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -4906,7 +4906,9 @@ function fallbackHint(p) {
const SUBS = [
// both can also come from CLIProxyAPI's auth files or the agent's own (importing below)
- { agent: "claude", name: "Claude", icon: "claude-color", plans: "Pro · Max · Team", importable: true },
+ // Anthropic has banned accounts it saw used from other tools: said before one is added
+ { agent: "claude", name: "Claude", icon: "claude-color", plans: "Pro · Max · Team", importable: true, risk: true,
+ riskNote: "Anthropic may suspend or ban a Claude account it sees used outside its own apps. magpie sends requests through Claude Code, but Anthropic may still act on them; you use it at your own risk. Use an account you can afford to lose." },
{ agent: "codex", name: "ChatGPT", icon: "openai", plans: "Plus · Pro · Business", importable: true },
// cursor-agent keeps one account; signing in again replaces it
{ agent: "cursor", name: "Cursor", icon: "cursor", plans: "Pro · Ultra · Teams", single: true },
@@ -6386,24 +6388,38 @@ const PERIODS = [["today", "Today"], ["7d", "7 days"], ["30d", "30 days"], ["all
// never waits for them. They don't depend on the period either.
let quotas = null;
let quotasAt = 0; // when they came in
-async function loadUsage() {
+// asked: the reader opened the page, the one time a Claude account's
+// usage is read, by running Claude Code's own /usage (never on a timer).
+async function loadUsage(asked) {
renderUsageTab();
+ if (asked) loadQuotas(true);
if (usageTab === "sessions") return loadSessions();
if (usageTab === "requests") return loadLedger();
renderUsageLoading();
- loadQuotas();
+ if (!asked) loadQuotas();
usage = await api("usage?period=" + period);
renderUsage();
}
+// An asked load is never swallowed by one already on its way that wasn't:
+// it goes after it.
let quotasLoading = null;
-function loadQuotas() {
- if (quotasLoading) return quotasLoading;
- quotasLoading = api("usage/quotas")
+function loadQuotas(asked) {
+ if (quotasLoading && (!asked || quotasLoading.asked)) return quotasLoading;
+ const p = Promise.resolve(quotasLoading).catch(() => {})
+ .then(() => api("usage/quotas" + (asked ? "?asked=1" : "")))
.then((q) => { quotas = q || []; quotasAt = Date.now(); }, () => { quotas = quotas || []; })
- .finally(() => { quotasLoading = null; renderQuotas(); });
- return quotasLoading;
+ .finally(() => { if (quotasLoading === p) quotasLoading = null; renderQuotas(); });
+ p.asked = !!asked;
+ quotasLoading = p;
+ return p;
}
+$("#quotaRefresh").onclick = async (e) => {
+ const b = e.currentTarget;
+ b.disabled = true;
+ b.classList.add("busy");
+ try { await loadQuotas(true); } finally { b.disabled = false; b.classList.remove("busy"); }
+};
// the period picker, in the page's head, for the Overview and Requests
function renderPeriod(loading) {
@@ -6785,9 +6801,8 @@ function panelQuotaCard(q) {
return card;
}
-// resetsWords: a Codex account's rate-limit resets, or a Claude account's
-// usage-limit resets, "↺ 2 resets · until Sat 22:30", the date only when
-// one of them runs out. A GLM Coding team
+// resetsWords: a Codex account's rate-limit resets, "↺ 2 resets · until
+// Sat 22:30", the date only when one of them runs out. A GLM Coding team
// plan's are counted by window, "↺ 2 five-hour resets · 1 weekly reset",
// and spent on the vendor's page, not here.
function resetsWords(r) {
@@ -6815,24 +6830,21 @@ function resetsWords(r) {
}
// resetUseTitle says which reset a use spends: the one that runs out
-// first, so no one hesitates for fear of losing one that lasts longer; a
-// Claude account's, the one Anthropic names next.
+// first, so no one hesitates for fear of losing one that lasts longer.
function resetUseTitle(q) {
const r = q.resets;
- return (r.until && q.provider === "claude"
- ? t("Uses the reset Anthropic names next, good until {when}.", { when: new Date(r.until).toLocaleString() })
- : r.until
+ return (r.until
? t("Uses the reset that runs out first ({when}), never one that lasts longer.", { when: new Date(r.until).toLocaleString() })
: t("Uses one of its resets; none of them runs out."))
+ "\n" + t("This account's windows start again at once, as if none had been used. You're asked before anything is spent.");
}
-// autoResetButton turns on or off a Codex or Claude account spending a
+// autoResetButton turns on or off a Codex account spending a
// reset by itself: once its week is used up and no other account can
// answer, one a week at most. Off unless the user turns it on; nothing for
// an account with no name to keep it by.
function autoResetButton(q, cls) {
- const kept = { codex: "codexAutoReset", claude: "claudeAutoReset" }[q.provider];
+ const kept = { codex: "codexAutoReset" }[q.provider];
if (!q.user || !kept) return null;
const who = q.user.toLowerCase();
const on = !!(state.settings?.[kept] || []).includes(who);
@@ -6856,21 +6868,20 @@ function autoResetButton(q, cls) {
return b;
}
-// askReset: spending a Codex or Claude account's reset can't be taken
+// askReset: spending a Codex account's reset can't be taken
// back, so it asks first; then it says what came of it and reads the
// usage again.
let resetAsk = null;
function askReset(q) {
- const claude = q.provider === "claude";
const ed = el("div", "editor reset-ask");
const head = el("div", "ehead");
- head.append(icon(q.icon || (claude ? "claude-color" : "codex")), el("b", "", t(claude ? "Use a Claude reset?" : "Use a Codex reset?")));
+ head.append(icon(q.icon || "codex"), el("b", "", t("Use a Codex reset?")));
ed.append(head);
const who = q.user || q.name;
ed.append(el("p", "lib-confirm", t(q.resets.count === 1
? "{who} has 1 reset. Using it starts its windows again at once, as if none of them had been used. It can't be undone."
: "{who} has {n} resets. Using one starts its windows again at once, as if none of them had been used. It can't be undone.", { who, n: q.resets.count })));
- if (q.resets.until) ed.append(el("p", "lib-confirm", t(claude ? "The one used is the one Anthropic names next, good until {when}." : "The one used is the one that runs out first, {when}.", { when: new Date(q.resets.until).toLocaleString() })));
+ if (q.resets.until) ed.append(el("p", "lib-confirm", t("The one used is the one that runs out first, {when}.", { when: new Date(q.resets.until).toLocaleString() })));
// nothing used yet: a reset would start nothing again
if (!q.windows?.some((w) => w.used > 0)) ed.append(el("p", "lib-confirm reset-idle", t("None of its windows has been used yet, so there is nothing to start again.")));
const bar = el("div", "bar");
@@ -6880,7 +6891,7 @@ function askReset(q) {
go.disabled = true;
go.classList.add("busy");
try {
- const out = await api(claude ? "usage/claude-reset" : "usage/codex-reset", { user: q.user || "" });
+ const out = await api("usage/codex-reset", { user: q.user || "" });
closeResetAsk();
status(who + ": " + resetOutcome(out), out.code === "reset" ? "ok" : "err");
loadQuotas();
@@ -9973,7 +9984,7 @@ function show(v) {
closeAgentModels();
if (v !== "providers" && editing !== null) cancelEdit();
if (v === "providers" || v === "gateway" || v === "routing") loadProviders().then(back, (e) => status(e.message, "err"));
- if (v === "usage") loadUsage().then(back, (e) => status(e.message, "err"));
+ if (v === "usage") loadUsage(true).then(back, (e) => status(e.message, "err"));
if (v === "settings") loadSettings().then(back, (e) => status(e.message, "err"));
if (v === "library") window.loadLibrary?.()?.then(back);
if (v === "plugins") window.loadPlugins?.()?.then(back);
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index 0dc1aa37..96bfb999 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -262,6 +262,7 @@ const I18N = {
// providers
"Qoder has no public API for this; magpie signs requests as its desktop client would, which Qoder may treat as third-party use and act on. Use an account you can afford to lose.": "Qoder 没有公开的 API;magpie 会按照其桌面客户端的方式签发请求,Qoder 可能将其视为第三方使用并采取措施。请用一个丢了也不心疼的账号。",
"Zed serves these models to its own editor; magpie signs requests as the editor would, which Zed may treat as third-party use and act on. Use an account you can afford to lose.": "Zed 只向自己的编辑器提供这些模型;magpie 会按照编辑器的方式签发请求,Zed 可能将其视为第三方使用并采取措施。请用一个丢了也不心疼的账号。",
+ "Anthropic may suspend or ban a Claude account it sees used outside its own apps. magpie sends requests through Claude Code, but Anthropic may still act on them; you use it at your own risk. Use an account you can afford to lose.": "Anthropic 官方可能会封禁在其官方应用之外使用的 Claude 账号。magpie 的请求经由 Claude Code 发出,但 Anthropic 仍可能对此采取措施,风险由你自行承担。请用一个丢了也不心疼的账号。",
"Factory serves these models to its own Droid CLI; magpie signs requests as Droid would, which Factory may treat as third-party use and act on. Use an account you can afford to lose.": "Factory 只向自己的 Droid CLI 提供这些模型;magpie 会按照 Droid 的方式签发请求,Factory 可能将其视为第三方使用并采取措施。请用一个丢了也不心疼的账号。",
"A Go plan account is used through Command Code's private interface, which Command Code may treat as a breach of its terms and ban the account for. Pro, Max and the other plans use its Provider API. Use a Go account you can afford to lose.": "Go 套餐的账号要通过 Command Code 的私有接口使用,Command Code 可能将其视为违反服务条款并封禁该账号。Pro、Max 等其他套餐走的是它的 Provider API。请用一个丢了也不心疼的 Go 账号。",
"Xiaomi serves these models to its own MiMo app; magpie signs requests as the app would, which Xiaomi may treat as third-party use and act on. Use an account you can afford to lose.": "小米只向自己的 MiMo 应用提供这些模型;magpie 会按照该应用的方式签发请求,小米可能将其视为第三方使用并采取措施。请用一个丢了也不心疼的账号。",
@@ -608,9 +609,7 @@ const I18N = {
"Use one…": "用一次…",
"Use a reset": "用一次重置",
"Its week was used up, so one of {account}'s Codex resets was used by itself first.": "本周额度已用完,所以先自动使用了 {account} 的一次 Codex 重置。",
- "Its week was used up, so one of {account}'s Claude resets was used by itself first.": "本周额度已用完,所以先自动使用了 {account} 的一次 Claude 重置。",
"{who} answered {status}: its week is used up and nobody else could take the request, so one of {account}'s Codex resets was used by itself and the request is asked again, before any of the reply reaches {agent}.": "{who} 返回 {status}:本周额度已用完,且没有其他账号能接这个请求,于是自动使用了 {account} 的一次 Codex 重置并重新发送请求,此时 {agent} 尚未收到任何回复。",
- "{who} answered {status}: its week is used up and nobody else could take the request, so one of {account}'s Claude resets was used by itself and the request is asked again, before any of the reply reaches {agent}.": "{who} 返回 {status}:本周额度已用完,且没有其他账号能接这个请求,于是自动使用了 {account} 的一次 Claude 重置并重新发送请求,此时 {agent} 尚未收到任何回复。",
"Auto-use": "自动使用",
"On: a reset is used by itself when this account's week is used up and no other account can answer, one a week at most. Click to turn it off.": "已开启:这个账号的每周额度用完、且没有其他账号能接手时,自动用掉一次重置,每周最多一次。点击关闭。",
"Use a reset by itself when this account's week is used up and no other account can answer, one a week at most. The five hours running out never uses one.": "这个账号的每周额度用完、且没有其他账号能接手时,自动用掉一次重置,每周最多一次。只是 5 小时额度用完不会动用重置。",
@@ -621,9 +620,6 @@ const I18N = {
"This account's windows start again at once, as if none had been used. You're asked before anything is spent.": "用掉后这个账号的额度窗口立即重新开始,就像都没用过一样。点击后会先确认,不会直接用掉。",
"The one used is the one that runs out first, {when}.": "用掉的是最早过期的那一次({when} 过期)。",
"Use a Codex reset?": "用一次 Codex 重置?",
- "Use a Claude reset?": "用一次 Claude 重置?",
- "Uses the reset Anthropic names next, good until {when}.": "用掉的是 Anthropic 指定的下一次重置({when} 前有效)。",
- "The one used is the one Anthropic names next, good until {when}.": "用掉的是 Anthropic 指定的下一次重置({when} 前有效)。",
"nothing to reset — no window is used up yet, and the reset is kept": "没有可重置的——还没有额度窗口用完,这次重置保留着",
"a reset was used a short while ago — try again later": "刚刚用过一次重置——请稍后再试",
"the account can't use a reset": "这个账号不能使用重置",
@@ -1356,6 +1352,7 @@ const I18N = {
"In turn: each conversation's next turn goes to the next member's account or key, spreading the load.": "轮流:会话的每一轮交给下一个成员的账号或 Key,均摊负载。",
"Least used first: the account or key with the most of its allowance left goes first.": "最少使用优先:剩余额度最多的账号或 Key 排第一。",
"Mask the email addresses on this page, for a screenshot to share": "给本页的邮箱地址打码,方便截图分享",
+ "Read the allowances again; a Claude account's is read by running Claude Code's own /usage": "重新读取额度;Claude 账号的额度通过运行 Claude Code 自带的 /usage 读取",
"Least used": "最少使用",
"requests": "请求",
"rerouted": "改道",
diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html
index 6ea72ce0..7b4637b2 100644
--- a/internal/gui/assets/index.html
+++ b/internal/gui/assets/index.html
@@ -112,6 +112,7 @@
Allowances
+
diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js
index 945fe8cc..5d443327 100644
--- a/internal/gui/assets/routing.js
+++ b/internal/gui/assets/routing.js
@@ -455,11 +455,10 @@
let name = w ? `${who(w)} (${w.model})` : tr.id;
if (tr.effort) name += " " + t("at {level} reasoning", { level: tr.effort });
if (!tr.done) return t("{who} is answering…", { who: name });
- // a Codex or Claude reset spent by itself: with Codex's own sign-in
- // the one try it was spent for is the one that then answered
- const claude = tr.reset?.agent === "claude";
+ // a Codex reset spent by itself: with Codex's own sign-in the one try
+ // it was spent for is the one that then answered
const spent = tr.reset && tr.status < 400
- ? t(claude ? "Its week was used up, so one of {account}'s Claude resets was used by itself first." : "Its week was used up, so one of {account}'s Codex resets was used by itself first.", { account: tr.reset.who }) + " "
+ ? t("Its week was used up, so one of {account}'s Codex resets was used by itself first.", { account: tr.reset.who }) + " "
: "";
if (tr.status < 400) {
const tk = (r.tokens ? " · " + t("{n} tokens", { n: tokens(r.tokens) }) : "") + firstNote(r, tr);
@@ -468,9 +467,7 @@
: t("{who} answered in {ms}{tk}.", { who: name, ms: took(tr.ms), tk }));
}
if (tr.reset)
- return t(claude
- ? "{who} answered {status}: its week is used up and nobody else could take the request, so one of {account}'s Claude resets was used by itself and the request is asked again, before any of the reply reaches {agent}."
- : "{who} answered {status}: its week is used up and nobody else could take the request, so one of {account}'s Codex resets was used by itself and the request is asked again, before any of the reply reaches {agent}.",
+ return t("{who} answered {status}: its week is used up and nobody else could take the request, so one of {account}'s Codex resets was used by itself and the request is asked again, before any of the reply reaches {agent}.",
{ who: name, status: tr.status, account: tr.reset.who, agent });
if (tr.fail === "canceled")
return t("{agent} canceled the request while {who} was answering: nobody failed, so nobody rests and nobody else is asked.", { who: name, agent });
diff --git a/internal/gui/settings_keep_test.go b/internal/gui/settings_keep_test.go
index 76b5342d..dc002b7a 100644
--- a/internal/gui/settings_keep_test.go
+++ b/internal/gui/settings_keep_test.go
@@ -64,26 +64,25 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) {
}
one, two := 1.0, 2.0
was := settings.Settings{
- AgentOrder: []string{"codex"},
- AgentsHidden: []string{"goose"},
- AgentsShown: []string{"pi"},
- Visible: map[string][]string{"claude": {"anthropic"}},
- HiddenModels: map[string][]string{"claude": {"p/m"}, "codex": {"p/n", "group/g"}},
- ModelNames: map[string]string{"p/m": "Mine"},
- ModelEfforts: map[string][]string{"p/m": {"low"}},
- ModelImages: map[string]bool{"p/m": true},
- ModelOutputs: map[string]int{"p/m": 131072},
- ModelPrices: map[string]settings.ModelPrice{"p/m": {Input: &one, Output: &two}},
- RedactRules: []redact.Rule{{Kind: "prefix", Prefix: "oc_sk_"}},
- LAN: true,
- LANKey: "sk-lan",
- QuotaLeft: true,
- PlainNames: true,
- CodexAutoReset: []string{"me@example.com"},
- ClaudeAutoReset: []string{"me@example.com"},
- TextSize: 125,
- UpdateSkip: "0.1.500",
- Window: []int{900, 700},
+ AgentOrder: []string{"codex"},
+ AgentsHidden: []string{"goose"},
+ AgentsShown: []string{"pi"},
+ Visible: map[string][]string{"claude": {"anthropic"}},
+ HiddenModels: map[string][]string{"claude": {"p/m"}, "codex": {"p/n", "group/g"}},
+ ModelNames: map[string]string{"p/m": "Mine"},
+ ModelEfforts: map[string][]string{"p/m": {"low"}},
+ ModelImages: map[string]bool{"p/m": true},
+ ModelOutputs: map[string]int{"p/m": 131072},
+ ModelPrices: map[string]settings.ModelPrice{"p/m": {Input: &one, Output: &two}},
+ RedactRules: []redact.Rule{{Kind: "prefix", Prefix: "oc_sk_"}},
+ LAN: true,
+ LANKey: "sk-lan",
+ QuotaLeft: true,
+ PlainNames: true,
+ CodexAutoReset: []string{"me@example.com"},
+ TextSize: 125,
+ UpdateSkip: "0.1.500",
+ Window: []int{900, 700},
}
if err := settings.Save(was); err != nil {
t.Fatal(err)
diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md
index 4310fddd..b7d4b5f5 100644
--- a/internal/gui/tests/README.md
+++ b/internal/gui/tests/README.md
@@ -521,15 +521,16 @@ reset used by itself: a try out of its week says whose reset was used and
that the request was asked again, and Codex's own sign-in answering after
one says so before it answered; English and Chinese, Chromium and WebKit.
-`claude-resets.test.cjs` checks a Claude account's usage-limit resets (a Max
-subscriber's Opus 5.5 launch reset): the Usage page's card and the menu bar
-panel's say how many and until when; Auto-use posts
-`settings/claude-auto-reset` for that account; "Use a reset" asks first, as a
-Claude reset (the one Anthropic names next), then posts `usage/claude-reset`
-and says what came of it, Anthropic's `not_limited` in words. The Routing
-page's story names a Claude reset used by itself as Claude's. No click moves
-the page, nothing has a left-border accent; English and Chinese, Chromium and
-WebKit.
+`claude-risk.test.cjs` checks starting a Claude sign-in first says Anthropic
+may ban the account, as Command Code's does, and asks nothing of the backend
+until "Sign in anyway"; the click moves nothing, no left-border accent.
+English and Chinese, Chromium and WebKit.
+
+`claude-usage-asked.test.cjs` checks a Claude account's usage is only asked
+for when the reader opens Usage or presses Refresh: opening the page and
+Refresh load `usage/quotas?asked=1`, the timed reload and the window coming
+back don't; an asked load isn't swallowed by one already on its way.
+Chromium and WebKit.
`panel-effort.test.cjs` opens a row in the tray panel whose effort is not one
of the levels offered (omp at auto, an agent with none set): the slider shows
it as it is ("auto", "default"; 自动 in Chinese) at a stop of its own, a touch
@@ -606,7 +607,7 @@ with `/api/plugins` faked.
With Node.js and Playwright available:
```sh
-node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-resets.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs
+node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-usage-asked.test.cjs internal/gui/tests/claude-risk.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs
```
If Playwright is installed outside the repository, set `NODE_PATH` to the
diff --git a/internal/gui/tests/claude-resets.test.cjs b/internal/gui/tests/claude-resets.test.cjs
deleted file mode 100644
index 5a02c24e..00000000
--- a/internal/gui/tests/claude-resets.test.cjs
+++ /dev/null
@@ -1,206 +0,0 @@
-// Run with Node's test runner and Playwright on the module path; see README.md.
-// A Claude account's usage-limit resets (a Max subscriber on Discord,
-// holding Opus 5.5's launch reset): the Usage page's card and the menu bar
-// panel's say how many and until when, as a Codex account's do; "Auto-use"
-// posts settings/claude-auto-reset for that account; "Use a reset" asks
-// first — a Claude reset, the one Anthropic names next — then posts
-// usage/claude-reset and says what came of it, Anthropic's own answers
-// in words. The Routing page's story names a Claude reset used by itself
-// as Claude's. No click moves the page; no left-border accent. English and
-// Chinese, Chromium and WebKit; no backend, the API is faked here.
-const assert = require("node:assert/strict");
-const fs = require("node:fs/promises");
-const path = require("node:path");
-const { test } = require("node:test");
-const { chromium, webkit } = require("playwright");
-
-const assets = path.resolve(__dirname, "../assets");
-const now = new Date();
-const later = new Date(Date.now() + 2 * 864e5).toISOString();
-const ends = new Date(Date.now() + 22 * 864e5).toISOString();
-const quotas = [
- { provider: "claude", name: "Claude Code", icon: "claude-color", user: "Me@example.com", plan: "Max 20x",
- windows: [{ name: "5 hours", used: 84, resetsAt: later }, { name: "7 days", used: 92, resetsAt: later }],
- resets: { count: 1, until: ends } },
-];
-
-function serve(lang, posts, outcome) {
- let auto = [];
- const settings = () => ({ theme: "light", lang, tray: "panel", quotaLeft: false, currency: "usd", trayUsages: ["claude"], trayUsageEvery: 3, claudeAutoReset: auto });
- return async (route) => {
- const req = route.request(), url = new URL(req.url());
- const json = (data) => route.fulfill({ json: data });
- if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
- if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
- if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: settings() });
- if (url.pathname === "/api/settings") return json(settings());
- if (url.pathname === "/api/settings/claude-auto-reset" || url.pathname === "/api/settings/codex-auto-reset" || url.pathname === "/api/usage/claude-reset" || url.pathname === "/api/usage/codex-reset") {
- const body = req.postDataJSON();
- posts.push([url.pathname, body]);
- if (url.pathname === "/api/usage/claude-reset") return json(outcome.shift());
- if (url.pathname !== "/api/settings/claude-auto-reset") return json({});
- const who = body.user.toLowerCase();
- auto = auto.filter((u) => u !== who);
- if (body.on) auto.push(who);
- return json(settings());
- }
- if (url.pathname === "/api/usage/quotas") return json(quotas);
- if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
- if (url.pathname === "/api/plugins") return json({ plugins: [] });
- if (url.pathname.startsWith("/api/")) return json({});
- const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
- const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
- await route.fulfill({ body: await fs.readFile(file), contentType });
- };
-}
-
-const words = {
- en: { n: "↺ 1 reset", auto: "Auto-use", on: "Me@example.com uses a reset by itself once its week is used up",
- ask: "Use a Claude reset?", next: "The one used is the one Anthropic names next", title: "Uses the reset Anthropic names next",
- done: "Me@example.com: 3 windows started again", kept: "Me@example.com: nothing to reset — no window is used up yet, and the reset is kept" },
- zh: { n: "↺ 1 次重置", auto: "自动使用", on: "Me@example.com 的每周额度用完时会自动使用一次重置",
- ask: "用一次 Claude 重置?", next: "用掉的是 Anthropic 指定的下一次重置", title: "用掉的是 Anthropic 指定的下一次重置",
- done: "Me@example.com: 3 个额度窗口已重新开始", kept: "Me@example.com: 没有可重置的——还没有额度窗口用完,这次重置保留着" },
-};
-
-for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
- for (const lang of ["en", "zh"]) {
- const w = words[lang];
- test(`${engine} ${lang}: a Claude account's resets shown, used, and used by themselves`, async (t) => {
- const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
- const pages = [];
- t.after(async () => {
- if (process.env.ARTIFACT_DIR) {
- await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
- for (const [i, p] of pages.entries()) await p.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-claude-resets-${i}.png`) });
- }
- await browser.close();
- });
- const errors = [];
- const open = async (url, viewport, posts, outcome) => {
- const page = await (await browser.newContext({ viewport, reducedMotion: "reduce" })).newPage();
- pages.push(page);
- page.setDefaultTimeout(5000);
- page.on("pageerror", (e) => errors.push(e.message));
- await page.route("**/*", serve(lang, posts, outcome));
- await page.goto(url);
- return page;
- };
- const wait = async (posts, n) => { for (let i = 0; i < 60 && posts.length < n; i++) await new Promise((r) => setTimeout(r, 50)); };
- const where = (page, sel) => page.locator(sel).evaluate((e) => [e.getBoundingClientRect().top, document.scrollingElement.scrollTop, ...[...document.querySelectorAll(".view")].map((v) => v.scrollTop)]);
-
- // the Usage page's card
- const posts = [];
- const page = await open("http://magpie.test/?view=usage", { width: 900, height: 700 }, posts,
- [{ code: "reset", windows: 3 }, { code: "not_limited" }]);
- const row = ".quota-resets";
- await page.locator(row).waitFor();
- assert.equal(await page.locator(row + " .resets-n").textContent(), w.n);
- assert(await page.locator(row + " .resets-until").count(), "until when it is good");
- const sel = row + " .auto-reset";
- assert.equal((await page.locator(sel).textContent()).trim(), w.auto);
- assert.equal(await page.locator(sel).getAttribute("aria-pressed"), "false", "off until turned on");
- const at = await where(page, sel);
- await page.locator(sel).click();
- await wait(posts, 1);
- assert.deepEqual(posts.at(-1), ["/api/settings/claude-auto-reset", { user: "Me@example.com", on: true }]);
- await page.locator(sel + '[aria-pressed="true"]').waitFor();
- assert.equal(await page.locator("#status").textContent(), w.on);
- await page.waitForTimeout(200);
- assert.deepEqual(await where(page, sel), at, "the click moved the page");
-
- // use one: asked first, as a Claude reset
- const use = row + " button.text:not(.auto-reset)";
- assert((await page.locator(use).getAttribute("title")).startsWith(w.title));
- await page.locator(use).click();
- const ask = page.locator(".reset-ask");
- await ask.waitFor();
- assert.equal(await ask.locator(".ehead b").textContent(), w.ask);
- assert(await ask.locator(".ehead img, .ehead svg, .ehead .icon").count(), "the dialog has Claude's icon");
- assert((await ask.textContent()).includes(w.next));
- assert.equal(posts.length, 1, "nothing spent before it is asked");
- await ask.locator("button.primary").click();
- await wait(posts, 2);
- assert.deepEqual(posts.at(-1), ["/api/usage/claude-reset", { user: "Me@example.com" }]);
- await page.waitForFunction((s) => document.querySelector("#status")?.textContent === s, w.done);
- await ask.waitFor({ state: "detached" });
- // Anthropic's answer when nothing is used up, in words
- await page.locator(use).click();
- await page.locator(".reset-ask button.primary").click();
- await wait(posts, 3);
- await page.waitForFunction((s) => document.querySelector("#status")?.textContent === s, w.kept);
- assert(!posts.some(([p]) => p.includes("codex")), "nothing went to Codex's endpoints");
- const border = await page.evaluate(() => [...document.querySelectorAll(".quota-resets, .quota-resets *")].map((e) => getComputedStyle(e).borderLeftWidth).filter((b) => parseFloat(b) > 1));
- assert.deepEqual(border, [], "no left-border accent");
-
- // the menu bar panel's card
- const panelPosts = [];
- const panel = await open("http://magpie.test/?mode=panel", { width: 440, height: 600 }, panelPosts, []);
- await panel.locator('#ptabs [data-ptab="usage"]').click();
- const psel = ".pq-resets .pq-auto";
- await panel.locator(psel).waitFor();
- assert.equal(await panel.locator(".pq-resets .resets-n").textContent(), w.n);
- assert.equal(await panel.locator(psel).getAttribute("aria-pressed"), "false");
- await panel.locator(psel).click();
- await wait(panelPosts, 1);
- assert.deepEqual(panelPosts.at(-1), ["/api/settings/claude-auto-reset", { user: "Me@example.com", on: true }]);
- const [a, u] = await panel.evaluate(() => [...document.querySelectorAll(".pq-resets button")].map((b) => b.getBoundingClientRect().top));
- assert.equal(Math.round(a), Math.round(u), "the toggle and Use one… sit on one line");
-
- const missing = await page.evaluate(() => [
- "Use a Claude reset?", "Uses the reset Anthropic names next, good until {when}.", "The one used is the one Anthropic names next, good until {when}.",
- "that reset was already used", "nothing to reset — no window is used up yet, and the reset is kept",
- "a reset was used a short while ago — try again later", "the account can't use a reset", "resets can't be used right now — try again later",
- "Its week was used up, so one of {account}'s Claude resets was used by itself first.",
- "{who} answered {status}: its week is used up and nobody else could take the request, so one of {account}'s Claude resets was used by itself and the request is asked again, before any of the reply reaches {agent}.",
- ].filter((k) => !I18N.zh[k]));
- assert.deepEqual(missing, [], "every string has its Chinese");
- assert.deepEqual(errors, []);
- });
-
- test(`${engine} ${lang}: a Claude reset used by itself is Claude's in the story`, async (t) => {
- const day = [now.getFullYear(), now.getMonth() + 1, now.getDate()].map((n) => String(n).padStart(2, "0")).join("-");
- const at = new Date(now.getTime() - 60e3).toISOString();
- const key = { id: "claude-me", provider: "claude", name: "Claude Code", kind: "account", model: "claude-sonnet-5" };
- const reset = { who: "me@example.com", text: "3 windows started again", agent: "claude" };
- const routes = [{ id: 100, seq: 100, time: at, agent: "claude", model: "claude-sonnet-5", provider: "claude", order: [key], done: true, status: 200, ms: 700,
- tries: [{ id: key.id, model: key.model, start: at, done: true, status: 429, ms: 300, fail: "quota", reset },
- { id: key.id, model: key.model, start: at, done: true, status: 200, ms: 400 }] }];
- const state = { agents: [{ id: "claude", name: "Claude Code", path: "/test/settings.json", fields: [] }], profiles: [], settings: { lang, theme: "light" } };
- const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
- t.after(() => browser.close());
- const page = await (await browser.newContext({ viewport: { width: 1100, height: 760 }, reducedMotion: "reduce" })).newPage();
- page.setDefaultTimeout(5000);
- const errors = [];
- page.on("pageerror", (e) => errors.push(e.message));
- await page.route("**/*", async (route) => {
- const url = new URL(route.request().url());
- const json = (data) => route.fulfill({ json: data });
- if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
- if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
- if (url.pathname === "/api/state") return json(state);
- if (url.pathname === "/api/plugins") return json({ plugins: [] });
- if (url.pathname === "/api/gateway/trace") {
- if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3));
- return json({ mine: true, now: now.toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
- }
- if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [{ day, requests: 1 }], routes: url.searchParams.get("day") ? routes : [] });
- if (url.pathname === "/api/groups") return json({ groups: [] });
- if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true, window: true } });
- if (url.pathname.startsWith("/api/")) return json({});
- const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
- const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
- await route.fulfill({ body: await fs.readFile(file), contentType });
- });
- await page.goto("http://magpie.test/?view=routing");
- await page.locator(".rt-day").nth(1).click();
- await page.locator(".rt-req").first().click();
- await page.waitForTimeout(300);
- const got = await page.locator(".rt-steps li").evaluateAll((ls) => ls.map((l) => l.textContent));
- const said = lang === "zh" ? "于是自动使用了 me@example.com 的一次 Claude 重置" : "so one of me@example.com's Claude resets was used by itself";
- assert(got.some((s) => s.includes(said)), JSON.stringify(got));
- assert(!got.some((s) => s.includes("Codex")), JSON.stringify(got));
- assert.deepEqual(errors, []);
- });
- }
-}
diff --git a/internal/gui/tests/claude-risk.test.cjs b/internal/gui/tests/claude-risk.test.cjs
new file mode 100644
index 00000000..3f8d285f
--- /dev/null
+++ b/internal/gui/tests/claude-risk.test.cjs
@@ -0,0 +1,90 @@
+// Run with Node's test runner and Playwright on the module path; see README.md.
+// Claude's subscription (the user: 添加 Claude 订阅账号时要像其他 Subscription
+// Provider 一样显示风险提示): starting its sign-in first says Anthropic may
+// ban the account, as Antigravity's and Command Code's do, and nothing is
+// asked of the backend until "Sign in anyway". The click moves nothing on the
+// page. English and Chinese; no backend, the API is faked here.
+const assert = require("node:assert/strict");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { test } = require("node:test");
+const { chromium, webkit } = require("playwright");
+
+const assets = path.resolve(__dirname, "../assets");
+
+function server(lang, asked) {
+ return async (route) => {
+ const url = new URL(route.request().url());
+ const json = (data) => route.fulfill({ json: data });
+ if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
+ if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
+ if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
+ if (url.pathname === "/api/providers") return json({ providers: [{ id: "openai", name: "OpenAI", icon: "openai", preset: "openai", models: [], agents: [], key: { set: true, masked: "sk-…ab12" } }], presets: [], excluded: [], gateway: { running: true, window: true } });
+ if (url.pathname === "/api/groups") return json({ groups: [], pools: [] });
+ if (url.pathname === "/api/gateway/trace") return json({ routes: [] });
+ if (url.pathname === "/api/signin") {
+ asked.push(route.request().postDataJSON());
+ return json({ id: "s1", agent: "claude", state: "waiting", url: "https://claude.ai/oauth/authorize" });
+ }
+ if (url.pathname.startsWith("/api/signin/")) return json({ id: "s1", agent: "claude", state: "waiting" });
+ if (url.pathname.startsWith("/api/")) return json({});
+ const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
+ const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
+ try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
+ };
+}
+
+const L = {
+ en: { title: "Claude accounts can be suspended", note: /^Anthropic may suspend or ban a Claude account it sees used outside its own apps\..*at your own risk\./, anyway: "Sign in anyway", cancel: "Cancel" },
+ zh: { title: "Claude 账号可能被封禁", note: /^Anthropic 官方可能会封禁在其官方应用之外使用的 Claude 账号。.*风险由你自行承担。/, anyway: "仍然登录", cancel: "取消" },
+};
+
+// where everything that can scroll stands
+const scrolls = (page) => page.evaluate(() => [window.scrollX, window.scrollY, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop || e.scrollLeft).map((e) => e.scrollTop + "," + e.scrollLeft)].join("|"));
+
+for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
+ test(engine + ": Claude's sign-in warns of a ban first", async (t) => {
+ const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
+ t.after(() => browser.close());
+ for (const lang of ["en", "zh"]) {
+ await t.test(lang, async () => {
+ const w = L[lang];
+ const page = await (await browser.newContext({ viewport: { width: 900, height: 800 } })).newPage();
+ const errors = [], asked = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ await page.route("**/*", server(lang, asked));
+ await page.goto("http://magpie.test/?view=providers");
+ await page.locator("#addProvider").click();
+ const sheet = page.locator("#addSheet");
+ const row = sheet.locator('.tile[data-pick="Claude"]');
+ await row.waitFor();
+
+ // Cancel leaves it unasked
+ await row.click();
+ let box = sheet.locator(".signing");
+ await box.waitFor();
+ await box.locator("button", { hasText: w.cancel }).click();
+ await box.waitFor({ state: "detached" });
+ assert.equal(asked.length, 0);
+
+ const before = await scrolls(page);
+ await row.click();
+ box = sheet.locator(".signing");
+ await box.waitFor();
+ assert.equal(await scrolls(page), before, "the click moved nothing");
+ assert.equal(await box.locator(".n").textContent(), w.title);
+ assert.match(await box.locator(".s").textContent(), w.note);
+ assert.doesNotMatch(await box.textContent(), /Antigravity|Google|Command Code/);
+ // a dot marks it, not a coloured stripe down its left side
+ const edge = await box.evaluate((b) => { const s = getComputedStyle(b); return [s.borderLeftWidth, s.borderLeftColor, s.borderRightColor]; });
+ assert.ok(edge[0] === "0px" || edge[1] === edge[2], "no left-border accent: " + edge);
+ assert.equal(asked.length, 0, "nothing asked before it is said");
+
+ await box.locator("button", { hasText: w.anyway }).click();
+ for (let i = 0; i < 50 && !asked.length; i++) await page.waitForTimeout(50);
+ assert.deepEqual(asked, [{ agent: "claude" }]);
+ assert.deepEqual(errors, []);
+ });
+ }
+ });
+}
diff --git a/internal/gui/tests/claude-usage-asked.test.cjs b/internal/gui/tests/claude-usage-asked.test.cjs
new file mode 100644
index 00000000..68fc91ef
--- /dev/null
+++ b/internal/gui/tests/claude-usage-asked.test.cjs
@@ -0,0 +1,85 @@
+// Run with Node's test runner and Playwright on the module path; see README.md.
+// A Claude account's usage is read only when the reader asks (the user:
+// 不要伪造任何的 Claude 请求,能否通过 claude cli 获取): magpie runs Claude
+// Code's own /usage, and only when the Usage page is opened or Refresh is
+// pressed. Opening the page and Refresh load usage/quotas?asked=1; the
+// window coming back to the front and the timed reload load it without; an
+// asked load isn't swallowed by an unasked one already on its way, it goes
+// after it. Chromium and WebKit; no backend, the API is faked here.
+const assert = require("node:assert/strict");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { test } = require("node:test");
+const { chromium, webkit } = require("playwright");
+
+const assets = path.resolve(__dirname, "../assets");
+
+function serve(asks, hold) {
+ return async (route) => {
+ const req = route.request(), url = new URL(req.url());
+ const json = (data) => route.fulfill({ json: data });
+ if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"en",theme:"light",web:false};` });
+ if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
+ if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang: "en", theme: "light" } });
+ if (url.pathname === "/api/usage/quotas") {
+ asks.push(url.search);
+ if (hold.on) await new Promise((r) => (hold.release = r));
+ return json([{ provider: "claude", name: "Claude", user: "a@example.com", windows: [{ name: "5 hours", used: 13 }] }]);
+ }
+ if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
+ if (url.pathname === "/api/plugins") return json({ plugins: [] });
+ if (url.pathname.startsWith("/api/")) return json({});
+ const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
+ const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
+ try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
+ };
+}
+
+for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
+ test(`${engine}: Claude's usage is asked for only on opening Usage or Refresh`, async (t) => {
+ const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
+ t.after(() => browser.close());
+ const page = await (await browser.newContext({ viewport: { width: 900, height: 700 }, reducedMotion: "reduce" })).newPage();
+ page.setDefaultTimeout(5000);
+ const errors = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ const asks = [], hold = { on: false };
+ await page.route("**/*", serve(asks, hold));
+ const asked = () => asks.filter((s) => s === "?asked=1").length;
+ const settle = async (n) => { for (let i = 0; i < 100 && asks.length < n; i++) await page.waitForTimeout(20); await page.waitForTimeout(150); };
+
+ // opening the page asks
+ await page.goto("http://magpie.test/?view=usage");
+ await page.locator("#quotaRefresh").waitFor();
+ await settle(1);
+ assert.equal(asked(), 1, `opening Usage asks once: ${JSON.stringify(asks)}`);
+
+ // the window coming back to the front reads what is kept, asking nothing
+ let n = asks.length;
+ await page.evaluate(() => window.dispatchEvent(new Event("focus")));
+ await settle(n + 1);
+ assert.equal(asked(), 1, `focus must not ask: ${JSON.stringify(asks)}`);
+ // the timed reload is loadQuotas() with nothing
+ n = asks.length;
+ await page.evaluate(() => loadQuotas());
+ await settle(n + 1);
+ assert.equal(asked(), 1, `a timed reload must not ask: ${JSON.stringify(asks)}`);
+
+ // Refresh asks
+ await page.locator("#quotaRefresh").click();
+ await settle(asks.length + 1);
+ assert.equal(asked(), 2, `Refresh asks: ${JSON.stringify(asks)}`);
+
+ // an unasked load on its way doesn't swallow Refresh: it goes after it
+ hold.on = true;
+ n = asks.length;
+ await page.evaluate(() => { loadQuotas(); });
+ for (let i = 0; i < 100 && !hold.release; i++) await page.waitForTimeout(20);
+ await page.locator("#quotaRefresh").click();
+ hold.on = false;
+ hold.release();
+ await settle(n + 2);
+ assert.deepEqual(asks.slice(n), ["", "?asked=1"], "Refresh went after the load on its way");
+ assert.deepEqual(errors, []);
+ });
+}
diff --git a/internal/gui/usage.go b/internal/gui/usage.go
index 7adaf747..889c34c7 100644
--- a/internal/gui/usage.go
+++ b/internal/gui/usage.go
@@ -198,7 +198,12 @@ func usageRoutes(mux *http.ServeMux, w Windows) {
// keys' balances come from the
// vendors, which can be slow or unreachable, so the page asks for them
// apart from the local log.
+ // ?asked=1 is the user opening or refreshing the page, the one time
+ // Claude Code's own /usage is run (provider.AskClaudeUsage).
mux.HandleFunc("GET /api/usage/quotas", func(rw http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("asked") != "" {
+ provider.AskClaudeUsage()
+ }
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
writeJSON(rw, provider.Quotas(ctx))
@@ -220,20 +225,4 @@ func usageRoutes(mux *http.ServeMux, w Windows) {
}
writeJSON(rw, out)
})
- // and one of a Claude account's usage-limit resets, the same way
- mux.HandleFunc("POST /api/usage/claude-reset", func(rw http.ResponseWriter, r *http.Request) {
- var in struct{ User string }
- if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
- fail(rw, err)
- return
- }
- ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
- defer cancel()
- out, err := provider.UseClaudeReset(ctx, in.User)
- if err != nil {
- fail(rw, err)
- return
- }
- writeJSON(rw, out)
- })
}
diff --git a/internal/provider/claude_autoreset.go b/internal/provider/claude_autoreset.go
deleted file mode 100644
index 4c0f7b63..00000000
--- a/internal/provider/claude_autoreset.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package provider
-
-// Spending a Claude usage-limit reset by itself, for the accounts the user
-// said may, by the rules a Codex account's are (codex_autoreset.go): only
-// the week's own window being used up counts — not the five hours, nor a
-// model's week — one a week at most, kept in claude-autoreset.json.
-
-import (
- "context"
- "slices"
- "strings"
- "time"
-
- "github.com/yetone/magpie/internal/settings"
-)
-
-var claudeAutoReset = autoResets{file: "claude-autoreset.json"}
-
-// ClaudeAutoReset says whether the Claude account user spends a reset by
-// itself once its week is used up.
-func ClaudeAutoReset(user string) bool {
- return user != "" && slices.Contains(settings.Load().ClaudeAutoReset, strings.ToLower(user))
-}
-
-// ClaudeSignedIn is the Claude account Claude Code is signed in to now.
-func ClaudeSignedIn() (string, bool) {
- l, ok := liveLogin("claude")
- return l.User, ok
-}
-
-// AutoResets says whether agent's (Codex's or Claude's) account user
-// spends its resets by itself.
-func AutoResets(agent, user string) bool {
- switch agent {
- case "codex":
- return CodexAutoReset(user)
- case "claude":
- return ClaudeAutoReset(user)
- }
- return false
-}
-
-// SetClaudeAutoReset turns that on or off for user.
-func SetClaudeAutoReset(user string, on bool) error {
- user = strings.ToLower(strings.TrimSpace(user))
- s := settings.Load()
- s.ClaudeAutoReset = slices.DeleteFunc(s.ClaudeAutoReset, func(u string) bool { return u == user })
- if on && user != "" {
- s.ClaudeAutoReset = append(s.ClaudeAutoReset, user)
- }
- return settings.Save(s)
-}
-
-// AutoUseClaudeReset spends one of the Claude account user's resets (the
-// one Claude Code is signed in to when "") if the user turned that on for
-// it, its week is used up, the reset Anthropic names next refills the
-// week, and none was spent by itself in this week yet. Code is "" when
-// none was tried; the caller asks again only on "reset".
-func AutoUseClaudeReset(ctx context.Context, user string) (ResetOutcome, error) {
- if user == "" {
- live, ok := liveLogin("claude")
- if !ok {
- return ResetOutcome{}, nil
- }
- user = live.User
- }
- if !ClaudeAutoReset(user) {
- return ResetOutcome{}, nil
- }
- var who string
- return claudeAutoReset.use(user, func(now time.Time) (*time.Time, bool, error) {
- var tok string
- var err error
- who, tok, err = claudeUserToken(ViaLogin(ctx, "claude", user), user)
- if err != nil {
- return nil, false, err
- }
- // read afresh: what was read a while ago may not be used up yet;
- // what was, when Anthropic turns the usage endpoint away
- ws, grants, err := readClaudeUsage(ViaLogin(ctx, "claude", who), tok)
- if err != nil {
- claudeUsage.Lock()
- e, ok := claudeUsage.m[strings.ToLower(who)]
- claudeUsage.Unlock()
- if !ok || e.ws == nil {
- return nil, false, err
- }
- ws, grants = elapsed(e.ws, now), e.grants
- }
- g, ok := grants.next(now)
- return claudeWeekUsedUp(ws, now), ok && slices.Contains(g.Clears, "seven_day"), nil
- }, func() (ResetOutcome, error) { return UseClaudeReset(ctx, who) })
-}
-
-// claudeWeekUsedUp is when the week's own window of ws ends if it is used
-// up: a model's week (Opus, Fable) being used up leaves the others.
-func claudeWeekUsedUp(ws []QuotaWindow, now time.Time) *time.Time {
- return weekUsedUp(slices.DeleteFunc(slices.Clone(ws), func(w QuotaWindow) bool { return w.Model != "" }), now)
-}
diff --git a/internal/provider/claude_limits_test.go b/internal/provider/claude_limits_test.go
index 6b0000d7..1448c34a 100644
--- a/internal/provider/claude_limits_test.go
+++ b/internal/provider/claude_limits_test.go
@@ -2,16 +2,19 @@ package provider
import (
"context"
+ "sync/atomic"
"testing"
"time"
)
// What Claude Code says as it answers is the account's allowance, without
-// the usage endpoint, which keeps the windows it alone tells.
+// its /usage, which keeps the windows it alone tells.
func TestNoteClaudeLimits(t *testing.T) {
- old := claudeBase
- claudeBase = "http://127.0.0.1:1" // nothing answers there
- defer func() { claudeBase = old }()
+ var out atomic.Value
+ out.Store("")
+ var fail atomic.Bool
+ fail.Store(true) // /usage fails
+ fakeClaudeUsage(t, &out, &fail)
reset := time.Now().Add(2 * time.Hour).Truncate(time.Second)
claudeUsage.Lock()
claudeUsage.m = map[string]claudeUsageEntry{"kev@example.com": {at: time.Now().Add(-time.Hour), ws: []QuotaWindow{
@@ -21,7 +24,7 @@ func TestNoteClaudeLimits(t *testing.T) {
defer func() { claudeUsage.Lock(); claudeUsage.m = nil; claudeUsage.Unlock() }()
NoteClaudeLimits("Kev@example.com", []ClaudeLimit{{Kind: "five_hour", Used: 0.42, ResetsAt: reset.Unix()}, {Kind: "seven_day", Used: 0.1}, {Kind: "overage", Used: 1}})
- ws, err := claudeWindows(context.Background(), "kev@example.com", "tok")
+ ws, err := claudeWindows(context.Background(), "kev@example.com", true)
if err != nil {
t.Fatal(err)
}
@@ -33,21 +36,23 @@ func TestNoteClaudeLimits(t *testing.T) {
t.Fatalf("windows: %v %+v", names, ws)
}
- // past the cache, the usage endpoint failing leaves what was heard
+ // asked, /usage failing leaves what was heard
+ AskClaudeUsage()
claudeUsage.Lock()
e := claudeUsage.m["kev@example.com"]
e.at = time.Now().Add(-10 * time.Minute)
claudeUsage.m["kev@example.com"] = e
claudeUsage.Unlock()
- if ws, err := claudeWindows(context.Background(), "kev@example.com", "tok"); err != nil || len(ws) != 3 {
+ if ws, err := claudeWindows(context.Background(), "kev@example.com", true); err != nil || len(ws) != 3 {
t.Fatalf("after the endpoint failed: %v %v", ws, err)
}
// heard long ago, the failure is told
+ AskClaudeUsage()
claudeUsage.Lock()
e.heard = time.Now().Add(-2 * time.Hour)
claudeUsage.m["kev@example.com"] = e
claudeUsage.Unlock()
- if _, err := claudeWindows(context.Background(), "kev@example.com", "tok"); err == nil {
+ if _, err := claudeWindows(context.Background(), "kev@example.com", true); err == nil {
t.Fatal("an old word hid the endpoint's failure")
}
}
diff --git a/internal/provider/claude_resets.go b/internal/provider/claude_resets.go
deleted file mode 100644
index 74b7701c..00000000
--- a/internal/provider/claude_resets.go
+++ /dev/null
@@ -1,260 +0,0 @@
-package provider
-
-// Claude usage-limit resets: grants a Claude subscription is given (for a
-// model's launch, say) that each refill its usage windows at once. The
-// usage reading tells of them when asked for them, as Claude Code asks
-// (cedar_ember=1); Claude Code's /limit-reset spends one with
-// POST /api/organizations/{org}/reset_rate_limits, the grant named, and
-// so does magpie. Shown on the account's card like a Codex account's
-// resets, and spent only when the user says so, or by itself once the
-// week is used up if the user let it (claude_autoreset.go).
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "regexp"
- "slices"
- "strings"
- "time"
-)
-
-// claudeUsagePath is the usage endpoint as Claude Code asks it when it
-// wants the resets too; skip_spend leaves out what was spent past the plan.
-const claudeUsagePath = "/api/oauth/usage?cedar_ember=1&skip_spend=1"
-
-// claudeResetProgram names the resets to Anthropic, as Claude Code does.
-const claudeResetProgram = "cedar_ember"
-
-// claudeGrantID is what Claude Code accepts as a grant's id before it
-// sends it on.
-var claudeGrantID = regexp.MustCompile(`^[a-z0-9_-]{1,40}$`)
-
-// claudeGrants is the usage reading's cedar_ember: whether the account
-// may use resets at all, the grants it holds, and the one to use next.
-type claudeGrants struct {
- Eligible bool `json:"eligible"`
- Grants []claudeGrant `json:"grants"`
- Next *string `json:"next_grant_id"`
-}
-
-type claudeGrant struct {
- ID string `json:"id"`
- Label string `json:"label"`
- ResetsLeft int `json:"resets_left"`
- EndsAt *string `json:"ends_at"`
- Clears []string `json:"clears"`
- Paused bool `json:"paused"`
- UsableNow bool `json:"usable_now"`
-}
-
-func claudeTime(s *string) *time.Time {
- if s == nil {
- return nil
- }
- t, err := time.Parse(time.RFC3339, *s)
- if err != nil {
- return nil
- }
- return &t
-}
-
-// held says whether g still has resets to use, now or before it ends.
-func (g claudeGrant) held(now time.Time) bool {
- if g.ResetsLeft <= 0 || g.Paused || g.ID == "" {
- return false
- }
- if end := claudeTime(g.EndsAt); end != nil && !end.After(now) {
- return false
- }
- return true
-}
-
-// credits is the resets the account holds as a card shows them: their
-// count, and when the one used next runs out. nil when it holds none, or
-// may not use them.
-func (c *claudeGrants) credits(now time.Time) *ResetCredits {
- if c == nil || !c.Eligible {
- return nil
- }
- out := &ResetCredits{}
- for _, g := range c.Grants {
- if !g.held(now) {
- continue
- }
- out.Count += g.ResetsLeft
- end := claudeTime(g.EndsAt)
- if end == nil {
- continue
- }
- if c.Next != nil && g.ID == *c.Next || out.Until == nil {
- out.Until = end
- }
- }
- if out.Count == 0 {
- return nil
- }
- return out
-}
-
-// next is the grant a reset is spent from, the one Anthropic names, as
-// Claude Code does; false when there is none to use now.
-func (c *claudeGrants) next(now time.Time) (claudeGrant, bool) {
- if c == nil || !c.Eligible || c.Next == nil {
- return claudeGrant{}, false
- }
- for _, g := range c.Grants {
- if g.ID == *c.Next && g.held(now) && g.UsableNow && claudeGrantID.MatchString(g.ID) {
- return g, true
- }
- }
- return claudeGrant{}, false
-}
-
-// claudeResetsOf is the resets the Claude account user holds, as its
-// usage was last read.
-func claudeResetsOf(user string) *ResetCredits {
- claudeUsage.Lock()
- e := claudeUsage.m[strings.ToLower(user)]
- claudeUsage.Unlock()
- return e.grants.credits(time.Now())
-}
-
-// UseClaudeReset spends one of the usage-limit resets of the Claude
-// account user (the one Claude Code is signed in to when ""): the grant
-// Anthropic names next, which refills the windows it clears at once. It
-// can't be undone: callers ask first. The account's usage is read afresh
-// after.
-func UseClaudeReset(ctx context.Context, user string) (ResetOutcome, error) {
- who, tok, err := claudeUserToken(ViaLogin(ctx, "claude", user), user)
- if err != nil {
- return ResetOutcome{}, err
- }
- ctx = ViaLogin(ctx, "claude", who)
- key := strings.ToLower(who)
- // which grant is next, read afresh; what was read last when Anthropic
- // turns the usage endpoint away, as it often does
- _, grants, err := readClaudeUsage(ctx, tok)
- if err != nil {
- claudeUsage.Lock()
- grants = claudeUsage.m[key].grants
- claudeUsage.Unlock()
- if grants == nil {
- return ResetOutcome{}, fmt.Errorf("couldn't read which reset is next, so none was used: %w", err)
- }
- }
- g, ok := grants.next(time.Now())
- if !ok {
- return ResetOutcome{Code: "no_credit"}, nil
- }
- p, err := claudeProfile(ctx, tok)
- if err != nil || p.Organization.UUID == "" {
- return ResetOutcome{}, fmt.Errorf("couldn't read the account's organization, so no reset was used: %v", err)
- }
- out, err := claimClaudeReset(ctx, tok, p.Organization.UUID, g.ID, newRedeemID())
- if err != nil {
- return out, err
- }
- if out.Code == "reset" && out.Windows == 0 {
- out.Windows = len(g.Clears)
- }
- claudeUsage.Lock()
- if e, ok := claudeUsage.m[key]; ok {
- // read again next time; what Claude Code said as it answered is
- // of before the reset
- e.at, e.heard, e.retry = time.Time{}, time.Time{}, time.Time{}
- if out.Code == "reset" {
- ws := slices.Clone(e.ws)
- for i, w := range ws {
- for _, k := range claudeKinds {
- if k.name == w.Name && slices.Contains(g.Clears, k.kind) {
- ws[i].Used, ws[i].ResetsAt = 0, nil
- }
- }
- }
- e.ws = ws
- }
- claudeUsage.m[key] = e
- }
- claudeUsage.Unlock()
- StaleAllowance("claude", who)
- if out.Code == "reset" {
- renewedNow("claude", who)
- }
- subscriptionUsageCache.Lock()
- subscriptionUsageCache.at = time.Time{}
- subscriptionUsageCache.data = nil
- subscriptionUsageCache.Unlock()
- return out, nil
-}
-
-// claudeUserToken signs in as the Claude account user: the one Claude
-// Code itself is signed in to, or one magpie keeps — and says which.
-func claudeUserToken(ctx context.Context, user string) (who, tok string, err error) {
- if live, ok := liveLogin("claude"); user == "" || ok && strings.EqualFold(live.User, user) {
- tok, err = claudeToken(ctx)
- return live.User, tok, err
- }
- for _, l := range Logins("claude") {
- if strings.EqualFold(l.User, user) {
- tok, _, err = savedLoginToken(ctx, "claude", l.User)
- return l.User, tok, err
- }
- }
- return "", "", fmt.Errorf("no Claude account %q", user)
-}
-
-// PointClaudeAt sends what magpie asks Anthropic's API itself (usage,
-// resets, the profile) to base instead, the usage read so far forgotten,
-// and says how to put it back: for tests outside this package.
-func PointClaudeAt(base string) (restore func()) {
- was := claudeBase
- claudeBase = base
- forget := func() {
- claudeUsage.Lock()
- claudeUsage.m = nil
- claudeUsage.Unlock()
- }
- forget()
- return func() { claudeBase = was; forget() }
-}
-
-// claimClaudeReset spends a reset from grant; id makes a retry of the same
-// request spend it once. The request is Claude Code's own.
-func claimClaudeReset(ctx context.Context, token, org, grant, id string) (ResetOutcome, error) {
- var out ResetOutcome
- body, _ := json.Marshal(map[string]string{"program": claudeResetProgram, "grant_id": grant, "request_id": id})
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, claudeBase+"/api/organizations/"+url.PathEscape(org)+"/reset_rate_limits", bytes.NewReader(body))
- if err != nil {
- return out, err
- }
- req.Header.Set("Authorization", "Bearer "+token)
- req.Header.Set("anthropic-beta", "oauth-2025-04-20")
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Content-Type", "application/json")
- res, err := http.DefaultClient.Do(req)
- if err != nil {
- return out, err
- }
- defer res.Body.Close()
- b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
- if res.StatusCode == http.StatusTooManyRequests {
- return out, errors.New("Claude reset: Anthropic is rate limiting it; try again in a while")
- }
- if res.StatusCode < 200 || res.StatusCode >= 300 {
- return out, fmt.Errorf("Claude reset: %s", http.StatusText(res.StatusCode))
- }
- var wire struct {
- Result string `json:"result"`
- Cleared []string `json:"cleared"`
- }
- if err := json.Unmarshal(b, &wire); err != nil || wire.Result == "" {
- return out, errors.New("Claude reset: an answer that says nothing")
- }
- return ResetOutcome{Code: wire.Result, Windows: len(wire.Cleared)}, nil
-}
diff --git a/internal/provider/claude_resets_test.go b/internal/provider/claude_resets_test.go
deleted file mode 100644
index 5ca0dbbf..00000000
--- a/internal/provider/claude_resets_test.go
+++ /dev/null
@@ -1,285 +0,0 @@
-package provider
-
-import (
- "context"
- "encoding/json"
- "io"
- "net/http"
- "net/http/httptest"
- "path/filepath"
- "regexp"
- "strings"
- "sync"
- "testing"
- "time"
-)
-
-// claudeCedarEmber is claude.ai's usage answer as a Max subscriber shared
-// it (Discord), asked with cedar_ember=1: one usage-limit reset, for Opus
-// 5.5's launch.
-const claudeCedarEmber = `{
- "five_hour": {"utilization": 84, "resets_at": "2026-09-30T12:00:00+00:00"},
- "seven_day": {"utilization": 92, "resets_at": "2026-10-02T07:00:00+00:00"},
- "cedar_ember": {
- "eligible": true, "ineligible_reason": null, "at_limit": false, "exhausted": [],
- "grants": [{
- "id": "opus55-launch-promax-20260921",
- "label": "Claude Opus 5.5 launch: one usage-limit reset for Pro and Max",
- "resets_total": 1, "resets_left": 1,
- "starts_at": "2026-09-22T16:00:00+00:00", "ends_at": "2026-10-22T16:00:00+00:00",
- "clears": ["five_hour","seven_day","seven_day_overage_included"],
- "paused": false, "usable_now": true, "use_requires_limit": false,
- "percent_used": {"five_hour":84,"seven_day":92,"seven_day_overage_included":0},
- "blocking": [], "arm": null }],
- "next_grant_id": "opus55-launch-promax-20260921",
- "weekly_resets_at": "2026-10-02T07:00:00+00:00",
- "cooldown_until": null,
- "event_props": {"surface":"claude_ai","tier":"claude_max_20x"}
- },
- "limits": [{"kind":"session","group":"session","percent":84,"severity":"warning","resets_at":"2026-09-30T12:00:00+00:00"}]
-}`
-
-// The grant as Anthropic tells it: one reset, until 22 October, the one
-// named next; none shown when it is paused, over, or the account may not
-// use resets.
-func TestClaudeGrants(t *testing.T) {
- now := time.Date(2026, 9, 30, 8, 0, 0, 0, time.UTC)
- read := func(edit func(string) string) *claudeGrants {
- t.Helper()
- var data struct {
- Grants *claudeGrants `json:"cedar_ember"`
- }
- if err := json.Unmarshal([]byte(edit(claudeCedarEmber)), &data); err != nil {
- t.Fatal(err)
- }
- return data.Grants
- }
- same := func(s string) string { return s }
- g := read(same)
- r := g.credits(now)
- if r == nil || r.Count != 1 || r.Until == nil || !r.Until.Equal(time.Date(2026, 10, 22, 16, 0, 0, 0, time.UTC)) || r.ByWindow {
- t.Fatalf("credits %+v", r)
- }
- if next, ok := g.next(now); !ok || next.ID != "opus55-launch-promax-20260921" || len(next.Clears) != 3 {
- t.Fatalf("next %+v %v", next, ok)
- }
- for name, edit := range map[string]func(string) string{
- "paused": func(s string) string { return strings.Replace(s, `"paused": false`, `"paused": true`, 1) },
- "ineligible": func(s string) string { return strings.Replace(s, `"eligible": true`, `"eligible": false`, 1) },
- "used": func(s string) string { return strings.Replace(s, `"resets_left": 1`, `"resets_left": 0`, 1) },
- "over": func(s string) string {
- return strings.Replace(s, `"2026-10-22T16:00:00+00:00"`, `"2026-09-29T16:00:00+00:00"`, 1)
- },
- } {
- g := read(edit)
- if r := g.credits(now); r != nil {
- t.Errorf("%s: credits %+v", name, r)
- }
- if _, ok := g.next(now); ok {
- t.Errorf("%s: a grant to use", name)
- }
- }
- // held but not usable yet (one that waits for the limit): shown, not spent
- g = read(func(s string) string { return strings.Replace(s, `"usable_now": true`, `"usable_now": false`, 1) })
- if r := g.credits(now); r == nil || r.Count != 1 {
- t.Fatalf("waiting: %+v", r)
- }
- if _, ok := g.next(now); ok {
- t.Fatal("waiting: spent")
- }
- if g := read(func(s string) string {
- return strings.Replace(s, `"next_grant_id": "opus55-launch-promax-20260921"`, `"next_grant_id": null`, 1)
- }); g.credits(now) == nil {
- t.Fatal("no next: count lost")
- } else if _, ok := g.next(now); ok {
- t.Fatal("no next: spent")
- }
-}
-
-// claudeResetFake stands in for Anthropic's usage endpoint, which tells of
-// the resets only when asked for them as Claude Code asks, the profile,
-// and spending one, which refills the windows.
-type claudeResetFake struct {
- mu sync.Mutex
- week float64 // seven_day used
- left int // resets left
- asked []string
- claims []map[string]string
- heads []http.Header
- paths []string
-}
-
-func newClaudeResetFake(t *testing.T, week float64) *claudeResetFake {
- t.Helper()
- f := &claudeResetFake{week: week, left: 1}
- // the grant runs out after the test, whenever it runs
- ends := time.Now().Add(20 * 24 * time.Hour).UTC().Format(time.RFC3339)
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- f.mu.Lock()
- defer f.mu.Unlock()
- switch {
- case r.URL.Path == "/api/oauth/usage":
- f.asked = append(f.asked, r.URL.RawQuery)
- var body map[string]any
- json.Unmarshal([]byte(strings.Replace(claudeCedarEmber, "2026-10-22T16:00:00+00:00", ends, 1)), &body)
- body["five_hour"] = map[string]any{"utilization": 84, "resets_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339)}
- body["seven_day"] = map[string]any{"utilization": f.week, "resets_at": time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)}
- ce := body["cedar_ember"].(map[string]any)
- ce["grants"].([]any)[0].(map[string]any)["resets_left"] = f.left
- if r.URL.Query().Get("cedar_ember") != "1" {
- delete(body, "cedar_ember")
- }
- json.NewEncoder(w).Encode(body)
- case r.URL.Path == "/api/oauth/profile":
- io.WriteString(w, `{"account":{"uuid":"u-a","email":"a@example.com"},"organization":{"uuid":"0f1e2d3c-org","organization_type":"claude_max"}}`)
- case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/reset_rate_limits"):
- var in map[string]string
- json.NewDecoder(r.Body).Decode(&in)
- f.claims, f.heads, f.paths = append(f.claims, in), append(f.heads, r.Header.Clone()), append(f.paths, r.URL.Path)
- if f.left == 0 {
- io.WriteString(w, `{"result":"already_used","reason":"already_used","resets_left":0}`)
- return
- }
- f.left--
- f.week = 0
- io.WriteString(w, `{"result":"reset","reason":null,"resets_left":0,"cleared":["five_hour","seven_day","seven_day_overage_included"],"weekly_resets_at":"2026-10-02T07:00:00+00:00"}`)
- default:
- w.WriteHeader(404)
- }
- }))
- t.Cleanup(srv.Close)
- claudeBase = srv.URL // isolate puts it back
- claudeUsage.Lock()
- claudeUsage.m = nil
- claudeUsage.Unlock()
- t.Cleanup(func() { claudeUsage.Lock(); claudeUsage.m = nil; claudeUsage.Unlock() })
- return f
-}
-
-func (f *claudeResetFake) claimed() int {
- f.mu.Lock()
- defer f.mu.Unlock()
- return len(f.claims)
-}
-
-// claudeSignedInAs signs Claude Code in to a@example.com in a sandbox HOME.
-func claudeSignedInAs(t *testing.T) {
- t.Helper()
- home := claudeHome(t)
- claudeSignIn(t, home, time.Now().Add(time.Hour)) // sk-ant-oat01-old
- writeFile(t, filepath.Join(home, ".claude.json"), map[string]any{"oauthAccount": map[string]any{
- "emailAddress": "a@example.com", "accountUuid": "u-a", "organizationUuid": "0f1e2d3c-org"}})
-}
-
-// The Usage page's card: the Claude account's resets are read with its
-// usage, asked for as Claude Code asks, and shown with when the one used
-// next runs out.
-func TestClaudeResetsOnCard(t *testing.T) {
- claudeSignedInAs(t)
- f := newClaudeResetFake(t, 92)
- q := claudeSubscriptionUsage(context.Background())
- if q.Error != "" || len(q.Windows) < 2 || q.Windows[1].Used != 92 {
- t.Fatalf("usage %+v", q)
- }
- if q.Resets == nil || q.Resets.Count != 1 || q.Resets.Until == nil || q.Resets.Until.Sub(time.Now()) < 19*24*time.Hour {
- t.Fatalf("resets %+v", q.Resets)
- }
- if len(f.asked) != 1 || f.asked[0] != "cedar_ember=1&skip_spend=1" {
- t.Fatalf("asked %q", f.asked)
- }
- b, _ := json.Marshal(q)
- if !strings.Contains(string(b), `"resets":{"count":1,"until":`) {
- t.Fatalf("json %s", b)
- }
-}
-
-// Use a reset: the grant Anthropic names next is claimed as Claude Code's
-// /limit-reset claims it, for the account's organization, and the windows
-// it cleared read as unused at once; with none left, none is claimed.
-func TestUseClaudeReset(t *testing.T) {
- claudeSignedInAs(t)
- f := newClaudeResetFake(t, 100)
- out, err := UseClaudeReset(context.Background(), "")
- if err != nil || out.Code != "reset" || out.Windows != 3 || out.Text() != "3 windows started again" {
- t.Fatalf("%+v %v", out, err)
- }
- if f.claimed() != 1 {
- t.Fatalf("claims %d", f.claimed())
- }
- c, h := f.claims[0], f.heads[0]
- if c["program"] != "cedar_ember" || c["grant_id"] != "opus55-launch-promax-20260921" || !regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`).MatchString(c["request_id"]) || len(c) != 3 {
- t.Fatalf("claim %v", c)
- }
- if f.paths[0] != "/api/organizations/0f1e2d3c-org/reset_rate_limits" || h.Get("Authorization") != "Bearer sk-ant-oat01-old" || h.Get("anthropic-beta") != "oauth-2025-04-20" {
- t.Fatalf("sent to %s with %v", f.paths[0], h)
- }
- q := claudeSubscriptionUsage(context.Background())
- if q.Resets != nil || q.Windows[1].Used != 0 {
- t.Fatalf("after: %+v", q)
- }
- if out, err := UseClaudeReset(context.Background(), "a@example.com"); err != nil || out.Code != "no_credit" || f.claimed() != 1 {
- t.Fatalf("again: %+v %v, %d claims", out, err, f.claimed())
- }
-}
-
-// A reset is spent by itself only for an account the user let, only when
-// its week is used up, and once in that week.
-func TestAutoUseClaudeReset(t *testing.T) {
- claudeSignedInAs(t)
- f := newClaudeResetFake(t, 100)
- if out, err := AutoUseClaudeReset(context.Background(), ""); err != nil || out.Code != "" || f.claimed() != 0 {
- t.Fatalf("not turned on: %+v %v", out, err)
- }
- if err := SetClaudeAutoReset("A@example.com", true); err != nil {
- t.Fatal(err)
- }
- if !AutoResets("claude", "a@example.com") || AutoResets("codex", "a@example.com") {
- t.Fatal("setting read back wrong")
- }
- f.mu.Lock()
- f.week = 92
- f.mu.Unlock()
- claudeAutoReset.Lock()
- claudeAutoReset.path = "" // read afresh, no wait left over
- claudeAutoReset.Unlock()
- if out, err := AutoUseClaudeReset(context.Background(), ""); err != nil || out.Code != "" || f.claimed() != 0 {
- t.Fatalf("week not used up: %+v %v", out, err)
- }
- f.mu.Lock()
- f.week = 100
- f.mu.Unlock()
- claudeAutoReset.Lock()
- claudeAutoReset.path = ""
- claudeAutoReset.Unlock()
- out, err := AutoUseClaudeReset(context.Background(), "")
- if err != nil || out.Code != "reset" || f.claimed() != 1 {
- t.Fatalf("week used up: %+v %v, %d claims", out, err, f.claimed())
- }
- // used up again in the same week, with another reset: kept
- f.mu.Lock()
- f.week, f.left = 100, 1
- f.mu.Unlock()
- claudeAutoReset.Lock()
- claudeAutoReset.path = "" // as a new magpie would
- claudeAutoReset.Unlock()
- if out, err := AutoUseClaudeReset(context.Background(), ""); err != nil || out.Code != "" || f.claimed() != 1 {
- t.Fatalf("second in the week: %+v %v, %d claims", out, err, f.claimed())
- }
-}
-
-// A model's own week (Fable's, Opus's) used up doesn't spend a reset:
-// only the week every model counts toward.
-func TestClaudeWeekUsedUp(t *testing.T) {
- now := time.Now()
- later := now.Add(48 * time.Hour)
- week := 7 * 24 * time.Hour
- fable := QuotaWindow{Name: "7 days · Fable", Span: week, Used: 100, ResetsAt: &later, Model: "fable"}
- all := QuotaWindow{Name: "7 days", Span: week, Used: 60, ResetsAt: &later}
- if claudeWeekUsedUp([]QuotaWindow{all, fable}, now) != nil {
- t.Fatal("a model's week counted")
- }
- all.Used = 100
- if claudeWeekUsedUp([]QuotaWindow{all, fable}, now) == nil {
- t.Fatal("the week not counted")
- }
-}
diff --git a/internal/provider/claude_scoped_test.go b/internal/provider/claude_scoped_test.go
index b46d7f25..6778e7d9 100644
--- a/internal/provider/claude_scoped_test.go
+++ b/internal/provider/claude_scoped_test.go
@@ -2,39 +2,18 @@ package provider
import (
"context"
- "net/http"
- "net/http/httptest"
+ "sync/atomic"
"testing"
"time"
)
-// The usage endpoint's limits[] carries a week's allowance per model (#139).
+// Claude Code's /usage tells a week's allowance per model (#139).
func TestClaudeWindowsModelScoped(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Write([]byte(`{
- "five_hour": { "utilization": 2, "resets_at": "2026-07-20T10:00:00+00:00" },
- "seven_day": { "utilization": 88, "resets_at": "2026-07-24T20:00:00+00:00" },
- "seven_day_opus": { "utilization": 12, "resets_at": "2026-07-24T20:00:00+00:00" },
- "limits": [
- { "kind": "weekly_scoped", "percent": 55, "scope": null },
- {
- "kind": "weekly_scoped",
- "percent": 64,
- "resets_at": "2026-07-24T20:00:00+00:00",
- "is_active": false,
- "scope": { "model": { "display_name": "Fable" } }
- },
- { "kind": "weekly_scoped", "percent": 12, "resets_at": "2026-07-24T20:00:00+00:00", "scope": { "model": { "display_name": "Opus" } } },
- { "kind": "session", "percent": 2, "scope": { "model": { "display_name": "Haiku" } } }
- ]
-}`))
- }))
- defer srv.Close()
- old := claudeBase
- claudeBase = srv.URL
- defer func() { claudeBase = old }()
-
- ws, err := readClaudeWindows(context.Background(), "t")
+ now := time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)
+ ws, err := parseClaudeUsage(`Current session: 2% used · resets Jul 20 at 10am (UTC)
+Current week (all models): 88% used · resets Jul 24 at 8pm (UTC)
+Current week (Opus): 12% used · resets Jul 24 at 8pm (UTC)
+Current week (Fable): 64% used · resets Jul 24 at 8pm (UTC)`, now)
if err != nil {
t.Fatal(err)
}
@@ -52,7 +31,6 @@ func TestClaudeWindowsModelScoped(t *testing.T) {
}
// Fable used up stops Fable, not the other models
- now := time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)
ws[3].Used = 100
a := allowanceOf(ws, now)
if used, _ := a.For("claude-fable-5-1", now); used != 100 {
@@ -77,9 +55,9 @@ func TestClaudeScopeModel(t *testing.T) {
// Claude Code's seven_day_overage_included is the Fable week, in place of
// the one the usage endpoint told.
func TestNoteClaudeLimitsFable(t *testing.T) {
- old := claudeBase
- claudeBase = "http://127.0.0.1:1" // nothing answers there
- defer func() { claudeBase = old }()
+ var out atomic.Value
+ out.Store("")
+ fakeClaudeUsage(t, &out, nil)
claudeUsage.Lock()
claudeUsage.m = map[string]claudeUsageEntry{"kev@example.com": {at: time.Now().Add(-time.Hour), ws: []QuotaWindow{
{Name: "5 hours", Used: 5}, {Name: "7 days · Fable", Used: 30, Model: "fable"},
@@ -88,7 +66,7 @@ func TestNoteClaudeLimitsFable(t *testing.T) {
defer func() { claudeUsage.Lock(); claudeUsage.m = nil; claudeUsage.Unlock() }()
NoteClaudeLimits("kev@example.com", []ClaudeLimit{{Kind: "seven_day_overage_included", Used: 1}})
- ws, err := claudeWindows(context.Background(), "kev@example.com", "tok")
+ ws, err := claudeWindows(context.Background(), "kev@example.com", true)
if err != nil {
t.Fatal(err)
}
diff --git a/internal/provider/claude_usage_cli.go b/internal/provider/claude_usage_cli.go
new file mode 100644
index 00000000..1054ef90
--- /dev/null
+++ b/internal/provider/claude_usage_cli.go
@@ -0,0 +1,131 @@
+package provider
+
+// A Claude account's allowance, as Claude Code's own /usage tells it:
+// magpie runs `claude -p /usage` (a command Claude Code answers itself,
+// asking no model) and reads its lines, never asking Anthropic itself.
+
+import (
+ "context"
+ "errors"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// claudeCLIUsage runs Claude Code's /usage for the account it is signed in
+// to and is what it printed. The gateway, which runs Claude Code, sets it
+// (UsageClaudeVia).
+var claudeCLIUsage func(ctx context.Context) (string, error)
+
+// UsageClaudeVia sets how Claude Code's /usage runs.
+func UsageClaudeVia(f func(ctx context.Context) (string, error)) { claudeCLIUsage = f }
+
+// readClaudeUsage is the allowance of the account Claude Code is signed in
+// to, from its /usage.
+func readClaudeUsage(ctx context.Context) ([]QuotaWindow, error) {
+ if claudeCLIUsage == nil {
+ return []QuotaWindow{}, errors.New("Claude Code can't be run from here")
+ }
+ text, err := claudeCLIUsage(ctx)
+ if err != nil {
+ return []QuotaWindow{}, err
+ }
+ return parseClaudeUsage(text, time.Now())
+}
+
+// "Current session: 13% used · resets Oct 1 at 3:30pm (Asia/Shanghai)",
+// "Current week (all models): 4% used · resets Oct 3 at 2pm (Asia/Shanghai)",
+// "Current week (Fable): 0% used"
+var claudeUsageLineRE = regexp.MustCompile(`^Current (session|week(?: \(([^)]+)\))?):\s*([0-9.]+)% used(?:\s*·\s*resets (.+))?$`)
+
+// parseClaudeUsage reads /usage's windows; now dates a reset that names no
+// year.
+func parseClaudeUsage(text string, now time.Time) ([]QuotaWindow, error) {
+ out := []QuotaWindow{}
+ const week = 7 * 24 * time.Hour
+ for _, line := range strings.Split(text, "\n") {
+ m := claudeUsageLineRE.FindStringSubmatch(strings.TrimSpace(line))
+ if m == nil {
+ continue
+ }
+ used, err := strconv.ParseFloat(m[3], 64)
+ if err != nil {
+ continue
+ }
+ w := QuotaWindow{Name: "5 hours", Used: used, Span: 5 * time.Hour}
+ if m[1] != "session" {
+ w.Name, w.Span = "7 days", week
+ if scope := strings.TrimSpace(m[2]); scope != "" && !strings.EqualFold(scope, "all models") {
+ w.Name, w.Model = "7 days · "+scope, claudeScopeModel(scope)
+ }
+ }
+ if slices.ContainsFunc(out, func(x QuotaWindow) bool { return x.Name == w.Name }) {
+ continue
+ }
+ if t, ok := claudeResetTime(m[4], now); ok {
+ w.ResetsAt = &t
+ }
+ out = append(out, w)
+ }
+ if len(out) == 0 {
+ first, _, _ := strings.Cut(strings.TrimSpace(text), "\n")
+ if first == "" {
+ first = "nothing"
+ }
+ return out, errors.New("Claude Code's /usage told no allowance: " + clipLine(first))
+ }
+ return out, nil
+}
+
+func clipLine(s string) string {
+ if r := []rune(s); len(r) > 120 {
+ return string(r[:120]) + "…"
+ }
+ return s
+}
+
+// claudeResetTime reads "Oct 1 at 3:30pm (Asia/Shanghai)" or "3pm
+// (Asia/Shanghai)", in the zone named, else local time; a date with no
+// year is the next one from a day before now.
+func claudeResetTime(s string, now time.Time) (time.Time, bool) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return time.Time{}, false
+ }
+ loc := time.Local
+ if i := strings.LastIndex(s, "("); i >= 0 && strings.HasSuffix(s, ")") {
+ if l, err := time.LoadLocation(s[i+1 : len(s)-1]); err == nil {
+ loc = l
+ }
+ s = strings.TrimSpace(s[:i])
+ }
+ s = strings.ReplaceAll(strings.ReplaceAll(s, "AM", "am"), "PM", "pm")
+ ref := now.In(loc)
+ for _, layout := range []string{"Jan 2 at 3:04pm", "Jan 2 at 3pm", "Jan 2, 2006 at 3:04pm", "Jan 2, 2006 at 3pm"} {
+ t, err := time.ParseInLocation(layout, s, loc)
+ if err != nil {
+ continue
+ }
+ if t.Year() == 0 {
+ t = t.AddDate(ref.Year(), 0, 0)
+ if t.Before(ref.Add(-24 * time.Hour)) {
+ t = t.AddDate(1, 0, 0)
+ }
+ }
+ return t, true
+ }
+ for _, layout := range []string{"3:04pm", "3pm"} {
+ t, err := time.ParseInLocation(layout, s, loc)
+ if err != nil {
+ continue
+ }
+ t = time.Date(ref.Year(), ref.Month(), ref.Day(), t.Hour(), t.Minute(), 0, 0, loc)
+ if t.Before(ref) {
+ t = t.AddDate(0, 0, 1)
+ }
+ return t, true
+ }
+ return time.Time{}, false
+}
diff --git a/internal/provider/claude_usage_test.go b/internal/provider/claude_usage_test.go
index 64690bcd..f3c8c86d 100644
--- a/internal/provider/claude_usage_test.go
+++ b/internal/provider/claude_usage_test.go
@@ -2,65 +2,146 @@ package provider
import (
"context"
+ "errors"
"net/http"
"net/http/httptest"
- "strings"
"sync/atomic"
"testing"
"time"
)
-func TestClaudeWindowsRateLimit(t *testing.T) {
- var hits atomic.Int32
- limited := atomic.Bool{}
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- hits.Add(1)
- if limited.Load() {
- w.Header().Set("Retry-After", "600")
- w.WriteHeader(http.StatusTooManyRequests)
- return
+// fakeClaudeUsage has Claude Code's /usage print out (or fail with err),
+// counting its runs, and fails the test on any request magpie makes to
+// Anthropic itself.
+func fakeClaudeUsage(t *testing.T, out *atomic.Value, fail *atomic.Bool) *atomic.Int32 {
+ t.Helper()
+ var runs atomic.Int32
+ old, oldAsked := claudeCLIUsage, claudeAsked.Load()
+ UsageClaudeVia(func(context.Context) (string, error) {
+ runs.Add(1)
+ if fail != nil && fail.Load() {
+ return "", errors.New("Claude Code: offline")
}
- w.Write([]byte(`{"five_hour":{"utilization":40,"resets_at":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339) +
- `"},"seven_day":{"utilization":10,"resets_at":"` + time.Now().Add(48*time.Hour).UTC().Format(time.RFC3339) + `"}}`))
+ return out.Load().(string), nil
+ })
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Errorf("magpie asked Anthropic itself: %s", r.URL)
}))
- defer srv.Close()
- old := claudeBase
+ oldBase := claudeBase
claudeBase = srv.URL
- defer func() { claudeBase = old }()
+ claudeAsked.Store(0)
+ claudeUsage.Lock()
claudeUsage.m = nil
+ claudeUsage.Unlock()
+ t.Cleanup(func() {
+ claudeCLIUsage, claudeBase = old, oldBase
+ claudeAsked.Store(oldAsked)
+ srv.Close()
+ claudeUsage.Lock()
+ claudeUsage.m = nil
+ claudeUsage.Unlock()
+ subscriptionUsageCache.Lock()
+ subscriptionUsageCache.asked = false
+ subscriptionUsageCache.Unlock()
+ })
+ return &runs
+}
+
+// Claude's usage is Claude Code's /usage, run only when the user asks,
+// once an ask, and only for the account Claude Code is signed in to.
+func TestClaudeWindowsAsked(t *testing.T) {
+ var out atomic.Value
+ out.Store("Current session: 40% used · resets Oct 1 at 3:30pm (UTC)\nCurrent week (all models): 10% used · resets Oct 3 at 2pm (UTC)\n")
+ var fail atomic.Bool
+ runs := fakeClaudeUsage(t, &out, &fail)
ctx := context.Background()
- ws, err := claudeWindows(ctx, "A@x", "t")
- if err != nil || len(ws) != 2 || ws[0].Used != 40 {
- t.Fatalf("first: %v %+v", err, ws)
+ // nobody asked: nothing is run
+ for range 3 {
+ if _, err := claudeWindows(ctx, "A@x", true); err != errClaudeNotAsked || runs.Load() != 0 {
+ t.Fatalf("unasked: %v %d", err, runs.Load())
+ }
}
- // the Usage page and the switch list ask again: one request between them
- if ws, err = claudeWindows(ctx, "a@x", "t2"); err != nil || len(ws) != 2 || hits.Load() != 1 {
- t.Fatalf("cached: %v %d", err, hits.Load())
+ // a saved account is never read, asked or not
+ AskClaudeUsage()
+ if _, err := claudeWindows(ctx, "saved@x", false); err != errClaudeSaved || runs.Load() != 0 {
+ t.Fatalf("saved: %v %d", err, runs.Load())
}
- // past the cache and turned away: what was known stays, a window that
- // has reset since starting from nothing, and nothing is asked until
- // the wait Anthropic named is over
- limited.Store(true)
+ // asked: one run, then what it told
+ ws, err := claudeWindows(ctx, "A@x", true)
+ if err != nil || len(ws) != 2 || ws[0].Used != 40 || ws[1].Used != 10 || runs.Load() != 1 {
+ t.Fatalf("asked: %v %+v %d", err, ws, runs.Load())
+ }
+ for range 3 {
+ if ws, err = claudeWindows(ctx, "a@x", true); err != nil || len(ws) != 2 || runs.Load() != 1 {
+ t.Fatalf("kept: %v %d", err, runs.Load())
+ }
+ }
+ // asked again at once: still the one run
+ AskClaudeUsage()
+ if _, err = claudeWindows(ctx, "a@x", true); err != nil || runs.Load() != 1 {
+ t.Fatalf("too soon: %v %d", err, runs.Load())
+ }
+
+ // asked later: run again; a failed run isn't run again until asked
+ claudeUsage.Lock()
e := claudeUsage.m["a@x"]
- e.at = e.at.Add(-2 * time.Hour)
- past := time.Now().Add(-time.Minute)
- e.ws[0].ResetsAt = &past
+ e.tried = e.tried.Add(-time.Hour)
claudeUsage.m["a@x"] = e
- if ws, err = claudeWindows(ctx, "a@x", "t"); err != nil || len(ws) != 2 || ws[0].Used != 0 || ws[1].Used != 10 || hits.Load() != 2 {
- t.Fatalf("limited: %v %+v %d", err, ws, hits.Load())
+ claudeUsage.Unlock()
+ fail.Store(true)
+ AskClaudeUsage()
+ if _, err = claudeWindows(ctx, "a@x", true); err == nil || runs.Load() != 2 {
+ t.Fatalf("failed: %v %d", err, runs.Load())
}
- if _, err = claudeWindows(ctx, "a@x", "t"); err != nil || hits.Load() != 2 {
- t.Fatalf("waiting: %v %d", err, hits.Load())
- }
-
- // an account never read, turned away: said so, and not asked again
- // until the wait is over
- if _, err = claudeWindows(ctx, "b@x", "t"); err == nil || !strings.Contains(err.Error(), "10m") || hits.Load() != 3 {
- t.Fatalf("new limited: %v %d", err, hits.Load())
- }
- if _, err = claudeWindows(ctx, "b@x", "t"); err == nil || hits.Load() != 3 {
- t.Fatalf("new waiting: %v %d", err, hits.Load())
+ if _, err = claudeWindows(ctx, "a@x", true); runs.Load() != 2 {
+ t.Fatalf("ran again unasked: %v %d", err, runs.Load())
+ }
+}
+
+func TestParseClaudeUsage(t *testing.T) {
+ now := time.Date(2026, 10, 1, 4, 0, 0, 0, time.UTC)
+ text := `You are currently using your subscription to power your Claude Code usage
+
+Current session: 13% used · resets Oct 1 at 3:30pm (Asia/Shanghai)
+Current week (all models): 4% used · resets Oct 3 at 2pm (Asia/Shanghai)
+Current week (Opus): 12.5% used · resets Jan 2 at 2pm (Asia/Shanghai)
+Current week (Fable): 0% used
+
+What's contributing to your limits usage?
+ 96% of your usage came from sessions active for 8+ hours`
+ ws, err := parseClaudeUsage(text, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sh, _ := time.LoadLocation("Asia/Shanghai")
+ want := []struct {
+ name, model string
+ used float64
+ span time.Duration
+ reset time.Time
+ }{
+ {"5 hours", "", 13, 5 * time.Hour, time.Date(2026, 10, 1, 15, 30, 0, 0, sh)},
+ {"7 days", "", 4, 7 * 24 * time.Hour, time.Date(2026, 10, 3, 14, 0, 0, 0, sh)},
+ {"7 days · Opus", "opus", 12.5, 7 * 24 * time.Hour, time.Date(2027, 1, 2, 14, 0, 0, 0, sh)},
+ {"7 days · Fable", "fable", 0, 7 * 24 * time.Hour, time.Time{}},
+ }
+ if len(ws) != len(want) {
+ t.Fatalf("windows %+v", ws)
+ }
+ for i, w := range want {
+ g := ws[i]
+ if g.Name != w.name || g.Model != w.model || g.Used != w.used || g.Span != w.span ||
+ (w.reset.IsZero() != (g.ResetsAt == nil)) || (g.ResetsAt != nil && !g.ResetsAt.Equal(w.reset)) {
+ t.Errorf("%d: %+v, want %+v", i, g, w)
+ }
+ }
+ // a time alone is the next one
+ if r, ok := claudeResetTime("3am (UTC)", now); !ok || !r.Equal(time.Date(2026, 10, 2, 3, 0, 0, 0, time.UTC)) {
+ t.Fatalf("time alone: %v %v", r, ok)
+ }
+ if _, err := parseClaudeUsage("Error: not logged in", now); err == nil {
+ t.Fatal("nothing told, no error")
}
}
diff --git a/internal/provider/codex_autoreset.go b/internal/provider/codex_autoreset.go
index f968607b..873c6215 100644
--- a/internal/provider/codex_autoreset.go
+++ b/internal/provider/codex_autoreset.go
@@ -27,6 +27,12 @@ func CodexAutoReset(user string) bool {
return user != "" && slices.Contains(settings.Load().CodexAutoReset, strings.ToLower(user))
}
+// AutoResets says whether agent's account user spends its resets by
+// itself; only a Codex account does.
+func AutoResets(agent, user string) bool {
+ return agent == "codex" && CodexAutoReset(user)
+}
+
// CodexSignedIn is the ChatGPT account Codex is signed in to now.
func CodexSignedIn() (string, bool) {
l, ok := liveLogin("codex")
diff --git a/internal/provider/codex_resets.go b/internal/provider/codex_resets.go
index 98145e60..d14076d4 100644
--- a/internal/provider/codex_resets.go
+++ b/internal/provider/codex_resets.go
@@ -138,15 +138,6 @@ func (o ResetOutcome) Text() string {
return "no reset left on the account"
case "already_redeemed", "already_used":
return "that reset was already used"
- // Anthropic's words for a Claude reset (claude_resets.go)
- case "not_limited":
- return "nothing to reset — no window is used up yet, and the reset is kept"
- case "cooldown":
- return "a reset was used a short while ago — try again later"
- case "ineligible":
- return "the account can't use a reset"
- case "unavailable":
- return "resets can't be used right now — try again later"
}
return o.Code
}
diff --git a/internal/provider/codex_switch_test.go b/internal/provider/codex_switch_test.go
index 4b31b763..cb32f40f 100644
--- a/internal/provider/codex_switch_test.go
+++ b/internal/provider/codex_switch_test.go
@@ -88,7 +88,9 @@ func TestCodexSwitchedWhenUsedUp(t *testing.T) {
// 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).
+// .claude.json's account both — and not before (#208, #209). What it
+// goes by is what Claude Code told as it answered: Anthropic's usage
+// endpoint isn't read for it.
func TestClaudeSwitchedWhenSpent(t *testing.T) {
home := claudeHome(t)
cred := claudeSignIn(t, home, time.Now().Add(time.Hour)) // sk-ant-oat01-old
@@ -105,10 +107,9 @@ func TestClaudeSwitchedWhenSpent(t *testing.T) {
}
loginsMu.Unlock()
- used := map[string]float64{"Bearer sk-ant-oat01-old": 97, "Bearer sk-ant-oat01-b": 10}
+ used := map[string]float64{"a@example.com": 97, "b@example.com": 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)}})
+ t.Errorf("usage endpoint read: %s", r.URL)
}))
defer fake.Close()
claudeBase = fake.URL // isolate puts it back
@@ -120,6 +121,9 @@ func TestClaudeSwitchedWhenSpent(t *testing.T) {
claudeUsage.Lock()
claudeUsage.m = nil
claudeUsage.Unlock()
+ for user, u := range used {
+ NoteClaudeLimits(user, []ClaudeLimit{{Kind: "five_hour", Used: u / 100, ResetsAt: time.Now().Add(time.Hour).Unix()}})
+ }
to, err := SwitchWhenSpent(context.Background(), "claude")
if err != nil {
t.Fatal(err)
@@ -131,7 +135,7 @@ func TestClaudeSwitchedWhenSpent(t *testing.T) {
if to := switched(); to != "" {
t.Fatalf("switched to %s at 97%%", to)
}
- used["Bearer sk-ant-oat01-old"] = 98
+ used["a@example.com"] = 98
if to := switched(); to != "b@example.com" {
t.Fatalf("switched to %q at 98%%", to)
}
@@ -153,7 +157,7 @@ func TestClaudeSwitchedWhenSpent(t *testing.T) {
if to := switched(); to != "" {
t.Fatalf("switched again, to %s", to)
}
- used["Bearer sk-ant-oat01-b"] = 99
+ used["b@example.com"] = 99
if to := switched(); to != "" {
t.Fatalf("switched to %s, spent as well", to)
}
diff --git a/internal/provider/google.go b/internal/provider/google.go
index dee332f9..de4d4947 100644
--- a/internal/provider/google.go
+++ b/internal/provider/google.go
@@ -102,6 +102,10 @@ func googleAppOf(agent string) (googleApp, bool) {
// added.
const AntigravityRisk = "Google may suspend an Antigravity account it sees used outside Antigravity. Use one you can afford to lose."
+// ClaudeRisk is what magpie says before a Claude account is added, as the
+// window does.
+const ClaudeRisk = "Anthropic may suspend or ban a Claude account it sees used outside its own apps. magpie sends requests through Claude Code, but Anthropic may still act on them; you use it at your own risk. Use an account you can afford to lose."
+
// geminiCLIVersion is the Gemini CLI magpie says it is.
const geminiCLIVersion = "0.61.0"
diff --git a/internal/provider/login_usage.go b/internal/provider/login_usage.go
index 57134cb7..66117695 100644
--- a/internal/provider/login_usage.go
+++ b/internal/provider/login_usage.go
@@ -167,11 +167,19 @@ func loginQuota(ctx context.Context, l Login) SubscriptionQuota {
return SubscriptionQuota{Provider: l.Agent, Plan: l.Plan, Windows: []QuotaWindow{}, Error: "not signed in"}
}
q := SubscriptionQuota{Provider: l.Agent, Plan: l.Plan, Windows: []QuotaWindow{}}
+ if l.Agent == "claude" && !l.Active {
+ // a saved account is never asked: what Claude Code told of it
+ ws, err := claudeWindows(ctx, l.User, false)
+ q.Windows = ws
+ if err != nil {
+ q.Error = err.Error()
+ }
+ return q
+ }
var tok, accountID string
var err error
switch {
- case l.Active && l.Agent == "claude":
- tok, err = claudeToken(ctx)
+ case l.Agent == "claude": // Claude Code reads its own (claudeWindows)
case l.Active:
tok, accountID, err = codexToken(ctx, codexAuthPath())
default:
@@ -179,8 +187,7 @@ func loginQuota(ctx context.Context, l Login) SubscriptionQuota {
}
if err == nil {
if l.Agent == "claude" {
- q.Windows, err = claudeWindows(ctx, l.User, tok)
- q.Resets = claudeResetsOf(l.User)
+ q.Windows, err = claudeWindows(ctx, l.User, true)
} else {
var plan string
if plan, q.Windows, q.Resets, err = codexWindows(ctx, tok, accountID); plan != "" {
diff --git a/internal/provider/quota_last.go b/internal/provider/quota_last.go
index 0c0d3e91..e457e6f7 100644
--- a/internal/provider/quota_last.go
+++ b/internal/provider/quota_last.go
@@ -39,8 +39,10 @@ type lastWindow struct {
func lastQuotasPath() string { return filepath.Join(filepath.Dir(Path()), "quotas.json") }
// passing is an error that says nothing about the account: the vendor
-// rate limiting, failing or out of reach. A sign-in gone bad is not one.
-var passing = regexp.MustCompile(`(?i)rate.?limit|too many requests|429|internal server error|bad gateway|service unavailable|gateway timeout|deadline exceeded|timeout|timed out|connection (refused|reset)|no such host|network is unreachable|EOF|asks again|fetch failed|unable to connect|ECONN[A-Z]+|ENOTFOUND|EAI_AGAIN|socket hang up|HTTP 5\d\d`)
+// rate limiting, failing or out of reach, or a Claude account's usage not
+// read this time (errClaudeNotAsked, errClaudeSaved). A sign-in gone bad
+// is not one.
+var passing = regexp.MustCompile(`(?i)not read yet|doesn't read a saved account|rate.?limit|too many requests|429|internal server error|bad gateway|service unavailable|gateway timeout|deadline exceeded|timeout|timed out|connection (refused|reset)|no such host|network is unreachable|EOF|asks again|fetch failed|unable to connect|ECONN[A-Z]+|ENOTFOUND|EAI_AGAIN|socket hang up|HTTP 5\d\d`)
// keyTag names a key's card among the last readings, by what it is (a
// plan's windows, a balance) and a digest of the key, never the key: a
diff --git a/internal/provider/quota_last_test.go b/internal/provider/quota_last_test.go
index e499329c..107dc12f 100644
--- a/internal/provider/quota_last_test.go
+++ b/internal/provider/quota_last_test.go
@@ -25,10 +25,10 @@ func TestKeepLast(t *testing.T) {
restart() // what was read is on disk
limited := SubscriptionQuota{Provider: "claude", Name: "Claude Code", User: "a@x.com", Windows: []QuotaWindow{},
- Error: claudeLimited(5 * time.Minute).Error()}
+ Error: errClaudeNotAsked.Error()}
q := keepLast(limited, "")
if q.Error != "" || q.AsOf == nil || len(q.Windows) != 2 || q.Windows[0].Used != 40 || q.Windows[1].Model != "opus" || q.Windows[0].Span != 5*time.Hour {
- t.Fatalf("rate limited: %+v", q)
+ t.Fatalf("not asked: %+v", q)
}
// the same through LoginUsage, which knows the user but not the name
diff --git a/internal/provider/subscription_usage.go b/internal/provider/subscription_usage.go
index 9c7b21d3..c2dc2007 100644
--- a/internal/provider/subscription_usage.go
+++ b/internal/provider/subscription_usage.go
@@ -9,11 +9,11 @@ import (
"net/http"
"os"
"path/filepath"
- "slices"
"sort"
"strconv"
"strings"
"sync"
+ "sync/atomic"
"time"
"github.com/yetone/magpie/internal/netproxy"
@@ -63,9 +63,8 @@ type SubscriptionQuota struct {
// AsOf is when an allowance shown in place of one that couldn't be
// read was read (see keepLast); nil for a reading just made.
AsOf *time.Time `json:"asOf,omitempty"`
- // Resets are the rate-limit resets a Codex account holds, or the
- // usage-limit resets a Claude account does, nil when it holds none
- // (codex_resets.go, claude_resets.go).
+ // Resets are the rate-limit resets a Codex account holds, nil when it
+ // holds none (codex_resets.go).
Resets *ResetCredits `json:"resets,omitempty"`
}
@@ -74,6 +73,7 @@ var subscriptionUsageCache struct {
at time.Time
data []SubscriptionQuota
pending chan struct{} // closed when the refresh in flight is done
+ asked bool // the user asked (AskClaudeUsage): wait for the refresh
}
// OnSubscriptionUsage is told when a refresh has landed, for what shows a
@@ -94,13 +94,20 @@ func SubscriptionUsage(ctx context.Context) []SubscriptionQuota {
c := &subscriptionUsageCache
c.Lock()
have, fresh := c.data != nil, time.Since(c.at) < time.Minute
+ if c.asked {
+ have, c.asked = false, false
+ }
if !fresh && c.pending == nil {
done := make(chan struct{})
c.pending = done
go func() {
+ start := time.Now()
out := fetchSubscriptionUsage()
c.Lock()
c.at, c.data, c.pending = time.Now(), out, nil
+ if claudeAsked.Load() > start.UnixNano() {
+ c.at = time.Time{} // asked meanwhile: read again
+ }
c.Unlock()
close(done)
if f := OnSubscriptionUsage; f != nil {
@@ -376,77 +383,96 @@ func (e *accountStatusError) Error() string { return http.StatusText(e.status) }
func claudeSubscriptionUsage(ctx context.Context) SubscriptionQuota {
q := SubscriptionQuota{Provider: "claude", Name: "Claude Code", Icon: "claude-color", Windows: []QuotaWindow{}}
- token, err := claudeToken(ctx)
- if err != nil {
- q.Error = err.Error()
+ if _, _, ok := claudeCredential(); !ok {
+ q.Error = "Claude Code is signed out; run claude auth login"
return q
}
user, plan, _ := claudeIdentity()
q.Plan = plan
- q.Windows, err = claudeWindows(ctx, user, token)
- q.Resets = claudeResetsOf(user)
+ var err error
+ q.Windows, err = claudeWindows(ctx, user, true)
if err != nil {
q.Error = err.Error()
}
return q
}
-// claudeUsage keeps each Claude account's allowance as last read, by user.
-// Anthropic's usage endpoint turns away an account asked more than a few
-// times in a while (429, rate_limit_error), and the Usage page, the switch
-// list and routing all ask for it: they share what was read in the last
-// few minutes, and one turned away waits as long as it is told, or five
-// minutes, showing what was known until then.
+// claudeUsage keeps each Claude account's allowance as Claude Code last
+// told it: its /usage, run when the user asks (AskClaudeUsage) and only for
+// the account it is signed in to, or what it said as it answered
+// (NoteClaudeLimits). magpie never asks Anthropic for it itself; the Usage
+// page's timer, the switch list and routing show what is kept here.
var claudeUsage struct {
sync.Mutex
m map[string]claudeUsageEntry
}
type claudeUsageEntry struct {
- at, retry time.Time
- ws []QuotaWindow
- heard time.Time // when Claude Code last told it, answering
- // grants: the usage-limit resets the reading told of, nil when it
- // told of none (claude_resets.go)
- grants *claudeGrants
+ at time.Time
+ ws []QuotaWindow
+ heard time.Time // when Claude Code last told it, answering
+ tried time.Time // when the endpoint was last asked, answered or not
}
-const claudeUsageTTL = 3 * time.Minute
+// claudeAskFloor is the least time between two readings, however often the
+// user refreshes.
+const claudeAskFloor = 30 * time.Second
-// claudeWindows is the allowance of the Claude account user, token signs
-// in to.
-func claudeWindows(ctx context.Context, user, token string) ([]QuotaWindow, error) {
+// claudeAsked is when the user last asked to see Claude's usage (unix
+// nanoseconds; zero: never).
+var claudeAsked atomic.Int64
+
+// AskClaudeUsage is the user asking to see Claude's usage — opening the
+// Usage page, refreshing it, `magpie quota` — the one time Claude Code's
+// /usage is run; the next SubscriptionUsage waits for it.
+func AskClaudeUsage() {
+ claudeAsked.Store(time.Now().UnixNano())
+ c := &subscriptionUsageCache
+ c.Lock()
+ c.at, c.asked = time.Time{}, true
+ c.Unlock()
+ l := &loginUsageCache
+ l.Lock()
+ for k := range l.m {
+ if strings.HasPrefix(k, "claude/") {
+ delete(l.m, k)
+ }
+ }
+ l.Unlock()
+}
+
+// claudeWindows is the allowance of the Claude account user. Only the
+// account Claude Code is signed in to (active) is read, by Claude Code,
+// and only when the user asked since it last was; any other time it is
+// what was kept.
+func claudeWindows(ctx context.Context, user string, active bool) ([]QuotaWindow, error) {
key := strings.ToLower(user)
c := &claudeUsage
+ now := time.Now()
+ asked := claudeAsked.Load()
c.Lock()
e, ok := c.m[key]
- c.Unlock()
- now := time.Now()
- if ok && e.ws != nil && (now.Sub(e.at) < claudeUsageTTL || now.Before(e.retry)) {
- return elapsed(e.ws, now), nil
- }
- if ok && now.Before(e.retry) {
- return []QuotaWindow{}, claudeLimited(e.retry.Sub(now))
- }
- ws, grants, err := readClaudeUsage(ctx, token)
- var st *accountStatusError
- if errors.As(err, &st) && st.status == http.StatusTooManyRequests {
- wait := st.retryAfter
- if wait <= 0 {
- wait = 5 * time.Minute
- }
- c.Lock()
+ // one reading an ask, its first caller's; the others keep to it
+ read := active && asked != 0 && (e.tried.IsZero() || asked > e.tried.UnixNano()) && now.Sub(e.tried) >= claudeAskFloor
+ if read {
if c.m == nil {
c.m = map[string]claudeUsageEntry{}
}
- e.retry = now.Add(wait)
+ e.tried = now
c.m[key] = e
- c.Unlock()
- if e.ws != nil {
- return elapsed(e.ws, now), nil
- }
- return []QuotaWindow{}, claudeLimited(wait)
}
+ c.Unlock()
+ if !read {
+ switch {
+ case ok && e.ws != nil:
+ return elapsed(e.ws, now), nil
+ case active:
+ return []QuotaWindow{}, errClaudeNotAsked
+ default:
+ return []QuotaWindow{}, errClaudeSaved
+ }
+ }
+ ws, err := readClaudeUsage(ctx)
if err != nil {
if e.ws != nil && now.Sub(e.heard) < claudeHeard {
return elapsed(e.ws, now), nil // what Claude Code said stands
@@ -457,15 +483,15 @@ func claudeWindows(ctx context.Context, user, token string) ([]QuotaWindow, erro
if c.m == nil {
c.m = map[string]claudeUsageEntry{}
}
- c.m[key] = claudeUsageEntry{at: now, ws: ws, heard: e.heard, grants: grants}
+ c.m[key] = claudeUsageEntry{at: now, ws: ws, heard: e.heard, tried: now}
c.Unlock()
return ws, nil
}
-// claudeLimited is Anthropic turning the usage endpoint away for wait.
-func claudeLimited(wait time.Duration) error {
- return fmt.Errorf("Anthropic is rate limiting its usage endpoint; magpie asks again in %s", wait.Round(time.Minute).String())
-}
+var (
+ errClaudeNotAsked = errors.New("not read yet: magpie reads Claude's usage when you open or refresh Usage")
+ errClaudeSaved = errors.New("magpie doesn't read a saved account's usage; it shows what Claude Code reports while using it")
+)
// elapsed is ws as of now: a window that has reset since it was read
// starts again from nothing.
@@ -480,72 +506,6 @@ func elapsed(ws []QuotaWindow, now time.Time) []QuotaWindow {
return out
}
-// readClaudeWindows asks Anthropic for the allowance of the account token
-// signs in to.
-func readClaudeWindows(ctx context.Context, token string) ([]QuotaWindow, error) {
- ws, _, err := readClaudeUsage(ctx, token)
- return ws, err
-}
-
-// readClaudeUsage is readClaudeWindows with the usage-limit resets the
-// account holds, which Anthropic tells only when asked for them
-// (cedar_ember=1, as Claude Code asks), nil when it holds none.
-func readClaudeUsage(ctx context.Context, token string) ([]QuotaWindow, *claudeGrants, error) {
- var data struct {
- FiveHour *quotaWire `json:"five_hour"`
- SevenDay *quotaWire `json:"seven_day"`
- SevenDayOpus *quotaWire `json:"seven_day_opus"`
- SevenDaySonnet *quotaWire `json:"seven_day_sonnet"`
- // a week's allowance per model (Fable), which the fields above
- // don't carry; one with no scope is seven_day again
- Limits []struct {
- Kind string `json:"kind"`
- Percent *float64 `json:"percent"`
- ResetsAt string `json:"resets_at"`
- Scope *struct {
- Model *struct {
- DisplayName string `json:"display_name"`
- } `json:"model"`
- } `json:"scope"`
- } `json:"limits"`
- Grants *claudeGrants `json:"cedar_ember"`
- }
- err := accountJSON(ctx, claudeBase+claudeUsagePath, token, map[string]string{
- "anthropic-beta": "oauth-2025-04-20", "user-agent": "magpie",
- }, &data)
- if err != nil {
- return []QuotaWindow{}, nil, err
- }
- out := []QuotaWindow{}
- const week = 7 * 24 * time.Hour
- for _, x := range []struct {
- name, model string
- span time.Duration
- w *quotaWire
- }{{"5 hours", "", 5 * time.Hour, data.FiveHour}, {"7 days", "", week, data.SevenDay},
- {"7 days · Opus", "opus", week, data.SevenDayOpus}, {"7 days · Sonnet", "sonnet", week, data.SevenDaySonnet}} {
- if x.w != nil {
- w := x.w.window(x.name)
- w.Span, w.Model = x.span, x.model
- out = append(out, w)
- }
- }
- for _, l := range data.Limits {
- if l.Kind != "weekly_scoped" || l.Scope == nil || l.Scope.Model == nil || l.Percent == nil {
- continue
- }
- name := strings.TrimSpace(l.Scope.Model.DisplayName)
- model := claudeScopeModel(name)
- if model == "" || slices.ContainsFunc(out, func(w QuotaWindow) bool { return w.Model == model }) {
- continue
- }
- w := quotaWire{Utilization: *l.Percent, ResetsAt: l.ResetsAt}.window("7 days · " + name)
- w.Span, w.Model = week, model
- out = append(out, w)
- }
- return out, data.Grants, nil
-}
-
// claudeScopeModel is the word a model-scoped window counts models by, from
// the name Anthropic gives it: "Fable" counts claude-fable-*, "Fable 5.1"
// claude-fable-5-1.
diff --git a/internal/settings/settings.go b/internal/settings/settings.go
index ab20a6bc..5711a9d2 100644
--- a/internal/settings/settings.go
+++ b/internal/settings/settings.go
@@ -86,10 +86,6 @@ type Settings struct {
// used up and no other account can take the request: at most one a
// week each (see provider.AutoUseCodexReset).
CodexAutoReset []string `json:"codexAutoReset,omitempty"`
- // ClaudeAutoReset are the Claude accounts (lower-case) that spend one
- // of their usage-limit resets by themselves the same way (see
- // provider.AutoUseClaudeReset).
- ClaudeAutoReset []string `json:"claudeAutoReset,omitempty"`
// WorkBuddyCheckin presses WorkBuddy's daily check-in (签到) for each
// signed-in WorkBuddy (China) account once a Beijing day, claiming the
// credits it gives while its event runs.
@@ -527,10 +523,6 @@ func Save(s Settings) error {
s.CodexAutoReset[i] = strings.ToLower(u)
}
s.CodexAutoReset = ids(s.CodexAutoReset)
- for i, u := range s.ClaudeAutoReset {
- s.ClaudeAutoReset[i] = strings.ToLower(u)
- }
- s.ClaudeAutoReset = ids(s.ClaudeAutoReset)
s.TrayUsage = ""
if len(s.TrayUsages) > 0 {
s.TrayUsage = s.TrayUsages[0]
diff --git a/internal/tui/pages.go b/internal/tui/pages.go
index 8297b8fb..2e25f1ab 100644
--- a/internal/tui/pages.go
+++ b/internal/tui/pages.go
@@ -446,6 +446,7 @@ var periodNames = map[usage.Period]string{usage.Today: "today", usage.Week: "7 d
type quotaMsg []provider.SubscriptionQuota
func quotasCmd() tea.Msg {
+ provider.AskClaudeUsage() // the page opened, or r pressed
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
return quotaMsg(provider.Quotas(ctx))
diff --git a/quota_cli.go b/quota_cli.go
index d18c7c76..72d3cc89 100644
--- a/quota_cli.go
+++ b/quota_cli.go
@@ -61,6 +61,7 @@ func quotaCmd(args []string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
qs := []provider.Quota{}
+ provider.AskClaudeUsage()
for _, q := range provider.QuotaReport(ctx, time.Now()) {
if len(only) == 0 || quotaMatches(q, only) {
qs = append(qs, q)