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 @@ 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)