From dfd881231eef6fbb606c43a5f50f9f19ed15936e Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 8 Oct 2026 22:13:06 +0800 Subject: [PATCH] =?UTF-8?q?usage:=20a=20DeepSeek=20balance=20in=20CNY=20an?= =?UTF-8?q?d=20USD=20at=20once=20reads=20in=20one=20order=20every=20read?= =?UTF-8?q?=20("$11.12=20=C2=B7=20=C2=A5-0.05",=20the=20currency=20with=20?= =?UTF-8?q?money=20left=20first),=20and=20its=20trend=20is=20that=20curren?= =?UTF-8?q?cy's=20own=20line=20in=20its=20own=20sign,=20not=20=C2=A5-0.05?= =?UTF-8?q?=20and=20$11.12=20joined=20into=20one=20alternating=20line=20(g?= =?UTF-8?q?akki=20on=20Discord)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek's /user/balance lists balance_infos in an order that changes from read to read. readDeepSeek wrote them as they came, and the balance history kept the first number of the string, so the chart alternated between the two currencies. readDeepSeek now sorts them (money left first, then by code). The history keeps each currency of such a balance under a key of its own (balanceCurrencies), the card's trend is the first one's with BalanceTrend.currency set, and the card's old line (which can't be split again) is dropped on the first such read. app.js says the curve's legend, readings and pace in that sign alone ("$11.12", not "$11.12 · ¥-0.05" after every amount). The usage alert reads the first amount too, so it no longer flips between the two currencies either. Red before: TestBalanceHistoryTwoCurrencies (reporter's shape: CNY -0.05 and USD 11.12, order swapped each read) fails with 'card says "¥-0.05 · $11.12", want "$11.12 · ¥-0.05"' on the old readDeepSeek, and with the old history alone the trend holds 11.2, -0.05, 11.15, …; TestBalanceReaders' two new cases fail the same way. balance-currencies.test.cjs fails on the old app.js with 'Balance$11.12 · ¥-0.05' and 'about $0.44 · ¥-0.05 a day'. Run (Docker golang:1.26, temp HOME): go test -tags nogui ./... (only internal/agent's TestAside* write-failure tests fail, as root in the container; they pass as uid 501), go vet ./internal/provider ./internal/gui, GOOS=linux/windows/darwin go vet ./internal/provider, gofmt -l. GUI: balance-currencies, balance-curve, balance-parts, balance-fix in Chromium and WebKit (110 pass; en/zh, light/dark, 900 and 440px). No new t() strings. Not tried with a real DeepSeek account holding both currencies. --- internal/gui/assets/app.js | 6 +- .../gui/tests/balance-currencies.test.cjs | 86 +++++++++++++++ internal/provider/balance.go | 29 ++++- internal/provider/balance_history.go | 103 +++++++++++++++--- internal/provider/balance_history_test.go | 60 ++++++++++ internal/provider/balance_test.go | 2 + 6 files changed, 267 insertions(+), 19 deletions(-) create mode 100644 internal/gui/tests/balance-currencies.test.cjs 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"},