usage: a DeepSeek balance in CNY and USD at once reads in one order every read ("$11.12 · ¥-0.05", the currency with money left first), and its trend is that currency's own line in its own sign, not ¥-0.05 and $11.12 joined into one alternating line (gakki on Discord)

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.
This commit is contained in:
yetone committed 2026-10-08 22:13:09 +08:00
1 parent 0d8f85e47b
commit dfd881231e
6 files changed
+267 -19

No files matched your search

+5 -1
View File
@@ -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
@@ -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, []);
});
}
}
+26 -3
View File
@@ -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
}
+88 -15
View File
@@ -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
}
}
}
+60
View File
@@ -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)
}
}
}
+2
View File
@@ -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"},