accounts: a Claude account's usage-limit resets show on its card, and can be used, by hand or by themselves (StringKe on Discord: claude-code usage 里有 reset) — magpie read Claude usage without the resets. It now asks as Claude Code does (/api/oauth/usage?cedar_ember=1&skip_spend=1) and shows ↺ N resets · until … on the card and panel. "Use a reset" spends the grant Anthropic names next through POST /api/organizations/{org}/reset_rate_limits, as Claude Code's /limit-reset does, the organization read from /api/oauth/profile; Auto-use (off by default, per account) spends one only when the account's whole week is used up, every other candidate is resting and the grant clears seven_day, at most one a week. The routing page labels an automatic one. Checked against fake servers with the user's JSON, never against Anthropic. TestClaudeGrants, TestClaudeResetsOnCard, TestUseClaudeReset, TestAutoUseClaudeReset, TestClaudeAutoReset and claude-resets.test.cjs (Chromium, WebKit, en/zh) fail without the change; codex-auto-reset, routing-auto-reset, click-scroll, old-webkit pass. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.

This commit is contained in:
yetone committed 2026-09-30 22:53:36 +08:00
1 parent 69b366adec
commit a9ff84fd80
23 files changed
+1225 -104

No files matched your search

+1 -1
View File
@@ -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)
+146
View File
@@ -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())
}
}
+20 -15
View File
@@ -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"
}
+11 -11
View File
@@ -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)
+4 -3
View File
@@ -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 {
+22 -2
View File
@@ -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
+37 -23
View File
@@ -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;
}
+9
View File
@@ -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.": "它的额度窗口都还没用过,现在重置不会有任何效果。",
+7 -4
View File
@@ -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 });
+17 -16
View File
@@ -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)
+11 -1
View File
@@ -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
+206
View File
@@ -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, []);
});
}
}
+16
View File
@@ -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)
})
}
+99
View File
@@ -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)
}
+260
View File
@@ -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
}
+285
View File
@@ -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")
}
}
+29 -16
View File
@@ -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{}
+13 -3
View File
@@ -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
}
+1
View File
@@ -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 != "" {
+21 -7
View File
@@ -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
+8
View File
@@ -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]
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)