usage: quota cards draw what was left over time, against an even burn, and the tray card its cycle's line (#651, songlairui) — each reading magpie makes anyway of a subscription's windows (and of a plan bought with a key) is kept as a point in quota-history.json beside the config: provider, account, window, when read, percent left, the window's start and reset — no token, no prompt. A failed reading, one kept from before (As of), an unlimited window and a model family's aren't kept; Antigravity's pools are kept by pool. Within a cycle points are 5 minutes apart at least and a run of one figure is its two ends; a cycle ends where the window starts again (its reset passed, or moved further than the time between two readings, so a rolling window's isn't one). 45 days and 6000 points a window are kept. The Usage page's card has, under its meters, a line a window (several tiers of one account on one chart), broken at each reset, a dashed even burn from each window's start to its reset and an upright now line, with 2 days / Cycle (the account's longest current window, start to reset) in the app's segmented control, remembered, turning every card without moving the page. The tray's card has a thin line of its first window's current cycle. GET /v1/magpie/quotas/history (?days=, ?provider=, ?user=; local or LAN with the key, as /v1/magpie/quotas) and /api/usage/quotas/history serve it; /v1/magpie/quotas and magpie quota stay snapshots. Computers sharing usage (WebDAV/S3, Settings › Sync › usage) put their history beside their days, sealed, as <id>.magpie-quotas, and merge the others': the union by account, window and time, two points within 30 s the newer, nothing twice however often they sync; an older magpie never reads the file. TestQuotaHistoryRecords, TestQuotaHistoryCycles, TestMergeQuotaHistory, TestQuotasHistory and TestQuotaHistorySharedBetweenComputers cover recording, cycles, pruning, merge, the API and two computers on one WebDAV folder; quota-curve.test.cjs (WebKit, en/zh, light and dark) the curves, the toggle, no curve without readings, and the tray's line (Chromium doesn't start in this sandbox). go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried with real accounts; pay-as-you-go balances aren't drawn.
This commit is contained in:
1 parent
c91529f9aa
commit
9ccabdfbd8
14 files changed
+1140
-22
No files matched your search
@@ -0,0 +1,91 @@
|
||||
package davsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// Two computers sharing usage share what they read of their accounts'
|
||||
// windows too (#651): each puts its own sealed beside its days, and each
|
||||
// ends with the points of both, merged by account and window, nothing
|
||||
// twice however often it syncs.
|
||||
func TestQuotaHistorySharedBetweenComputers(t *testing.T) {
|
||||
old := usageEvery
|
||||
usageEvery = 0
|
||||
t.Cleanup(func() { usageEvery = old })
|
||||
fake := &usageDAV{fakeDAV: &fakeDAV{files: map[string][]byte{}, etags: map[string]string{}, dirs: map[string]bool{"/dav": true}}}
|
||||
srv := httptest.NewServer(fake)
|
||||
defer srv.Close()
|
||||
cfg := Config{URL: srv.URL + "/dav/", User: "me", Password: "pw", Passphrase: "correct horse", Keys: true, Usage: true}
|
||||
sync := func(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := Now(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v := Status(); v.UsageError != "" {
|
||||
t.Fatalf("usage not shared: %+v", v)
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
seed := func(t *testing.T, mins ...int) {
|
||||
t.Helper()
|
||||
var pts []string
|
||||
for _, m := range mins {
|
||||
pts = append(pts, fmt.Sprintf(`{"at":%q,"left":%d}`, now.Add(-time.Duration(m)*time.Minute).Format(time.RFC3339), 100-m/10))
|
||||
}
|
||||
if err := provider.MergeQuotaHistory([]byte(`{"codex|a@x.com":{"5 hours":[`+strings.Join(pts, ",")+`]}}`), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
points := func(t *testing.T) int {
|
||||
t.Helper()
|
||||
hs := provider.QuotaHistories(time.Time{}, "codex", "a@x.com")
|
||||
if len(hs) != 1 {
|
||||
t.Fatalf("histories: %+v", hs)
|
||||
}
|
||||
return len(hs[0].Lines[0].Points)
|
||||
}
|
||||
|
||||
a, b := newComputer(t), newComputer(t)
|
||||
a.use(t)
|
||||
if err := Configure(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed(t, 100, 60)
|
||||
sync(t)
|
||||
var sealed []byte
|
||||
for _, f := range fake.usageFiles() {
|
||||
if strings.HasSuffix(f, quotasExt) {
|
||||
sealed = fake.files["/dav/magpie/usage/"+f]
|
||||
}
|
||||
}
|
||||
if sealed == nil || strings.Contains(string(sealed), "a@x.com") {
|
||||
t.Fatalf("a's history on the server, sealed: %v", fake.usageFiles())
|
||||
}
|
||||
|
||||
b.use(t)
|
||||
if err := Configure(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed(t, 80, 20)
|
||||
sync(t)
|
||||
if n := points(t); n != 4 {
|
||||
t.Fatalf("b has %d points, want its 2 and a's 2", n)
|
||||
}
|
||||
sync(t)
|
||||
sync(t)
|
||||
if n := points(t); n != 4 {
|
||||
t.Fatalf("b, synced again: %d points", n)
|
||||
}
|
||||
a.use(t)
|
||||
sync(t)
|
||||
if n := points(t); n != 4 {
|
||||
t.Fatalf("a has %d points, want its 2 and b's 2", n)
|
||||
}
|
||||
}
|
||||
+85
-18
@@ -3,7 +3,10 @@ package davsync
|
||||
// Shared usage (#542): with Config.Usage on, each computer puts its calls on
|
||||
// the server beside the backup, at magpie/usage/<id>-<day>.magpie-usage, one
|
||||
// file a day, sealed with the passphrase, and brings the other computers'
|
||||
// down for the Usage page. A computer writes and deletes its own files
|
||||
// down for the Usage page. Beside its days, a computer puts what it read
|
||||
// of its accounts' windows over time (#651), at magpie/usage/<id>.magpie-quotas,
|
||||
// sealed alike, and merges the others' into its own (provider.MergeQuotaHistory)
|
||||
// for the quota cards' curves. A computer writes and deletes its own files
|
||||
// alone, so none is ever written over by another; nothing of them is in the
|
||||
// backup, nor in what is hashed to tell whether it changed. A magpie
|
||||
// before them never looks in the folder.
|
||||
@@ -24,6 +27,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/backup"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
"github.com/yetone/magpie/internal/usage"
|
||||
)
|
||||
|
||||
@@ -31,6 +35,7 @@ import (
|
||||
const (
|
||||
usageFolder = "usage"
|
||||
usageExt = ".magpie-usage"
|
||||
quotasExt = ".magpie-quotas"
|
||||
)
|
||||
|
||||
// usageEvery is how often a sync shares usage; a sync asked for (SyncNow)
|
||||
@@ -48,6 +53,10 @@ type usageState struct {
|
||||
// yesterday and today are made again
|
||||
Whole bool `json:"whole,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// QuotasSent is this computer's quota history as last put, hashed;
|
||||
// Quotas the others' brought in, each at the version listed
|
||||
QuotasSent string `json:"quotasSent,omitempty"`
|
||||
Quotas map[string]string `json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
// files are a remote's usage files: listed with a version each, read,
|
||||
@@ -186,6 +195,59 @@ func shareWith(ctx context.Context, c Config, u *usageState, fs files) error {
|
||||
delete(u.Got, f)
|
||||
}
|
||||
}
|
||||
return shareQuotas(ctx, c, u, fs, listed, id)
|
||||
}
|
||||
|
||||
// shareQuotas merges the other computers' quota histories into this one's,
|
||||
// then puts this one's up when it changed. The merge keeps each point once
|
||||
// (by account, window and when it was read), so what came from another and
|
||||
// goes up again with this one's is nothing twice.
|
||||
func shareQuotas(ctx context.Context, c Config, u *usageState, fs files, listed map[string]string, id string) error {
|
||||
if u.Quotas == nil {
|
||||
u.Quotas = map[string]string{}
|
||||
}
|
||||
for f, ver := range listed {
|
||||
who, ok := strings.CutSuffix(f, quotasExt)
|
||||
if !ok || who == id || !usage.ValidComputer(who) {
|
||||
continue
|
||||
}
|
||||
if ver != "" && u.Quotas[f] == ver {
|
||||
continue
|
||||
}
|
||||
plain, err := readSealed(ctx, fs, f, c.Passphrase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := provider.MergeQuotaHistory(plain, time.Now()); err != nil {
|
||||
return fmt.Errorf("reading %s: %w", f, err)
|
||||
}
|
||||
u.Quotas[f] = ver
|
||||
}
|
||||
for f := range u.Quotas {
|
||||
if _, ok := listed[f]; !ok {
|
||||
delete(u.Quotas, f)
|
||||
}
|
||||
}
|
||||
plain := provider.QuotaHistoryData()
|
||||
if len(plain) == 0 {
|
||||
return nil
|
||||
}
|
||||
h := sum(plain)
|
||||
if u.QuotasSent == h {
|
||||
return nil
|
||||
}
|
||||
var z bytes.Buffer
|
||||
w := gzip.NewWriter(&z)
|
||||
w.Write(plain)
|
||||
w.Close()
|
||||
sealed, err := backup.SealData(z.Bytes(), c.Passphrase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fs.write(ctx, id+quotasExt, sealed); err != nil {
|
||||
return err
|
||||
}
|
||||
u.QuotasSent = h
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -216,28 +278,33 @@ func usageName(f string) (who, day string, ok bool) {
|
||||
|
||||
func readDay(ctx context.Context, fs files, f, pass string) (usage.SharedDay, error) {
|
||||
var d usage.SharedDay
|
||||
sealed, err := fs.read(ctx, f)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
z, err := backup.OpenData(sealed, pass)
|
||||
if errors.Is(err, backup.ErrPassphrase) {
|
||||
return d, fmt.Errorf("the passphrase doesn't open %s: another computer shares its usage sealed with another one", f)
|
||||
}
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
zr, err := gzip.NewReader(bytes.NewReader(z))
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
plain, err := io.ReadAll(io.LimitReader(zr, 256<<20))
|
||||
plain, err := readSealed(ctx, fs, f, pass)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
return d, json.Unmarshal(plain, &d)
|
||||
}
|
||||
|
||||
// readSealed is a usage folder's file read, opened and unzipped.
|
||||
func readSealed(ctx context.Context, fs files, f, pass string) ([]byte, error) {
|
||||
sealed, err := fs.read(ctx, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z, err := backup.OpenData(sealed, pass)
|
||||
if errors.Is(err, backup.ErrPassphrase) {
|
||||
return nil, fmt.Errorf("the passphrase doesn't open %s: another computer shares its usage sealed with another one", f)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
zr, err := gzip.NewReader(bytes.NewReader(z))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(zr, 256<<20))
|
||||
}
|
||||
|
||||
// WebDAV
|
||||
|
||||
func (d *dav) usageURL(name string) string { return d.url(folder, usageFolder, name) }
|
||||
@@ -273,7 +340,7 @@ func (d *dav) list(ctx context.Context) (map[string]string, error) {
|
||||
h = u.Path
|
||||
}
|
||||
name := pathpkg.Base(strings.TrimRight(h, "/"))
|
||||
if r.Collected != nil || !strings.HasSuffix(name, usageExt) {
|
||||
if r.Collected != nil || !strings.HasSuffix(name, usageExt) && !strings.HasSuffix(name, quotasExt) {
|
||||
continue
|
||||
}
|
||||
v := r.ETag
|
||||
|
||||
@@ -448,6 +448,7 @@ func (s *Server) Handler() http.Handler {
|
||||
writeJSON(w, 200, map[string]any{"name": "magpie", "version": Version})
|
||||
})
|
||||
mux.HandleFunc("GET /v1/magpie/quotas", s.quotas)
|
||||
mux.HandleFunc("GET /v1/magpie/quotas/history", s.quotasHistory)
|
||||
mux.HandleFunc("GET /v1/magpie/route", s.sessionRoute)
|
||||
mux.HandleFunc("GET /v1/magpie/concurrency", s.concurrency)
|
||||
mux.HandleFunc("GET /v1/magpie/limit", s.keyLimit)
|
||||
@@ -482,7 +483,7 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
func (s *Server) info(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"name": "magpie", "version": Version, "models": len(provider.Catalog()), "window": Window,
|
||||
"apis": []string{"/v1/chat/completions", "/v1/responses", "/v1/messages", "/v1/systemone", "/v1beta/models/{model}:generateContent", "/v1/images/generations", "/v1/images/edits", "/v1/videos", "/v1/magpie/quotas", "/v1/magpie/route"}})
|
||||
"apis": []string{"/v1/chat/completions", "/v1/responses", "/v1/messages", "/v1/systemone", "/v1beta/models/{model}:generateContent", "/v1/images/generations", "/v1/images/edits", "/v1/videos", "/v1/magpie/quotas", "/v1/magpie/quotas/history", "/v1/magpie/route"}})
|
||||
}
|
||||
|
||||
// quotas is what is left of every subscription, plan and key magpie has,
|
||||
@@ -500,6 +501,19 @@ func (s *Server) quotas(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"object": "list", "data": provider.QuotaReport(ctx, time.Now())})
|
||||
}
|
||||
|
||||
// quotasHistory is what was left of each account's windows over time, as
|
||||
// this magpie (and the computers sharing usage with it) read them (#651):
|
||||
// ?days= back (45 at most), ?provider= and ?user= to pick an account.
|
||||
// /v1/magpie/quotas stays the snapshot.
|
||||
func (s *Server) quotasHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if !local(r) && !sharedWith(r) {
|
||||
writeError(w, provider.Chat, http.StatusForbidden, "magpie's quotas are told to another machine only when magpie is shared on the local network (Settings → Share on local network) and the request carries its API key (Authorization: Bearer <key> or x-api-key: <key>)")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
writeJSON(w, 200, map[string]any{"object": "list", "data": provider.QuotaHistories(provider.QuotaHistorySince(q.Get("days"), time.Now()), q.Get("provider"), q.Get("user"))})
|
||||
}
|
||||
|
||||
func modelObject(e provider.Entry) map[string]any {
|
||||
type reasoningLevel struct {
|
||||
Effort string `json:"effort"`
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// GET /v1/magpie/quotas/history is each account's windows over time
|
||||
// (#651): the points of the last ?days=, ?provider= and ?user= picking
|
||||
// one account's, and another machine without the shared key gets nothing.
|
||||
func TestQuotasHistory(t *testing.T) {
|
||||
fresh(t)
|
||||
t.Setenv("MAGPIE_ADDR", "")
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
ago := func(h int) string { return now.Add(-time.Duration(h) * time.Hour).Format(time.RFC3339) }
|
||||
seed := fmt.Sprintf(`{"codex|a@x.com":{"5 hours":[{"at":%q,"left":90},{"at":%q,"left":70}]},
|
||||
"claude|":{"Weekly":[{"at":%q,"left":40}]}}`, ago(100), ago(1), ago(2))
|
||||
if err := provider.MergeQuotaHistory([]byte(seed), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := lanGuard(New().Handler())
|
||||
get := func(from, q string) (int, []provider.QuotaHistory) {
|
||||
r := httptest.NewRequest("GET", "/v1/magpie/quotas/history"+q, nil)
|
||||
r.RemoteAddr = from
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
var out struct{ Data []provider.QuotaHistory }
|
||||
json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return w.Code, out.Data
|
||||
}
|
||||
c, all := get("127.0.0.1:5000", "")
|
||||
if c != 200 || len(all) != 2 {
|
||||
t.Fatalf("all: %d %+v", c, all)
|
||||
}
|
||||
_, codex := get("127.0.0.1:5000", "?provider=codex&user=A@x.com&days=2")
|
||||
if len(codex) != 1 || codex[0].User != "a@x.com" || len(codex[0].Lines[0].Points) != 2 {
|
||||
t.Fatalf("codex, 2 days (the point the range begins in, and the last): %+v", codex)
|
||||
}
|
||||
if c, _ := get("192.168.1.9:5000", ""); c == http.StatusOK {
|
||||
t.Fatal("another machine without a key got the history")
|
||||
}
|
||||
}
|
||||
@@ -555,6 +555,20 @@ body.window .field.solo { grid-column: auto; }
|
||||
.credit-days .cd-day.unknown { background: none; outline: 1px dashed var(--line); outline-offset: -1px; }
|
||||
.credit-days .cd-day i { display: block; width: 100%; border-radius: 2px; background: var(--accent); }
|
||||
.credit-days .cd-note { margin-top: 4px; font-size: 10px; color: var(--faint); }
|
||||
/* what was left over time (#651): a line a window, an even burn dashed, now upright */
|
||||
.quota-curve { margin-top: 10px; }
|
||||
.quota-curve .qc-head { display: flex; justify-content: space-between; align-items: center; gap: 6px; font-size: 10.5px; color: var(--muted); }
|
||||
.quota-curve .qc-range .opt { height: 20px; padding: 0 9px; font-size: 11px; }
|
||||
.quota-curve .qc-plot { display: block; width: 100%; height: 64px; margin-top: 6px; overflow: hidden; }
|
||||
.qc-grid { stroke: var(--line); stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||||
.qc-line { fill: none; stroke-width: 1.6; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
|
||||
.qc-even { stroke-width: 1; stroke-dasharray: 3 3; opacity: .55; vector-effect: non-scaling-stroke; }
|
||||
.qc-now { stroke: var(--faint); stroke-width: 1; stroke-dasharray: 1 2; vector-effect: non-scaling-stroke; }
|
||||
.quota-curve .qc-axis { display: flex; justify-content: space-between; margin-top: 3px; font-size: 10px; color: var(--faint); font-variant-numeric: tabular-nums; }
|
||||
.quota-curve .qc-legend { display: flex; flex-wrap: wrap; gap: 3px 12px; margin-top: 4px; font-size: 10.5px; color: var(--muted); }
|
||||
.qc-key { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.qc-key i { width: 7px; height: 7px; border-radius: 50%; }
|
||||
.qc-key b { color: var(--fg-2); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.quota-resets { display: flex; align-items: center; gap: 8px; margin-top: 9px; font-size: 10.5px; font-variant-numeric: tabular-nums; }
|
||||
.quota-resets .text { margin-left: auto; flex: none; font-size: 11px; padding: 2px 7px; }
|
||||
.subscription-card.several .quota-resets + .subscription-account { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); }
|
||||
@@ -1146,6 +1160,10 @@ body.panel .profiles #save { height: 25px; } /* the field's: the line keeps its
|
||||
.pq-rn { max-width: 44px; font-size: 9.5px; line-height: 1.2; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pq-rn2 { display: flex; flex-direction: column; align-items: center; }
|
||||
.pq-rn2 > span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
||||
/* the first window's current cycle, a thin line across the card (#651) */
|
||||
.pq-spark-box { grid-column: 1 / -1; display: block; height: 16px; }
|
||||
.pq-spark { display: block; width: 100%; height: 16px; overflow: hidden; }
|
||||
.pq-spark .qc-line { stroke-width: 1.2; }
|
||||
/* a Codex account's rate-limit resets, under a hairline across the card */
|
||||
.pq-resets { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; margin-top: 5px; padding-top: 7px; border-top: 1px solid var(--line-2); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.resets-words { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
+186
-2
@@ -8711,6 +8711,7 @@ 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
|
||||
let quotaHist = []; // each account's windows over time, for the curves (#651)
|
||||
// asked: the reader opened the page, so a Claude account's usage is read
|
||||
// at once, by running Claude Code's own /usage, rather than when its last
|
||||
// reading is due (every 5 to 15 minutes, at random, once Claude Code was used).
|
||||
@@ -8732,8 +8733,8 @@ let quotasLoading = null;
|
||||
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 || []; })
|
||||
.then(() => Promise.all([api("usage/quotas" + (asked ? "?asked=1" : "")), api("usage/quotas/history?days=35").catch(() => null)]))
|
||||
.then(([q, h]) => { quotas = q || []; quotasAt = Date.now(); if (h) quotaHist = h; }, () => { quotas = quotas || []; })
|
||||
.finally(() => { if (quotasLoading === p) quotasLoading = null; renderQuotas(); });
|
||||
p.asked = !!asked;
|
||||
quotasLoading = p;
|
||||
@@ -8872,6 +8873,9 @@ function renderQuotas() {
|
||||
card.append(who);
|
||||
} else if (every) head.append(every);
|
||||
card.append(meters);
|
||||
// what was left over time, against an even burn (#651)
|
||||
const curve = !sub.error && quotaCurve(sub);
|
||||
if (curve) card.append(curve);
|
||||
// WorkBuddy's credits, day by day, as magpie counted them (#568)
|
||||
if (sub.daily && !sub.error) card.append(creditDays(sub));
|
||||
// what is left besides the windows, under them
|
||||
@@ -8945,6 +8949,183 @@ function creditDays(sub) {
|
||||
return box;
|
||||
}
|
||||
|
||||
// What was left of an account's windows over time (#651), from magpie's
|
||||
// readings of them (and those of the computers sharing usage with it): a
|
||||
// line a window, the percent left, broken where the window started again;
|
||||
// a dashed line from the window's start to its reset is an even burn, and
|
||||
// a thin upright one is now. "2 days" is the last two days; "Cycle" the
|
||||
// current cycle of the account's longest window, its start to its reset.
|
||||
let quotaRange = "cycle";
|
||||
try { if (localStorage.getItem("magpie.quotaRange") === "2d") quotaRange = "2d"; } catch {}
|
||||
const QUOTA_RANGES = [["2d", "2 days"], ["cycle", "Cycle"]];
|
||||
|
||||
function quotaHistOf(sub) {
|
||||
const user = (sub.user || "").toLowerCase();
|
||||
return quotaHist?.find?.((h) => h.provider === sub.provider && h.user === user);
|
||||
}
|
||||
// the lines of sub's windows, as its card lists them, with points
|
||||
function quotaLines(sub) {
|
||||
const h = quotaHistOf(sub);
|
||||
if (!h) return [];
|
||||
const out = [];
|
||||
for (const w of sub.windows || []) {
|
||||
if (w.unlimited || w.family) continue;
|
||||
// a pool's window is kept under the pool's name and its own
|
||||
const name = w.pool ? w.pool + " · " + w.name : w.name;
|
||||
const l = h.lines.find((x) => x.name === name);
|
||||
if (!l?.points?.length || out.some((x) => x.name === l.name)) continue;
|
||||
out.push({ name, label: w.pool ? w.pool + " · " + t(w.name) : t(w.name), window: w, points: l.points.map((p) => ({
|
||||
at: Date.parse(p.at), left: p.left,
|
||||
start: p.start ? Date.parse(p.start) : null, reset: p.resetsAt ? Date.parse(p.resetsAt) : null,
|
||||
})) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// quotaCycleBreak: whether b, read after a, is of its window started again,
|
||||
// as magpie's own record of them says (quota_history.go's newCycle)
|
||||
function quotaCycleBreak(a, b) {
|
||||
if (a.reset == null && b.reset == null) return b.left > a.left + 5;
|
||||
if (a.reset == null || b.reset == null) return true;
|
||||
if (a.reset <= b.at) return true;
|
||||
const moved = b.reset - a.reset, slack = 5 * 60e3;
|
||||
return moved > b.at - a.at + slack || moved < -slack;
|
||||
}
|
||||
// the current cycle of a line: its start and its reset, the window's as
|
||||
// the card has it, else its last reading's
|
||||
function quotaCycle(l, now) {
|
||||
const last = l.points[l.points.length - 1];
|
||||
let reset = l.window.resetsAt ? Date.parse(l.window.resetsAt) : last.reset;
|
||||
let start = last.start != null && last.reset != null && reset != null ? last.start + (reset - last.reset) : last.start;
|
||||
if (start == null) { // its first reading of this cycle
|
||||
let i = l.points.length - 1;
|
||||
while (i > 0 && !quotaCycleBreak(l.points[i - 1], l.points[i])) i--;
|
||||
start = l.points[i].at;
|
||||
}
|
||||
if (reset != null && reset <= now) reset = null;
|
||||
return { start: Math.min(start, now), reset };
|
||||
}
|
||||
function quotaSpan(lines, range, now) {
|
||||
if (range === "2d") return [now - 48 * 3600e3, now];
|
||||
let x0 = Infinity, x1 = now;
|
||||
for (const l of lines) {
|
||||
const c = quotaCycle(l, now);
|
||||
x0 = Math.min(x0, c.start);
|
||||
if (c.reset) x1 = Math.max(x1, c.reset);
|
||||
}
|
||||
if (!(x0 < now)) x0 = now - 3600e3;
|
||||
return [x0, x1];
|
||||
}
|
||||
// quotaPaths: a line's points in [x0, x1] as path data on a w×h box, a
|
||||
// new subpath at each cycle's start; the point before x0 and the one after
|
||||
// x1 go along, so the line reaches the box's edges
|
||||
function quotaPaths(points, x0, x1, w, h) {
|
||||
const X = (t) => ((t - x0) / (x1 - x0)) * w, Y = (v) => h - (Math.max(0, Math.min(100, v)) / 100) * h;
|
||||
let d = "", prev = null;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
const next = points[i + 1];
|
||||
if (p.at < x0 && (!next || next.at < x0 || quotaCycleBreak(p, next))) continue;
|
||||
if (prev && p.at > x1 && prev.at > x1) break;
|
||||
const brk = !prev || quotaCycleBreak(prev, p);
|
||||
d += (brk ? "M" : "L") + X(p.at).toFixed(1) + " " + Y(p.left).toFixed(1);
|
||||
if (brk && (!points[i + 1] || quotaCycleBreak(p, points[i + 1]))) d += "h0.01"; // a lone point is a dot
|
||||
prev = p;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
function quotaTimeText(at, now) {
|
||||
const d = new Date(at), lang = locale === "zh" ? "zh-CN" : undefined;
|
||||
if (Math.abs(at - now) < 60e3) return t("Now");
|
||||
const sameDay = d.toDateString() === new Date(now).toDateString();
|
||||
const time = d.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
return sameDay ? time : d.toLocaleDateString(lang, { month: "short", day: "numeric" }) + " " + time;
|
||||
}
|
||||
// drawQuotaPlot puts lines' curves for [x0, x1] in svg, a w×h box:
|
||||
// the grid, the even burns, the lines, and now.
|
||||
function drawQuotaPlot(g, lines, x0, x1, w, h, now, thin) {
|
||||
g.replaceChildren();
|
||||
if (!thin) for (const v of [0, 50, 100]) g.append(sv("line", { x1: 0, x2: w, y1: h - (v / 100) * h, y2: h - (v / 100) * h, class: "qc-grid" }));
|
||||
const X = (t) => ((t - x0) / (x1 - x0)) * w;
|
||||
lines.forEach((l, i) => {
|
||||
const color = `var(--c${(i % 7) + 1})`;
|
||||
const c = quotaCycle(l, now);
|
||||
if (c.reset && c.start < c.reset) {
|
||||
const e = sv("line", { x1: X(c.start), y1: 0, x2: X(c.reset), y2: h, class: "qc-even" }, { stroke: color });
|
||||
g.append(e);
|
||||
}
|
||||
const d = quotaPaths(l.points, x0, x1, w, h);
|
||||
if (d) g.append(sv("path", { d, class: "qc-line", "data-name": l.name }, { stroke: color }));
|
||||
});
|
||||
if (now >= x0 && now <= x1) g.append(sv("line", { x1: X(now), x2: X(now), y1: 0, y2: h, class: "qc-now" }));
|
||||
}
|
||||
// quotaCurve: an account's windows over time, under its meters, with the
|
||||
// range turned for every card at once
|
||||
function quotaCurve(sub) {
|
||||
const lines = quotaLines(sub);
|
||||
if (!lines.length) return null;
|
||||
const box = el("div", "quota-curve");
|
||||
const head = el("div", "qc-head");
|
||||
head.append(el("span", "", t("Left over time")));
|
||||
const seg = el("div", "segs qc-range");
|
||||
for (const [id, name] of QUOTA_RANGES) {
|
||||
const b = el("button", "opt" + (id === quotaRange ? " on" : ""), t(name));
|
||||
b.dataset.range = id;
|
||||
b.title = t(id === "2d" ? "The last two days" : "The current cycle, from the window's start to its reset");
|
||||
b.onclick = () => setQuotaRange(id);
|
||||
seg.append(b);
|
||||
}
|
||||
head.append(seg);
|
||||
const W = 300, H = 60;
|
||||
const g = sv("svg", { viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: "none", class: "qc-plot", role: "img" });
|
||||
const axis = el("div", "qc-axis");
|
||||
const legend = el("div", "qc-legend");
|
||||
lines.forEach((l, i) => {
|
||||
const k = el("span", "qc-key");
|
||||
const sw = el("i");
|
||||
sw.style.background = `var(--c${(i % 7) + 1})`;
|
||||
const last = l.points[l.points.length - 1];
|
||||
k.append(sw, el("span", "", l.label), el("b", "", Math.round(last.left) + "%"));
|
||||
k.title = t("{name}: {n} left, read {when}", { name: l.label, n: Math.round(last.left) + "%", when: quotaTimeText(last.at, Date.now()) });
|
||||
legend.append(k);
|
||||
});
|
||||
box.draw = () => {
|
||||
const now = Date.now();
|
||||
const [x0, x1] = quotaSpan(lines, quotaRange, now);
|
||||
drawQuotaPlot(g, lines, x0, x1, W, H, now);
|
||||
g.setAttribute("aria-label", t("Left over time") + " · " + t(quotaRange === "2d" ? "2 days" : "Cycle"));
|
||||
axis.replaceChildren(el("span", "", quotaTimeText(x0, now)), el("span", "", quotaTimeText(x1, now)));
|
||||
for (const b of seg.querySelectorAll(".opt")) b.classList.toggle("on", b.dataset.range === quotaRange);
|
||||
// its thumb is put once the card is on the page
|
||||
if (seg.isConnected) slide(seg, "quotaRange"); else queueMicrotask(() => slide(seg, "quotaRange"));
|
||||
};
|
||||
box.append(head, g, axis, legend);
|
||||
box.draw();
|
||||
return box;
|
||||
}
|
||||
function setQuotaRange(id) {
|
||||
if (id === quotaRange) return;
|
||||
quotaRange = id;
|
||||
try { localStorage.setItem("magpie.quotaRange", id); } catch {}
|
||||
for (const b of document.querySelectorAll(".quota-curve")) b.draw?.();
|
||||
}
|
||||
// quotaSpark: the tray card's thin line of a window's current cycle, the
|
||||
// even burn faint under it
|
||||
function quotaSpark(q, w) {
|
||||
const lines = quotaLines({ ...q, windows: [w.window ? { ...w, name: w.window } : w] });
|
||||
if (!lines.length) return null;
|
||||
const now = Date.now(), c = quotaCycle(lines[0], now);
|
||||
const x1 = c.reset || now;
|
||||
if (!(c.start < x1)) return null;
|
||||
const W = 200, H = 16;
|
||||
const g = sv("svg", { viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: "none", class: "pq-spark", role: "img" });
|
||||
g.setAttribute("aria-label", t("{name}: this cycle", { name: t(w.name) }));
|
||||
drawQuotaPlot(g, lines, c.start, x1, W, H, now, true);
|
||||
const s = el("span", "pq-spark-box");
|
||||
s.title = t("{name}: what was left this cycle, against an even burn", { name: t(w.name) });
|
||||
s.append(g);
|
||||
return s;
|
||||
}
|
||||
|
||||
// planTerm says when a plan's paid time ends: renewed then, over, or
|
||||
// either (the vendor doesn't say which), as short as a panel row needs.
|
||||
function planTerm(q) {
|
||||
@@ -9398,6 +9579,9 @@ function panelQuotaCard(q) {
|
||||
rings.append(r);
|
||||
}
|
||||
card.append(rings);
|
||||
// the first window's cycle, a thin line under the card (#651)
|
||||
const spark = ws[0] && !ws[0].tiers ? quotaSpark(q, ws[0]) : null;
|
||||
if (spark) card.append(spark);
|
||||
if (q.asOf) card.append(el("span", "pq-sub pq-asof", asOfText(q, true)));
|
||||
if (q.resets?.count) {
|
||||
const r = el("div", "pq-resets");
|
||||
|
||||
@@ -1669,6 +1669,15 @@ const I18N = {
|
||||
"live, as the gateway decides": "实时 · 网关正在做的决定",
|
||||
"Hide accounts": "账号打码",
|
||||
"Credits used per day": "每天用掉的积分",
|
||||
"Left over time": "剩余额度走势",
|
||||
"2 days": "2 天",
|
||||
"Cycle": "本周期",
|
||||
"The last two days": "最近两天",
|
||||
"The current cycle, from the window's start to its reset": "当前周期:从窗口开始到重置",
|
||||
"{name}: {n} left, read {when}": "{name}:剩余 {n},读取于 {when}",
|
||||
"Now": "现在",
|
||||
"{name}: this cycle": "{name}:本周期",
|
||||
"{name}: what was left this cycle, against an even burn": "{name}:本周期的剩余额度,虚线为匀速消耗",
|
||||
"Counted from magpie's readings of the vendor's meter, since {date}: what is used while magpie isn't reading it is counted on the day it next does": "自 {date} 起,由 magpie 读取的额度累计算出;magpie 没在读取时用掉的积分,记在下一次读到的那天",
|
||||
"{n} today": "今天 {n}",
|
||||
"{n} in {days} days": "{days} 天共 {n}",
|
||||
|
||||
@@ -1374,10 +1374,16 @@ yet saved and an id typed in that the vendor doesn't list (added by hand)
|
||||
outlive both, and Save sends them. No click moves the page. English and
|
||||
Chinese, Chromium and WebKit, with `/api/plugins` faked.
|
||||
|
||||
`quota-curve.test.cjs` checks the quota cards' curves (#651): a line a
|
||||
window under the meters, broken at a reset, a dashed even burn and a now
|
||||
line, the 2 days / Cycle control turning every card without moving the page,
|
||||
no curve without readings, the theme's chart colours in light and dark, and
|
||||
the tray card's thin current-cycle line, in Chinese and English.
|
||||
|
||||
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/routing-log-icons.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 internal/gui/tests/provider-typed.test.cjs internal/gui/tests/phone-web.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-blocked.test.cjs internal/gui/tests/routing-zcode-blocked.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/signin-paste-key.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 internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/plugin-movable-review.test.cjs internal/gui/tests/deprecation-notice.test.cjs internal/gui/tests/routing-time.test.cjs internal/gui/tests/unlisted-models.test.cjs internal/gui/tests/plugin-updates.test.cjs internal/gui/tests/request-archive.test.cjs internal/gui/tests/group-member-fast.test.cjs internal/gui/tests/providers-off-fold.test.cjs internal/gui/tests/account-return.test.cjs internal/gui/tests/usage-ledger-detail.test.cjs internal/gui/tests/usage-ledger-chart.test.cjs internal/gui/tests/usage-chart-axis.test.cjs internal/gui/tests/panel-usage.test.cjs internal/gui/tests/usage-refresh.test.cjs internal/gui/tests/usage-ledger-content.test.cjs internal/gui/tests/claude-direct.test.cjs internal/gui/tests/signin-again.test.cjs internal/gui/tests/sessions-calendar-fill.test.cjs internal/gui/tests/editor-short-window.test.cjs internal/gui/tests/group-from-model-add.test.cjs internal/gui/tests/sync-other-kind.test.cjs internal/gui/tests/web-page.test.cjs internal/gui/tests/balance-parts.test.cjs internal/gui/tests/search-api.test.cjs internal/gui/tests/searcher-pick.test.cjs internal/gui/tests/provider-file-error.test.cjs internal/gui/tests/sessions-manage.test.cjs internal/gui/tests/number-units.test.cjs internal/gui/tests/sessions-bar-names.test.cjs internal/gui/tests/model-suffix-own.test.cjs internal/gui/tests/routing-effort-change.test.cjs internal/gui/tests/every-skill.test.cjs internal/gui/tests/header-centre.test.cjs internal/gui/tests/public-url.test.cjs internal/gui/tests/strip-scrollbar.test.cjs internal/gui/tests/remove-all-skills.test.cjs internal/gui/tests/request-archive-usage.test.cjs internal/gui/tests/agent-layout.test.cjs internal/gui/tests/group-found-off.test.cjs internal/gui/tests/provider-pickLine truncated
|
||||
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/routing-log-icons.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 internal/gui/tests/provider-typed.test.cjs internal/gui/tests/phone-web.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-blocked.test.cjs internal/gui/tests/routing-zcode-blocked.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/signin-paste-key.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 internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/plugin-movable-review.test.cjs internal/gui/tests/deprecation-notice.test.cjs internal/gui/tests/routing-time.test.cjs internal/gui/tests/unlisted-models.test.cjs internal/gui/tests/plugin-updates.test.cjs internal/gui/tests/request-archive.test.cjs internal/gui/tests/group-member-fast.test.cjs internal/gui/tests/providers-off-fold.test.cjs internal/gui/tests/account-return.test.cjs internal/gui/tests/usage-ledger-detail.test.cjs internal/gui/tests/usage-ledger-chart.test.cjs internal/gui/tests/usage-chart-axis.test.cjs internal/gui/tests/panel-usage.test.cjs internal/gui/tests/usage-refresh.test.cjs internal/gui/tests/usage-ledger-content.test.cjs internal/gui/tests/claude-direct.test.cjs internal/gui/tests/signin-again.test.cjs internal/gui/tests/sessions-calendar-fill.test.cjs internal/gui/tests/editor-short-window.test.cjs internal/gui/tests/group-from-model-add.test.cjs internal/gui/tests/sync-other-kind.test.cjs internal/gui/tests/web-page.test.cjs internal/gui/tests/balance-parts.test.cjs internal/gui/tests/search-api.test.cjs internal/gui/tests/searcher-pick.test.cjs internal/gui/tests/provider-file-error.test.cjs internal/gui/tests/sessions-manage.test.cjs internal/gui/tests/number-units.test.cjs internal/gui/tests/sessions-bar-names.test.cjs internal/gui/tests/model-suffix-own.test.cjs internal/gui/tests/routing-effort-change.test.cjs internal/gui/tests/every-skill.test.cjs internal/gui/tests/header-centre.test.cjs internal/gui/tests/public-url.test.cjs internal/gui/tests/strip-scrollbar.test.cjs internal/gui/tests/remove-all-skills.test.cjs internal/gui/tests/request-archive-usage.test.cjs internal/gui/tests/agent-layout.test.cjs internal/gui/tests/group-found-off.test.cjs internal/gui/tests/provider-pickLine truncated
|
||||
```
|
||||
|
||||
If Playwright is installed outside the repository, set `NODE_PATH` to the
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// What was left over time (#651): under an account's meters on the Usage
|
||||
// page, a line a window from magpie's readings — broken where the 5-hour
|
||||
// window started again — a dashed even burn from each window's start to its
|
||||
// reset, and an upright line at now; "2 days" / "Cycle" turns every card's
|
||||
// range at once, in the app's own segmented control, the pick remembered,
|
||||
// and the click doesn't move the page. An account with no readings has no
|
||||
// curve. The tray's card has a thin line of its first window's cycle.
|
||||
// English and Chinese, light and dark; 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 H = 3600e3;
|
||||
const iso = (ms) => new Date(ms).toISOString();
|
||||
|
||||
function fixtures(now) {
|
||||
const r5 = now + 2 * H, rw = now + 3 * 24 * H;
|
||||
const quotas = [
|
||||
{ provider: "codex", name: "Codex", icon: "openai", plan: "Plus", user: "a@x.com", windows: [
|
||||
{ name: "5 hours", used: 40, resetsAt: iso(r5) },
|
||||
{ name: "Weekly", used: 30, resetsAt: iso(rw) },
|
||||
] },
|
||||
{ provider: "kimi", name: "Kimi", icon: "kimi-color", windows: [{ name: "Weekly", used: 10, resetsAt: iso(rw) }] },
|
||||
];
|
||||
const five = [], week = [];
|
||||
// the 5-hour window before the last reset, then this one
|
||||
for (let m = 0; m <= 180; m += 30) five.push({ at: iso(r5 - 10 * H + m * 60e3), left: 100 - m / 3, start: iso(r5 - 10 * H), resetsAt: iso(r5 - 5 * H) });
|
||||
for (let m = 0; m <= 180; m += 30) five.push({ at: iso(r5 - 5 * H + m * 60e3), left: 100 - m / 3, start: iso(r5 - 5 * H), resetsAt: iso(r5) });
|
||||
for (let h = 90; h >= 0; h -= 6) week.push({ at: iso(now - h * H), left: 70 + h / 9, start: iso(rw - 168 * H), resetsAt: iso(rw) });
|
||||
const history = [{ provider: "codex", user: "a@x.com", lines: [{ name: "5 hours", points: five }, { name: "Weekly", points: week }] }];
|
||||
return { quotas, history };
|
||||
}
|
||||
|
||||
function serve(lang, theme, panel, data) {
|
||||
const settings = { theme, lang, tray: "panel", quotaLeft: false, currency: "usd" };
|
||||
return async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const json = (body) => route.fulfill({ json: body });
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"${theme}",web:${!panel}};` });
|
||||
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 });
|
||||
if (url.pathname === "/api/settings") return json(settings);
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
|
||||
if (url.pathname === "/api/usage/quotas") return json(data.quotas);
|
||||
if (url.pathname === "/api/usage/quotas/history") return json(data.history);
|
||||
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: "" }); }
|
||||
};
|
||||
}
|
||||
|
||||
const words = {
|
||||
en: { head: "Left over time", two: "2 days", cycle: "Cycle", five: "5 hours", week: "Weekly" },
|
||||
zh: { head: "剩余额度走势", two: "2 天", cycle: "本周期", five: "5 小时", week: "每周" },
|
||||
};
|
||||
|
||||
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}: quota cards draw what was left over time`, 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 [name, p] of pages) await p.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-quota-curve-${name}.png`) });
|
||||
}
|
||||
await browser.close();
|
||||
});
|
||||
const errors = [];
|
||||
const data = fixtures(Date.now());
|
||||
const open = async (name, url, theme, viewport) => {
|
||||
const page = await (await browser.newContext({ viewport, reducedMotion: "reduce" })).newPage();
|
||||
pages.push([name, page]);
|
||||
page.setDefaultTimeout(5000);
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", serve(lang, theme, url.includes("mode=panel"), data));
|
||||
await page.goto(url);
|
||||
return page;
|
||||
};
|
||||
|
||||
const strokes = {};
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const page = await open(theme, "http://magpie.test/?view=usage", theme, { width: 900, height: 620 });
|
||||
const card = page.locator(".subscription-card", { hasText: "Codex" });
|
||||
const curve = card.locator(".quota-curve");
|
||||
await curve.waitFor();
|
||||
assert.equal(await curve.locator(".qc-head > span").textContent(), w.head);
|
||||
assert.deepEqual(await curve.locator(".qc-range .opt").allTextContents(), [w.two, w.cycle]);
|
||||
assert.equal(await curve.locator(".qc-range select").count(), 0);
|
||||
assert.equal(await curve.locator(".qc-range .opt.on").textContent(), w.cycle, "Cycle first");
|
||||
// a line a window, an even burn each, now
|
||||
assert.equal(await curve.locator("path.qc-line").count(), 2);
|
||||
assert.equal(await curve.locator("line.qc-even").count(), 2);
|
||||
assert.equal(await curve.locator("line.qc-now").count(), 1);
|
||||
const five = await curve.locator('path.qc-line[data-name="5 hours"]').getAttribute("d");
|
||||
assert.equal((five.match(/M/g) || []).length, 2, "the 5-hour line breaks where its window started again: " + five);
|
||||
const legend = await curve.locator(".qc-key").allTextContents();
|
||||
assert.deepEqual(legend, [w.five + "40%", w.week + "70%"]);
|
||||
assert.equal(await page.locator(".subscription-card", { hasText: "Kimi" }).locator(".quota-curve").count(), 0, "no readings, no curve");
|
||||
// the colours are the theme's chart colours, nothing outside the card
|
||||
strokes[theme] = await curve.locator("path.qc-line").first().evaluate((e) => getComputedStyle(e).stroke);
|
||||
const c1 = await page.evaluate(() => { const s = document.createElement("i"); s.style.color = "var(--c1)"; document.body.append(s); const c = getComputedStyle(s).color; s.remove(); return c; });
|
||||
assert.equal(strokes[theme], c1);
|
||||
const box = await card.boundingBox(), plot = await curve.locator(".qc-plot").boundingBox();
|
||||
assert.ok(plot.x >= box.x && plot.x + plot.width <= box.x + box.width + 0.5, "the plot fits its card");
|
||||
const left = await curve.evaluate((e) => [...e.querySelectorAll("*")].filter((x) => parseFloat(getComputedStyle(x).borderLeftWidth) > 0).length);
|
||||
assert.equal(left, 0, "no left borders");
|
||||
|
||||
if (theme === "light") {
|
||||
// 2 days: the range turns, the page doesn't move, the pick is kept
|
||||
const scroller = await page.evaluate(() => { const s = document.scrollingElement; s.scrollTop = 40; return s.scrollTop; });
|
||||
const before = await curve.locator(".qc-axis").textContent();
|
||||
await curve.locator(".qc-range .opt", { hasText: w.two }).click();
|
||||
assert.equal(await curve.locator(".qc-range .opt.on").textContent(), w.two);
|
||||
assert.notEqual(await curve.locator(".qc-axis").textContent(), before);
|
||||
assert.notEqual(await curve.locator('path.qc-line[data-name="5 hours"]').getAttribute("d"), five);
|
||||
assert.equal(await page.evaluate(() => document.scrollingElement.scrollTop), scroller, "the click moved the page");
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem("magpie.quotaRange")), "2d");
|
||||
}
|
||||
}
|
||||
assert.notEqual(strokes.light, strokes.dark, "dark has its own line colour");
|
||||
|
||||
// the tray: a thin line of the 5-hour window's cycle, across the card
|
||||
const panel = await open("panel", "http://magpie.test/?mode=panel", "dark", { width: 440, height: 640 });
|
||||
await panel.locator('#ptabs [data-ptab="usage"]').click();
|
||||
const pcard = panel.locator(".pq-card", { hasText: "a@x.com" });
|
||||
const spark = pcard.locator(".pq-spark");
|
||||
await spark.waitFor();
|
||||
assert.equal(await spark.locator("path.qc-line").count(), 1);
|
||||
assert.equal((((await spark.locator("path.qc-line").getAttribute("d")).match(/M/g)) || []).length, 1, "the current cycle only");
|
||||
assert.equal(await spark.locator("line.qc-even").count(), 1);
|
||||
const sb = await spark.boundingBox(), cb = await pcard.boundingBox();
|
||||
assert.ok(sb.height <= 17 && sb.width > cb.width * 0.7, `thin and across the card: ${JSON.stringify(sb)}`);
|
||||
assert.equal(await panel.locator(".pq-card", { hasText: "Kimi" }).locator(".pq-spark").count(), 0);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -414,6 +414,11 @@ func usageRoutes(mux *http.ServeMux, w Windows) {
|
||||
defer cancel()
|
||||
writeJSON(rw, provider.Quotas(ctx))
|
||||
})
|
||||
// what was left of each window over time, for the quota cards' curves
|
||||
// (#651); ?days= back
|
||||
mux.HandleFunc("GET /api/usage/quotas/history", func(rw http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(rw, provider.QuotaHistories(provider.QuotaHistorySince(r.URL.Query().Get("days"), time.Now()), "", ""))
|
||||
})
|
||||
// spends one of a Codex account's rate-limit resets, which the page
|
||||
// has asked the user about first; what it did comes back
|
||||
mux.HandleFunc("POST /api/usage/codex-reset", func(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -514,6 +514,7 @@ func PlanQuotas(ctx context.Context) []SubscriptionQuota {
|
||||
}
|
||||
out = append(out, <-stepfun...)
|
||||
if ctx.Err() == nil {
|
||||
noteQuotaHistory(out, time.Now())
|
||||
c.Lock()
|
||||
c.at, c.data = time.Now(), out
|
||||
c.Unlock()
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package provider
|
||||
|
||||
// What was left of each window over time (#651): each reading of an
|
||||
// account's windows that magpie makes anyway (the subscriptions', a minute
|
||||
// apart at most while something asks; the plans' bought with a key) is kept
|
||||
// as a point — when it was read, the percent left, when the window started
|
||||
// and when it starts again — so the Usage page can draw the cycle's curve
|
||||
// against an even burn, and the tray its current cycle's line.
|
||||
//
|
||||
// Kept in quota-history.json, by provider and account name, and by window:
|
||||
// the figures only — no token, no prompt, nothing else of the vendor's. A
|
||||
// reading kept from before (AsOf) or failed isn't a new one; a window with no
|
||||
// limit, or one set aside, has nothing to draw. Points come at least
|
||||
// quotaHistGap apart within a cycle (a nearer one moves the last instead),
|
||||
// and a run of the same figure is its first and last point only. A cycle
|
||||
// ends where the window starts again: its reset passed, or moved further
|
||||
// than the time between two readings (a rolling window's moves with it).
|
||||
// Points older than quotaHistKept go, and a window keeps quotaHistMax.
|
||||
//
|
||||
// Other computers' points come in through the usage sync (davsync), merged
|
||||
// by MergeQuotaHistory: the union of both, sorted, a point within
|
||||
// quotaHistSame of the next one dropped for it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/filememo"
|
||||
)
|
||||
|
||||
const (
|
||||
quotaHistKept = 45 * 24 * time.Hour
|
||||
quotaHistMax = 6000
|
||||
quotaHistGap = 5 * time.Minute
|
||||
quotaHistSame = 30 * time.Second
|
||||
// a reset moved this much past the time between two readings is a
|
||||
// window started again, not a reset read a little differently
|
||||
quotaHistSlack = 5 * time.Minute
|
||||
)
|
||||
|
||||
// QuotaPoint is one reading of a window: at At, Left percent was left;
|
||||
// the window began at Start and starts again at ResetsAt, when told.
|
||||
type QuotaPoint struct {
|
||||
At time.Time `json:"at"`
|
||||
Left float64 `json:"left"`
|
||||
Start *time.Time `json:"start,omitempty"`
|
||||
ResetsAt *time.Time `json:"resetsAt,omitempty"`
|
||||
}
|
||||
|
||||
// QuotaLine is one window's points, oldest first.
|
||||
type QuotaLine struct {
|
||||
Name string `json:"name"`
|
||||
Points []QuotaPoint `json:"points"`
|
||||
}
|
||||
|
||||
// QuotaHistory is one account's windows over time. User is lowercased.
|
||||
type QuotaHistory struct {
|
||||
Provider string `json:"provider"`
|
||||
User string `json:"user"`
|
||||
Lines []QuotaLine `json:"lines"`
|
||||
}
|
||||
|
||||
// quotaHist is the file: account ("provider|user") → window → points.
|
||||
type quotaHist map[string]map[string][]QuotaPoint
|
||||
|
||||
var quotaHistMu sync.Mutex
|
||||
|
||||
func quotaHistPath() string { return filepath.Join(filepath.Dir(Path()), "quota-history.json") }
|
||||
|
||||
func parseQuotaHist(b []byte) (quotaHist, error) {
|
||||
h := quotaHist{}
|
||||
if err := json.Unmarshal(b, &h); err != nil || h == nil {
|
||||
return quotaHist{}, nil
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func readQuotaHist() quotaHist {
|
||||
if b, err := os.ReadFile(quotaHistPath()); err == nil {
|
||||
h, _ := parseQuotaHist(b)
|
||||
return h
|
||||
}
|
||||
return quotaHist{}
|
||||
}
|
||||
|
||||
func writeQuotaHist(h quotaHist) {
|
||||
b, err := json.Marshal(h)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
path := quotaHistPath()
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0o700)
|
||||
_ = writeFileAtomic(path, b)
|
||||
}
|
||||
|
||||
var spanWords = regexp.MustCompile(`(?i)(\d+)\s*-?\s*(h|hr|hour|d|day|w|wk|week|month)s?\b`)
|
||||
|
||||
// windowSpan is how long a window runs: as its vendor said, else as its
|
||||
// name says ("5 hours", "7d", "Weekly"), else 0, not known.
|
||||
func windowSpan(w QuotaWindow) time.Duration {
|
||||
if w.Span > 0 {
|
||||
return w.Span
|
||||
}
|
||||
unit := map[string]time.Duration{"h": time.Hour, "hr": time.Hour, "hour": time.Hour, "d": 24 * time.Hour, "day": 24 * time.Hour,
|
||||
"w": 7 * 24 * time.Hour, "wk": 7 * 24 * time.Hour, "week": 7 * 24 * time.Hour, "month": 30 * 24 * time.Hour}
|
||||
if m := spanWords.FindStringSubmatch(w.Name); m != nil {
|
||||
if n, err := strconv.Atoi(m[1]); err == nil && n > 0 {
|
||||
return time.Duration(n) * unit[strings.ToLower(m[2])]
|
||||
}
|
||||
}
|
||||
switch n := strings.ToLower(w.Name); {
|
||||
case strings.Contains(n, "daily"):
|
||||
return 24 * time.Hour
|
||||
case strings.Contains(n, "weekly"):
|
||||
return 7 * 24 * time.Hour
|
||||
case strings.Contains(n, "monthly"):
|
||||
return 30 * 24 * time.Hour
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// newCycle says whether b, read after a, is of a window started again.
|
||||
func newCycle(a, b QuotaPoint) bool {
|
||||
if a.ResetsAt == nil && b.ResetsAt == nil {
|
||||
return b.Left > a.Left+5 // filled again
|
||||
}
|
||||
if a.ResetsAt == nil || b.ResetsAt == nil { // begun, or over
|
||||
return true
|
||||
}
|
||||
if !a.ResetsAt.After(b.At) { // its reset has passed
|
||||
return true
|
||||
}
|
||||
moved := b.ResetsAt.Sub(*a.ResetsAt)
|
||||
return moved > b.At.Sub(a.At)+quotaHistSlack || moved < -quotaHistSlack
|
||||
}
|
||||
|
||||
// pointOf is what w says at now, if it is something to draw.
|
||||
func pointOf(w QuotaWindow, now time.Time) (QuotaPoint, bool) {
|
||||
if w.Unlimited || w.Aside || w.Family != "" || w.Name == "" || math.IsNaN(w.Used) {
|
||||
return QuotaPoint{}, false
|
||||
}
|
||||
p := QuotaPoint{At: now.UTC().Truncate(time.Second), Left: math.Round(max(0, min(100, 100-w.Used))*100) / 100}
|
||||
reset := w.ResetsAt
|
||||
if reset == nil && w.ResetSecs > 0 {
|
||||
r := now.Add(time.Duration(w.ResetSecs) * time.Second)
|
||||
reset = &r
|
||||
}
|
||||
if reset != nil {
|
||||
r := reset.UTC().Truncate(time.Second)
|
||||
p.ResetsAt = &r
|
||||
if span := windowSpan(w); span > 0 {
|
||||
s := r.Add(-span)
|
||||
p.Start = &s
|
||||
}
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
// addPoint puts n after pts, the last moved to it when near or the same.
|
||||
func addPoint(pts []QuotaPoint, n QuotaPoint) []QuotaPoint {
|
||||
k := len(pts)
|
||||
if k > 0 && !n.At.After(pts[k-1].At) {
|
||||
return pts // not newer than what is kept
|
||||
}
|
||||
if k >= 2 {
|
||||
p1, p2 := pts[k-2], pts[k-1]
|
||||
if !newCycle(p1, p2) && !newCycle(p2, n) {
|
||||
flat := math.Abs(n.Left-p2.Left) < 0.05 && math.Abs(p2.Left-p1.Left) < 0.05
|
||||
if flat || n.At.Sub(p1.At) < quotaHistGap {
|
||||
pts[k-1] = n
|
||||
return pts
|
||||
}
|
||||
}
|
||||
}
|
||||
if k >= 1 && n.At.Sub(pts[k-1].At) < quotaHistSame && !newCycle(pts[k-1], n) {
|
||||
pts[k-1] = n
|
||||
return pts
|
||||
}
|
||||
return append(pts, n)
|
||||
}
|
||||
|
||||
// prune drops what is older than kept and what is past quotaHistMax.
|
||||
func (h quotaHist) prune(now time.Time) {
|
||||
cut := now.Add(-quotaHistKept)
|
||||
for key, ws := range h {
|
||||
for name, pts := range ws {
|
||||
i, _ := slices.BinarySearchFunc(pts, cut, func(p QuotaPoint, t time.Time) int { return p.At.Compare(t) })
|
||||
pts = pts[i:]
|
||||
if len(pts) > quotaHistMax {
|
||||
pts = pts[len(pts)-quotaHistMax:]
|
||||
}
|
||||
if len(pts) == 0 {
|
||||
delete(ws, name)
|
||||
} else {
|
||||
ws[name] = slices.Clip(pts)
|
||||
}
|
||||
}
|
||||
if len(ws) == 0 {
|
||||
delete(h, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func quotaHistKey(provider, user string) string { return provider + "|" + strings.ToLower(user) }
|
||||
|
||||
// noteQuotaHistory keeps what each window among qs says, read at now.
|
||||
func noteQuotaHistory(qs []SubscriptionQuota, now time.Time) {
|
||||
quotaHistMu.Lock()
|
||||
defer quotaHistMu.Unlock()
|
||||
var h quotaHist
|
||||
changed := false
|
||||
for _, q := range qs {
|
||||
if q.Error != "" || q.AsOf != nil || q.Provider == "" {
|
||||
continue // a reading kept from before isn't a new one
|
||||
}
|
||||
for _, w := range q.Windows {
|
||||
p, ok := pointOf(w, now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if h == nil {
|
||||
h = readQuotaHist()
|
||||
}
|
||||
key := quotaHistKey(q.Provider, q.User)
|
||||
if h[key] == nil {
|
||||
h[key] = map[string][]QuotaPoint{}
|
||||
}
|
||||
name := w.Name
|
||||
if w.Pool != "" { // Antigravity's pools each have a 5-hour window
|
||||
name = w.Pool + " · " + w.Name
|
||||
}
|
||||
before := h[key][name]
|
||||
k := len(before)
|
||||
var last QuotaPoint
|
||||
if k > 0 {
|
||||
last = before[k-1]
|
||||
}
|
||||
after := addPoint(before, p)
|
||||
if len(after) != k || k > 0 && after[k-1] != last {
|
||||
changed = true
|
||||
}
|
||||
h[key][name] = after
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
h.prune(now)
|
||||
writeQuotaHist(h)
|
||||
}
|
||||
|
||||
// mergeQuotaHist puts b's points into a: the union of both, a point within
|
||||
// quotaHistSame of the next one (of the same cycle) dropped for it.
|
||||
func mergeQuotaHist(a, b quotaHist) (changed bool) {
|
||||
for key, ws := range b {
|
||||
if a[key] == nil {
|
||||
a[key] = map[string][]QuotaPoint{}
|
||||
}
|
||||
for name, pts := range ws {
|
||||
old := a[key][name]
|
||||
all := slices.Concat(old, pts)
|
||||
slices.SortStableFunc(all, func(x, y QuotaPoint) int { return x.At.Compare(y.At) })
|
||||
out := all[:0:0]
|
||||
for i, p := range all {
|
||||
if i+1 < len(all) && all[i+1].At.Sub(p.At) < quotaHistSame && !newCycle(p, all[i+1]) {
|
||||
continue // the newer of two near the same
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if !slices.EqualFunc(old, out, samePoint) {
|
||||
a[key][name] = out
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func samePoint(x, y QuotaPoint) bool {
|
||||
eq := func(a, b *time.Time) bool { return a == nil && b == nil || a != nil && b != nil && a.Equal(*b) }
|
||||
return x.At.Equal(y.At) && x.Left == y.Left && eq(x.Start, y.Start) && eq(x.ResetsAt, y.ResetsAt)
|
||||
}
|
||||
|
||||
// QuotaHistoryData is this computer's history as kept, for the usage sync
|
||||
// to share.
|
||||
func QuotaHistoryData() []byte {
|
||||
quotaHistMu.Lock()
|
||||
defer quotaHistMu.Unlock()
|
||||
b, _ := os.ReadFile(quotaHistPath())
|
||||
return b
|
||||
}
|
||||
|
||||
// MergeQuotaHistory merges another computer's history (as QuotaHistoryData
|
||||
// gave it there) into this one's.
|
||||
func MergeQuotaHistory(data []byte, now time.Time) error {
|
||||
var b quotaHist
|
||||
if err := json.Unmarshal(data, &b); err != nil {
|
||||
return err
|
||||
}
|
||||
quotaHistMu.Lock()
|
||||
defer quotaHistMu.Unlock()
|
||||
h := readQuotaHist()
|
||||
if !mergeQuotaHist(h, b) {
|
||||
return nil
|
||||
}
|
||||
h.prune(now)
|
||||
writeQuotaHist(h)
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuotaHistories is every account's windows' points since since, the
|
||||
// accounts and windows in name order; provider and user, when given, pick
|
||||
// one provider's, or one account's.
|
||||
func QuotaHistories(since time.Time, provider, user string) []QuotaHistory {
|
||||
h, err := filememo.Read("quota-history", quotaHistPath(), parseQuotaHist)
|
||||
if err != nil {
|
||||
return []QuotaHistory{}
|
||||
}
|
||||
out := []QuotaHistory{}
|
||||
for _, key := range slices.Sorted(maps.Keys(h)) {
|
||||
p, u, _ := strings.Cut(key, "|")
|
||||
if provider != "" && p != provider || user != "" && u != strings.ToLower(user) {
|
||||
continue
|
||||
}
|
||||
a := QuotaHistory{Provider: p, User: u, Lines: []QuotaLine{}}
|
||||
for _, name := range slices.Sorted(maps.Keys(h[key])) {
|
||||
pts := h[key][name]
|
||||
i, _ := slices.BinarySearchFunc(pts, since, func(p QuotaPoint, t time.Time) int { return p.At.Compare(t) })
|
||||
if i > 0 {
|
||||
i-- // the one before, so a line reaches the range's edge
|
||||
}
|
||||
if i < len(pts) {
|
||||
a.Lines = append(a.Lines, QuotaLine{Name: name, Points: slices.Clone(pts[i:])})
|
||||
}
|
||||
}
|
||||
if len(a.Lines) > 0 {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// QuotaHistorySince is the start of a range days long (as a query gives
|
||||
// it) to now: all that is kept when not a number of days.
|
||||
func QuotaHistorySince(days string, now time.Time) time.Time {
|
||||
n, err := strconv.ParseFloat(days, 64)
|
||||
if err != nil || n <= 0 || n*24*float64(time.Hour) > float64(quotaHistKept) {
|
||||
return now.Add(-quotaHistKept)
|
||||
}
|
||||
return now.Add(-time.Duration(n * 24 * float64(time.Hour)))
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func histCard(user string, used5h float64, reset5h time.Time, usedWeek float64, resetWeek time.Time) SubscriptionQuota {
|
||||
return SubscriptionQuota{Provider: "codex", Name: "Codex", User: user, Windows: []QuotaWindow{
|
||||
{Name: "5 hours", Used: used5h, ResetsAt: &reset5h},
|
||||
{Name: "Weekly", Used: usedWeek, ResetsAt: &resetWeek, Span: 7 * 24 * time.Hour},
|
||||
{Name: "Opus", Used: 1, Family: "Claude"},
|
||||
{Name: "Reviews", Unlimited: true},
|
||||
}}
|
||||
}
|
||||
|
||||
func lineOf(t *testing.T, hs []QuotaHistory, user, name string) []QuotaPoint {
|
||||
t.Helper()
|
||||
for _, h := range hs {
|
||||
if h.User != user {
|
||||
continue
|
||||
}
|
||||
for _, l := range h.Lines {
|
||||
if l.Name == name {
|
||||
return l.Points
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("no %s line for %s in %+v", name, user, hs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestQuotaHistoryRecords: each reading of an account's windows is a point
|
||||
// of the percent left, with when its window began and starts again; a
|
||||
// window of a family, an unlimited one, a failed reading and one kept from
|
||||
// before aren't kept; readings a minute apart within five are one point,
|
||||
// and a run of the same figure its first and last (#651).
|
||||
func TestQuotaHistoryRecords(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t0 := time.Date(2026, 10, 3, 8, 0, 0, 0, time.UTC)
|
||||
r5, rw := t0.Add(3*time.Hour), t0.Add(4*24*time.Hour)
|
||||
at := func(m int) time.Time { return t0.Add(time.Duration(m) * time.Minute) }
|
||||
|
||||
noteQuotaHistory([]SubscriptionQuota{histCard("A@x.com", 10, r5, 40, rw)}, at(0))
|
||||
noteQuotaHistory([]SubscriptionQuota{histCard("A@x.com", 12, r5, 40, rw)}, at(1)) // within 5 min of the first: one more point
|
||||
noteQuotaHistory([]SubscriptionQuota{histCard("A@x.com", 13, r5, 40, rw)}, at(2)) // moves it
|
||||
noteQuotaHistory([]SubscriptionQuota{histCard("A@x.com", 20, r5, 40, rw)}, at(10))
|
||||
noteQuotaHistory([]SubscriptionQuota{histCard("A@x.com", 20, r5, 40, rw)}, at(20)) // the same: the run's end moves
|
||||
noteQuotaHistory([]SubscriptionQuota{histCard("A@x.com", 20, r5, 40, rw)}, at(30))
|
||||
bad := histCard("A@x.com", 90, r5, 90, rw)
|
||||
bad.Error = "401"
|
||||
stale := histCard("A@x.com", 90, r5, 90, rw)
|
||||
stale.AsOf = &t0
|
||||
noteQuotaHistory([]SubscriptionQuota{bad, stale}, at(40))
|
||||
|
||||
hs := QuotaHistories(t0.Add(-time.Hour), "", "")
|
||||
if len(hs) != 1 || hs[0].Provider != "codex" || hs[0].User != "a@x.com" || len(hs[0].Lines) != 2 {
|
||||
t.Fatalf("histories = %+v", hs)
|
||||
}
|
||||
five := lineOf(t, hs, "a@x.com", "5 hours")
|
||||
want := []struct {
|
||||
m int
|
||||
left float64
|
||||
}{{0, 90}, {2, 87}, {10, 80}, {30, 80}}
|
||||
if len(five) != len(want) {
|
||||
t.Fatalf("5 hours points = %+v", five)
|
||||
}
|
||||
for i, w := range want {
|
||||
if !five[i].At.Equal(at(w.m)) || five[i].Left != w.left {
|
||||
t.Errorf("point %d = %v %v, want %v %v", i, five[i].At, five[i].Left, at(w.m), w.left)
|
||||
}
|
||||
}
|
||||
if five[0].ResetsAt == nil || !five[0].ResetsAt.Equal(r5) || five[0].Start == nil || !five[0].Start.Equal(r5.Add(-5*time.Hour)) {
|
||||
t.Errorf("5 hours window = %v .. %v", five[0].Start, five[0].ResetsAt)
|
||||
}
|
||||
week := lineOf(t, hs, "a@x.com", "Weekly")
|
||||
if len(week) != 2 || week[0].Left != 60 || !week[0].Start.Equal(rw.Add(-7*24*time.Hour)) {
|
||||
t.Errorf("weekly = %+v", week)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaHistoryCycles: a window started again is a new cycle, never
|
||||
// folded into the one before, a rolling window's reset moving with the
|
||||
// clock isn't one, and points older than 45 days go.
|
||||
func TestQuotaHistoryCycles(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t0 := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
week := t0.Add(7 * 24 * time.Hour)
|
||||
card := func(used float64, r5 time.Time) []SubscriptionQuota {
|
||||
return []SubscriptionQuota{histCard("", used, r5, 0, week)}
|
||||
}
|
||||
// a flat run, then the 5-hour window starts again at the same figure
|
||||
noteQuotaHistory(card(30, t0.Add(time.Hour)), t0)
|
||||
noteQuotaHistory(card(30, t0.Add(time.Hour)), t0.Add(20*time.Minute))
|
||||
noteQuotaHistory(card(30, t0.Add(time.Hour)), t0.Add(50*time.Minute))
|
||||
noteQuotaHistory(card(30, t0.Add(6*time.Hour)), t0.Add(70*time.Minute))
|
||||
noteQuotaHistory(card(30, t0.Add(6*time.Hour)), t0.Add(90*time.Minute))
|
||||
pts := lineOf(t, QuotaHistories(time.Time{}, "codex", ""), "", "5 hours")
|
||||
if len(pts) != 4 || !pts[1].At.Equal(t0.Add(50*time.Minute)) || !pts[2].At.Equal(t0.Add(70*time.Minute)) {
|
||||
t.Fatalf("cycles folded: %+v", pts)
|
||||
}
|
||||
if !newCycle(pts[1], pts[2]) || newCycle(pts[0], pts[1]) {
|
||||
t.Errorf("newCycle wrong at the reset")
|
||||
}
|
||||
// a rolling window: its reset 5 hours from each reading
|
||||
a := QuotaPoint{At: t0, ResetsAt: new(t0.Add(5 * time.Hour))}
|
||||
b := QuotaPoint{At: t0.Add(30 * time.Minute), ResetsAt: new(t0.Add(5*time.Hour + 30*time.Minute))}
|
||||
if newCycle(a, b) {
|
||||
t.Error("a rolling window's reset moving with the clock read as a new cycle")
|
||||
}
|
||||
|
||||
// 46 days on, the old points go
|
||||
later := t0.Add(46 * 24 * time.Hour)
|
||||
noteQuotaHistory([]SubscriptionQuota{histCard("", 5, later.Add(time.Hour), 0, later.Add(24*time.Hour))}, later)
|
||||
pts = lineOf(t, QuotaHistories(time.Time{}, "", ""), "", "5 hours")
|
||||
if len(pts) != 1 || !pts[0].At.Equal(later) {
|
||||
t.Errorf("after 46 days: %+v", pts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeQuotaHistory: another computer's points join this one's: the
|
||||
// union, sorted, two within half a minute of each other the newer one,
|
||||
// and merging the same again changes nothing.
|
||||
func TestMergeQuotaHistory(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t0 := time.Date(2026, 10, 3, 8, 0, 0, 0, time.UTC)
|
||||
r := t0.Add(4 * time.Hour)
|
||||
at := func(m int) time.Time { return t0.Add(time.Duration(m) * time.Minute) }
|
||||
noteQuotaHistory([]SubscriptionQuota{histCard("a@x.com", 10, r, 0, r)}, at(0))
|
||||
noteQuotaHistory([]SubscriptionQuota{histCard("a@x.com", 30, r, 0, r)}, at(20))
|
||||
|
||||
other := quotaHist{
|
||||
"codex|a@x.com": {"5 hours": {
|
||||
{At: at(10), Left: 80, ResetsAt: &r},
|
||||
{At: at(20).Add(10 * time.Second), Left: 69, ResetsAt: &r}, // near this one's: the newer stays
|
||||
{At: at(40), Left: 60, ResetsAt: &r},
|
||||
}},
|
||||
"claude|b@y.com": {"Weekly": {{At: at(5), Left: 50}}},
|
||||
}
|
||||
data, _ := json.Marshal(other)
|
||||
if err := MergeQuotaHistory(data, at(41)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
five := lineOf(t, QuotaHistories(time.Time{}, "", ""), "a@x.com", "5 hours")
|
||||
lefts := []float64{}
|
||||
for _, p := range five {
|
||||
lefts = append(lefts, p.Left)
|
||||
}
|
||||
if len(lefts) != 4 || lefts[0] != 90 || lefts[1] != 80 || lefts[2] != 69 || lefts[3] != 60 {
|
||||
t.Fatalf("merged = %v", lefts)
|
||||
}
|
||||
lineOf(t, QuotaHistories(time.Time{}, "", ""), "b@y.com", "Weekly")
|
||||
|
||||
before := string(QuotaHistoryData())
|
||||
if err := MergeQuotaHistory(data, at(41)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(QuotaHistoryData()) != before {
|
||||
t.Error("merging the same points again changed the file")
|
||||
}
|
||||
if err := MergeQuotaHistory([]byte("{nope"), at(41)); err == nil {
|
||||
t.Error("a broken file merged")
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,7 @@ func SubscriptionUsage(ctx context.Context) []SubscriptionQuota {
|
||||
start := time.Now()
|
||||
out := fetchSubscriptionUsage()
|
||||
noteDailyCredits(out, time.Now())
|
||||
noteQuotaHistory(out, time.Now())
|
||||
c.Lock()
|
||||
c.at, c.data, c.pending = time.Now(), out, nil
|
||||
if claudeAsked.Load() > start.UnixNano() {
|
||||
|
||||
Reference in new issue
Block a user