diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 1aef0905..ad7cac08 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -13268,8 +13268,12 @@ function curveZoom(box, g) { // balanceAmount: v written as the card's balance writes its amount, "¥" // or "$" before it, "credits" after function balanceAmount(sub, v) { - const m = (sub.balance || "").match(/^(.*?)-?\d[\d,]*(?:\.\d+)?(.*)$/); const n = v.toLocaleString(intlLang(), { minimumFractionDigits: Math.abs(v) < 100 ? 2 : 0, maximumFractionDigits: 2 }); + // a balance in several currencies ("$11.12 · ¥-0.05"): its trend is the + // first currency's, in that currency's sign alone + const cur = sub.balanceTrend?.currency; + if (cur) return cur + n; + const m = (sub.balance || "").match(/^(.*?)-?\d[\d,]*(?:\.\d+)?(.*)$/); return m ? m[1] + n + m[2] : n; } // balanceCurve: a key's balance over time, as magpie read it, under its diff --git a/internal/gui/tests/balance-currencies.test.cjs b/internal/gui/tests/balance-currencies.test.cjs new file mode 100644 index 00000000..8238e48f --- /dev/null +++ b/internal/gui/tests/balance-currencies.test.cjs @@ -0,0 +1,86 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// A balance in two currencies (gakki on Discord): DeepSeek tells an +// account's CNY and USD at once, "$11.12 · ¥-0.05", and the trend under it +// is the USD's alone (balanceTrend.currency "$"). The curve's legend, its +// readings and its pace are said in dollars, not with the other currency +// stuck on each ("$11.12 · ¥-0.05" for every reading). English and Chinese, +// light and dark, at a narrow width; no backend, the API is faked here. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const H = 3600e3; +const iso = (ms) => new Date(ms).toISOString(); + +function fixtures(now) { + // the USD spent from $12 to $11.12 over two days; the CNY sits at -0.05 + const points = [[48, 12], [36, 11.8], [24, 11.6], [12, 11.35], [0, 11.12]].map(([h, v]) => ({ at: iso(now - h * H), amount: v })); + return [ + { provider: "deepseek", name: "DeepSeek", icon: "deepseek", windows: [], balance: "$11.12 · ¥-0.05", readAt: iso(now), + balanceTrend: { points, currency: "$", fitFrom: iso(now - 48 * H), fitStart: 12, fitNow: 11.12, perDay: 0.44 } }, + ]; +} + +function serve(lang, theme, quotas) { + const settings = { theme, lang, tray: "panel", quotaLeft: false, currency: "usd" }; + return async (route) => { + const url = new URL(route.request().url()); + const json = (body) => route.fulfill({ json: body }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"${theme}",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings }); + if (url.pathname === "/api/settings") return json(settings); + if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } }); + if (url.pathname === "/api/usage/quotas") return json(quotas); + if (url.pathname === "/api/usage/quotas/history") return json([]); + if (url.pathname === "/api/groups") return json({ groups: [], models: [] }); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); } + }; +} + +const words = { + en: { balance: "Balance", pace: "about $0.44 a day", head: "Latest readings:" }, + zh: { balance: "余额", pace: "约每天 $0.44", head: "最近的读数:" }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + const w = words[lang]; + test(`${engine} ${lang}: a balance in two currencies draws the first one's trend in its own sign`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const errors = []; + for (const theme of ["light", "dark"]) { + for (const width of [900, 440]) { + const page = await (await browser.newContext({ viewport: { width, height: 560 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, theme, fixtures(Date.now()))); + await page.goto("http://magpie.test/?view=usage"); + const card = page.locator(".subscription-card", { hasText: "DeepSeek" }); + const curve = card.locator(".balance-curve"); + await curve.waitFor(); + assert.ok((await card.textContent()).includes("$11.12 · ¥-0.05"), "the card still says both"); + assert.deepEqual(await curve.locator(".qc-key").allTextContents(), [w.balance + "$11.12", w.pace]); + const tip = (await curve.locator(".qc-key").first().getAttribute("title")).split("\n"); + assert.equal(tip[0], w.head); + assert.equal(tip.length, 6); + for (const line of tip.slice(1)) assert.match(line, /^\$\d+\.\d\d · /, `a reading in dollars alone: ${line}`); + assert.ok(tip[1].startsWith("$11.12 · "), tip[1]); + assert.equal(await curve.locator("path.qc-line").count(), 1); + const cb = await card.boundingBox(), pb = await curve.locator(".qc-plot").boundingBox(); + assert.ok(pb.x >= cb.x && pb.x + pb.width <= cb.x + cb.width + 0.5, `the plot fits its card at ${width}px`); + await page.context().close(); + } + } + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/balance.go b/internal/provider/balance.go index 21cf0099..4c69be7e 100644 --- a/internal/provider/balance.go +++ b/internal/provider/balance.go @@ -230,15 +230,38 @@ func readDeepSeek(b []byte) (string, error) { if err := json.Unmarshal(b, &r); err != nil { return "", err } - var parts []string + type amount struct { + code string + v float64 + } + var amts []amount for _, in := range r.Infos { if v, ok := number(in.Total); ok { - parts = append(parts, money(currencySign(in.Currency), v)) + amts = append(amts, amount{strings.ToUpper(in.Currency), v}) } } - if len(parts) == 0 { + if len(amts) == 0 { return "", errors.New("no balance in the reply") } + // DeepSeek lists an account's currencies (CNY, USD) in an order that + // changes from one read to the next (gakki on Discord), so they are + // put in one of magpie's own: a currency with money left first, then + // by its code. The first is the one the trend follows and an alert + // reads (balanceSeries, BalanceNumber), so it mustn't swap places + // between reads either. + slices.SortStableFunc(amts, func(a, b amount) int { + if (a.v > 0) != (b.v > 0) { + if a.v > 0 { + return -1 + } + return 1 + } + return strings.Compare(a.code, b.code) + }) + parts := make([]string, len(amts)) + for i, a := range amts { + parts[i] = money(currencySign(a.code), a.v) + } return strings.Join(parts, " · "), nil } diff --git a/internal/provider/balance_history.go b/internal/provider/balance_history.go index a8440b09..da3691ab 100644 --- a/internal/provider/balance_history.go +++ b/internal/provider/balance_history.go @@ -10,7 +10,9 @@ package provider // Kept in balance-history.json, by provider and key name: the amount only, // no key and nothing else of the vendor's. A balance kept from before // (AsOf), failed, or told as several amounts or a percent (BalanceParts) -// isn't one to keep. Points come at least balanceHistGap apart (a nearer one +// isn't one to keep. A balance in several currencies at once (DeepSeek's +// "$11.12 · ¥-0.05") keeps a line for each currency under a key of its +// own (balanceCurrencies), and its card draws the first one's. Points come at least balanceHistGap apart (a nearer one // moves the last instead), and a run of the same amount is its first and // last point only. Points older than balanceHistKept go, and a key keeps // balanceHistMax. @@ -20,7 +22,10 @@ import ( "math" "os" "path/filepath" + "regexp" "slices" + "strconv" + "strings" "sync" "time" ) @@ -56,6 +61,9 @@ type BalanceTrend struct { FitNow float64 `json:"fitNow,omitempty"` PerDay float64 `json:"perDay,omitempty"` RunsOut *time.Time `json:"runsOut,omitempty"` + // Currency is the sign the points are in ("$"), set when the balance + // is told in several currencies and the points are only the first's + Currency string `json:"currency,omitempty"` } // balanceHist is the file: key ("provider|user") → points. @@ -104,22 +112,72 @@ func addBalancePoint(pts []BalancePoint, n BalancePoint) []BalancePoint { return append(pts, n) } -// balancePointOf is the point q's balance makes, if it is one to keep. -func balancePointOf(q SubscriptionQuota, now time.Time) (BalancePoint, bool) { +// balancePointsOf is the points q's balance makes, by the key each is kept +// under, if it is one to keep: one under the card's key, or, told in +// several currencies, one for each under the card's key and the currency. +func balancePointsOf(q SubscriptionQuota, now time.Time) map[string]BalancePoint { if q.Error != "" || q.AsOf != nil || q.Provider == "" || q.Balance == "" || len(q.BalanceParts) > 0 { - return BalancePoint{}, false - } - n, ok := BalanceNumber(q.Balance) - if !ok { - return BalancePoint{}, false + return nil } at := now if q.ReadAt != nil && !q.ReadAt.After(now) { at = *q.ReadAt } - return BalancePoint{At: at.UTC().Truncate(time.Second), Amount: n}, true + at = at.UTC().Truncate(time.Second) + key := quotaHistKey(q.Provider, q.User) + if cs := balanceCurrencies(q.Balance); cs != nil { + out := map[string]BalancePoint{} + for _, c := range cs { + out[currencyHistKey(key, c.Sign)] = BalancePoint{At: at, Amount: c.N} + } + return out + } + n, ok := BalanceNumber(q.Balance) + if !ok { + return nil + } + return map[string]BalancePoint{key: {At: at, Amount: n}} } +// currencyAmount is one amount of a balance told in several currencies: +// its sign as shown ("$", "CNY ") and its number. +type currencyAmount struct { + Sign string + N float64 +} + +var currencyPart = regexp.MustCompile(`^([^\d-]+?)(-?\d[\d,]*(?:\.\d+)?)$`) + +// balanceCurrencies is a balance's amounts when it is told in several +// currencies, each a sign and a number, apart by " · " as readDeepSeek +// writes them ("$11.12 · ¥-0.05"), in the order shown; nil for one amount, +// or for anything else ("$3 · 2 keys", the same sign twice). +func balanceCurrencies(s string) []currencyAmount { + parts := strings.Split(s, " · ") + if len(parts) < 2 { + return nil + } + var out []currencyAmount + seen := map[string]bool{} + for _, p := range parts { + m := currencyPart.FindStringSubmatch(strings.TrimSpace(p)) + if m == nil { + return nil + } + sign := strings.TrimSpace(m[1]) + n, err := strconv.ParseFloat(strings.ReplaceAll(m[2], ",", ""), 64) + if sign == "" || seen[sign] || err != nil || math.IsNaN(n) || math.IsInf(n, 0) { + return nil + } + seen[sign] = true + out = append(out, currencyAmount{m[1], n}) + } + return out +} + +// currencyHistKey is where one currency of a card's balance is kept. +func currencyHistKey(key, sign string) string { return key + "|" + strings.TrimSpace(sign) } + // noteBalanceHistory keeps the balance each card among qs tells, and gives // every card with a balance its trend. func noteBalanceHistory(qs []SubscriptionQuota, now time.Time) { @@ -128,11 +186,18 @@ func noteBalanceHistory(qs []SubscriptionQuota, now time.Time) { var h balanceHist changed := false for _, q := range qs { - if p, ok := balancePointOf(q, now); ok { - if h == nil { - h = readBalanceHist() - } - key := quotaHistKey(q.Provider, q.User) + pts := balancePointsOf(q, now) + if len(pts) > 0 && h == nil { + h = readBalanceHist() + } + if card := quotaHistKey(q.Provider, q.User); len(pts) > 1 && len(h[card]) > 0 { + // the card's own line, kept before its currencies were told + // apart, took whichever came first in a read and can't be + // split again: it goes + delete(h, card) + changed = true + } + for key, p := range pts { before := h[key] k := len(before) var last BalancePoint @@ -169,7 +234,15 @@ func noteBalanceHistory(qs []SubscriptionQuota, now time.Time) { if h == nil { h = readBalanceHist() } - qs[i].BalanceTrend = balanceTrend(h[quotaHistKey(q.Provider, q.User)], now) + key := quotaHistKey(q.Provider, q.User) + cs := balanceCurrencies(q.Balance) + if cs != nil { + key = currencyHistKey(key, cs[0].Sign) + } + qs[i].BalanceTrend = balanceTrend(h[key], now) + if cs != nil && qs[i].BalanceTrend != nil { + qs[i].BalanceTrend.Currency = cs[0].Sign + } } } diff --git a/internal/provider/balance_history_test.go b/internal/provider/balance_history_test.go index dbaa3c12..33d63ed5 100644 --- a/internal/provider/balance_history_test.go +++ b/internal/provider/balance_history_test.go @@ -109,3 +109,63 @@ func TestBalanceTrendRunsOut(t *testing.T) { t.Fatalf("one point within the span shown: %+v", tr) } } + +// TestBalanceHistoryTwoCurrencies (gakki on Discord): DeepSeek tells an +// account's CNY and USD balances in an order that changes between reads. +// The card says them in one order every time, the money left first, and +// the trend is the USD alone, in dollars, not ¥-0.05 and $11.12 joined +// into one line; the CNY keeps a line of its own. The card's old line, +// kept before and alternating, goes. +func TestBalanceHistoryTwoCurrencies(t *testing.T) { + testenv.SetHome(t, t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + cny := `{"currency":"CNY","total_balance":"-0.05","granted_balance":"0.00","topped_up_balance":"-0.05"}` + usd := func(v string) string { + return `{"currency":"USD","total_balance":"` + v + `","granted_balance":"0.00","topped_up_balance":"` + v + `"}` + } + t0 := time.Date(2026, 10, 8, 8, 0, 0, 0, time.UTC) + key := quotaHistKey("deepseek", "main") + // what an earlier magpie kept: whichever amount came first + writeBalanceHist(balanceHist{key: {{t0.Add(-30 * time.Minute), 11.2}, {t0.Add(-20 * time.Minute), -0.05}, {t0.Add(-10 * time.Minute), 11.15}}}) + var last []SubscriptionQuota + for i, v := range []string{"11.12", "11.10", "11.08", "11.05"} { + body := `{"is_available":true,"balance_infos":[` + cny + `,` + usd(v) + `]}` + if i%2 == 1 { + body = `{"is_available":true,"balance_infos":[` + usd(v) + `,` + cny + `]}` + } + bal, err := readDeepSeek([]byte(body)) + if err != nil { + t.Fatal(err) + } + if want := "$" + v + " · ¥-0.05"; bal != want { + t.Fatalf("read %d: card says %q, want %q", i, bal, want) + } + last = []SubscriptionQuota{balCard("Main", bal)} + noteBalanceHistory(last, t0.Add(time.Duration(i)*10*time.Minute)) + } + tr := last[0].BalanceTrend + if tr == nil || tr.Currency != "$" || len(tr.Points) != 4 { + t.Fatalf("trend = %+v, want the four USD readings in $", tr) + } + for _, p := range tr.Points { + if p.Amount < 11 { + t.Fatalf("a CNY amount in the USD line: %+v", tr.Points) + } + } + h := readBalanceHist() + if c := h[currencyHistKey(key, "¥")]; len(c) != 2 || c[0].Amount != -0.05 { + t.Fatalf("CNY line = %+v, want its own run of -0.05", c) + } + if old, ok := h[key]; ok { + t.Fatalf("the old alternating line is kept: %+v", old) + } + if n, ok := BalanceNumber(last[0].Balance); !ok || n != 11.05 { + t.Fatalf("an alert reads %v, want the USD's 11.05", n) + } + // anything else with " · " in it is one amount, as before + for _, s := range []string{"$3 · 2 keys", "$1 · $2", "¥12.30", "12 · 13"} { + if cs := balanceCurrencies(s); cs != nil { + t.Errorf("%q read as currencies %+v", s, cs) + } + } +} diff --git a/internal/provider/balance_test.go b/internal/provider/balance_test.go index 248d5259..a0a1c738 100644 --- a/internal/provider/balance_test.go +++ b/internal/provider/balance_test.go @@ -23,6 +23,8 @@ func TestBalanceReaders(t *testing.T) { }{ {"deepseek", readDeepSeek, `{"is_available":true,"balance_infos":[{"currency":"CNY","total_balance":"110.00","granted_balance":"10.00"}]}`, "¥110.00"}, {"deepseek two", readDeepSeek, `{"balance_infos":[{"currency":"CNY","total_balance":"1"},{"currency":"USD","total_balance":"2.5"}]}`, "¥1.00 · $2.50"}, + {"deepseek two, USD first", readDeepSeek, `{"balance_infos":[{"currency":"USD","total_balance":"2.5"},{"currency":"CNY","total_balance":"1"}]}`, "¥1.00 · $2.50"}, + {"deepseek, the CNY spent", readDeepSeek, `{"is_available":true,"balance_infos":[{"currency":"CNY","total_balance":"-0.05","granted_balance":"0.00","topped_up_balance":"-0.05"},{"currency":"USD","total_balance":"11.12","granted_balance":"0.00","topped_up_balance":"11.12"}]}`, "$11.12 · ¥-0.05"}, {"kimi", readMoonshot("¥"), `{"code":0,"data":{"available_balance":49.58894,"voucher_balance":46.5,"cash_balance":3.0},"status":true}`, "¥49.59"}, {"openrouter", readOpenRouter, `{"data":{"total_credits":20,"total_usage":3.5}}`, "$16.50"}, {"commandcode", readCommandCode, `{"credits":{"monthlyCredits":12.3,"purchasedCredits":2,"freeCredits":0},"windowLimits":{"limited":true,"fiveHour":{"used":4.2,"cap":10},"weekly":{"used":9,"cap":50}}}`, "$14.30"},