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:
23 files changed
+1225
-104
No files matched your search
+1
-1
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.": "它的额度窗口都还没用过,现在重置不会有任何效果。",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user