claude: magpie no longer asks Anthropic for a Claude account's usage, and adding a Claude account warns of a ban (the user: 不要伪造任何的 Claude 请求,能否通过 claude cli 获取 / 我害怕被封; 添加账号时要像其他的 Subscription Provider 一样显示风险提示) — the usage was read by magpie itself calling Anthropic's /api/oauth/usage with the account's token, on a timer, for every saved account, and Claude's usage-limit resets (cedar_ember) were listed, spent and auto-spent the same way. Now the usage is what Claude Code's own /usage prints (claude -p /usage, a command Claude Code answers itself without asking a model), run only when Usage is opened or Refresh pressed (GUI, magpie quota, accounts, TUI), for the account Claude Code is signed in to, at most once in 30s; a saved account shows only what Claude Code reported while using it, or the last reading kept. The Claude resets, Auto-use and their settings are gone (Codex's stay). Session jsonl files hold no usage figures, so they aren't read. Starting a Claude sign-in or import (GUI and CLI) first says Anthropic may suspend or ban the account and that it is at the user's own risk. TestClaudeWindowsAsked, TestParseClaudeUsage, TestClaudeUsageRunsClaudeCode, claude-usage-asked.test.cjs and claude-risk.test.cjs fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; routing-auto-reset, codex-auto-reset, quota-families, tray-usages, click-scroll, old-webkit, plugin-signin, usage-alert, usage-ledger pass.

This commit is contained in:
yetone committed 2026-10-01 12:38:25 +08:00
1 parent 7a091e31ae
commit 9bf6d43527
34 files changed
+736 -1345

No files matched your search

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