@hystudio/dsh-quota-pill 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -30,9 +30,15 @@ There are **two credential families**, and this matters for every step:
30
30
 
31
31
  | Family | Providers | What the user needs |
32
32
  |---|---|---|
33
+ | DSH Platform sign-in | `deepseek` | **nothing** — works if they are signed in to DeepSeek Platform in DSH |
33
34
  | API key | `kimi`, `opencode`, `commandcode` | a key pasted into `~/.dsh/.credentials.yaml` |
34
35
  | OAuth via CLIProxyAPI | `xai`, `codex`, `agy` | **CLIProxyAPI installed and logged in once** |
35
36
 
37
+ **Check DeepSeek first.** It is the official account and needs no setup beyond a
38
+ DSH Platform sign-in, so it is usually the one the user actually wants. If it
39
+ reports `none` with "not signed in to DeepSeek Platform", tell them to use the
40
+ DSH account menu, then re-run step 5.
41
+
36
42
  ---
37
43
 
38
44
  ## 1. Verify the sidecar runs
@@ -81,6 +87,7 @@ Do **not** guess. Ask, in one message, listing the options with what each needs:
81
87
 
82
88
  > I can read quota for six providers. Which do you have?
83
89
  >
90
+ > - **DeepSeek** — works already if you are signed in to DeepSeek Platform in DSH
84
91
  > - **Kimi Coding** — needs a `KIMI_CODING_API_KEY`
85
92
  > - **OpenCode Go** — needs an `OPENCODE_GO_API_KEY`
86
93
  > - **Command Code** — needs a `COMMANDCODE_API_KEY`
@@ -170,7 +177,7 @@ For each provider look at `kind`:
170
177
  | `subscription-quota` | working — `remainingPct` and `windows` are populated |
171
178
  | `none` | not configured — the `error` field says exactly what to add |
172
179
  | `probe-failed` | configured but the upstream call failed — see `error` |
173
- | `payg` | metered billing with no ceiling; no percentage exists |
180
+ | `payg` | prepaid/metered — no ceiling, so no percentage. If `credits.remain` is set, that is the balance to report |
174
181
 
175
182
  Cross-check one provider against its official dashboard if the human is unsure.
176
183
  **The plugin reports remaining quota, not used.**
package/README.md CHANGED
@@ -15,6 +15,7 @@ into the live number.
15
15
 
16
16
  | Provider | Credential |
17
17
  |---|---|
18
+ | **DeepSeek** (official) | the Platform sign-in DSH already holds — nothing to add |
18
19
  | **Kimi Coding** | `refs.KIMI_CODING_API_KEY` in `~/.dsh/.credentials.yaml` |
19
20
  | **OpenCode Go** | `refs.OPENCODE_GO_API_KEY` |
20
21
  | **Command Code** | `refs.COMMANDCODE_API_KEY` |
@@ -22,6 +23,9 @@ into the live number.
22
23
  | **Codex (ChatGPT Plus)** | same — and CLIProxyAPI must be **running** |
23
24
  | **Antigravity** | same |
24
25
 
26
+ **DeepSeek works with no setup at all** if you are signed in to DeepSeek Platform
27
+ in DSH — that is the official account, and it is the one most users care about.
28
+
25
29
  The three API-key providers need nothing but the key. The three OAuth providers
26
30
  need CLIProxyAPI installed and logged in, because that is what holds the tokens;
27
31
  this plugin only reads them and never runs an OAuth flow itself.
@@ -39,6 +43,9 @@ dsh plugin add @hystudio/dsh-quota-pill
39
43
  npx @hystudio/dsh-quota-pill
