sumomok/dsh-plugins

balance

侧栏脚注显示 DeepSeek 账户余额,并按会话/今日/本月/累计统计花费,按可配置的分时价格表(按币种分表,内置 DeepSeek 人民币/美元默认表)计价。两个只读远程方法,无宿主路由,密钥每次经宿主凭据缝读取,只向配置的供应商域名出网。

用量与计费deepseek-harnessdsh

@sumomok/dsh-balance

English | 中文

Account balance and spend for the DeepSeek Harness web GUI. A chip near the sidebar foot shows what is left in whichever provider the current session's selected model belongs to (DeepSeek ¥12.34), following a session switch or a model switch within it. Hovering opens a breakdown of that balance, an explicit provider picker over the providers this deployment can currently show a balance for, and what this installation has spent on that provider today, this month, and in total — the spend follows the picker, so the figure under a provider's balance is that route's own. A line under the composer shows what the open conversation has cost.

The balance is the provider's own number: DeepSeek's and Moonshot AI's (moonshotai, moonshotai-cn) through dedicated adapters, Kimi For Coding's (kimi-coding) subscription quota through its own adapter (a used-percent per window rather than a money balance), any other configured provider through a best-effort generic adapter trying a short list of endpoint shapes common OpenAI-compatible gateways answer. The spend is the harness's own logged token usage multiplied by a price table this deployment owns — nothing is scraped at runtime, and a model the table does not price is reported as unpriced tokens rather than as zero.

What it shows, and where

Recon finding, sidebar.footer.action is where this lands, not beside Settings. No slot lets a third-party plugin share the Settings trigger's own row: sidebar.settings and sidebar.footer.action are separate full-width flex children stacked in SidebarRoot.tsx's .footArea (flex-direction: column), and the Settings trigger's own content (icon + label) is itself a single-occupant slot (settings.trigger) already taken by ui-settings-general. Putting a chip in the same row as Settings would need core to either (a) change .footArea to flex-direction: row with sidebar.footer.action right-aligned instead of stacked above sidebar.settings, or (b) add a new slot inside the trigger button reserved for trailing content that does not steal the button's own click target. Absent either, this stays in its existing position (sidebar.footer.action, directly above Settings), restyled as a single line.

SurfaceSlotShows
Sidebar footer chipsidebar.footer.actionProvider ¥12.34, tinted by the configured thresholds, for whichever provider the current session's selected model belongs to. An ordinary flex item — several plugins may share this row — that grows to fill whatever width the row leaves it (the whole row alone, its own share beside a sibling), putting the provider name hard left and the amount hard right; it degrades to ¥12.34 alone once even that width is too crowded for the provider name, rather than pushing a neighbour out or wrapping. On the 56 px rail it is the amount alone; wide, hovering opens the popover.
Popover—A provider picker naming every provider this deployment can currently show a balance for (§ below), then that provider's balance breakdown, and this installation's today / this month / all time spend on that provider with the share each price tier took, and the date the price table carries. A quota-metered provider's spend is its tokens "within plan" — a subscription has no per-request money; a pay-as-you-go provider the table does not price shows its tokens as unpriced. The picker is hidden with fewer than two such providers — nothing to pick between. Its footer carries Top up — for the provider the picker names, when that provider is one this plugin has a page for — beside Refresh.
Session spend lineconversation.composer.dock≈$0.12 this session, plus · N tok unpriced when the table priced nothing for some of the session's models. Unaffected by the provider picker: this stays the current session's own ledger line at the deployment's one price table (see "Out of scope").

Hovering the chip previews the popover; leaving it starts a 200 ms grace period so the pointer can cross the gap to a position: fixed popover without it closing, cancelled by entering either the chip or the popover. Clicking the chip pins the popover open — mouse leave no longer closes it — until a click outside both, Escape, or clicking the chip again; focus inside the popover (the provider picker's open dropdown) also blocks a mouse-leave close. A Refresh button in the popover's footer refreshes the followed provider's reads immediately; the chip's own click no longer does. Top up sits beside it and opens the picked provider's own top-up page in a new window — the system browser under the desktop app, a new tab in a plain browser.

Following the session

The chip's provider is the current session's selected model's provider, read straight off the session's own durable modelSelection projection — the same synchronous, no-round-trip read @haoran/dsh-vision-switch uses — never a separate guess. A session switch re-resolves it right away; the shared poll re-resolves it every tick too, which is what also picks up a model switch made without leaving the session. A blank/no-session view, an addressed-subagent session, or a session with no recorded selection yet falls back to DeepSeek.

The provider picker

The popover's picker lists only providers this deployment can currently show a balance for, in two passes: first, statically, to providers this plugin's adapters can ever address at all — DeepSeek's named adapter, plus every route the configurable-provider directory declares (the generic adapter's only addressable set); then, probed, to whichever of those actually has a credential resolving right now, one cached AdapterRegistry read each, so a poll tick already holding a fresh answer costs no extra network call. A provider excluded by either pass never reaches the dropdown at all — there is nothing left to click through to a "balance lookup is not supported" message. The followed provider always appears, first, whether or not it passed the filter: its balance has already been read by the same call that fetched this roster, and the browser half restores it if the host's own answer ever omitted it. Fewer than two entries hides the picker row entirely, leaving the rest of the popover. Picking a listed provider previews its balance and its own spend without changing what the footer chip follows; picking the followed provider back (or nothing) reverts to following. That pick lasts exactly as long as the popover it was made in: every opening — a hover or a pinning click — starts the picker on the provider the session follows, so a comparison made a moment ago never carries into the next look, and an opening after the session moved to another model starts on that model's provider.

