diff --git a/accounts_cli.go b/accounts_cli.go index db049975..61671dba 100644 --- a/accounts_cli.go +++ b/accounts_cli.go @@ -161,7 +161,7 @@ func accountsCmd(args []string) error { line += " " + quotaCell(w) } if r.Resets != nil { - line += " " + resetsCell(r.Resets, r.Agent == "codex" && provider.CodexAutoReset(r.User)) + line += " " + resetsCell(r.Resets, provider.AutoResets(r.Agent, r.User)) } if r.Lapsed != "" { line += " " + muted.Render(r.Lapsed) diff --git a/internal/gateway/claude_autoreset_test.go b/internal/gateway/claude_autoreset_test.go new file mode 100644 index 00000000..90eedf23 --- /dev/null +++ b/internal/gateway/claude_autoreset_test.go @@ -0,0 +1,146 @@ +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/codex_autoreset.go b/internal/gateway/codex_autoreset.go index 15afac2a..13405e85 100644 --- a/internal/gateway/codex_autoreset.go +++ b/internal/gateway/codex_autoreset.go @@ -1,9 +1,10 @@ package gateway -// 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). +// 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). import ( "context" @@ -17,7 +18,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 accounts, c — the last +// autoReset spends a reset of one of cands' Codex or Claude 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) { @@ -36,11 +37,11 @@ func (s *Server) autoReset(ctx context.Context, cands []candidate, c candidate) seen := map[string]bool{} for _, x := range append([]candidate{c}, cands...) { a := x.p.Account - if a == nil || a.Agent != "codex" || a.User == "" || seen[x.restKey()] || !provider.CodexAutoReset(a.User) { + if a == nil || a.User == "" || seen[x.restKey()] || !provider.AutoResets(a.Agent, a.User) { continue } seen[x.restKey()] = true - if out, ok := autoResetOf(ctx, a.User); ok { + if out, ok := autoResetOf(ctx, a.Agent, a.User); ok { s.Unrest(x.restKey()) return x, out, true } @@ -55,26 +56,30 @@ func (s *Server) autoResetSignedIn(ctx context.Context) (string, provider.ResetO if !ok || !provider.CodexAutoReset(who) { return "", provider.ResetOutcome{}, false } - out, ok := autoResetOf(ctx, who) + out, ok := autoResetOf(ctx, "codex", who) if ok { s.Unrest("codex@" + strings.ToLower(who)) } return who, out, ok } -// autoResetOf spends one of user's resets if its rules let it, and says -// whether the windows started again. -func autoResetOf(ctx context.Context, user string) (provider.ResetOutcome, bool) { +// autoResetOf spends one of agent's account user's resets if its rules +// let it, and says whether the windows started again. +func autoResetOf(ctx context.Context, agent, user string) (provider.ResetOutcome, bool) { ctx, cancel := context.WithTimeout(ctx, autoResetTimeout) defer cancel() - out, err := provider.AutoUseCodexReset(ctx, user) + use := provider.AutoUseCodexReset + if agent == "claude" { + use = provider.AutoUseClaudeReset + } + out, err := use(ctx, user) switch { case err != nil: - log.Printf("codex reset for %s not used: %v", user, err) + log.Printf("%s reset for %s not used: %v", agent, user, err) case out.Code == "reset": - log.Printf("codex reset used for %s, its week used up: %s", user, out.Text()) + log.Printf("%s reset used for %s, its week used up: %s", agent, user, out.Text()) case out.Code != "": - log.Printf("codex reset for %s not used: %s", user, out.Text()) + log.Printf("%s reset for %s not used: %s", agent, user, out.Text()) } return out, err == nil && out.Code == "reset" } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 5c7b13ed..238852af 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -907,13 +907,13 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro } tr := s.trace.begin(Route{Pinned: pin, Time: start, Agent: call.Agent, Kind: call.Kind, For: call.For, Model: call.Model, Effort: requestEffort(from, body), Provider: p.ID, Group: group, Rule: hit, Nested: nested, Affinity: shown, Order: pl.order, Left: pl.left}) var skipped []string - sent := "" // the reasoning the last try's model was asked for - where := "" // the last try's provider.Where, for the usage - again := 0 // times the last one left has been tried again - resealed := 0 // what of the conversation another account sealed was taken out: its reasoning, then its compaction - floored := false // the reply's length raised to what the provider takes - var other *Try // the first failure that wasn't an allowance run out - autoReset := false // a Codex reset looked at, once a request + sent := "" // the reasoning the last try's model was asked for + where := "" // the last try's provider.Where, for the usage + again := 0 // times the last one left has been tried again + resealed := 0 // what of the conversation another account sealed was taken out: its reasoning, then its compaction + floored := false // the reply's length raised to what the provider takes + var other *Try // the first failure that wasn't an allowance run out + autoReset := false // a Codex or Claude reset looked at, once a request for i := 0; i < len(cands); i++ { c := cands[i] last := i == len(cands)-1 @@ -1104,13 +1104,13 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro } // (not for one account pinned: the others weren't asked) if !autoReset && pin == "" && last && other == nil && hw.failed() && !hw.passing && failure(hw.code(), hw.errBody()) == failQuota { - // everyone is out of their allowance: a Codex account the user - // lets spend its resets by itself, its week used up, spends one - // and is asked again + // everyone is out of their allowance: a Codex or Claude account + // the user lets spend its resets by itself, its week used up, + // spends one and is asked again autoReset = true if pick, out, ok := s.autoReset(r.Context(), cands, c); ok { try.Fail = failQuota - try.Reset = &AutoReset{Who: pick.p.Account.User, Text: out.Text()} + try.Reset = &AutoReset{Who: pick.p.Account.User, Text: out.Text(), Agent: pick.p.Account.Agent} s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try }) skipped = append(skipped, c.label()+": "+call.Error, pick.label()+": used one of its resets by itself ("+out.Text()+")") cands = append(cands[:len(cands):len(cands)], pick) diff --git a/internal/gateway/trace.go b/internal/gateway/trace.go index a872131b..49c8ea11 100644 --- a/internal/gateway/trace.go +++ b/internal/gateway/trace.go @@ -177,10 +177,11 @@ type Try struct { Reset *AutoReset `json:"reset,omitempty"` } -// AutoReset is a Codex reset spent by itself, on Who's account. +// AutoReset is a Codex or Claude reset spent by itself, on Who's account. type AutoReset struct { - Who string `json:"who"` - Text string `json:"text"` + Who string `json:"who"` + Text string `json:"text"` + Agent string `json:"agent,omitempty"` // "claude" for a Claude account's; Codex's otherwise } type planned struct { diff --git a/internal/gui/api.go b/internal/gui/api.go index 626210ec..603db8e8 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -474,8 +474,8 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { in.RedactRules = cur.RedactRules // the masking rules, set on their own // used or left is the Usage page's toggle as much as Settings', set on its own in.QuotaLeft = cur.QuotaLeft - // which Codex accounts spend a reset by themselves, set on the Usage card - in.CodexAutoReset = cur.CodexAutoReset + // which Codex and Claude accounts spend a reset by themselves, set on the Usage card + in.CodexAutoReset, in.ClaudeAutoReset = cur.CodexAutoReset, cur.ClaudeAutoReset // and the text size, which the keyboard changes too (text-size below) in.TextSize = cur.TextSize if v := strings.TrimSpace(in.Vision); v != "" && v != "off" && v != cur.Vision { @@ -553,6 +553,26 @@ 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 51a67347..2e947238 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -6252,8 +6252,8 @@ function renderQuotas() { const r = el("div", "quota-resets"); r.append(resetsWords(sub.resets)); const use = el("button", "text", t("Use a reset")); - use.title = resetUseTitle(sub.resets); - use.onclick = () => askCodexReset(sub); + use.title = resetUseTitle(sub); + use.onclick = () => askReset(sub); if (!sub.resets.byWindow) { // a GLM team's are spent on bigmodel.cn const auto = autoResetButton(sub, "text auto-reset"); if (auto) r.append(auto); @@ -6491,8 +6491,8 @@ function panelQuotaCard(q) { const r = el("div", "pq-resets"); r.append(resetsWords(q.resets)); const use = el("button", "pq-use", t("Use one…")); - use.title = resetUseTitle(q.resets); - use.onclick = () => askCodexReset(q); + use.title = resetUseTitle(q); + use.onclick = () => askReset(q); if (!q.resets.byWindow) { const auto = autoResetButton(q, "pq-use pq-auto"); if (auto) r.append(auto); @@ -6503,8 +6503,9 @@ function panelQuotaCard(q) { return card; } -// 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 +// 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 // 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) { @@ -6532,22 +6533,27 @@ 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. -function resetUseTitle(r) { - return (r.until +// first, so no one hesitates for fear of losing one that lasts longer; a +// Claude account's, the one Anthropic names next. +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 ? 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 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. +// autoResetButton turns on or off a Codex or Claude 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) { - if (!q.user || q.provider !== "codex") return null; + const kept = { codex: "codexAutoReset", claude: "claudeAutoReset" }[q.provider]; + if (!q.user || !kept) return null; const who = q.user.toLowerCase(); - const on = !!(state.settings?.codexAutoReset || []).includes(who); + const on = !!(state.settings?.[kept] || []).includes(who); const b = el("button", cls + (on ? " on" : ""), t("Auto-use")); b.setAttribute("aria-pressed", String(on)); b.title = t(on ? "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." @@ -6556,7 +6562,7 @@ function autoResetButton(q, cls) { e.stopPropagation(); b.disabled = true; try { - prefs = await writingPrefs(api("settings/codex-auto-reset", { user: q.user, on: !on })); + prefs = await writingPrefs(api("settings/" + q.provider + "-auto-reset", { user: q.user, on: !on })); state.settings = prefs; status(t(on ? "{who} no longer uses a reset by itself" : "{who} uses a reset by itself once its week is used up", { who: q.user }), "ok"); renderQuotas(); @@ -6568,19 +6574,21 @@ function autoResetButton(q, cls) { return b; } -// askCodexReset: spending a reset can't be taken back, so it asks first; -// then it says what came of it and reads the usage again. +// askReset: spending a Codex or Claude 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 askCodexReset(q) { +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 || "codex"), el("b", "", t("Use a Codex reset?"))); + head.append(icon(q.icon || (claude ? "claude-color" : "codex")), el("b", "", t(claude ? "Use a Claude reset?" : "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("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(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() }))); // 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"); @@ -6590,7 +6598,7 @@ function askCodexReset(q) { go.disabled = true; go.classList.add("busy"); try { - const out = await api("usage/codex-reset", { user: q.user || "" }); + const out = await api(claude ? "usage/claude-reset" : "usage/codex-reset", { user: q.user || "" }); closeResetAsk(); status(who + ": " + resetOutcome(out), out.code === "reset" ? "ok" : "err"); loadQuotas(); @@ -6627,7 +6635,13 @@ function resetOutcome(out) { case "reset": return t(out.windows === 1 ? "1 window started again" : "{n} windows started again", { n: out.windows }); case "nothing_to_reset": return t("nothing to start again — no window has been used, and the reset is kept"); case "no_credit": return t("no reset left on the account"); - case "already_redeemed": return t("that reset was already used"); + case "already_redeemed": + case "already_used": return t("that reset was already used"); + // Anthropic's + case "not_limited": return t("nothing to reset — no window is used up yet, and the reset is kept"); + case "cooldown": return t("a reset was used a short while ago — try again later"); + case "ineligible": return t("the account can't use a reset"); + case "unavailable": return t("resets can't be used right now — try again later"); } return out.code; } diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index db4c0267..234057e6 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -559,7 +559,9 @@ 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 小时额度用完不会动用重置。", @@ -570,6 +572,13 @@ 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": "这个账号不能使用重置", + "resets can't be used right now — try again later": "现在无法使用重置——请稍后再试", "{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} 有 1 次重置。用掉后它的额度窗口立即重新开始,就像都没用过一样。此操作无法撤销。", "{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} 次重置。用掉一次后它的额度窗口立即重新开始,就像都没用过一样。此操作无法撤销。", "None of its windows has been used yet, so there is nothing to start again.": "它的额度窗口都还没用过,现在重置不会有任何效果。", diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index d5009510..2d139a30 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -452,10 +452,11 @@ 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 reset spent by itself: with Codex's own sign-in the one try - // it was spent for is the one that then answered + // 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"; const spent = tr.reset && tr.status < 400 - ? t("Its week was used up, so one of {account}'s Codex resets was used by itself first.", { account: tr.reset.who }) + " " + ? 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 }) + " " : ""; if (tr.status < 400) { const tk = (r.tokens ? " · " + t("{n} tokens", { n: tokens(r.tokens) }) : "") + firstNote(r, tr); @@ -464,7 +465,9 @@ : t("{who} answered in {ms}{tk}.", { who: name, ms: took(tr.ms), tk })); } if (tr.reset) - 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}.", + 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}.", { 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 a4505643..094eb0bb 100644 --- a/internal/gui/settings_keep_test.go +++ b/internal/gui/settings_keep_test.go @@ -64,22 +64,23 @@ 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}, - 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, - CodexAutoReset: []string{"me@example.com"}, - TextSize: 125, - 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}, + 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, + CodexAutoReset: []string{"me@example.com"}, + ClaudeAutoReset: []string{"me@example.com"}, + TextSize: 125, + 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 9176f20b..b1d277d8 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -434,10 +434,20 @@ 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. + 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/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 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/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/library-toggle-status.test.cjs internal/gui/tests/library-all.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/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 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/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 ``` 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 new file mode 100644 index 00000000..5a02c24e --- /dev/null +++ b/internal/gui/tests/claude-resets.test.cjs @@ -0,0 +1,206 @@ +// 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/usage.go b/internal/gui/usage.go index b8e2b52b..7adaf747 100644 --- a/internal/gui/usage.go +++ b/internal/gui/usage.go @@ -220,4 +220,20 @@ 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 new file mode 100644 index 00000000..4c0f7b63 --- /dev/null +++ b/internal/provider/claude_autoreset.go @@ -0,0 +1,99 @@ +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_resets.go b/internal/provider/claude_resets.go new file mode 100644 index 00000000..74b7701c --- /dev/null +++ b/internal/provider/claude_resets.go @@ -0,0 +1,260 @@ +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 new file mode 100644 index 00000000..5ca0dbbf --- /dev/null +++ b/internal/provider/claude_resets_test.go @@ -0,0 +1,285 @@ +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/codex_autoreset.go b/internal/provider/codex_autoreset.go index 4875b314..f968607b 100644 --- a/internal/provider/codex_autoreset.go +++ b/internal/provider/codex_autoreset.go @@ -44,10 +44,11 @@ func SetCodexAutoReset(user string, on bool) error { return settings.Save(s) } -var autoReset autoResets +var autoReset = autoResets{file: "codex-autoreset.json"} type autoResets struct { sync.Mutex + file string // its name in magpie's folder path string // the file read, read again when that changes (a new HOME) // spent: by account, the end of the week a reset was spent in, when, // and what spending it did — for the requests that found the same week @@ -67,7 +68,7 @@ type autoResetSpent struct { // again: its week not used up, or a reset that couldn't be spent. var autoResetWait = map[bool]time.Duration{false: time.Minute, true: 10 * time.Minute} -func autoResetPath() string { return filepath.Join(settings.Dir(), "codex-autoreset.json") } +func (a *autoResets) where() string { return filepath.Join(settings.Dir(), a.file) } // AutoUseCodexReset spends one of the Codex account user's resets (the one // Codex is signed in to when "") if the user turned that on for it, its @@ -85,9 +86,28 @@ func AutoUseCodexReset(ctx context.Context, user string) (ResetOutcome, error) { if !CodexAutoReset(user) { return ResetOutcome{}, nil } + var who string + return autoReset.use(user, func(now time.Time) (*time.Time, bool, error) { + var tok, accountID string + var err error + who, tok, accountID, err = codexUserToken(ViaLogin(ctx, "codex", user), user) + if err != nil { + return nil, false, err + } + _, windows, resets, err := codexWindows(ViaLogin(ctx, "codex", who), tok, accountID) + if err != nil { + return nil, false, err + } + return weekUsedUp(windows, now), resets != nil && resets.Count > 0, nil + }, func() (ResetOutcome, error) { return UseCodexReset(ctx, who) }) +} + +// use spends one of user's resets by itself if look finds its week used +// up (and when that week ends) and a reset held, and none was spent by +// itself in that week yet; spend spends it. +func (a *autoResets) use(user string, look func(now time.Time) (week *time.Time, held bool, err error), spend func() (ResetOutcome, error)) (ResetOutcome, error) { key := strings.ToLower(user) asked := time.Now() - a := &autoReset a.Lock() // one look at a time: those waiting take what it did defer a.Unlock() a.load() @@ -104,23 +124,16 @@ func AutoUseCodexReset(ctx context.Context, user string) (ResetOutcome, error) { if now.Before(a.next[key]) { return ResetOutcome{}, nil } - lookCtx := ViaLogin(ctx, "codex", user) - who, tok, accountID, err := codexUserToken(lookCtx, user) + week, held, err := look(now) if err != nil { a.next[key] = now.Add(autoResetWait[true]) return ResetOutcome{}, err } - _, windows, resets, err := codexWindows(ViaLogin(ctx, "codex", who), tok, accountID) - if err != nil { - a.next[key] = now.Add(autoResetWait[true]) - return ResetOutcome{}, err - } - week := weekUsedUp(windows, now) - if week == nil || resets == nil || resets.Count <= 0 { + if week == nil || !held { a.next[key] = now.Add(autoResetWait[week != nil]) return ResetOutcome{}, nil } - out, err := UseCodexReset(ctx, who) + out, err := spend() if err != nil || out.Code != "reset" { a.next[key] = now.Add(autoResetWait[true]) return out, err @@ -143,12 +156,12 @@ func weekUsedUp(windows []QuotaWindow, now time.Time) *time.Time { // load reads what was spent, once; a.Lock is held. func (a *autoResets) load() { - if a.path == autoResetPath() { + if a.path == a.where() { return } - a.path = autoResetPath() + a.path = a.where() a.spent, a.next = map[string]autoResetSpent{}, map[string]time.Time{} - if b, err := os.ReadFile(autoResetPath()); err == nil { + if b, err := os.ReadFile(a.path); err == nil { _ = json.Unmarshal(b, &a.spent) if a.spent == nil { a.spent = map[string]autoResetSpent{} diff --git a/internal/provider/codex_resets.go b/internal/provider/codex_resets.go index e5608bb0..98145e60 100644 --- a/internal/provider/codex_resets.go +++ b/internal/provider/codex_resets.go @@ -116,8 +116,9 @@ func codexResets(ctx context.Context, base, token, accountID string, count int) } // ResetOutcome is what spending a reset did: Code is the vendor's word for -// it (reset, nothing_to_reset, no_credit, already_redeemed) and Windows -// how many windows started again. +// it (reset, nothing_to_reset, no_credit, already_redeemed; for Claude +// already_used, not_limited, cooldown, ineligible, unavailable too) and +// Windows how many windows started again. type ResetOutcome struct { Code string `json:"code"` Windows int `json:"windows"` @@ -135,8 +136,17 @@ func (o ResetOutcome) Text() string { return "nothing to reset — no window has been used, and the reset is kept" case "no_credit": return "no reset left on the account" - case "already_redeemed": + 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/login_usage.go b/internal/provider/login_usage.go index 3fb4aef2..afb17db6 100644 --- a/internal/provider/login_usage.go +++ b/internal/provider/login_usage.go @@ -152,6 +152,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) } else { var plan string if plan, q.Windows, q.Resets, err = codexWindows(ctx, tok, accountID); plan != "" { diff --git a/internal/provider/subscription_usage.go b/internal/provider/subscription_usage.go index 749ec815..aa0240ad 100644 --- a/internal/provider/subscription_usage.go +++ b/internal/provider/subscription_usage.go @@ -58,8 +58,9 @@ 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, nil when - // it holds none (codex_resets.go). + // 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 *ResetCredits `json:"resets,omitempty"` } @@ -355,6 +356,7 @@ func claudeSubscriptionUsage(ctx context.Context) SubscriptionQuota { user, plan, _ := claudeIdentity() q.Plan = plan q.Windows, err = claudeWindows(ctx, user, token) + q.Resets = claudeResetsOf(user) if err != nil { q.Error = err.Error() } @@ -376,6 +378,9 @@ 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 } const claudeUsageTTL = 3 * time.Minute @@ -395,7 +400,7 @@ func claudeWindows(ctx context.Context, user, token string) ([]QuotaWindow, erro if ok && now.Before(e.retry) { return []QuotaWindow{}, claudeLimited(e.retry.Sub(now)) } - ws, err := readClaudeWindows(ctx, token) + ws, grants, err := readClaudeUsage(ctx, token) var st *accountStatusError if errors.As(err, &st) && st.status == http.StatusTooManyRequests { wait := st.retryAfter @@ -424,7 +429,7 @@ 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} + c.m[key] = claudeUsageEntry{at: now, ws: ws, heard: e.heard, grants: grants} c.Unlock() return ws, nil } @@ -450,6 +455,14 @@ func elapsed(ws []QuotaWindow, now time.Time) []QuotaWindow { // 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"` @@ -467,12 +480,13 @@ func readClaudeWindows(ctx context.Context, token string) ([]QuotaWindow, error) } `json:"model"` } `json:"scope"` } `json:"limits"` + Grants *claudeGrants `json:"cedar_ember"` } - err := accountJSON(ctx, claudeBase+"/api/oauth/usage", token, map[string]string{ + err := accountJSON(ctx, claudeBase+claudeUsagePath, token, map[string]string{ "anthropic-beta": "oauth-2025-04-20", "user-agent": "magpie", }, &data) if err != nil { - return []QuotaWindow{}, err + return []QuotaWindow{}, nil, err } out := []QuotaWindow{} const week = 7 * 24 * time.Hour @@ -501,7 +515,7 @@ func readClaudeWindows(ctx context.Context, token string) ([]QuotaWindow, error) w.Span, w.Model = week, model out = append(out, w) } - return out, nil + return out, data.Grants, nil } // claudeScopeModel is the word a model-scoped window counts models by, from diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 4554c694..7ad13db7 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -86,6 +86,10 @@ 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. @@ -484,6 +488,10 @@ 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 aa4d5ad1..8297b8fb 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -684,7 +684,7 @@ func quotaLines(qs []provider.SubscriptionQuota, asked, left bool, width int, no if r.Until != nil { c += sFaint.Render(" until " + provider.ResetClock(*r.Until, now)) } - if q.Provider == "codex" && provider.CodexAutoReset(q.User) { + if provider.AutoResets(q.Provider, q.User) { c += sFaint.Render(" · auto") // spent by itself once the week is used up } cells = append(cells, c) diff --git a/quota_cli.go b/quota_cli.go index 2b8dc624..82f5c62f 100644 --- a/quota_cli.go +++ b/quota_cli.go @@ -85,7 +85,7 @@ func quotaCmd(args []string) error { line += " " + bold.Render(q.Balance) + muted.Render(" left") } if q.Resets != nil { - line += " " + resetsCell(q.Resets, q.Provider == "codex" && provider.CodexAutoReset(q.User)) + line += " " + resetsCell(q.Resets, provider.AutoResets(q.Provider, q.User)) } if q.Error != "" { line += " " + muted.Render(q.Error)