dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
在右下角浮层显示 DeepSeek 账户余额与本会话花费,支持预算告警、峰时提醒,以及可选开启的官方价格同步。
English | 简体中文
An always-on HUD in the bottom-right corner of the DSH (DeepSeek Harness) Web GUI that shows your DeepSeek account balance and the cost of the current session.
简体中文说明 · Design notes · 设计说明 · LICENSE

About these images: they are not photographs of a live DSH window. They are renders of the plugin's own layout and colours, driven by its real style values and real figures — a capture tool for the running GUI was not available when they were made. Treat them as a faithful layout reference, not as evidence of a running install. Pull requests replacing them with genuine screenshots are welcome.
ctx.deepseekAccount.getBalance()).# Windows — double-click tools\install.cmd, or:
tools\install.cmd
# macOS / Linux:
sh tools/install.sh
Both launchers find Node for you — including the runtime DSH already ships, which is not on
PATH. If your machine has no Node at all, they say so and tell you what to do, instead of failing
with node: command not found. Install into a different profile with --profile <dir>, preview with
--dry-run, undo with --uninstall.
Prefer to run it yourself? The plain form assumes Node is on PATH:
node tools/install.mjs # install into ~/.dsh/profiles/desktop, then restart DSH
node tools/smoke.mjs # offline smoke suite (83 assertions), no DSH install needed
Then restart DSH. Adding a plugin changes the profile's bundle list, which is not hot-reloaded; a page refresh alone will not pick it up.
┌──────────────────────────────┐
│ Balance ¥43.42 │
│ Session est. ¥0.59 │
└──────────────────────────────┘
Hovering the card expands a detail panel: balance rows (recharge / bonus / total), the three priced
components of the estimate, the actual figure, token counts, and — with parallel sessions — the
apportionment rows (Session share, Other sessions, Tracked sessions, Account total). The
panel uses 4 decimals; the card uses 2. The interface ships in Chinese and English and follows
DSH's language by default; the English names below are what each row means.
| Field | Meaning |
|---|---|
| Balance | Total account balance (recharge + bonus), 2 decimals |
| Session est. | Estimate for the current session; 2 decimals on the card, 4 in the panel; — when the session has no token usage yet |
| Actual charged | Accumulated real spending for this session (single session; becomes Session share when parallel) |
Amounts below the smallest displayed unit render as <¥0.01 on the card and <¥0.0001 in the
panel, so real spending is never rounded down to a flat zero.
mouseleave across the
gap so the panel's mouseenter cancels it.role="button" with tabIndex=0 and toggles the fold on Enter and
Space. The panel is a role="dialog".Updated at HH:MM:SS timestamp, plus the manual I just topped up button, which shows a querying
state while it works.title attributes anywhere in the HUD. A native browser tooltip
appears as an opaque black box about a second after hover and covers the panel, so the summary
lives in aria-label instead — asserted by the smoke suite as a regression guard.The plugin contributes one preference row — 余额与花费 HUD — to the DSH slot
settings.general.item, which DSH declares as "one preference row inside the General section — the
additive seat for a single setting that needs no page of its own" (a whole page is
settings.section; the same slot carries DSH's own Language, link-opening and performance-usage
rows). Find it at Settings → General → 余额与花费 HUD. It does not take over a whole settings
page.
| Setting | Values | Default | Effect |
|---|---|---|---|
Model (计价模型) | deepseek-flash, deepseek-v4-pro | deepseek-flash | Price table used for the estimate and the apportionment weights |
Price auto-sync interval (自动同步价格) | off / 6 h / 24 h / 3 d / 7 d | off | Auto-sync cadence for the official price table; nothing runs until you pick one (see Prices and syncing) |
Foreground poll interval (前台刷新间隔) | 30 / 60 / 120 / 300 s | 60 s | Balance poll cadence while the page is visible |
Hidden interval (后台(页面隐藏)) | 30 / 60 / 120 / 300 s | 300 s | Balance poll cadence while the page is hidden |
Peak-warning lead time (峰前提醒) | 5 / 10 / 15 / 30 min | 10 min | How early the peak/off-peak warning appears |
Session budget (本会话预算(元)) | CNY, 0 = unset | 0 | Budget for this session's estimated spend |
Daily budget (每日预算(元)) | CNY, 0 = unset | 0 | Budget for the Beijing-day confirmed spend |
Show account total row (显示账户级合计) | on / off | on | Account total row in the panel when parallel sessions exist |
Peak warnings (峰时提醒) | on / off | on | The peak/off-peak warning line in the panel |
System notification on budget breach (预算超限发系统通知) | on / off | off | Optional Notification when a budget level is first reached |
The shipped row is labelled in Chinese; the English names above are what each control means.
Persistence. The row writes the localStorage key dsh-balance-hud:settings:v1 — deliberately
separate from the accounting key (dsh-balance-hud:tracker:v4), so the two can never interfere.
Two registrations, one container. The settings row (settings.general.item) and the HUD overlay
(shell.overlay) are two independent slot registrations with no common React ancestor, so they
cannot share React state. They share a small subscription container instead —
createSettingsStore → get / set / subscribe: set merges a partial patch, persists it and
broadcasts, so editing in either place updates the other immediately — no reload, no shared parent.
useSettings is the React binding on top of the same container.
Dirty config is never trusted. normalizeSettings validates field by field against
whitelists: an unknown model, an interval outside the choices, a negative budget or a non-boolean
toggle falls back to the default rather than propagating into the UI. Corrupt JSON in localStorage
also falls back to defaults instead of throwing. The config is user-writable and version-dependent,
so it is re-validated on every read.
Set a session budget and/or a daily budget (CNY; 0 means unset) in the settings row.
evaluateBudget(spent, budget) returns { level, ratio, remaining }, with level one of
none | warn | critical | over:
| Usage | Level |
|---|---|
| < 75 % | none |
| ≥ 75 % | warn |
| ≥ 90 % | critical |
| ≥ 100 % | over |
remaining is clamped at 0, so "over budget" never reports a negative remainder. worstBudgetLevel
then picks the more severe of the session and daily budgets, so an exhausted daily budget still warns
while the session itself is cheap.
Visual. When a budget is at or over a threshold the card gets a coloured 3 px left border —
warn / critical use the theme warning colour, over the error colour. It is deliberately not
an extra grid cell: the card grid must stay 2 columns × 2 rows, and the smoke suite pins that with
an assertion. The expanded panel adds a budget line showing spent / budget and either the remaining
amount or 已超限 (over budget).
Optional system notification. With system notification on budget breach enabled (off by
default), each level fires at most once per mount, so it cannot become a nag. It uses the browser
Notification API and only sends when permission has been granted; if it has not, the plugin silently
degrades to the coloured border only.
The daily budget uses a per-day ledger. The localStorage key dsh-balance-hud:daily:v1 holds
{ date, total }, where date is the Beijing calendar day (beijingDateKey) — the official
billing and peak windows are both Beijing-based, so the day boundary follows the same clock. It only
accumulates confirmed spend — the value a balance read confirmed as spending — never the
interpolated display value, so the same money cannot be counted twice. Reading on a new day resets it
to 0.
Peak windows are Beijing time Monday–Friday 09:00–12:00 and 14:00–18:00, and the peak price is
exactly 2× the off-peak price. minutesUntilTierChange(now) returns { minutes, to }, where to
is 'peak' (about to enter the expensive window) or 'off' (about to return to the cheap one).
The panel shows a warning line when the next tier change falls within the configured lead time:
约 N 分钟后进入峰时,价格翻倍 — entering the peak window in about N minutes; the price doubles.约 N 分钟后回到闲时价 — back to off-peak pricing in about N minutes.A 60-second tick drives both the countdown and the daily rollover, so the numbers stay live while the page is open. The countdown is correct across the lunch break, overnight and weekends — e.g. Friday 18:30 → Monday 09:00 is 870 minutes — and the smoke suite asserts those cases.
| Figure | How it is computed | Nature |
|---|---|---|
| Estimate | Session tokenUsage projection × the official price table | Exactly computable, but covers model tokens only |
| Actual | Accumulated sum of observed balance decreases classified as spending (drops beyond the token bound are external reductions and are excluded), stored per session in localStorage | What the platform really charged, but settled with a delay |
The estimate splits usage into four token classes — uncached input, cache hit, cache write and output — and prices each class with peak/off-peak rates. Cache writes are charged at the cache-miss rate, matching how the platform bills them. The actual figure is a counter, not a subtraction: every observed balance drop is added to the current session's counter, so neither a top-up nor an externally reclaimed bonus can disturb it.
Principle: money entering or leaving the balance from outside changes the balance but must not change the spending counter.
The classifier applies the token-estimate upper bound in both directions rather than an arbitrary epsilon:
balance rise > total token estimate + 0.02 → externally added money (topup)
top-up, bonus grant, billing correction
balance drop > total token estimate + 0.02 → externally removed money (expire)
bonus expiry, refund reversal, billing correction
everything else → ordinary spending, accumulate as usual
The platform can never have charged more than the tokens consumed are worth, so a bigger rise must have come from outside. Symmetrically, it cannot charge for tokens that were not consumed: a drop larger than the worth of every token spent in the window cannot have come from consumption either. The same bound therefore catches a bonus expiry as well, survives a "top-up and spending at the same time" event, and cannot be triggered by normal spending noise.
Why the reduction direction exists: bonus expiry. A bonus grant that lapses is reclaimed by the
platform, so the balance drops with no token consumption behind it. The previous implementation
classified every decrease as spending (if (delta < 0) return { kind: 'spend', amount: -delta }),
so a ¥5 bonus expiring was recorded as ¥5 of session spending — and because the counter only grows,
the error stayed in the record until the entry was pruned.
Both directions take the same code path (applyExternalChange): only the account-level bookmark
moves — every session's baseline and lastBalance — while spent is left untouched, and the
event is appended to the topups[] history carrying its kind ('topup' or 'expire'). Because
the balance is account-wide, one event re-anchors every session. The function deliberately never
touches lastRead — "the balance this session actually observed last" is session-private — so a
session that has not yet observed the change still sees the full drop on its next read and
classifies it as an external change instead of charging it to its own spending. Each session
self-heals, and the event is not double-counted because the session that triggered it already
advanced its own lastRead.
Three layers, in order of authority:
GET /api/balance-hud/bonuses reads
ctx.deepseekAccount.getUnnotifiedBonuses(). It is read-only and deliberately does not call
ackBonusNotified, so it never consumes DSH's own bonus notifications; the data stays
readable and the client de-duplicates by orderId. This layer corroborates grants only:
there is no official "expired" feed, which is exactly why the token bound has to be the
authority in the reduction direction. If the route is unreachable a top-up is still detected —
this layer is only corroboration. normalizeBonuses also passes expiresAt through (it is
not what the accounting decision uses) so the notice can name the earliest lapse date.Notices. An external change in either direction shows a one-line notice in the panel for about 12 seconds:
已识别充值 ¥50.00,花费继续累计(官方赠金 ¥5.00 印证),最早到期 2025-12-31。 —
"recognised a ¥50.00 top-up; spending keeps accumulating (corroborated by an official ¥5.00
bonus); earliest expiry 2025-12-31". The earliest bonus expiresAt is appended only when the
bonuses route confirms a grant.检测到 ¥5.00 非消费性扣减(赠金过期回收等),未计入花费。 — "detected a
¥5.00 non-consumption deduction (e.g. a bonus expiry being reclaimed); not counted as spending".
It is stated explicitly because the money left the balance without appearing in any counter.The account balance is account-level: it does not say which session spent it, so the real cost of one session is not observable locally. The plugin apportions it:
session share = session accumulated spend × (session token cost / all sessions' token cost)
Token costs use the same four-class, peak/off-peak pricing for every session, so the weights are comparable. With a single session the weight is exactly 1 and the value degrades to the exact figure.
This is an estimate. Two sessions do not consume tokens in lockstep with the platform's
settlement, so the ratio drifts; when a new session joins the denominator the number steps down
once. The panel shows Session share, Other sessions and Tracked sessions so you can see what
the weight was.
Records live in localStorage under dsh-balance-hud:tracker:v4, keyed by session id:
{ "sessions": { "<session id>": { "baseline": 43.42, "lastBalance": 43.12, "lastRead": 43.12, "spent": 0.3, "ts": 0 } } }
| Situation | Behaviour |
|---|---|
| Page refresh / remount | Read back from localStorage, counter keeps accumulating |
| Switching sessions | Each session has its own record; checkoutSession merges into it rather than rebuilding it, so baseline, lastRead, lastBalance and spent survive the switch |
| External change detected (top-up or reclaimed bonus) | Only the baseline moves; spent is untouched |
| Older than 30 days, or more than 30 sessions | Pruned on write; the 30 most recent records are kept |
localStorage unavailable | Degrades to memory-only; the HUD still works |
The in-memory store also carries a topups[] history and a lastObservedBalance while the page
lives; the current writer persists only sessions. Within a record, lastRead is the
session-private "balance this session actually observed last" and is the preferred comparison point
on the next read; lastBalance, baseline and the shared lastObservedBalance are only fallbacks
for records migrated from older versions that predate lastRead. Old data is migrated automatically
— v3 was { sessions: { … } }, v2 a flat { [sessionId]: { … } } dictionary, v1 a single
{ sessionId, … } record — and the legacy key is removed afterwards.
The balance is polled every 60 seconds (every 5 minutes while the page is hidden) by default, refetched immediately when the page becomes visible, and retried on failure with a 1s → 2s → 5s → 10s → 20s backoff. Not being logged in is not an error and does not trigger the fast retry. Both intervals are configurable in the settings row (see Settings).
Three more keys exist alongside the accounting one, and they are independent of it:
dsh-balance-hud:settings:v1 (the preference row, see Settings),
dsh-balance-hud:daily:v1 (the Beijing-day ledger, see Budgets) and
dsh-balance-hud:prices:v1 (the synced price table plus its validators, see
Prices and syncing). Clearing or corrupting one never affects the others.
browser (client half, lib/client.js) host (Host half, lib/index.js)
registers the `shell.overlay` slot inject: [deepseekAccount, webServer]
polls every 60 s │
fetch ──────────────────────────────────────► GET /api/balance-hud/balance (read-only)
GET /api/balance-hud/bonuses (read-only)
GET /api/balance-hud/pricing (price sync, opt-in)
GET /api/balance-hud/models (model list, loopback only)
└─► ctx.deepseekAccount.getBalance()
ctx.deepseekAccount.getUnnotifiedBonuses()
└─► official account platform
fetch(PRICING_DOC_URL) ← opt-in sync only
└─► official pricing docs
Why the relay exists. The browser half cannot call ctx.remote.account.getBalance directly:
accessing it without declaring it throws
cannot get property "remote.account" without inject, and declaring inject = ["remote"] on the
client half cannot be satisfied — the fiber hangs forever and the plugin never activates. Both were
reproduced against real @deepseek-ai/cordis 4.0.4, so the Host half reads the balance and hands it
to the browser over a loopback-only, read-only route. All routes accept loopback sources only
(127.0.0.1, ::1, IPv4-mapped loopback forms) and answer 403 to anything else; they are
GET-only, never mutate state, never return credentials, and send cache-control: no-store. One of
them can cause an outbound request — the opt-in price sync — and only once the user has asked for it.
| Payload | Meaning | HUD shows |
|---|---|---|
{ kind: 'ready', currency, rechargeTotal, bonusTotal, total } | Logged in, wallets available | ¥xx.xx |
{ kind: 'unavailable', reason } | Not logged in / credentials changed mid-query | not logged in |
{ kind: 'error', reason } | Query failed, route unreachable, response malformed | read failed (reason in the panel) |
CNY per million tokens, from the official models & pricing page; off-peak is exactly half of peak.
| Model | Cache hit | Cache miss | Output |
|---|---|---|---|
deepseek-flash | off 0.02 / peak 0.04 | off 1 / peak 2 | off 4 / peak 8 |
deepseek-v4-pro | off 0.15 / peak 0.30 | off 4.5 / peak 9 | off 13.5 / peak 27 |
deepseek-flash), so the
estimate and the apportionment weights follow your choice. Unknown models fall back to flash prices
rather than to zero, and the panel says so. After an official price change you can sync the table
(see Prices and syncing) or edit PRICES and refresh the page.BUILTIN_PRICES_DATE). Once you
have synced, the newer official table is used instead.The estimate is priced from a table that ships inside the plugin. That table is a dated snapshot (the built-in table's date is 2026-09-29), and it stays in use unless you ask the plugin to do otherwise. DeepSeek can change prices at any time, so t