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:
34 files changed
+736
-1345
No files matched your search
+3
-2
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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": "改道",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user