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:
19 files changed
+1063
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "$ 美元",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 := ¬ifier{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)
|
||||
})
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user