The popover's Top up button opens the page the provider itself takes money on, for whichever provider the picker names. The addresses are a hardcoded table (src/client/top-up-links.ts), maintained by hand and re-checked at every release — these are console pages, not an API, and nothing here can detect one moving. Last re-checked: 2026-09-03.

ProviderPage
deepseek-officialhttps://platform.deepseek.com/top_up
moonshotaihttps://platform.kimi.ai/console/pay
moonshotai-cnhttps://platform.kimi.com/console/pay
kimi-codinghttps://www.kimi.com/membership/pricing

A provider the table does not name shows no button: that is every custom gateway a deployment configures itself, whose billing page this plugin cannot know and will not guess from an API origin. Kimi For Coding is sold as a subscription rather than a balance, so its entry is the membership page the plan is bought and renewed on. The button opens a new window rather than navigating — the desktop shell hands that to the system browser, so the running session is never replaced.

The adapter registry

One cache — refresh window, retry window, in-flight de-duplication, stale-on-failure serving — with two kinds of member:

  • Named adapters, each with its own dedicated endpoint parser, ahead of the generic fallback: DeepSeek, the query this plugin always had, and Moonshot AI's two routes.
  • The generic fallback, for every other provider ctx.llm's configurable-provider directory can address with a settings-configured baseURL (a custom base-URL entry, in Models-page terms). It tries, in order, a one-api/new-api-shaped user-quota endpoint (GET /api/user/self, converting the returned dimensionless quota to a currency amount at a configured ratio) and the legacy OpenAI dashboard billing pair (GET /dashboard/billing/subscription + /dashboard/billing/usage, hard_limit_usd minus total_usage in cents). Only endpoint shapes are modeled here, from public documentation — no third-party source is copied. The shape list is genericEndpoints (below), so a gateway answering neither can be told its own. The first shape to answer is remembered per provider (runtime-only; a restart re-probes) so a matched provider is not re-probed on every read. Every candidate URL is fenced to the provider's own configured origin. Any failure — no directory entry, no configured baseURL, no key, or every shape failing — answers the quiet unconfigured state (short-TTL negative-cached, same as the reader's normal retry window), never a named adapter's detailed unavailable + reason.

Named adapters

Provider idEndpointBase URLCurrencyBalance row mapping
deepseek-officialGET /user/balance (derived from the chat base URL)llm-deepseek settings, default https://api.deepseek.comthe account's own, from balance_infostotal/granted/toppedUp ← total_balance/granted_balance/topped_up_balance
moonshotaiGET /v1/users/me/balancellm-pi-ai settings (providers.moonshotai), default https://api.moonshot.aifixed USD — the endpoint names none of its owntotal/granted/toppedUp ← available_balance/voucher_balance/cash_balance
moonshotai-cnGET /v1/users/me/balancellm-pi-ai settings (providers.moonshotai-cn), default https://api.moonshot.cnfixed CNY — the endpoint names none of its ownsame mapping as moonshotai
kimi-codingGET /coding/v1/usages (404 → /coding/v1/usage)fixed https://api.kimi.com— (a subscription quota, no money balance)a quota view — one window per metered period, each a used percent (shown as what is left); see below

kimi-coding is the one adapter that reports a subscription quota rather than a money balance, so it produces the quota view (below) instead of ok. Kimi For Coding meters a subscription in usage windows — a weekly allowance and one or more rolling windows — read from Kimi's own coding-plan usage route, the same one the Kimi CLI reads a subscription's remaining quota from. That route is not part of the Moonshot Open Platform's documented HTTP surface; it answers the usage payload only to the Kimi CLI's own client identifier, so the request carries User-Agent: KimiCLI/1.6 (a client-identification header the route requires — the subscription key, not the header, is what authenticates the account, which is the caller's own). The response is decoded defensively: an undocumented route may change its shape between client releases, so any missing or off-type field degrades to unavailable rather than an error. Counts arrive either as JSON numbers or, since the route moved to protobuf-JSON (observed 2026-09-02), as decimal strings with enum-named units (TIME_UNIT_MINUTE); both are read, and a rolling span given in whole hours' worth of minutes is named in hours (300 MINUTE → 5h). The credential defaults to KIMI_CODING_API_KEY (the sk-kimi-* subscription key the CLI itself uses) when the profile names none; the fixed usage route ignores any configured chat base URL. No price table entry applies: a quota read carries no per-model spend.

Both Moonshot routes read the same documented response body — { code, data: { available_balance, voucher_balance, cash_balance }, scode, status }, verified against https://platform.kimi.com/docs/api/balance (China) and https://platform.kimi.ai/docs/api/balance (international) on 2026-08-31; both doc domains now redirect from platform.moonshot.cn/platform.moonshot.ai, unrelated to the API's own request domain (api.moonshot.cn/api.moonshot.ai), which neither redirect touches — and differ only in base URL and billed currency, so one parameterized adapter serves both, registered per id in the adapter registry. isAvailable reads the response's own status flag, the closest documented equivalent to DeepSeek's explicit is_available. Connection facts are found the same way the generic fallback finds any other pi-ai-routed provider's — through ctx.llm's configurable-provider directory — but the credential defaults to MOONSHOT_API_KEY when the profile names none: pi-ai's own built-in environment variable for both routes, rather than the per-id name (MOONSHOTAI_API_KEY/MOONSHOTAI_CN_API_KEY) the generic fallback would otherwise derive. No price table entry is needed or looked up for either route: the balance read carries no per-model spend, only the account total.

Refresh policy

The host caches the answer, not the key.

  • A successful read is served for refreshMs (default 60 s). Every browser tab in the window shares it, so a second monitor costs no extra provider request.
  • A failed read suppresses further attempts for retryMs (default 15 s), so a broken endpoint is not hammered once per open tab.
  • Concurrent callers share one in-flight request. Clicking the popover's Refresh button bypasses the refresh window but still joins a request already in flight.
  • When a refresh fails but an earlier read succeeded, the earlier numbers stay on screen dimmed and the popover says so. A balance from a minute ago is worth more than a dash, as long as it admits its age.
  • The browser polls on refreshMs and skips the tick entirely while the tab is hidden.

The states, and what each renders

get(provider?, force?) answers one of three states, for whichever provider it named (the followed provider when omitted).

StateFieldsRendered as
okcurrency, total, optional granted/toppedUp, isAvailable, fetchedAt, staleThe chip, with the total at two decimals. Normal above lowBalance; warning tint below it; critical tint below criticalBalance, or whenever the provider reports isAvailable: false — an account can be suspended with money in it. stale: true dims the chip and adds a note to the popover. granted/toppedUp are a named adapter's own breakdown (DeepSeek's granted_balance/topped_up_balance, or Moonshot's voucher_balance/cash_balance); a generic-adapter read carries total alone.
quotawindows (each key, usedPercent, resetsAt), isAvailable, fetchedAt, staleThe kimi-coding subscription quota. The chip shows what is left of every window, labelled (left 7d 58% · 5h 95%), in place of a money amount; the popover lists every window as 58% left with its reset time — the time of day when it is within the next day, the date and time otherwise. The weekly window is named as the 7-day window; a rolling window is named by its span (5-hour window). No tint (a quota carries no money threshold); isAvailable: false — the primary window fully consumed — adds the suspended note, and stale: true dims as for ok.
unconfigured—For the followed provider: nothing at all — no key resolves, no adapter serves this provider, or the generic adapter tried and found nothing. A deployment that never wanted this feature sees the sidebar it had before installing it. For a provider the picker explicitly named: "balance lookup is not supported for this provider" — the user asked, so this one explains rather than hides.
unavailablereason (http / network / timeout / malformed), optional status, fetchedAtNamed adapters only (DeepSeek, moonshotai, moonshotai-cn, kimi-coding) — the generic adapter's failures fold into the quiet unconfigured state above instead. A dimmed — with the reason in the tooltip. The reason is a class, never the provider's text or the endpoint.

providers() answers the picker's roster: { id, displayName }[], DeepSeek first.

spend(provider?) answers one provider's today, month, and allTime (the DeepSeek route when none is named) — each with a cost, a per-tier split, a request count, and unpriced tokens — plus provider, since (the oldest retained ledger row of that provider in that currency), currency (the price list actually used), pricesAsOf, and timezone. Totals are per provider and per currency: rows of one provider, or priced in one currency, are never folded into another's total; a row that recorded no provider counts under the empty id, which no picker entry reads.

Configuration

Every key is validated at load and changeable from cordis.yml. The bundle patch ships the full default block; a profile patch targeting the balance id replaces that whole block, so restate every key you want to keep.

A Settings → Balance page (settings.section) also exposes two of these keys graphically, read and written live through the harness's own settings document rather than cordis.yml: the price table's per-model base rates for a currency already in the table (add/remove a model row; adding a currency or editing a time-of-day tier is not exposed here — a tier's own windows are carried through unedited when its row is saved, never dropped, and summarized as read-only text), and lowBalance/criticalBalance. Both take effect immediately, no restart. Every other key in the table below — including a price entry's schedules — still takes effect only through cordis.yml/a raw settings-document edit and the next restart.

KeyDefaultMeaning
refreshMs60000How long a successful balance read is served. Minimum 5000.
retryMs15000How long a failed read suppresses further attempts. Minimum 1000.
timeoutMs8000Wall-clock budget for one balance request. Minimum 1000.
currency[CNY, USD]Currency codes in descending preference. One list serves two decisions: which balance row to show when the account holds several, and which price list to spend against before the account's own currency is known.
lowBalance10Warning tint below this total.
criticalBalance1Critical tint below this total. Must not exceed lowBalance.
ledgerDays400Days of ledger rows kept; older rows are dropped and the file rewritten at startup.
timezone''IANA zone the day and month spend boundaries are taken in. Empty means the zone the host process runs in.
root''Directory the ledger is written into. Empty means $DSH_HOME/dsh-balance.
surfaces.footertruePut up the sidebar-footer chip.
surfaces.sessionSpendtruePut up the per-session spend line.
pricesDeepSeek's published CNY and USD ratesThe price lists, one per currency. See below.
genericEndpointsone-api/new-api user-quota, then the OpenAI dashboard billing pairCandidate endpoint shapes the generic fallback adapter tries, in order, for any provider but DeepSeek. See "The adapter registry".

No provider's connection is configured here. DeepSeek's is read from the llm-deepseek settings section, exactly as that provider resolves it (baseURL, then $DEEPSEEK_BASE_URL, then https://api.deepseek.com; apiKeyEnv, defaulting to DEEPSEEK_API_KEY). moonshotai and moonshotai-cn are read from wherever ctx.llm's configurable-provider directory says the llm-pi-ai settings section's providers.moonshotai/providers.moonshotai-cn profile lives, same as any other pi-ai-routed provider; baseURL defaults to each route's own public origin (https://api.moonshot.ai, https://api.moonshot.cn) and apiKeyEnv defaults to MOONSHOT_API_KEY — pi-ai's own built-in default for both routes — rather than the per-id name derived below. Every other provider's baseURL/apiKeyEnv are read the same directory-addressed way, with an apiKeyEnv the profile does not name derived the way the Models page derives one (<PROVIDER>_API_KEY). Point a provider somewhere else and this plugin follows.

The price tables

The numbers are yours to maintain. Prices are quoted per currency, one list each, because a provider that bills two currencies publishes two price lists rather than one list and an exchange rate — and a CNY balance beside a USD spend total is two numbers nobody can compare.

The shipped default carries both lists DeepSeek publishes, transcribed on 2026-08-23:

ModelCNY per 1M (miss / hit / output)USD per 1M (miss / hit / output)
deepseek-v4-flash¥1.5 / ¥0.05 / ¥4.5$0.22 / $0.007 / $0.66
deepseek-v4-pro¥4.5 / ¥0.15 / ¥13.5$0.66 / $0.022 / $1.98
deepseek-v4-flash-vision-exp¥1.5 / ¥0.05 / ¥4.5$0.22 / $0.007 / $0.66

Those are the off-peak rates; peak doubles them. Sources: https://api-docs.deepseek.com/zh-cn/quick_start/pricing (CNY, which states 「空闲时段价格为高峰时段价格的一半。高峰时段为北京时间周一至周五 9:00 - 12:00、14:00 - 18:00」) and https://api-docs.deepseek.com/quick_start/pricing (USD, "Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday"). Those are the same instants — 09:00 Beijing is 01:00 UTC — and each list is written in the timezone its own page uses rather than converted, so either can be checked against its source. When DeepSeek changes a rate, update the list and bump asOf, which the popover shows beside the currency. Nothing here fetches those pages at runtime.

Which list is used

  1. The account's own billing currency, when the table prices it — this is what makes the balance and the spend total comparable, and it is known as soon as one balance read succeeds.
  2. Otherwise the first currency entry the table prices.
  3. Otherwise USD, then the first list by name, so the choice is never arbitrary.

The popover names the list it used: Prices: CNY (2026-08-23). A change re-prices everything — the per-session projection's cache version covers the currency as well as the rates.

The shape

The schema carries no vendor's vocabulary. An entry states its base rates and an ordered list of named schedules; each schedule claims wall-clock windows in the entry's own IANA timezone and either restates the rates or scales the base by a multiplier. The first schedule whose window contains the request instant wins; outside every window the base applies.