usage: a notification when a window is nearly used up or a balance runs low (#368) — Settings has Usage alert (off, or a share of 1–100%, 80 by default) and Low balance alert (off, or an amount, 5 by default), and magpie quota alert sets them from the command line. Each subscription or plan window, other than one set aside, is told once a run when it reaches the share, and a balance once until topped up past the amount again; what was said is kept, so a restart doesn't say it again, and a failed or stale reading neither alerts nor clears. The system's notifications are started only when first needed, so a Mac build that can't have them or a Linux desktop without a daemon still starts, and Settings says when they are refused or unavailable; a click opens the Usage page. TestDueAlerts and friends, TestAlertText and usage-alert.test.cjs fail without the change.

This commit is contained in:
yetone committed 2026-10-01 04:16:28 +08:00
1 parent 09e7a3f147
commit b0a0165fc9
19 files changed
+1063 -3

No files matched your search

+1
View File
@@ -20,6 +20,7 @@ require (
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+2
View File
@@ -1,3 +1,5 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+12
View File
@@ -187,6 +187,9 @@ type settingsJSON struct {
WorkBuddyCheckins []provider.WorkBuddyCheckin `json:"workbuddyCheckins,omitempty"`
// FX is the dollar-to-yuan rate the cny currency choice shows costs at
FX fxJSON `json:"fx"`
// NotifyProblem is why a usage alert set wouldn't be seen: "denied"
// (notifications turned off for magpie) or "unavailable"
NotifyProblem string `json:"notifyProblem,omitempty"`
}
func settingsState() settingsJSON {
@@ -198,6 +201,9 @@ func settingsState() settingsJSON {
s.TerminalDefault = found.Default
}
s.FX = currentFX()
if (s.UsageAlert > 0 || s.BalanceAlert > 0) && notifyProblem != nil {
s.NotifyProblem = notifyProblem()
}
s.ProxyNow, s.ProxySource = netproxy.Describe()
s.Login = autostart.Enabled()
if s.LAN {
@@ -503,6 +509,12 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
if (!slices.Equal(settings.Load().TrayUsages, cur.TrayUsages) || in.TrayUsageEvery != cur.TrayUsageEvery) && onTrayUsage != nil {
onTrayUsage()
}
// an alert turned on or moved is looked at now, the Mac asked for its
// leave to notify as it is turned on (#368)
if (in.UsageAlert != cur.UsageAlert || in.BalanceAlert != cur.BalanceAlert) &&
(in.UsageAlert > 0 || in.BalanceAlert > 0) && onAlerts != nil {
onAlerts()
}
// the tray menu follows the page's language (#301)
if in.Lang != cur.Lang && onLang != nil {
onLang()
+1
View File
@@ -416,6 +416,7 @@ func Run(version string, showMain bool, link string) error {
h.tray.SetMenu(menu)
h.tray.AttachWindow(h.panel).WindowOffset(6)
h.watchTrayUsage()
h.watchAlerts()
// the quick panel by the icon, or the main window if the user would
// rather (Settings → Tray icon)
h.tray.OnClick(func() {
+5
View File
@@ -1606,6 +1606,11 @@ input.words.rule-match { flex: 1 1 auto; width: auto; min-width: 0; }
.warm-at input.at { width: auto; height: 27px; padding: 0 8px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--fg); font: inherit; font-size: 12.5px; font-variant-numeric: tabular-nums; outline: none; transition: border-color .12s, box-shadow .12s; }
.warm-at input.at:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
.warm-at input.at::-webkit-calendar-picker-indicator { opacity: .55; }
/* a usage alert's share or amount: a short number field, its unit beside it */
.warm-at input.at.num { width: 64px; text-align: right; }
.warm-at .unit { color: var(--muted); font-size: 12.5px; margin-left: -4px; }
.row.pref .sub .warn { color: var(--amber); }
.row.pref .sub.wraps { white-space: normal; }
/* the field's clock and picker in the page's own palette */
:root[data-theme="dark"] .warm-at input.at { color-scheme: dark; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .warm-at input.at { color-scheme: dark; } }
+45 -1
View File
@@ -8961,6 +8961,7 @@ function renderTrayUsage(s, keep) {
$("#quotaLeftSegs").replaceChildren(segs([[false, t("Used")], [true, t("Left")]], !!s.quotaLeft,
(on) => { if (on !== quotaLeft) setQuotaLeft(on); }));
$("#currencySegs").replaceChildren(segs(CURRENCIES.map(([id, name]) => [id, t(name)]), s.currency || "usd", (v) => savePrefs({ ...keep, currency: v })));
renderAlerts(s, keep);
// the agents' lists name a model with its provider's after it, or alone
// (#335): set on its own, so the agents are told
$("#plainNamesSegs").replaceChildren(segs([["off", t("Off")], ["on", t("On")]], s.plainNames ? "off" : "on", (v) =>
@@ -9074,6 +9075,48 @@ function renderWarmAt(box, sub, at, onReset, via, save) {
box.append(segs([["off", t("Off")], ["on", t("On")]], at ? "on" : "off", (v) => save(v === "on" ? at || "06:00" : "")));
}
// renderAlerts draws the usage alerts (#368): a notification when a window
// that routing counts reaches the share set, or a balance falls to the
// amount set, each once; Off, or the number in a field beside On.
function renderAlerts(s, keep) {
const problem = s.notifyProblem === "denied" ? t("Notifications are turned off for magpie in the system's settings")
: s.notifyProblem === "unavailable" ? t("Notifications can't be shown on this system") : "";
const sub = (id, what) => {
const box = $(id);
box.textContent = what;
box.title = what;
// the problem said whole, the line let wrap for it
box.classList.toggle("wraps", !!problem);
if (problem) box.append(" · ", el("span", "warn", problem));
};
sub("#usageAlertSub", t("A notification when a 5-hour, weekly or monthly window reaches this share used, once each time it runs"));
sub("#balanceAlertSub", t("A notification when a balance falls to this amount, in its own currency or credits, once until it is topped up"));
const field = (box, value, label, unit, ok, save) => {
box.replaceChildren();
if (value) {
const i = input(String(value), "", "number");
i.className = "at num";
i.inputMode = "decimal";
i.setAttribute("aria-label", label);
i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") i.blur(); };
i.onchange = () => {
const n = Number(i.value);
if (!ok(n)) { i.value = String(value); return; }
if (n !== value) save(n);
};
box.append(i);
if (unit) box.append(el("span", "unit", unit));
}
return box;
};
field($("#usageAlertSegs"), s.usageAlert || 0, t("Share used"), "%", (n) => Number.isInteger(n) && n >= 1 && n <= 100,
(n) => savePrefs({ ...keep, usageAlert: n }))
.append(segs([["off", t("Off")], ["on", t("On")]], s.usageAlert ? "on" : "off", (v) => savePrefs({ ...keep, usageAlert: v === "on" ? s.usageAlert || 80 : 0 })));
field($("#balanceAlertSegs"), s.balanceAlert || 0, t("Amount"), "", (n) => Number.isFinite(n) && n > 0,
(n) => savePrefs({ ...keep, balanceAlert: n }))
.append(segs([["off", t("Off")], ["on", t("On")]], s.balanceAlert ? "on" : "off", (v) => savePrefs({ ...keep, balanceAlert: v === "on" ? s.balanceAlert || 5 : 0 })));
}
// renderImages: the model that describes images to a model that can't see
// them — the one magpie picks, one named, or none, and then such an image
// is turned away.
@@ -9414,7 +9457,8 @@ function prefsKeep(s) {
trayUsages: s.trayUsages || [],
redact: !!s.redact, redactPersonal: !!s.redactPersonal, redactWords: s.redactWords || [], codexWarmup: s.codexWarmup || "",
claudeWarmup: s.claudeWarmup || "", codexWarmAt: s.codexWarmAt || "", claudeWarmAt: s.claudeWarmAt || "", workbuddyCheckin: !!s.workbuddyCheckin, noStats: !!s.noStats,
trayUsage: s.trayUsage || "", trayUsageEvery: s.trayUsageEvery || 3, vision: s.vision || "", imageGen: s.imageGen || "", currency: s.currency || "usd" };
trayUsage: s.trayUsage || "", trayUsageEvery: s.trayUsageEvery || 3, vision: s.vision || "", imageGen: s.imageGen || "", currency: s.currency || "usd",
usageAlert: s.usageAlert || 0, balanceAlert: s.balanceAlert || 0 };
}
// savePrefs sends what the page was drawn with (prefsBase) and the choice
+8
View File
@@ -152,6 +152,14 @@ const I18N = {
"Usage shows": "用量显示",
"How much of each window is used, or how much is left, on the Usage page, the panel and the menu bar": "每个窗口显示已用多少或剩余多少,用量页、面板和菜单栏一致",
"Currency": "货币",
"Usage alert": "用量提醒",
"Low balance alert": "余额提醒",
"A notification when a 5-hour, weekly or monthly window reaches this share used, once each time it runs": "5 小时、每周或每月窗口用到这个比例时发一条系统通知,每个周期只提醒一次",
"A notification when a balance falls to this amount, in its own currency or credits, once until it is topped up": "余额降到这个数(按它自己的货币或积分)时发一条系统通知,充值回升前只提醒一次",
"Notifications are turned off for magpie in the system's settings": "系统设置里关闭了 magpie 的通知",
"Notifications can't be shown on this system": "此系统无法显示通知",
"Share used": "已用比例",
"Amount": "金额",
"Provider in model names": "模型名带提供商",
"Agents’ model lists name each model with its provider or “routing group” after it. Off, the name alone, unless two would read the same": "写给 agent 的模型列表里,每个模型名后面带上提供商或“routing group”。关闭后只用模型名,除非有两个会重名",
"$ USD": "$ 美元",
+8
View File
@@ -201,6 +201,14 @@
<div class="who"><div class="name" data-t>Currency</div><div class="sub" id="currencySub"></div></div>
<div id="currencySegs"></div>
</div>
<div class="row pref" id="usageAlertRow">
<div class="who"><div class="name" data-t>Usage alert</div><div class="sub" id="usageAlertSub"></div></div>
<div id="usageAlertSegs" class="warm-at"></div>
</div>
<div class="row pref" id="balanceAlertRow">
<div class="who"><div class="name" data-t>Low balance alert</div><div class="sub" id="balanceAlertSub"></div></div>
<div id="balanceAlertSegs" class="warm-at"></div>
</div>
<div class="row pref">
<div class="who"><div class="name" data-t>Provider in model names</div><div class="sub" data-t>Agents’ model lists name each model with its provider or “routing group” after it. Off, the name alone, unless two would read the same</div></div>
<div id="plainNamesSegs"></div>
+102
View File
@@ -0,0 +1,102 @@
package gui
import (
"fmt"
"math"
"regexp"
"strconv"
"time"
"github.com/yetone/magpie/internal/provider"
)
// onAlerts is told when the Settings page turns a usage or balance alert
// on, for the process that notifies to ask the system's leave to and look
// at once; set by the process that has the tray.
var onAlerts func()
// notifyProblem says why magpie's notifications can't be shown, "" when
// they can or it isn't known: "denied" (turned off for magpie in the
// system's settings) or "unavailable" (a build or desktop without them).
// Set by the process that has the tray.
var notifyProblem func() string
var (
hoursName = regexp.MustCompile(`^(\d+) hours?$`)
daysName = regexp.MustCompile(`^(\d+) days?$`)
)
// alertWindowZh is a window's name in Chinese, as the page's i18n.js has
// the common ones; one it doesn't know stays as the vendor named it.
func alertWindowZh(name string) string {
if m := hoursName.FindStringSubmatch(name); m != nil {
return m[1] + " 小时"
}
if m := daysName.FindStringSubmatch(name); m != nil {
return m[1] + " 天"
}
switch name {
case "Weekly":
return "每周"
case "Monthly":
return "每月"
case "Daily":
return "每日"
}
return name
}
// alertClockZh is provider.ResetClock in Chinese.
func alertClockZh(at, now time.Time) string {
at, now = at.Local(), now.Local()
day := func(t time.Time) time.Time { y, m, d := t.Date(); return time.Date(y, m, d, 0, 0, 0, 0, time.Local) }
switch days := int(math.Round(day(at).Sub(day(now)).Hours() / 24)); {
case days <= 0:
return at.Format("15:04")
case days == 1:
return "明天 " + at.Format("15:04")
case days < 7:
return [...]string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}[at.Weekday()] + " " + at.Format("15:04")
}
return fmt.Sprintf("%d月%d日 %s", at.Month(), at.Day(), at.Format("15:04"))
}
// alertText is a usage alert as a notification says it, in the language
// (en or zh): the card, and what reached the line. left says a window by
// how much of it is left, as the Usage page does when set to.
func alertText(lang string, a provider.QuotaAlert, bal float64, left bool, now time.Time) (title, body string) {
title = a.Name
if a.User != "" {
title += " · " + a.User
}
if a.Window == "" {
line := strconv.FormatFloat(bal, 'f', -1, 64)
if lang == "zh" {
return title, fmt.Sprintf("余额已降至 %s(提醒线 %s)", a.Balance, line)
}
return title, fmt.Sprintf("Balance down to %s (alert at %s)", a.Balance, line)
}
n := a.Used
if left {
n = max(0, 100-n)
}
pct := strconv.FormatFloat(math.Round(n*10)/10, 'f', -1, 64) + "%"
if lang == "zh" {
body = fmt.Sprintf("%s窗口已用 %s", alertWindowZh(a.Window), pct)
if left {
body = fmt.Sprintf("%s窗口剩余 %s", alertWindowZh(a.Window), pct)
}
if a.ResetsAt != nil {
body += "," + alertClockZh(*a.ResetsAt, now) + " 重置"
}
return title, body
}
body = fmt.Sprintf("%s: %s used", a.Window, pct)
if left {
body = fmt.Sprintf("%s: %s left", a.Window, pct)
}
if a.ResetsAt != nil {
body += ", resets " + provider.ResetClock(*a.ResetsAt, now)
}
return title, body
}
+124
View File
@@ -0,0 +1,124 @@
//go:build !nogui
package gui
import (
"context"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
// notifier is the system's notifications, started the first time an alert
// needs them rather than with the app: started with it, a Mac build that
// isn't a signed bundle, or a Linux desktop without a notification daemon,
// would stop the app from starting at all.
type notifier struct {
once sync.Once
ns *notifications.NotificationService
err error
h *host
// refused is the Mac's answer when asked this run was no: before it
// is asked, not yet allowed and turned off read the same
refused atomic.Bool
}
func (n *notifier) start() error {
n.once.Do(func() {
ns := notifications.New()
// on the main thread, as the app would start it: Windows registers
// its toast activator for the thread it is on
if err := application.InvokeSyncWithError(func() error {
return ns.ServiceStartup(context.Background(), application.ServiceOptions{})
}); err != nil {
n.err = err
log.Println("notifications:", err)
return
}
// a click on one opens the Usage page
ns.OnNotificationResponse(func(notifications.NotificationResult) {
n.h.whenReady(func() { application.InvokeAsync(func() { n.h.ShowMain("usage") }) })
})
n.ns = ns
})
return n.err
}
// allowed asks for the system's leave to notify where it is asked for (the
// Mac, which asks once and then keeps the answer).
func (n *notifier) allowed() bool {
if n.start() != nil {
return false
}
if ok, err := n.ns.CheckNotificationAuthorization(); err == nil && ok {
return true
}
ok, err := n.ns.RequestNotificationAuthorization()
n.refused.Store(err == nil && !ok)
return err == nil && ok
}
// problem is notifyProblem: why a notification wouldn't be seen.
func (n *notifier) problem() string {
select {
case <-n.h.ready:
default:
return "" // the app isn't running yet
}
if n.start() != nil {
return "unavailable"
}
if !n.refused.Load() {
return ""
}
if ok, err := n.ns.CheckNotificationAuthorization(); err == nil && !ok {
return "denied"
}
return ""
}
func (n *notifier) send(title, body string) {
if !n.allowed() {
return
}
id := fmt.Sprintf("magpie-alert-%d", time.Now().UnixNano())
if err := n.ns.SendNotification(notifications.NotificationOptions{ID: id, Title: title, Body: body}); err != nil {
log.Println("notifications:", err)
}
}
// watchAlerts tells the user, with a notification, of a window used past
// the share or a balance fallen to the amount set in Settings (#368).
func (h *host) watchAlerts() {
n := &notifier{h: h}
wake := make(chan struct{}, 1)
onAlerts = func() {
// the Mac's question asked as the alert is turned on, not later
// when one is due and the user is elsewhere
go n.allowed()
select {
case wake <- struct{}{}:
default:
}
}
notifyProblem = n.problem
go func() {
<-h.ready
// set before this run: asked now, so Settings can say if it was refused
if s := settings.Load(); s.UsageAlert > 0 || s.BalanceAlert > 0 {
go n.allowed()
}
provider.WatchQuotas(context.Background(), wake, func(a provider.QuotaAlert) {
s := settings.Load()
title, body := alertText(trayLang(s.Lang, systemLang), a, s.BalanceAlert, s.QuotaLeft, time.Now())
n.send(title, body)
})
}()
}
+38
View File
@@ -0,0 +1,38 @@
package gui
import (
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
func TestAlertText(t *testing.T) {
now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.Local)
reset := time.Date(2026, 10, 1, 14, 30, 0, 0, time.Local)
w := provider.QuotaAlert{Provider: "claude", Name: "Claude Code", User: "a@b.c", Window: "5 hours", Used: 82.34, ResetsAt: &reset}
b := provider.QuotaAlert{Provider: "deepseek", Name: "DeepSeek", Balance: "¥4.20"}
for _, c := range []struct {
lang string
a provider.QuotaAlert
left bool
title, body string
}{
{"en", w, false, "Claude Code · a@b.c", "5 hours: 82.3% used, resets 14:30"},
{"en", w, true, "Claude Code · a@b.c", "5 hours: 17.7% left, resets 14:30"},
{"zh", w, false, "Claude Code · a@b.c", "5 小时窗口已用 82.3%,14:30 重置"},
{"zh", w, true, "Claude Code · a@b.c", "5 小时窗口剩余 17.7%,14:30 重置"},
{"en", b, false, "DeepSeek", "Balance down to ¥4.20 (alert at 5)"},
{"zh", b, false, "DeepSeek", "余额已降至 ¥4.20(提醒线 5)"},
} {
title, body := alertText(c.lang, c.a, 5, c.left, now)
if title != c.title || body != c.body {
t.Errorf("%s left=%v: %q / %q, want %q / %q", c.lang, c.left, title, body, c.title, c.body)
}
}
tomorrow := reset.Add(24 * time.Hour)
week := provider.QuotaAlert{Name: "Codex", Window: "7 days", Used: 80, ResetsAt: &tomorrow}
if _, body := alertText("zh", week, 0, false, now); body != "7 天窗口已用 80%,明天 14:30 重置" {
t.Errorf("zh tomorrow: %q", body)
}
}
+9 -1
View File
@@ -525,10 +525,18 @@ moment; a tab picked while waiting shows its own outline and stays picked;
the Library folder waits. When the library comes the outline goes, the tabs
get their counts and don't move.
`usage-alert.test.cjs` sets the usage alerts (#368) on the Settings page:
"Usage alert" and "Low balance alert" start Off; On saves 80% (and 5 for a
balance), the field beside it saves the number typed and puts back a share
past 100 unsaved, and a setting saved later (the currency) keeps both
alerts. With magpie's notifications turned off in the system, both rows say
so. No click moves the page and no row has a coloured left border; English
and Chinese, Chromium and WebKit, 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/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
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs 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
```
If Playwright is installed outside the repository, set `NODE_PATH` to the
+226
View File
@@ -0,0 +1,226 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// Usage alerts (#368): the Settings page's "Usage alert" and "Low balance
// alert" rows start Off; On saves 80% (and 5 for a balance), the field
// beside it saves the number typed, a share outside 1-100 is put back
// unsaved, and a choice saved later elsewhere on the page keeps the alerts
// (the page sends all its settings each time, and the server keeps only
// what it is sent). When the system has magpie's notifications turned off
// the rows say so. No click moves the page, and the rows have no coloured
// left border. 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 settingsPayload(over) {
return {
theme: "light", lang: "en", tray: "panel", quotaLeft: false, currency: "usd",
dock: false, dockWindow: false, proxy: "", redact: false, redactPersonal: false, redactWords: [],
codexWarmup: "", claudeWarmup: "", codexWarmAt: "", claudeWarmAt: "", workbuddyCheckin: false, noStats: false,
trayUsage: "", trayUsageEvery: 3, vision: "", imageGen: "",
version: "0.1.400", dir: "~/.config/magpie", gateway: "http://127.0.0.1:3425",
proxyNow: "none", proxySource: "none", login: false,
visionModels: [], imageGenModels: [], workbuddyCheckins: [], lanURLs: [],
fx: { rate: 7.2, at: new Date().toISOString(), stale: false },
...over,
};
}
// server answers as magpie does: a POST to /api/settings replaces what is
// kept with what the page sent, and the notifications' state (problem) is
// said while an alert is on.
function server(lang, posts, problem) {
const fixed = settingsPayload({ lang });
let cur = fixed;
const answer = () => ((cur.usageAlert || cur.balanceAlert) && problem ? { ...cur, notifyProblem: problem } : cur);
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: { lang, theme: "light" }, fx: fixed.fx });
if (url.pathname === "/api/settings") {
if (req.method() === "POST") {
const body = req.postDataJSON();
posts.push(body);
cur = { ...fixed, ...body };
}
return json(answer());
}
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
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 view = (page) => page.locator("#view-settings").evaluate((v) => v.scrollTop);
// wheelTo scrolls the settings with the wheel, as a reader would (a script
// setting scrollTop is put back), until the alert rows are mid-page.
async function wheelTo(page) {
await page.locator("#view-settings").hover();
for (let i = 0; i < 40; i++) {
const box = await page.locator("#balanceAlertRow").boundingBox();
if (box && box.y + box.height < 300 && await view(page) > 0) return;
await page.mouse.wheel(0, 120);
await page.waitForTimeout(30);
}
throw new Error("couldn't scroll the alert rows into view");
}
const words = {
en: {
usage: "Usage alert", balance: "Low balance alert", off: "Off", on: "On",
usageSub: "A notification when a 5-hour, weekly or monthly window reaches this share used, once each time it runs",
balanceSub: "A notification when a balance falls to this amount, in its own currency or credits, once until it is topped up",
denied: "Notifications are turned off for magpie in the system's settings",
share: "Share used", amount: "Amount", cny: "¥ CNY",
},
zh: {
usage: "用量提醒", balance: "余额提醒", off: "关闭", on: "开启",
usageSub: "5 小时、每周或每月窗口用到这个比例时发一条系统通知,每个周期只提醒一次",
balanceSub: "余额降到这个数(按它自己的货币或积分)时发一条系统通知,充值回升前只提醒一次",
denied: "系统设置里关闭了 magpie 的通知",
share: "已用比例", amount: "金额", cny: "¥ 人民币",
},
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": usage and balance alerts are set on the Settings page", async (t) => {
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
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}-usage-alert-${i}.png`) });
}
await browser.close();
});
for (const lang of ["en", "zh"]) {
const w = words[lang];
await t.test(lang, async () => {
const errors = [], posts = [];
const context = await browser.newContext({ viewport: { width: 900, height: 480 }, reducedMotion: "reduce" });
const page = await context.newPage();
pages.push(page);
page.setDefaultTimeout(5000);
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(lang, posts, ""));
const posted = async (n) => { for (let i = 0; i < 100 && posts.length < n; i++) await page.waitForTimeout(20); assert.equal(posts.length, n, "posts"); return posts[n - 1]; };
const settled = () => page.waitForTimeout(300);
await page.goto("http://magpie.test/?view=settings");
const usageSegs = page.locator("#usageAlertSegs .opt"), balSegs = page.locator("#balanceAlertSegs .opt");
await usageSegs.first().waitFor();
// the rows, in the page's language, Off to begin with
assert.equal((await page.locator("#usageAlertRow .name").textContent()).trim(), w.usage);
assert.equal((await page.locator("#balanceAlertRow .name").textContent()).trim(), w.balance);
assert.equal((await page.locator("#usageAlertSub").textContent()).trim(), w.usageSub);
assert.equal((await page.locator("#balanceAlertSub").textContent()).trim(), w.balanceSub);
assert.deepEqual(await usageSegs.allTextContents(), [w.off, w.on]);
assert.equal(await page.locator("#usageAlertSegs .opt.on").textContent(), w.off);
assert.equal(await page.locator("#balanceAlertSegs .opt.on").textContent(), w.off);
assert.equal(await page.locator("#usageAlertSegs input, #balanceAlertSegs input").count(), 0, "no field while off");
// scrolled down with the wheel (the rows to the middle, clear of
// the footer), turning it on moves nothing
await wheelTo(page);
const before = await view(page);
assert(before > 0, "the settings list must be long enough to scroll");
await usageSegs.nth(1).click();
assert.equal((await posted(1)).usageAlert, 80, "On saves 80%");
const share = page.locator("#usageAlertSegs input");
await share.waitFor();
await settled();
assert.equal(await view(page), before, "turning the alert on must not scroll the settings page");
assert.equal(await share.inputValue(), "80");
assert.equal(await share.getAttribute("aria-label"), w.share);
assert.equal((await page.locator("#usageAlertSegs .unit").textContent()), "%");
assert.equal(await page.locator("#usageAlertSegs .opt.on").textContent(), w.on);
// a share typed in is saved; one past 100 is put back, unsaved
await share.fill("90");
await share.press("Enter");
assert.equal((await posted(2)).usageAlert, 90);
await settled();
await share.fill("150");
await share.press("Enter");
await settled();
assert.equal(posts.length, 2, "150% is not saved");
assert.equal(await share.inputValue(), "90");
// the balance: On saves 5, then the amount typed
await balSegs.nth(1).click();
const b = await posted(3);
assert.equal(b.balanceAlert, 5);
assert.equal(b.usageAlert, 90, "the usage alert is sent along");
const amount = page.locator("#balanceAlertSegs input");
await amount.waitFor();
assert.equal(await amount.getAttribute("aria-label"), w.amount);
await amount.fill("2.5");
await amount.press("Enter");
assert.equal((await posted(4)).balanceAlert, 2.5);
await settled();
// another setting saved later keeps both alerts
await page.locator("#currencySegs .opt", { hasText: w.cny }).click();
const c = await posted(5);
assert.equal(c.currency, "cny");
assert.equal(c.usageAlert, 90, "the usage alert is kept by another save");
assert.equal(c.balanceAlert, 2.5, "the balance alert is kept by another save");
await settled();
assert.equal(await share.inputValue(), "90");
assert.equal(await view(page), before, "no click moved the page");
// Off saves 0, and the field goes
await usageSegs.nth(0).click();
assert.equal((await posted(6)).usageAlert, 0);
await settled();
assert.equal(await page.locator("#usageAlertSegs input").count(), 0);
// no coloured stripe down a row's left
for (const sel of ["#usageAlertRow", "#balanceAlertRow", "#usageAlertSub", "#balanceAlertSub"]) {
assert.equal(await page.locator(sel).evaluate((e) => getComputedStyle(e).borderLeftWidth), "0px", sel);
}
assert.deepEqual(errors, []);
await context.close();
});
await t.test(lang + ": notifications turned off in the system", async () => {
const errors = [], posts = [];
const context = await browser.newContext({ viewport: { width: 900, height: 480 }, reducedMotion: "reduce" });
const page = await context.newPage();
pages.push(page);
page.setDefaultTimeout(5000);
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(lang, posts, "denied"));
await page.goto("http://magpie.test/?view=settings");
await page.locator("#usageAlertSegs .opt").first().waitFor();
await wheelTo(page);
// nothing said while every alert is off
assert.equal((await page.locator("#usageAlertSub").textContent()).trim(), w.usageSub);
await page.locator("#usageAlertSegs .opt").nth(1).click();
await page.locator("#usageAlertSegs input").waitFor();
await page.waitForTimeout(300);
for (const id of ["#usageAlertSub", "#balanceAlertSub"]) {
const sub = page.locator(id);
assert((await sub.textContent()).includes(w.denied), `${id} says notifications are off: ${await sub.textContent()}`);
assert.equal((await sub.locator(".warn").textContent()), w.denied, "the problem stands out");
assert.equal(await sub.evaluate((e) => getComputedStyle(e).borderLeftWidth), "0px");
}
assert.deepEqual(errors, []);
await context.close();
});
}
});
}
+223
View File
@@ -0,0 +1,223 @@
package provider
// Usage alerts (#368): a notification when a subscription's or plan's window
// has reached the share of it the user chose, or a balance has fallen to the
// amount they chose (settings.UsageAlert, settings.BalanceAlert). Each is
// said once: a window once each time it runs, a balance once until it is
// topped up past the amount again. What was said is kept in a file, so a
// restart doesn't say it again.
import (
"context"
"encoding/json"
"log"
"math"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/settings"
)
// QuotaAlert is one thing to tell the user: a window used past the share
// (Window set) or a balance at or under the amount (Balance set).
type QuotaAlert struct {
Provider string
Name string // the card's name, as the Usage page shows it
User string // the account or key, when the provider has several
Window string // the window's name
Used float64
ResetsAt *time.Time
Balance string // the balance as the vendor tells it
}
// alertMark is what was said of one window or balance: when, and for a
// window the reset it was said before, which is how a new run of the window
// is told from the same one read again.
type alertMark struct {
At time.Time `json:"at"`
Until *time.Time `json:"until,omitempty"`
}
// alertSameRun is how far apart two readings of a window's reset may be and
// still be the same run of it: Codex's is the reading's time plus the
// seconds left, and moves a little from one reading to the next.
const alertSameRun = 10 * time.Minute
// alertKeep is how long the mark of a window or balance no longer read
// (signed out, a key removed) is kept.
const alertKeep = 40 * 24 * time.Hour
// dueAlerts is what the readings qs call for with a window alert at pct
// percent used (0 off) and a balance alert at bal (0 off), and the marks
// brought up to date with it. A reading that failed, or is one kept from
// before (AsOf), neither alerts nor clears: nothing new is known.
func dueAlerts(qs []SubscriptionQuota, marks map[string]alertMark, pct int, bal float64, now time.Time) []QuotaAlert {
var out []QuotaAlert
seen := map[string]bool{}
for _, q := range qs {
if q.Error != "" || q.AsOf != nil {
for k := range marks {
if strings.HasPrefix(k, alertPrefix(q)) {
seen[k] = true
}
}
continue
}
for _, w := range q.Windows {
if w.Aside {
continue // using it up doesn't stop the account
}
key := alertPrefix(q) + "w|" + w.Name + "|" + w.Model
seen[key] = true
resets := w.ResetsAt
if resets == nil && w.ResetSecs > 0 {
t := now.Add(time.Duration(w.ResetSecs) * time.Second)
resets = &t
}
if pct <= 0 || w.Used < float64(pct) {
delete(marks, key)
continue
}
if m, ok := marks[key]; ok && sameRun(m.Until, resets) {
continue
}
marks[key] = alertMark{At: now, Until: resets}
out = append(out, QuotaAlert{Provider: q.Provider, Name: q.Name, User: q.User, Window: w.Name, Used: w.Used, ResetsAt: resets})
}
if q.Balance == "" {
continue
}
key := alertPrefix(q) + "b"
seen[key] = true
n, ok := BalanceNumber(q.Balance)
if !ok {
continue
}
if bal <= 0 || n > bal {
delete(marks, key)
continue
}
if _, ok := marks[key]; ok {
continue
}
marks[key] = alertMark{At: now}
out = append(out, QuotaAlert{Provider: q.Provider, Name: q.Name, User: q.User, Balance: q.Balance})
}
// one not read at all now is kept a while, in case it is read again
for k, m := range marks {
if !seen[k] && now.Sub(m.At) > alertKeep {
delete(marks, k)
}
}
return out
}
// alertPrefix begins the keys of one card's marks: its provider and account.
func alertPrefix(q SubscriptionQuota) string { return q.Provider + "|" + q.User + "|" }
// sameRun is whether a window marked as resetting at was and one read now
// resetting at now are one run of it. A window that says no reset is taken
// for the same run until its use falls under the share.
func sameRun(was, now *time.Time) bool {
if was == nil || now == nil {
return was == nil && now == nil
}
d := was.Sub(*now)
return d < alertSameRun && d > -alertSameRun
}
var balanceNum = regexp.MustCompile(`-?\d[\d,]*(?:\.\d+)?`)
// BalanceNumber is the amount a balance as a vendor tells it holds: "$12.34",
// "¥1,024.50", "CNY 8", "12.3k credits". A share ("45%"), or text with no
// number, is none.
func BalanceNumber(s string) (float64, bool) {
if strings.Contains(s, "%") {
return 0, false
}
loc := balanceNum.FindStringIndex(s)
if loc == nil {
return 0, false
}
n, err := strconv.ParseFloat(strings.ReplaceAll(s[loc[0]:loc[1]], ",", ""), 64)
if err != nil || math.IsNaN(n) || math.IsInf(n, 0) {
return 0, false
}
switch rest := strings.TrimSpace(s[loc[1]:]); {
case strings.HasPrefix(rest, "k") || strings.HasPrefix(rest, "K"):
n *= 1e3
case strings.HasPrefix(rest, "M"):
n *= 1e6
}
return n, true
}
func quotaAlertPath() string { return filepath.Join(filepath.Dir(Path()), "usage-alerts.json") }
// quotaAlertMu keeps the file to one writer in this magpie.
var quotaAlertMu sync.Mutex
func readAlertMarks(path string) map[string]alertMark {
m := map[string]alertMark{}
if b, err := os.ReadFile(path); err == nil {
json.Unmarshal(b, &m)
}
return m
}
// checkAlerts reads what is left everywhere and says what is due, keeping
// the marks in path.
func checkAlerts(path string, qs []SubscriptionQuota, pct int, bal float64, now time.Time) []QuotaAlert {
quotaAlertMu.Lock()
defer quotaAlertMu.Unlock()
marks := readAlertMarks(path)
before, _ := json.Marshal(marks)
out := dueAlerts(qs, marks, pct, bal, now)
if after, _ := json.Marshal(marks); string(after) != string(before) {
os.MkdirAll(filepath.Dir(path), 0o755)
os.WriteFile(path, append(after, '\n'), 0o600)
}
return out
}
// quotaAlertEvery is how often the allowances are read for the alerts: the
// vendors' private endpoints limit how often they are asked, and the
// windows move slowly.
const quotaAlertEvery = 5 * time.Minute
// WatchQuotas reads every allowance and balance while an alert is set, and
// hands send each one due, until ctx ends. wake, when it gets a value,
// has it look at once (an alert just turned on).
func WatchQuotas(ctx context.Context, wake <-chan struct{}, send func(QuotaAlert)) {
t := time.NewTimer(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
case <-wake:
}
if s := settings.Load(); s.UsageAlert > 0 || s.BalanceAlert > 0 {
cx, cancel := context.WithTimeout(ctx, time.Minute)
qs := Quotas(cx)
cancel()
for _, a := range checkAlerts(quotaAlertPath(), qs, s.UsageAlert, s.BalanceAlert, time.Now()) {
log.Printf("usage alert: %s %s %s %.0f%% %s", a.Name, a.User, a.Window, a.Used, a.Balance)
send(a)
}
}
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(quotaAlertEvery)
}
}
+148
View File
@@ -0,0 +1,148 @@
package provider
import (
"path/filepath"
"testing"
"time"
)
func TestDueAlertsWindowOncePerRun(t *testing.T) {
now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
reset := now.Add(3 * time.Hour)
card := func(used float64, resets time.Time) []SubscriptionQuota {
return []SubscriptionQuota{{Provider: "codex", Name: "Codex", User: "a@b.c", Windows: []QuotaWindow{
{Name: "5 hours", Used: used, ResetsAt: &resets},
{Name: "On-demand", Used: 100, Aside: true}, // set aside: never told
}}}
}
marks := map[string]alertMark{}
if got := dueAlerts(card(79.9, reset), marks, 80, 0, now); len(got) != 0 {
t.Fatalf("under the share: %+v", got)
}
got := dueAlerts(card(80, reset), marks, 80, 0, now)
if len(got) != 1 || got[0].Window != "5 hours" || got[0].User != "a@b.c" || got[0].Used != 80 || !got[0].ResetsAt.Equal(reset) {
t.Fatalf("at the share: %+v", got)
}
// read again, the reset a few seconds off (Codex's moves): the same run
if got := dueAlerts(card(95, reset.Add(20*time.Second)), marks, 80, 0, now.Add(5*time.Minute)); len(got) != 0 {
t.Fatalf("told twice in one run: %+v", got)
}
// the window reset and was used up to the share again: told again
next := reset.Add(5 * time.Hour)
if got := dueAlerts(card(85, next), marks, 80, 0, reset.Add(time.Hour)); len(got) != 1 {
t.Fatalf("a new run: %+v", got)
}
// falling under the share clears it, in the same run too
dueAlerts(card(10, next), marks, 80, 0, reset.Add(2*time.Hour))
if len(marks) != 0 {
t.Fatalf("marks kept under the share: %v", marks)
}
if got := dueAlerts(card(81, next), marks, 80, 0, reset.Add(3*time.Hour)); len(got) != 1 {
t.Fatalf("back over the share: %+v", got)
}
// off: nothing, and nothing kept
if got := dueAlerts(card(100, next), marks, 0, 0, reset.Add(4*time.Hour)); len(got) != 0 || len(marks) != 0 {
t.Fatalf("off: %+v %v", got, marks)
}
}
func TestDueAlertsSecondsLeft(t *testing.T) {
now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
q := func(secs int64) []SubscriptionQuota {
return []SubscriptionQuota{{Provider: "codex", Windows: []QuotaWindow{{Name: "7 days", Used: 90, ResetSecs: secs}}}}
}
marks := map[string]alertMark{}
got := dueAlerts(q(3600), marks, 80, 0, now)
if len(got) != 1 || got[0].ResetsAt == nil || !got[0].ResetsAt.Equal(now.Add(time.Hour)) {
t.Fatalf("reset from the seconds left: %+v", got)
}
if got := dueAlerts(q(3000), marks, 80, 0, now.Add(10*time.Minute)); len(got) != 0 {
t.Fatalf("the same run, counted down: %+v", got)
}
}
func TestDueAlertsStaleReadingKeepsMarks(t *testing.T) {
now := time.Now()
reset := now.Add(time.Hour)
ok := []SubscriptionQuota{{Provider: "claude", User: "x", Windows: []QuotaWindow{{Name: "5 hours", Used: 90, ResetsAt: &reset}}}}
marks := map[string]alertMark{}
if len(dueAlerts(ok, marks, 80, 0, now)) != 1 {
t.Fatal("not told")
}
// a failed reading, and one kept from before, say nothing new
failed := []SubscriptionQuota{{Provider: "claude", User: "x", Error: "429"}}
if got := dueAlerts(failed, marks, 80, 0, now.Add(alertKeep+time.Hour)); len(got) != 0 || len(marks) != 1 {
t.Fatalf("failed reading: %+v %v", got, marks)
}
stale := []SubscriptionQuota{{Provider: "claude", User: "x", AsOf: &now, Windows: []QuotaWindow{{Name: "5 hours", Used: 10, ResetsAt: &reset}}}}
if dueAlerts(stale, marks, 80, 0, now); len(marks) != 1 {
t.Fatalf("a stale reading cleared the mark: %v", marks)
}
if len(dueAlerts(ok, marks, 80, 0, now)) != 0 {
t.Fatal("told again after a failed reading")
}
// one no longer read at all is let go after a while
dueAlerts(nil, marks, 80, 0, now.Add(alertKeep+time.Hour))
if len(marks) != 0 {
t.Fatalf("kept forever: %v", marks)
}
}
func TestDueAlertsBalance(t *testing.T) {
now := time.Now()
q := func(b string) []SubscriptionQuota {
return []SubscriptionQuota{{Provider: "deepseek", Name: "DeepSeek", Balance: b, Windows: []QuotaWindow{}}}
}
marks := map[string]alertMark{}
if got := dueAlerts(q("¥5.01"), marks, 80, 5, now); len(got) != 0 {
t.Fatalf("over the amount: %+v", got)
}
got := dueAlerts(q("¥5.00"), marks, 80, 5, now)
if len(got) != 1 || got[0].Balance != "¥5.00" || got[0].Window != "" {
t.Fatalf("at the amount: %+v", got)
}
if got := dueAlerts(q("¥1.20"), marks, 80, 5, now); len(got) != 0 {
t.Fatalf("told twice: %+v", got)
}
// topped up, then down again
dueAlerts(q("¥100.00"), marks, 80, 5, now)
if got := dueAlerts(q("¥4.00"), marks, 80, 5, now); len(got) != 1 {
t.Fatalf("down again: %+v", got)
}
// a share isn't an amount, and off is off
if got := dueAlerts(q("3%"), map[string]alertMark{}, 80, 5, now); len(got) != 0 {
t.Fatalf("a share: %+v", got)
}
if got := dueAlerts(q("$0.10"), map[string]alertMark{}, 80, 0, now); len(got) != 0 {
t.Fatalf("off: %+v", got)
}
}
func TestBalanceNumber(t *testing.T) {
for s, want := range map[string]float64{
"$12.34": 12.34, "¥1,024.50": 1024.5, "CNY 8": 8, "12.3k credits": 12300, "$-3.20": -3.2, "2M tokens": 2e6,
} {
if n, ok := BalanceNumber(s); !ok || n != want {
t.Errorf("BalanceNumber(%q) = %v, %v; want %v", s, n, ok, want)
}
}
for _, s := range []string{"45%", "", "unlimited"} {
if _, ok := BalanceNumber(s); ok {
t.Errorf("BalanceNumber(%q) read a number", s)
}
}
}
// what was told is kept across a restart: the file, not memory
func TestCheckAlertsKeepsMarks(t *testing.T) {
path := filepath.Join(t.TempDir(), "usage-alerts.json")
now := time.Now()
reset := now.Add(time.Hour)
qs := []SubscriptionQuota{{Provider: "claude", User: "x", Windows: []QuotaWindow{{Name: "5 hours", Used: 90, ResetsAt: &reset}}}}
if got := checkAlerts(path, qs, 80, 0, now); len(got) != 1 {
t.Fatalf("first: %+v", got)
}
if got := checkAlerts(path, qs, 80, 0, now.Add(time.Minute)); len(got) != 0 {
t.Fatalf("told again after the file was read back: %+v", got)
}
}
+15
View File
@@ -119,6 +119,15 @@ type Settings struct {
// QuotaLeft shows a subscription's windows by how much of each is left,
// not used: the Usage page, the tray panel and the menu bar alike.
QuotaLeft bool `json:"quotaLeft,omitempty"`
// UsageAlert is how much of a subscription's or plan's window, in
// percent, is used when magpie says so with a notification (#368):
// once for each time the window runs, for every window routing counts
// (not one set aside, as on-demand spending is); 0 is off.
UsageAlert int `json:"usageAlert,omitempty"`
// BalanceAlert is the balance a key or account has fallen to when
// magpie says so, in that balance's own currency or credits, once until
// it is topped up past it again; 0 is off.
BalanceAlert float64 `json:"balanceAlert,omitempty"`
// PlainNames has the model lists magpie gives agents name each model
// by its name alone, without its provider's or "routing group" after it
// (#335) — but for two in one list that would read the same, which keep
@@ -474,6 +483,12 @@ func Save(s Settings) error {
if !slices.Contains(TrayEvery, s.TrayUsageEvery) {
return fmt.Errorf("the menu bar's usage is refreshed every %v minutes, not %d", TrayEvery, s.TrayUsageEvery)
}
if s.UsageAlert < 0 || s.UsageAlert > 100 {
return fmt.Errorf("a usage alert is at a percentage from 1 to 100, or 0 for off, not %d", s.UsageAlert)
}
if math.IsNaN(s.BalanceAlert) || math.IsInf(s.BalanceAlert, 0) || s.BalanceAlert < 0 {
return fmt.Errorf("a balance alert is at an amount of 0 or more (0 for off), not %v", s.BalanceAlert)
}
if !slices.Contains(TextSizes, s.TextSize) {
return fmt.Errorf("text size must be one of %v percent, not %d", TextSizes, s.TextSize)
}
+16
View File
@@ -483,3 +483,19 @@ func TestModelPriceKeysStayTheFilesOwnWhileMessagesSayTheParts(t *testing.T) {
t.Errorf("refusing a price: %v; want it to ask for a cache read price", err)
}
}
// the usage alerts' share and amount (#368): 0 is off, the rest in range
func TestAlertsChecked(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
for _, s := range []Settings{{UsageAlert: 101}, {UsageAlert: -1}, {BalanceAlert: -0.5}} {
if err := Save(s); err == nil {
t.Errorf("saved %+v", s)
}
}
if err := Save(Settings{UsageAlert: 80, BalanceAlert: 2.5}); err != nil {
t.Fatal(err)
}
if s := Load(); s.UsageAlert != 80 || s.BalanceAlert != 2.5 {
t.Fatalf("read back %d %v", s.UsageAlert, s.BalanceAlert)
}
}
+5
View File
@@ -154,6 +154,11 @@ func run(args []string) error {
return runGUI(true, "")
case "tray":
return runGUI(false, "")
case "-Embedding":
// Windows starting magpie for a click on one of its notifications
// (a usage alert, #368) left in the Action Center after it quit:
// the window, on the Usage page
return runWindow("usage")
case "panel":
return runPanel()
case "autostart":
+75 -1
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"slices"
"strconv"
"strings"
"time"
@@ -16,6 +17,7 @@ import (
const quotaUsage = `usage: magpie quota [<provider>…] [--json]
magpie quota reset [<codex account>] [--yes]
magpie quota auto-reset [<codex account>] [on|off]
magpie quota alert [<percent>|off] [--balance <amount>|off]
what is left of every subscription, plan and key magpie has: each window's use and
when it starts again, and each key's balance, asked of the vendors now (or less than
a minute ago). --json is for scripts and agents; the gateway answers the same at
@@ -25,7 +27,12 @@ const quotaUsage = `usage: magpie quota [<provider>…] [--json]
named). It can't be undone, so it asks first; --yes doesn't. quota auto-reset on lets
the account spend one by itself when its weekly window is used up and no other
account can take a request, one a week at most (the five hours running out never
does); off stops it, and alone it says which accounts do. It is off until turned on.`
does); off stops it, and alone it says which accounts do. It is off until turned on.
quota alert 80 has the magpie app notify when any window of a subscription or plan
reaches 80% used, once each time the window runs (not windows set aside, such as
on-demand spending); --balance 5 when a balance falls to 5 or under, in its own
currency or credits, once until topped up past it. off turns either off, and alone
it says what is set. Both are off until set.`
// quotaCmd: magpie quota [<provider>…] [--json]
func quotaCmd(args []string) error {
@@ -35,6 +42,9 @@ func quotaCmd(args []string) error {
if len(args) > 1 && args[1] == "auto-reset" {
return quotaAutoResetCmd(args[2:])
}
if len(args) > 1 && args[1] == "alert" {
return quotaAlertCmd(args[2:])
}
asJSON := false
var only []string
for _, a := range args[1:] {
@@ -232,6 +242,70 @@ func quotaAutoResetCmd(args []string) error {
return nil
}
// quotaAlertCmd: magpie quota alert [<percent>|off] [--balance <amount>|off]
// — the usage alerts Settings sets (#368), the notifications the app shows.
func quotaAlertCmd(args []string) error {
s := settings.Load()
changed := false
for i := 0; i < len(args); i++ {
a := strings.ToLower(strings.TrimSpace(args[i]))
switch {
case a == "help" || a == "-h" || a == "--help":
fmt.Println(quotaUsage)
return nil
case a == "--balance" || strings.HasPrefix(a, "--balance="):
v, ok := strings.CutPrefix(a, "--balance=")
if !ok {
if i+1 >= len(args) {
return fmt.Errorf("--balance needs an amount, or off")
}
i++
v = strings.ToLower(strings.TrimSpace(args[i]))
}
if v == "off" {
s.BalanceAlert = 0
} else {
n, err := strconv.ParseFloat(v, 64)
if err != nil || n <= 0 {
return fmt.Errorf("a balance alert is at an amount over 0, or off, not %q", v)
}
s.BalanceAlert = n
}
changed = true
case a == "off":
s.UsageAlert = 0
changed = true
default:
n, err := strconv.Atoi(strings.TrimSuffix(a, "%"))
if err != nil || n < 1 || n > 100 {
return fmt.Errorf("a usage alert is at a percentage from 1 to 100, or off, not %q", args[i])
}
s.UsageAlert = n
changed = true
}
}
if changed {
if err := settings.Save(s); err != nil {
return err
}
fmt.Print(green.Render("✓") + " ")
}
if s.UsageAlert > 0 {
fmt.Printf("notifies when a window reaches %d%% used, once each time it runs", s.UsageAlert)
} else {
fmt.Print("no usage alert")
}
if s.BalanceAlert > 0 {
fmt.Printf("; and when a balance falls to %s or under, once until topped up\n", strconv.FormatFloat(s.BalanceAlert, 'f', -1, 64))
} else {
fmt.Println("; no balance alert")
}
if s.UsageAlert > 0 || s.BalanceAlert > 0 {
fmt.Println(muted.Render(" the notifications come from the magpie app, while it runs"))
}
return nil
}
// quotaTitle is a quota's line head: its provider, plan and account.
func quotaTitle(q provider.Quota) string {
t := q.Provider