<div align="center">

<img src="assets/icon.svg" alt="dsh-balance" width="84" height="84">

# dsh-balance

**A balance readout for the DeepSeek Harness — project your API balance from this machine's own token spend, and how long it will last.**

[![npm](https://img.shields.io/npm/v/@zzxxxxxx/dsh-balance.svg)](https://www.npmjs.com/package/@zzxxxxxx/dsh-balance)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-%E2%89%A5%200.2.0--rc.1-6E56CF)](https://github.com/deepseek-ai/deepseek-harness)

**English** | [中文](README.md)

</div>

## Overview

- **An anchor, not a reading**: the platform settles 1–5 minutes late, so a copied reading sits still while you work. Each reading becomes a periodic **anchor** and the pill shows `anchor − k × locally consumed`, which keeps moving with every token.
- **A usage clock**: a reading taken at `T` only covers usage up to `T − settlementLagMs`, so local accumulation starts at that cutoff. Without the shift, the last few minutes would be charged twice.
- **Tiered rates plus one correction factor**: tokens are priced by the official peak tiers (cache hit / miss / output), and a scalar `k` learnt as the **median** of recent balance drops absorbs a stale table, an unlisted model, or a uniform discount.
- **Only decreases are summed**: a top-up raises the wallet and contributes nothing, so it cannot corrupt `k` or invalidate the calibration window, and chunky settlements count once. A **material** drop with no local usage is reported separately as a non-usage adjustment instead of being charged to you.
- **Peak/off-peak by occurrence time**: peak is Monday–Friday 09:00–12:00 and 14:00–18:00 Beijing time (statutory holidays excluded) and everything else is half price; the **ETA integrates across the switch**, because dividing by the current rate at 17:50 would miss the half-price period starting at 18:00 and understate the answer by up to 2×.
- **A calendar assumption that data can overturn**: make-up workdays are priced off-peak per the literal wording; samples crossing one go to an independent **probe**, and when they consistently differ from the main factor by about two the panel says so — **configuration is never rewritten**.
- **A pill under the composer**: it sits beside the built-in token statistics and shows the projected balance and time to empty; click it for the breakdown.

The host half owns the credential and all outbound traffic, and hands one snapshot to the browser half over two exact routes: the credential never reaches the browser, and both routes take part in the host's Host/Origin and cookie trust fence. There are exactly two outbound targets — the balance endpoint, and the holiday source when you explicitly enable it.

## Install

Requirements: DeepSeek Harness ≥ `0.2.0-rc.1`, Node `^22.19.0 || >=24.0.0`.

**From npm** (recommended):

```sh
dsh plugin --profile <profile> add @zzxxxxxx/dsh-balance
```

**From source / local development**:

```sh
git clone https://github.com/zzzxxxxxxxxxx/dsh-balance && cd dsh-balance
npm install        # build dependencies; needs network the first time
npm run build      # produces lib/, the plugin entry point (not committed)

dsh plugin --profile <profile> add "$PWD"
```

**From a packed tarball** (`npm pack` output already carries `lib/`, so nothing is built):

```sh
dsh plugin --profile <profile> add ./zzxxxxxx-dsh-balance-0.1.0.tgz
```

**Restart** `dsh` afterwards (a profile is composed at startup).

- The API key comes from the credential reference `DEEPSEEK_API_KEY`, the same one the built-in `deepseek-official` provider uses — if your models work, it is already configured. It can also be set in **Settings → Models**, in `$DSH_HOME/.credentials.yaml`, or by exporting the variable.
- Update: from source, `git pull && npm install && npm run build`; from a tarball, `add` it again. Restart either way.
- Remove: `dsh plugin --profile <p> remove @zzxxxxxx/dsh-balance`, then restart.
- The browser half (the readout pill) appears only in profiles with a Web UI; elsewhere the host half still polls, there is simply nobody looking.
- ⚠️ The **unscoped** `dsh-balance` on npm is a *different*, similar plugin — install this one by its scope.
- Development: `npm run typecheck`, `npm test` (87 cases, no network), `npm run build`, `npm run gen:holidays` (regenerate the holiday seed; the only step that really goes out to the network).

## Usage

- **Pill**: shows `¥36.35 · 2 d 4 h` — the projected balance and estimated time to empty. The dot is green normally, amber below `etaWarnMs`, red below `etaCriticalMs` or once the balance is gone, and grey while uncalibrated or when the host is unreachable.
- **Panel**: opens upward on click and keeps three figures **apart** — the platform reading, the locally projected spend, and the projected balance — alongside the current pricing period and next switch, the burn rate and its source, today and the last hour, non-usage adjustments, the correction factor with its sample count, and where the holiday calendar came from.
- **Refresh now**: the button in the panel's corner asks the host to poll the platform once, then re-reads the snapshot.
- **No slash command and no settings page**: this plugin only reads.
- The server exposes exactly two paths: `GET /dsh-balance/state` (the snapshot) and `POST /dsh-balance/refresh` (request one poll).

## Configuration

Every field lives in the plugin row's `config:` (this plugin has **no** settings page), and a change needs a restart. These are the complete defaults:

```yaml
- insert:
    - id: dsh-balance
      name: '@zzxxxxxx/dsh-balance'
      config:
        apiKeyEnv: DEEPSEEK_API_KEY
        apiOrigin: https://api.deepseek.com
        balancePath: /user/balance
        refreshIntervalMs: 60000          # how often the platform is polled
        requestTimeoutMs: 10000
        settlementLagMs: 300000           # upper bound on how far the reading lags usage
        rateWindowMs: 1800000             # window for the instantaneous rate
        calibrationWindowMs: 21600000     # window the correction factor k is learnt over
        correctionSamples: 9              # median window for k
        probeSamples: 5                   # probes needed before advising a calendar change
        retentionMs: 604800000
        staleAfterMs: 180000
        etaCapMs: 31536000000
        minBurnPerHour: 0.01
        minPredictedSpend: 0.01           # quantization step, and the "material drop" bound
        preferredCurrency: auto           # auto | CNY | USD
        priceCurrency: CNY
        priceTable:                       # peak rates, currency per 1M tokens
          deepseek-flash: { input: 1, cacheRead: 0.02, output: 4 }
          deepseek-v4-pro: { input: 4.5, cacheRead: 0.15, output: 13.5 }
        offPeakFactor: 0.5
        peakDays: [1, 2, 3, 4, 5]
        peakWindows: ['09:00-12:00', '14:00-18:00']
        priceTimeZone: Asia/Shanghai
        holidays: []                      # ['2027-01-01', ...]
        makeupWorkdays: []                # ['2027-02-20', ...]
        treatMakeupWorkdaysAsPeak: false
        holidaySourceEnabled: false       # a second outbound target; off by default
        holidaySourceUrl: https://timor.tech/api/holiday/year/{year}
        holidaySourceFormat: auto         # auto | timor | holiday-cn
        holidayRefreshIntervalMs: 604800000
        holidayMissingYearRetryMs: 86400000
        holidayTimeoutMs: 8000
        weights: { uncachedInput: 1, cacheWrite: 1, cacheRead: 0.1, output: 4 }
        etaIntegratesSchedule: true
        etaWarnMs: 86400000
        etaCriticalMs: 14400000
        clientPollIntervalMs: 15000
        persist: true
        historyRoot: ''
        allowLoopbackHttp: false
```

| Field | Default | Meaning |
| --- | --- | --- |
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference (an environment-variable name) holding the key |
| `apiOrigin` | `https://api.deepseek.com` | Balance endpoint origin; must be https (loopback excepted) |
| `balancePath` | `/user/balance` | Balance endpoint path |
| `refreshIntervalMs` | `60000` | How often the platform is polled |
| `requestTimeoutMs` | `10000` | Per-request timeout |
| `settlementLagMs` | `300000` | Upper bound on how far the reading lags usage; local accumulation starts there |
| `rateWindowMs` | `1800000` | Window the instantaneous rate is measured over |
| `calibrationWindowMs` | `21600000` | Window the correction factor `k` is learnt over |
| `correctionSamples` | `9` | How many recent candidates the median is taken over |
| `probeSamples` | `5` | Probe samples needed before advising a calendar change |
| `retentionMs` | `604800000` | How long readings and token buckets are kept (7 days) |
| `staleAfterMs` | `180000` | How long without a reading before the snapshot is marked stale |
| `etaCapMs` | `31536000000` | Time-to-empty clamp; beyond it the pill says `> 1 year` |
| `minBurnPerHour` | `0.01` | Rates below this count as idle, and no ETA is reported |
| `minPredictedSpend` | `0.01` | Quantization step, and the bound for calling a drop "material" |
| `preferredCurrency` | `auto` | Wallet preference: `auto` prefers a funded CNY wallet, then USD |
| `priceCurrency` | `CNY` | Currency the price table is denominated in; a mismatch disables that path |
| `priceTable` | `deepseek-flash` `deepseek-v4-pro` | Peak rates per route, currency per 1M tokens |
| `offPeakFactor` | `0.5` | Off-peak multiplier against peak |
| `peakDays` | `[1,2,3,4,5]` | ISO weekdays carrying peak windows |
| `peakWindows` | `09:00-12:00` `14:00-18:00` | The peak windows |
| `priceTimeZone` | `Asia/Shanghai` | Zone the peak windows are expressed in |
| `holidays` | `[]` | Statutory holidays (`YYYY-MM-DD`); highest priority |
| `makeupWorkdays` | `[]` | Make-up workdays; also highest priority |
| `treatMakeupWorkdaysAsPeak` | `false` | Whether a make-up workday prices its windows at peak |
| `holidaySourceEnabled` | `false` | Whether the optional remote holiday source is enabled |
| `holidaySourceUrl` | `timor.tech` | Remote URL template; must contain `{year}` |
| `holidaySourceFormat` | `auto` | Remote payload shape: `auto` / `timor` / `holiday-cn` |
| `holidayRefreshIntervalMs` | `604800000` | Normal remote refresh interval |
| `holidayMissingYearRetryMs` | `86400000` | Retry interval while the current year is unpublished |
| `holidayTimeoutMs` | `8000` | Remote request timeout |
| `weights` | `1 / 1 / 0.1 / 4` | Relative cost shape for routes the price table misses |
| `etaIntegratesSchedule` | `true` | Whether time-to-empty integrates across the switch |
| `etaWarnMs` | `86400000` | Below this time-to-empty the dot turns amber |
| `etaCriticalMs` | `14400000` | Below this it turns red |
| `clientPollIntervalMs` | `15000` | How often the browser reads the snapshot |
| `persist` | `true` | Whether readings and token buckets are written to `$DSH_HOME/dsh-balance/` |
| `historyRoot` | `''` | Override for the persistence directory; empty means the default |
| `allowLoopbackHttp` | `false` | Whether loopback `http` endpoints are accepted (local development) |

A few semantics:

- **Platform lag and the ETA**: the ETA's numerator comes from the anchor, and the only residual is that the numerator itself lags, so it reads low by exactly `settlementLagMs` (under 5 minutes by default) and never drifts with the lag.
- **A top-up landing in the same poll interval as usage**: that pair of readings nets upward and the hidden usage is invisible — bounded by one interval of spend, and diluted by the median. The panel's "non-usage adjustments" count only **material drops with no local usage to explain them**.
- **The correction factor needs a few drops to settle**: until then the rate falls back to balance drops over a 6-hour window, the dot turns grey, and the panel says "uncalibrated". When the price table is right, `k` lands near `1.0` — it is the best self-check there is.
- **A session's first turn is not counted**: the first observation only seeds the baseline, because otherwise resuming a session would re-bill its whole history.
- **The route comes from a projection, not a session event**: `session/event` is scope-filtered and a plugin at the profile level never receives `request/context`, so the model is **pulled** from the `modelSelection` projection (for a selection already settled before load, a change event never fires).
- **The price-table currency must match the wallet** (CNY by default): on a mismatch the table path is disabled, pricing falls back to `weights`, and the panel says so.
- **The holiday calendar needs yearly attention**: enable `holidaySourceEnabled`, or re-run `npm run gen:holidays` after a new year is published to regenerate the seed. A year that is not published yet counts as **unknown** (never as "no holidays" — that would price every weekday of it at peak and never retry); it is retried daily and surfaced in the panel.
- **The make-up-workday reading is an interpretation of prose**: peak is preconditioned on "Monday–Friday" while a make-up day is by construction a Saturday or Sunday, so the default prices it off-peak. Because that is an interpretation rather than a measurement, a probe turns it into an assumption data can overturn — and the hint never rewrites configuration.
- **Both routes take part in the host trust fence**: a custom host route is not behind that gate automatically, so this plugin asks for the verdict itself (Host/Origin plus cookie), and `connection` is a **required** injection — without it the plugin does not load, so the fence can never silently fail open. The cost is a `503` while the service is briefly unavailable, instead of answering unfenced.
- This plugin is young and may still have bugs. Please report problems in [Issues](https://github.com/zzzxxxxxxxxxx/dsh-balance/issues) and **attach the pill's figures plus `$DSH_HOME/dsh-balance/history.json`** — it holds the readings and token buckets, which explains far more than a screenshot.