40
44
  ```
41
45
 
46
+ > The npm package is `@hystudio/...` while the repo lives at `598934321/...` —
47
+ > they are the same author under two account names.
48
+
42
49
  The pill shows **"quota service is not running"** with that exact command until
43
50
  the sidecar is up. Keep it running in the background — see
44
51
  [Running it as a service](#running-it-as-a-service).
@@ -183,6 +190,14 @@ would flicker between models you do not even have configured.
183
190
  `monthlyCap = weekly.cap × 2`, an identity verified against two different plans
184
191
  (31.6 + 38.4 = 70 with `weekly.cap` 35; 0.225 + 9.775 = 10 with `weekly.cap` 6).
185
192
 
193
+ **DeepSeek** is a prepaid CNY wallet, so there is no percentage to show — the pill
194
+ shows the balance and, separately, any bonus credit. Two traps worth recording:
195
+ the stored credential is a *Platform login grant*, not an `api.deepseek.com` API
196
+ key (sending it there returns 401), and calling `platform.deepseek.com` directly
197
+ returns 429 from Cloudflare. The balance is therefore read through DSH's own
198
+ `account/getBalance` RPC, which already holds the grant and projects the response
199
+ down to wallet currency and balance. We never handle the Platform token ourselves.
200
+
186
201
  ## Privacy
187
202
 
188
203
  - Credentials are read from disk, used for the upstream call, and never logged.
package/client.js CHANGED
@@ -221,6 +221,8 @@ window.__ModuleLoader__.load({
221
221
  spendUnit: (req, tok) => [`${req} 次 · ${tok}`, `${req} reqs · ${tok}`],
222
222
  poolStatus: ['状态未知', 'Status unknown'],
223
223
  noSource: ['无数据源', 'No source'],
224
+ balanceLeft: ['余额剩余', 'Balance left'],
225
+ bonusBalance: ['赠金余额', 'Bonus balance'],
224
226
  offlineTitle: ['额度服务未启动', 'Quota service is not running'],
225
227
  offlineHint: ['在本机运行下面的命令即可(保持后台)', 'Start it locally in the background:'],
226
228
  creditsAll: ['全部积分', 'All credits'],
@@ -408,7 +410,15 @@ window.__ModuleLoader__.load({
408
410
  }
409
411
  // v.label 来自 adapter,本身就是双语无关的短词,优先用它。
410
412
  if (kind === 'free-usable') return { text: v.label || pick(lang, 'free'), tone: 'ok', pct: null };
411
- if (kind === 'payg') return { text: v.label || pick(lang, 'payg'), tone: 'muted', pct: null };
413
+ if (kind === 'payg') {
414
+ // Prepaid wallets (e.g. the official DeepSeek account) have a real
415
+ // balance to show. Only fall back to the label when there is no amount.
416
+ const c = v.credits;
417
+ if (c && typeof c.remain === 'number') {
418
+ return { text: money(c.remain, c.unit), tone: 'ok', pct: null };
419
+ }
420
+ return { text: v.label || pick(lang, 'payg'), tone: 'muted', pct: null };
421
+ }
412
422
  // `none` = 这个 provider 当前取不到任何读数(渠道被删、插件被卸、没凭据)。
413
423
  // 之前没有这个分支,会掉到最后的 String(kind),界面上直接显示字面量 "none"。
414
424
  if (kind === 'none') {
@@ -979,13 +989,23 @@ window.__ModuleLoader__.load({
979
989
  }
980
990
 
981
991
  function paygBody(data, lang) {
992
+ const c = data && data.credits;
993
+ const hasBalance = c && typeof c.remain === 'number';
982
994
  return h(React.Fragment, null,
983
995
  h('div', { className: 'quota-pill-header' },
984
- h('span', { className: 'quota-pill-headline' }, (data && data.label) ? String(data.label) : pick(lang, 'payg')),
985
- h('span', { className: 'quota-pill-figures' }, (data && data.note) ? String(data.note) : pick(lang, 'noCeiling')),
996
+ h('span', { className: 'quota-pill-headline' },
997
+ hasBalance ? pick(lang, 'balanceLeft') : ((data && data.label) ? String(data.label) : pick(lang, 'payg'))),
998
+ hasBalance
999
+ ? h('span', { className: 'quota-pill-figures' }, money(c.remain, c.unit))
1000
+ : h('span', { className: 'quota-pill-figures' }, (data && data.note) ? String(data.note) : pick(lang, 'noCeiling')),
986
1001
  ),
987
1002
  h('dl', { className: 'quota-pill-rows' },
988
- data.account && statRow(pick(lang, 'account'), String(data.account)),
1003
+ // A prepaid wallet's bonus is worth showing separately: it is spendable
1004
+ // but not the same money, and mixing it into the balance would overstate.
1005
+ data.bonus && typeof data.bonus.amount === 'number'
1006
+ ? statRow(pick(lang, 'bonusBalance'), money(data.bonus.amount, data.bonus.unit))
1007
+ : null,
1008
+ !hasBalance && data.account && statRow(pick(lang, 'account'), String(data.account)),
989
1009
  data.usedAmount != null && statRow(pick(lang, 'usedThisPeriod'), String(data.usedAmount) + pick(lang, 'usedRaw')),
990
1010
  data.monthlyLimit != null && statRow(pick(lang, 'monthlyCap'), String(data.monthlyLimit)),
991
1011
  data.resetAt && statRow(pick(lang, 'nextReset'), String(data.resetAt).slice(0, 10)),
@@ -1092,6 +1112,17 @@ window.__ModuleLoader__.load({
1092
1112
  }
1093
1113
 
1094
1114
  /** 到期时间:积分包给的是「哪天到期」,用绝对日期比「X 天后重置」贴切。 */
1115
+ /**
1116
+ * Currency amount. Uses a symbol for the few currencies we may see and a
1117
+ * bare code otherwise, so we never imply a currency we did not get.
1118
+ */
1119
+ function money(amount, unit) {
1120
+ if (typeof amount !== 'number' || !Number.isFinite(amount)) return '';
1121
+ const sym = { CNY: '\u00a5', USD: '$', EUR: '\u20ac' }[String(unit || '').toUpperCase()];
1122
+ const n = Math.abs(amount) >= 100 ? amount.toFixed(1) : amount.toFixed(2);
1123
+ return sym ? sym + n : n + (unit ? ' ' + unit : '');
1124
+ }
1125
+
1095
1126
  function fmtExpiry(iso, lang) {
1096
1127
  if (!iso) return '';
1097
1128
  const d = new Date(iso);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hystudio/dsh-quota-pill",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Quota pill for DeepSeek Harness: remaining quota for every LLM subscription you have, in one collapsible pill",
@@ -0,0 +1,230 @@
1
+ /**
2
+ * DeepSeek Platform (the official account).
3
+ *
4
+ * The credential in ~/.dsh/.credentials.yaml is a **Platform login grant**, not
5
+ * an api.deepseek.com API key. Two consequences, both verified:
6
+ *
7
+ * 1. Sending it to api.deepseek.com/user/balance returns 401 — those are
8
+ * different systems.
9
+ * 2. Calling platform.deepseek.com/api/v0/users/get_user_summary directly
10
+ * returns 429 from Cloudflare (non-browser TLS).
11
+ *
12
+ * So the balance is read through DSH's own `account/getBalance` RPC, which
13
+ * already holds the grant, derives the right headers, and projects the answer
14
+ * down to just wallet currency + balance. That is also the most conservative
15
+ * route: we never handle a Platform token ourselves.
16
+ *
17
+ * The RPC needs a signed session cookie:
18
+ * name = "dsh-auth-" + base64url(sha256(authority))
19
+ * value = "v1." + base64url(JSON payload) + "." + base64url(HMAC-SHA256(secret, body))
20
+ * payload = { version: 1, authority, issuedAt, expiresAt } ← milliseconds
21
+ */
22
+ import crypto from 'node:crypto';
23
+ import { execFileSync } from 'node:child_process';
24
+ import { URL } from 'node:url';
25
+ import { deepseekGrant, browserSessionSecret } from './credentials.js';
26
+
27
+ const RPC_PORT_GUESSES = [8319, 8080, 3000];
28
+ const DSH_BASE_ENV = process.env.DSH_RPC_BASE || process.env.QUOTA_PILL_DSH_BASE || null;
29
+
30
+ const b64u = (buf) => Buffer.from(buf).toString('base64url');
31
+
32
+ /** Find the DSH web server port. macOS/Linux: ask lsof; otherwise try env then guesses. */
33
+ function findDshBase() {
34
+ if (DSH_BASE_ENV) return DSH_BASE_ENV.replace(/\/$/, '');
35
+ if (process.env.QUOTA_PILL_DSH_PORT) {
36
+ return `http://127.0.0.1:${process.env.QUOTA_PILL_DSH_PORT}`;
37
+ }
38
+ try {
39
+ const out = execFileSync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], {
40
+ encoding: 'utf8',
41
+ timeout: 4000,
42
+ });
43
+ for (const line of out.split('\n')) {
44
+ // The DSH process shows up under its app name; match on the command column.
45
+ if (!/DeepSeek|dsh/i.test(line)) continue;
46
+ const m = /127\.0\.0\.1:(\d+)/.exec(line);
47
+ if (m) return `http://127.0.0.1:${m[1]}`;
48
+ }
49
+ } catch {
50
+ /* lsof missing or blocked — fall through */
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /** Build the signed session cookie DSH expects. */
56
+ function signCookie(base, secretB64) {
57
+ const authority = new URL(base).host;
58
+ let raw;
59
+ try {
60
+ raw = Buffer.from(secretB64, 'base64url');
61
+ } catch {
62
+ raw = Buffer.from(secretB64, 'utf8');
63
+ }
64
+ const name = 'dsh-auth-' + b64u(crypto.createHash('sha256').update(authority).digest());
65
+ const now = Date.now();
66
+ const payload = {
67
+ version: 1,
68
+ authority,
69
+ issuedAt: now,
70
+ expiresAt: now + 3600 * 1000,
71
+ };
72
+ const body = b64u(JSON.stringify(payload));
73
+ const sig = b64u(crypto.createHmac('sha256', raw).update(body).digest());
74
+ return { name, value: `v1.${body}.${sig}` };
75
+ }
76
+
77
+ /** POST one RPC to DSH and return its result payload. */
78
+ async function dshRpc(method, args, base) {
79
+ const secret = browserSessionSecret();
80
+ if (!secret) return { ok: false, error: 'no browser-session secret in credentials' };
81
+
82
+ const { name, value } = signCookie(base, secret);
83
+ const body = JSON.stringify({
84
+ type: 'client-request',
85
+ rpcId: 'quota-pill-' + method,
86
+ method,
87
+ payload: { args },
88
+ });
89
+
90
+ const controller = new AbortController();
91
+ const timer = setTimeout(() => controller.abort(), 15000);
92
+ try {
93
+ const res = await fetch(base + '/api/' + method.replace(/\./g, '/'), {
94
+ method: 'POST',
95
+ headers: {
96
+ 'Content-Type': 'application/json',
97
+ Accept: 'application/json',
98
+ Cookie: `${name}=${value}`,
99
+ },
100
+ body,
101
+ signal: controller.signal,
102
+ });
103
+ const text = await res.text();
104
+ if (!res.ok) {
105
+ return { ok: false, status: res.status, error: `dsh rpc http ${res.status}: ${text.slice(0, 120)}` };
106
+ }
107
+ let env;
108
+ try {
109
+ env = JSON.parse(text);
110
+ } catch {
111
+ return { ok: false, error: 'dsh rpc returned unparsable json' };
112
+ }
113
+ const result = (env && env.result) || {};
114
+ if (result.ok === false) {
115
+ const e = result.error || {};
116
+ return { ok: false, error: `${e.code || 'rpc-failed'}: ${e.message || ''}`.trim() };
117
+ }
118
+ return { ok: true, value: result.value };
119
+ } catch (e) {
120
+ return { ok: false, error: String((e && e.message) || e) };
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Official DeepSeek balance.
128
+ *
129
+ * Reported as a `payg` reading: it is a prepaid CNY wallet, not a percentage
130
+ * against a ceiling. `remainingPct` is deliberately null — inventing a
131
+ * percentage from a balance we cannot divide by anything would be a fake number.
132
+ */
133
+ export async function deepseek() {
134
+ const grant = deepseekGrant();
135
+ if (!grant) {
136
+ return {
137
+ kind: 'none',
138
+ provider: 'deepseek',
139
+ error: 'not signed in to DeepSeek Platform '
140
+ + '(no deepseek-account-platform/default grant in ~/.dsh/.credentials.yaml)',
141
+ setup: { kind: 'dsh-account', hint: 'Sign in to DeepSeek Platform from the DSH account menu.' },
142
+ };
143
+ }
144
+
145
+ const base = findDshBase();
146
+ if (!base) {
147
+ return {
148
+ kind: 'none',
149
+ provider: 'deepseek',
150
+ error: 'cannot locate the DSH web server (needed to read Platform balance)',
151
+ setup: { kind: 'dsh-account', hint: 'Set QUOTA_PILL_DSH_PORT or DSH_RPC_BASE.' },
152
+ };
153
+ }
154
+
155
+ // The client metadata identifies the calling UI. `version` is a string here,
156
+ // not a number — the strict wire codec rejects a numeric version.
157
+ const res = await dshRpc('account/getBalance', {
158
+ client: { version: '1', locale: 'zh-CN', timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60 },
159
+ }, base);
160
+
161
+ if (!res.ok) {
162
+ return {
163
+ kind: 'probe-failed',
164
+ provider: 'deepseek',
165
+ error: res.error,
166
+ setup: { kind: 'dsh-account' },
167
+ };
168
+ }
169
+
170
+ const value = res.value;
171
+ // DSH returns {status:'ready', value:[{currency,balance}], bonusWallets:[...]}
172
+ if (!value || value.status !== 'ready') {
173
+ return {
174
+ kind: 'none',
175
+ provider: 'deepseek',
176
+ error: 'Platform balance unavailable (status: '
177
+ + ((value && value.status) || 'unknown') + ')',
178
+ };
179
+ }
180
+
181
+ const wallets = Array.isArray(value.value) ? value.value : [];
182
+ const bonus = Array.isArray(value.bonusWallets) ? value.bonusWallets : [];
183
+ if (!wallets.length && !bonus.length) {
184
+ return { kind: 'none', provider: 'deepseek', error: 'Platform returned no wallets' };
185
+ }
186
+
187
+ const primary = wallets[0] || bonus[0] || {};
188
+ const toNum = (v) => {
189
+ const n = Number(v);
190
+ return Number.isFinite(n) ? n : null;
191
+ };
192
+ const currency = primary.currency || 'CNY';
193
+ const balance = toNum(primary.balance);
194
+ const bonusTotal = bonus.reduce((sum, w) => {
195
+ const n = toNum(w && w.balance);
196
+ return n == null ? sum : sum + n;
197
+ }, 0);
198
+
199
+ return {
200
+ kind: 'payg',
201
+ provider: 'deepseek',
202
+ account: currency,
203
+ label: 'prepaid',
204
+ note: `prepaid balance ${balance == null ? '?' : balance.toFixed(2)} ${currency}`
205
+ + (bonusTotal > 0 ? ` (+${bonusTotal.toFixed(2)} bonus)` : ''),
206
+ remainingPct: null,
207
+ usedPct: null,
208
+ // Amounts are kept separately so the UI can show a currency figure.
209
+ credits: {
210
+ remain: balance,
211
+ total: null,
212
+ unit: currency,
213
+ },
214
+ windows: balance == null ? [] : [{
215
+ key: 'balance',
216
+ remainPct: null,
217
+ amount: balance,
218
+ unit: currency,
219
+ resetAt: null,
220
+ }],
221
+ bonus: bonusTotal > 0
222
+ ? { amount: bonusTotal, unit: currency }
223
+ : null,
224
+ raw: {
225
+ source: 'DSH account/getBalance (Platform grant)',
226
+ wallets,
227
+ bonusWallets: bonus,
228
+ },
229
+ };
230
+ }
@@ -161,3 +161,38 @@ export function credentialSource(provider) {
161
161
  };
162
162
  return { kind: 'api-key', dir: null, ref: refByProvider[provider] || null };
163
163
  }
164
+
165
+ /**
166
+ * Read a `records` entry (grants and session material live here, not in refs).
167
+ * Returns the record's payload object, or null.
168
+ */
169
+ export function readRecord(name) {
170
+ try {
171
+ const doc = parseCredentialsYaml(fs.readFileSync(CREDENTIALS_FILE, 'utf8'));
172
+ const rec = (doc.records || {})[name];
173
+ return rec && rec.payload ? rec.payload : null;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The DeepSeek Platform grant.
181
+ *
182
+ * Note this is NOT an api.deepseek.com API key — it is a Platform login grant,
183
+ * used as an `x-dsh-auth-token` header against platform.deepseek.com. Sending it
184
+ * to api.deepseek.com fails with 401 (verified), so the balance must be read
185
+ * through DSH's own account/getBalance RPC.
186
+ */
187
+ export function deepseekGrant() {
188
+ const p = readRecord('deepseek-account-platform/default');
189
+ if (!p || typeof p.token !== 'string' || !p.token) return null;
190
+ return { token: p.token, issuer: p.issuer || 'https://platform.deepseek.com' };
191
+ }
192
+
193
+ /** The browser-session secret, used to sign DSH RPC cookies. */
194
+ export function browserSessionSecret() {
195
+ const p = readRecord('client-connection/browser-session');
196
+ if (!p || typeof p.secret !== 'string' || !p.secret) return null;
197
+ return p.secret;
198
+ }
package/src/server.js CHANGED
@@ -22,16 +22,20 @@
22
22
  import http from 'node:http';
23
23
  import { kimi, opencode, commandcode } from './adapters-apikey.js';
24
24
  import { xai, codex, agy } from './adapters-cliproxy.js';
25
+ import { deepseek } from './adapters-deepseek.js';
25
26
  import { credentialSource, hasCliProxy, CLI_PROXY_DIR, CREDENTIALS_FILE } from './credentials.js';
26
27
 
27
28
  const PORT = Number(process.env.QUOTA_PILL_PORT || 8319);
28
29
  const HOST = process.env.QUOTA_PILL_HOST || '127.0.0.1';
29
30
  const CACHE_TTL_MS = Number(process.env.QUOTA_PILL_CACHE_TTL || 60) * 1000;
30
31
 
31
- const ADAPTERS = { kimi, opencode, commandcode, xai, codex, agy };
32
+ // Order matters: the pill lists providers in this order, and the official
33
+ // DeepSeek account is the one most people look for first.
34
+ const ADAPTERS = { deepseek, kimi, opencode, commandcode, xai, codex, agy };
32
35
 
33
36
  /** Display names. Deliberately small: the table stays in sync with ADAPTERS. */
34
37
  const DISPLAY_NAMES = {
38
+ deepseek: 'DeepSeek',
35
39
  kimi: 'Kimi Coding',
36
40
  opencode: 'OpenCode Go',
37
41
  commandcode: 'Command Code',
@@ -52,6 +56,7 @@ const PREFIX_ALIASES = {
52
56
  codex: 'codex',
53
57
  antigravity: 'agy',
54
58
  agy: 'agy',
59
+ deepseek: 'deepseek',
55
60
  };
56
61
 
57
62
  /** provider|model → { at, payload }. Keyed by model because readings differ per model. */