@qthinker/opencode-deepseek-search 0.3.0 → 0.3.2

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/src/gui/index.tsx CHANGED
@@ -40,9 +40,14 @@ export default Plugin.define({
40
40
  // `status` takes a required first argument: plain JSON Schema degrades the
41
41
  // input type to `unknown`, and the host validates the payload against the
42
42
  // method's object schema, so an omitted input is rejected as invalid.
43
- async function refresh() {
43
+ //
44
+ // `force` asks the server to re-probe the account balance instead of serving
45
+ // the cache; only the panel's refresh button spends that request. The
46
+ // `changed` event sends `{}` exactly — an older server rejects unknown input
47
+ // properties, so a stray `force: false` would break it.
48
+ async function refresh(force = false) {
44
49
  try {
45
- const next = readStatus(await rpc.status({}))
50
+ const next = readStatus(await rpc.status(force ? { force: true } : {}))
46
51
  if (next) {
47
52
  setStatus(next)
48
53
  setError(undefined)
@@ -68,13 +73,14 @@ export default Plugin.define({
68
73
  }
69
74
  }
70
75
 
71
- // Mirrors the TUI card: no status or a stale error rereads, otherwise
72
- // switch. A missing key still allows releasing the takeover (the shared
73
- // model hides the button only where there is genuinely nothing to do).
76
+ // Mirrors the TUI card: no status or a stale error rereads (a retry is a
77
+ // user action, so it also re-probes the balance), otherwise switch. A
78
+ // missing key still allows releasing the takeover (the shared model hides
79
+ // the button only where there is genuinely nothing to do).
74
80
  function toggle() {
75
81
  const current = status()
76
82
  if (!current || error()) {
77
- void refresh()
83
+ void refresh(true)
78
84
  return
79
85
  }
80
86
  void applyMode(current.mode === "deepseek" ? "native" : "deepseek")
@@ -91,7 +97,7 @@ export default Plugin.define({
91
97
  error={error()}
92
98
  busy={busy()}
93
99
  onToggle={toggle}
94
- onRefresh={() => void refresh()}
100
+ onRefresh={(force) => void refresh(force)}
95
101
  />
96
102
  ),
97
103
  })
package/src/gui/panel.tsx CHANGED
@@ -4,15 +4,20 @@ import type { JSX } from "solid-js"
4
4
  import { For, Show, createMemo, onCleanup, onMount } from "solid-js"
5
5
  import { usePlugin } from "@opencode/plugin/gui"
6
6
  import { t, type Key } from "../i18n/index.ts"
7
- import type { SearchStatus } from "../rpc.ts"
8
- import { sidebarRows, type SidebarRows } from "../status-view.ts"
7
+ import type { BalanceStatus, Language, SearchStatus } from "../rpc.ts"
8
+ import { CURRENCY_SYMBOL, sidebarRows, type SidebarRows } from "../status-view.ts"
9
9
 
10
10
  export interface DeepSeekPanelProps {
11
11
  readonly status: SearchStatus | undefined
12
12
  readonly error: string | undefined
13
13
  readonly busy: boolean
14
14
  readonly onToggle: () => void
15
- readonly onRefresh: () => void
15
+ /**
16
+ * `force` re-probes the server's balance cache; only a user action (the
17
+ * refresh button) may set it. The 10s poll timer calls it bare, so the
18
+ * automatic path keeps sending `{}` and never bypasses the server TTL.
19
+ */
20
+ readonly onRefresh: (force?: boolean) => void
16
21
  }
17
22
 
18
23
  type Tone = SidebarRows["tone"]
@@ -67,6 +72,20 @@ function help(action: string, mono: string): JSX.Element {
67
72
  )
68
73
  }
69
74
 
75
+ /**
76
+ * The balance tooltip carries the granted/topped-up split, because those are the
77
+ * two numbers behind the total. It is skipped when only one component is
78
+ * non-zero: there it would just restate the total. The currency code rides
79
+ * along only for USD, whose `$` needs no introduction.
80
+ */
81
+ function balanceTip(balance: BalanceStatus, language: Language): string | undefined {
82
+ const hasGranted = Number.parseFloat(balance.grantedBalance) > 0
83
+ const hasTopped = Number.parseFloat(balance.toppedUpBalance) > 0
84
+ if (!hasGranted || !hasTopped) return undefined
85
+ const text = t(language, "balance.breakdown", { granted: balance.grantedBalance, topped: balance.toppedUpBalance })
86
+ return balance.currency === "USD" ? `${text} (USD)` : text
87
+ }
88
+
70
89
  export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
71
90
  const ctx = usePlugin()
72
91
  // The desktop plugin has no forced locale: the server resolves one and carries
@@ -77,6 +96,18 @@ export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
77
96
  const ink = createMemo(() => INK[ctx.appearance.mode()])
78
97
  const mono = () => ctx.appearance.font("mono")
79
98
 
99
+ /**
100
+ * The account block mirrors the view model's own emit condition (enabled or
101
+ * native mode), so `rows().lines` ends with the balance row exactly when
102
+ * this is defined and the usage table must stop before it. The block renders
103
+ * from the structured payload, not from re-parsing the line.
104
+ */
105
+ const balance = createMemo<BalanceStatus | undefined>(() => {
106
+ const account = props.status?.balance
107
+ if (account === undefined || (rows().tone !== "on" && rows().tone !== "off")) return undefined
108
+ return account
109
+ })
110
+
80
111
  // Cross-half `changed` delivery is the one runtime behaviour this repo cannot
81
112
  // verify, so the panel rereads on its own timer while it is mounted — the same
82
113
  // fallback the TUI sidebar uses.
@@ -94,14 +125,14 @@ export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
94
125
  }}
95
126
  >
96
127
  <header style={{ display: "flex", "align-items": "flex-start", gap: "8px" }}>
97
- <span aria-hidden="true" style={{ "flex-shrink": 0, "font-size": "10px", "line-height": "20px", "font-weight": 700, color: ink()[rows().tone] }}>
128
+ <span aria-hidden="true" style={{ "flex-shrink": 0, "font-size": "12px", "line-height": "20px", width: "12px", "text-align": "center", "font-weight": 700, color: ink()[rows().tone] }}>
98
129
  {GLYPH[rows().tone]}
99
130
  </span>
100
131
  <h2 aria-live="polite" style={{ flex: 1, "min-width": 0, margin: 0, "font-size": "13px", "font-weight": 600, "line-height": "20px", "overflow-wrap": "anywhere" }}>
101
132
  {headline(rows().title, ink()[rows().tone])}
102
133
  </h2>
103
134
  <ctx.ui.Tooltip value={t(language(), "panel.tooltip.refresh")} placement="bottom">
104
- <ctx.ui.IconButton icon="refresh" label={t(language(), "panel.tooltip.refresh")} size="small" onClick={props.onRefresh} />
135
+ <ctx.ui.IconButton icon="refresh" label={t(language(), "panel.tooltip.refresh")} size="small" onClick={() => props.onRefresh(true)} />
105
136
  </ctx.ui.Tooltip>
106
137
  </header>
107
138
 
@@ -116,31 +147,66 @@ export function DeepSeekPanel(props: DeepSeekPanelProps): JSX.Element {
116
147
  )
117
148
  ) : (
118
149
  <dl style={{ margin: "16px 0 0", "border-top": `1px solid ${ink().rule}`, "border-bottom": `1px solid ${ink().rule}` }}>
119
- <For each={rows().lines}>
150
+ <For each={balance() !== undefined ? rows().lines.slice(0, -1) : rows().lines}>
120
151
  {(line, index) => {
121
152
  const parts = splitLine(line)
122
153
  return (
123
154
  <div style={{ display: "flex", "align-items": "baseline", gap: "12px", padding: "10px 0", "border-top": index() > 0 ? `1px solid ${ink().rule}` : undefined }}>
124
- <dt style={{ "flex-shrink": 0, "font-size": "11px", "letter-spacing": "0.08em", "line-height": "1.9", opacity: 0.62 }}>
155
+ <dt style={{ "flex-shrink": 0, "font-size": "11px", "letter-spacing": /^[\x20-\x7E]+$/.test(parts.label) ? "0.08em" : undefined, "line-height": "1.9", opacity: 0.62 }}>
125
156
  {parts.label}
126
157
  </dt>
127
158
  <dd style={{
128
159
  margin: 0, "margin-left": "auto", "min-width": 0, display: "flex", "flex-wrap": "wrap", "justify-content": "flex-end",
129
- "column-gap": "6px", "row-gap": "2px", "font-family": mono(), "font-size": "13px", "line-height": "1.55",
160
+ "column-gap": "10px", "row-gap": "2px", "font-family": mono(), "font-size": "13px", "line-height": "1.55",
130
161
  "font-variant-numeric": "tabular-nums", "text-align": "right",
131
162
  }}>
132
163
  <For each={parts.chunks}>
133
- {(chunk, i) => (
134
- <span style={{ "min-width": 0, "overflow-wrap": "anywhere", "font-weight": chunk.startsWith("¥") ? 500 : 400, opacity: chunk.startsWith("¥") ? 1 : 0.8 }}>
135
- {chunk}{i() < parts.chunks.length - 1 ? " ·" : ""}
136
- </span>
137
- )}
164
+ {(chunk) => {
165
+ // A money chunk must never wrap apart from its digits, and
166
+ // it is the only chunk that reads at full weight.
167
+ const money = /^[¥$]/.test(chunk)
168
+ return (
169
+ <span style={{ "min-width": 0, "overflow-wrap": "anywhere", "font-weight": money ? 500 : 400, opacity: money ? 1 : 0.8, "flex-shrink": money ? 0 : undefined }}>
170
+ {chunk}
171
+ </span>
172
+ )
173
+ }}
138
174
  </For>
139
175
  </dd>
140
176
  </div>
141
177
  )
142
178
  }}
143
179
  </For>
180
+ {/* The account block closes the table like a receipt: the usage rows
181
+ above are local estimates for this machine, the balance under them
182
+ is the authoritative amount still on the account. */}
183
+ <Show when={balance()} keyed>
184
+ {(account) => {
185
+ const label = t(language(), "balance.label")
186
+ const tip = balanceTip(account, language())
187
+ const amount = (
188
+ <dd style={{
189
+ margin: 0, "margin-left": "auto", "font-family": mono(), "font-size": "14px", "line-height": "1.55",
190
+ "font-variant-numeric": "tabular-nums", "text-align": "right", "font-weight": 500, "flex-shrink": 0,
191
+ color: account.isAvailable ? undefined : ink().warn,
192
+ }}>
193
+ {CURRENCY_SYMBOL[account.currency] ?? ""}{account.totalBalance}
194
+ </dd>
195
+ )
196
+ return (
197
+ <div style={{ display: "flex", "align-items": "baseline", gap: "12px", padding: "10px 0 4px", "border-top": `1px solid ${ink().rule}` }}>
198
+ <dt style={{ "flex-shrink": 0, "font-size": "11px", "letter-spacing": /^[\x20-\x7E]+$/.test(label) ? "0.08em" : undefined, "line-height": "1.9", opacity: 0.62 }}>
199
+ {label}
200
+ </dt>
201
+ {tip !== undefined ? (
202
+ <ctx.ui.Tooltip value={tip} placement="bottom">{amount}</ctx.ui.Tooltip>
203
+ ) : (
204
+ amount
205
+ )}
206
+ </div>
207
+ )
208
+ }}
209
+ </Show>
144
210
  </dl>
145
211
  )}
146
212
 
package/src/i18n/en.ts CHANGED
@@ -85,6 +85,10 @@ export const en = {
85
85
  "usage.label.total": "Total",
86
86
  "usage.summary.line": "Search {search} · Fetch {fetch} · empty/failed {skipped} · {cost}",
87
87
 
88
+ // ---------- account balance (the card row and the desktop tooltip) ----------
89
+ "balance.label": "Balance",
90
+ "balance.breakdown": "Granted {granted} · Topped up {topped}",
91
+
88
92
  // ---------- errors ----------
89
93
  "error.missingKey":
90
94
  "DeepSeek API key is not configured. Set the DEEPSEEK_API_KEY environment variable " +
@@ -131,17 +135,32 @@ export type Messages = Record<Key, string>
131
135
  * Usage-row ladder, richest first. Each entry is a complete row, so nothing is
132
136
  * ever left dangling and no number is ever cut from its unit. `{label}` is the
133
137
  * localized period label; `Search`/`Fetch` stay English in every language.
138
+ *
139
+ * Order is by what the row is worth, then by width, so it runs strictly
140
+ * descending (36/33/25/17/11 columns for a typical fixture). The amount
141
+ * outranks the words and the counters: the old ladder kept a wider words-only
142
+ * row and hid the cost until 36 columns, which is the number this card exists
143
+ * for, so those two rungs are gone. The amount-only row (which carries no count
144
+ * at all) is composed in `status-view.ts` and tried just before the last rung —
145
+ * an amount is worth more than a lone counter, but the ladder itself only holds
146
+ * rows that carry counts.
134
147
  */
135
148
  export const enUsageForms: readonly string[] = [
136
149
  "{label} Search {search} Fetch {fetch} {cost}",
137
150
  "{label} Search {search} Fetch {fetch} {cost}",
138
- "{label} Search {search} Fetch {fetch}",
139
- "{label} Search {search} Fetch {fetch}",
140
151
  "{label} {search}S {fetch}F {cost}",
141
152
  "{label} {search}S {fetch}F",
142
- "{label} {search}S",
153
+ "{label} {search}S",
143
154
  ]
144
155
 
156
+ /**
157
+ * Balance-row ladder, verbose first: the terminal takes the compact form when
158
+ * the verbose one does not fit, a surface with an unbounded budget (the desktop)
159
+ * always reads the first. `{amount}` already carries the currency symbol — the
160
+ * view model composes it, because only it knows the currency code.
161
+ */
162
+ export const enBalanceForms: readonly string[] = ["Balance {amount}", "Bal. {amount}"]
163
+
145
164
  /** Missing-key hint ladder, richest first. The env var name is always present. */
146
165
  export const enHintForms: readonly string[] = [
147
166
  "Set DEEPSEEK_API_KEY and restart",
package/src/i18n/index.ts CHANGED
@@ -6,9 +6,9 @@
6
6
  // plugin or host imports, so every runtime can load this module.
7
7
 
8
8
  import { isLanguage, type Language } from "../rpc.ts"
9
- import { en, enHintForms, enUsageForms, type Key, type Messages } from "./en.ts"
10
- import { ja, jaHintForms, jaUsageForms } from "./ja.ts"
11
- import { zh, zhHintForms, zhUsageForms } from "./zh.ts"
9
+ import { en, enBalanceForms, enHintForms, enUsageForms, type Key, type Messages } from "./en.ts"
10
+ import { ja, jaBalanceForms, jaHintForms, jaUsageForms } from "./ja.ts"
11
+ import { zh, zhBalanceForms, zhHintForms, zhUsageForms } from "./zh.ts"
12
12
 
13
13
  export type { Key, Messages }
14
14
 
@@ -19,12 +19,14 @@ export interface LocalePack {
19
19
  readonly usageForms: readonly string[]
20
20
  /** Missing-key hint ladder, richest first. */
21
21
  readonly hintForms: readonly string[]
22
+ /** Balance-row ladder, verbose first; `{amount}` carries the currency symbol. */
23
+ readonly balanceForms: readonly string[]
22
24
  }
23
25
 
24
26
  const PACKS: Readonly<Record<Language, LocalePack>> = {
25
- en: { messages: en, usageForms: enUsageForms, hintForms: enHintForms },
26
- ja: { messages: ja, usageForms: jaUsageForms, hintForms: jaHintForms },
27
- "zh-CN": { messages: zh, usageForms: zhUsageForms, hintForms: zhHintForms },
27
+ en: { messages: en, usageForms: enUsageForms, hintForms: enHintForms, balanceForms: enBalanceForms },
28
+ ja: { messages: ja, usageForms: jaUsageForms, hintForms: jaHintForms, balanceForms: jaBalanceForms },
29
+ "zh-CN": { messages: zh, usageForms: zhUsageForms, hintForms: zhHintForms, balanceForms: zhBalanceForms },
28
30
  }
29
31
 
30
32
  export function pack(language: Language): LocalePack {
package/src/i18n/ja.ts CHANGED
@@ -83,6 +83,10 @@ export const ja = {
83
83
  "usage.label.total": "合計",
84
84
  "usage.summary.line": "検索 {search} · 取得 {fetch} · 空/失敗 {skipped} · {cost}",
85
85
 
86
+ // ---------- アカウント残高(カードの行とデスクトップのツールチップ) ----------
87
+ "balance.label": "残高",
88
+ "balance.breakdown": "付与 {granted} · チャージ {topped}",
89
+
86
90
  // ---------- エラー ----------
87
91
  "error.missingKey":
88
92
  "DeepSeek APIキーが未設定です。環境変数 DEEPSEEK_API_KEY を設定してください" +
@@ -118,17 +122,22 @@ export const ja = {
118
122
  "error.fetch.truncated": "[DeepSeek Fetch 切り詰め] 元の本文は {raw} 文字で、上限 {max} 文字を超えています。先頭 {max} 文字のみ保持しました。",
119
123
  } satisfies Messages
120
124
 
121
- /** 使用量行の階段:完全な行から最も短い行まで。各段は単体で完結する。 */
125
+ /**
126
+ * 使用量行の階段:価値の高い順、同じ価値なら幅の広い順。各段は単体で完結する。
127
+ * 金額は語や回数より上位なので、金額を含まない「語だけ」の段は廃止した。金額だけの
128
+ * 行は件数を持たないため status-view.ts 側で組み立て、最後の 1 段の直前に試す。
129
+ */
122
130
  export const jaUsageForms: readonly string[] = [
123
131
  "{label} Search {search} Fetch {fetch} {cost}",
124
132
  "{label} Search {search} Fetch {fetch} {cost}",
125
- "{label} Search {search} Fetch {fetch}",
126
- "{label} Search {search} Fetch {fetch}",
127
133
  "{label} {search}S {fetch}F {cost}",
128
134
  "{label} {search}S {fetch}F",
129
- "{label} {search}S",
135
+ "{label} {search}S",
130
136
  ]
131
137
 
138
+ /** 残高行の階段:完全な形が先。`{amount}` は通貨記号込み。 */
139
+ export const jaBalanceForms: readonly string[] = ["残高 {amount}"]
140
+
132
141
  /** APIキー未設定のヒント階段:最後の段でも何を設定するかは残る。 */
133
142
  export const jaHintForms: readonly string[] = [
134
143
  "DEEPSEEK_API_KEY を設定して再起動",
package/src/i18n/zh.ts CHANGED
@@ -81,6 +81,10 @@ export const zh = {
81
81
  "usage.label.total": "累计",
82
82
  "usage.summary.line": "搜索 {search} · 抓取 {fetch} · 空/失 {skipped} · {cost}",
83
83
 
84
+ // ---------- 账户余额(卡片行与桌面端浮动提示) ----------
85
+ "balance.label": "余额",
86
+ "balance.breakdown": "赠金 {granted} · 充值 {topped}",
87
+
84
88
  // ---------- 错误信息 ----------
85
89
  "error.missingKey":
86
90
  "未配置 DeepSeek API Key:请设置环境变量 DEEPSEEK_API_KEY" +
@@ -113,17 +117,23 @@ export const zh = {
113
117
  "error.fetch.truncated": "[DeepSeek Fetch 截断提示] 原文共 {raw} 字符,超出上限 {max},此处仅保留前 {max} 字符。",
114
118
  } satisfies Messages
115
119
 
116
- /** 用量行阶梯:从最完整到最紧凑;每一条都是完整的一行。 */
120
+ /**
121
+ * 用量行阶梯:按价值从高到低排列,同等价值再按宽度从宽到窄;每一条都是完整的一行。
122
+ * 金额优先于文字与计数,因此删掉了不带金额的“纯文字”档——旧阶梯会为了更宽的
123
+ * 纯文字行而把金额藏到 36 列。只带金额的一行不含计数,改由 status-view.ts 组装,
124
+ * 并在最后一档之前尝试。
125
+ */
117
126
  export const zhUsageForms: readonly string[] = [
118
127
  "{label} Search {search} Fetch {fetch} {cost}",
119
128
  "{label} Search {search} Fetch {fetch} {cost}",
120
- "{label} Search {search} Fetch {fetch}",
121
- "{label} Search {search} Fetch {fetch}",
122
129
  "{label} {search}S {fetch}F {cost}",
123
130
  "{label} {search}S {fetch}F",
124
- "{label} {search}S",
131
+ "{label} {search}S",
125
132
  ]
126
133
 
134
+ /** 余额行阶梯:完整写法在前;`{amount}` 已含货币符号。 */
135
+ export const zhBalanceForms: readonly string[] = ["余额 {amount}"]
136
+
127
137
  /** 缺 key 提示阶梯:最后一档仍然指明要设置什么。 */
128
138
  export const zhHintForms: readonly string[] = [
129
139
  "设置 DEEPSEEK_API_KEY 后重启",
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ import { Plugin } from "@opencode/plugin"
16
16
  import type { Result as ToolResult, ToolContext } from "@opencode/plugin/promise/tool"
17
17
  import type { RpcRegistration } from "@opencode/plugin/promise/rpc"
18
18
  import {
19
+ deepseekBalance,
19
20
  deepseekFetch,
20
21
  deepseekSearch,
21
22
  formatSearchResults,
@@ -25,7 +26,7 @@ import {
25
26
  type SearchResult,
26
27
  } from "./deepseek.ts"
27
28
  import { resolveLanguage, t } from "./i18n/index.ts"
28
- import { DeepSeekRpc, type ModeSwitchResult, type SearchStatus } from "./rpc.ts"
29
+ import { DeepSeekRpc, type BalanceRead, type ModeSwitchResult, type SearchStatus } from "./rpc.ts"
29
30
  import {
30
31
  USAGE_KEY,
31
32
  applyTally,
@@ -375,13 +376,52 @@ export default Plugin.define({
375
376
  })
376
377
  })
377
378
 
379
+ // ---------- account balance cache ----------
380
+ // The sidebars poll `status`, so the balance is cached for a minute: at most
381
+ // one upstream probe per TTL, shared by every caller. Failures keep the last
382
+ // good snapshot and back off instead of retrying on every poll.
383
+ const BALANCE_TTL_MS = 60_000
384
+ let balanceCache: { value: BalanceRead; at: number } | undefined
385
+ let balanceInFlight: Promise<void> | undefined
386
+ let lastBalanceAttemptAt = 0
387
+ // Never rejects: the balance is decoration on `status`, not a feature that
388
+ // may break it, so every failure is swallowed here.
389
+ const refreshBalance = (force: boolean): Promise<void> => {
390
+ if (!apiKey) return Promise.resolve()
391
+ if (balanceInFlight) return balanceInFlight
392
+ const startedAt = Date.now()
393
+ if (!force) {
394
+ if (balanceCache && startedAt - balanceCache.at < BALANCE_TTL_MS) return Promise.resolve()
395
+ if (startedAt - lastBalanceAttemptAt < BALANCE_TTL_MS) return Promise.resolve()
396
+ }
397
+ lastBalanceAttemptAt = startedAt
398
+ const run = (async (): Promise<void> => {
399
+ try {
400
+ const value = await deepseekBalance(apiKey, language)
401
+ balanceCache = { value, at: Date.now() }
402
+ notifyChanged()
403
+ } catch {
404
+ /* keep the previous snapshot */
405
+ } finally {
406
+ balanceInFlight = undefined
407
+ }
408
+ })()
409
+ balanceInFlight = run
410
+ return run
411
+ }
412
+
378
413
  // ---------- RPC contract (TUI / desktop sidebars) ----------
379
414
  // Both sidebars read `status` and call `setMode`; nothing else crosses the
380
415
  // process boundary, so the plugin's state stays the single source of truth.
381
416
  rpcRegistration = await ctx.rpc.register(DeepSeekRpc, {
382
417
  // Reads through `withLedger` so it observes the same serialized chain.
383
- status: async (): Promise<SearchStatus> =>
384
- withLedger((ledger): SearchStatus => {
418
+ status: async (input: unknown): Promise<SearchStatus> => {
419
+ // A manual refresh (the sidebar's refresh button) may ask for a fresh
420
+ // probe; timer paths send `{}` so old servers still accept the input.
421
+ const force = (input as { force?: unknown } | undefined)?.force === true
422
+ // Kicked, not awaited: `status` must stay fast even when the probe hangs.
423
+ void refreshBalance(force)
424
+ return withLedger((ledger): SearchStatus => {
385
425
  const todayKey = dayKey()
386
426
  return {
387
427
  mode,
@@ -398,8 +438,12 @@ export default Plugin.define({
398
438
  month: sumDays(ledger, (k) => k.startsWith(todayKey.slice(0, 7))),
399
439
  total: sumDays(ledger, () => true),
400
440
  },
441
+ // Absent until the first successful probe; `fetchedAt` is stamped by
442
+ // the cache, so clients can render staleness.
443
+ balance: balanceCache ? { ...balanceCache.value, fetchedAt: balanceCache.at } : undefined,
401
444
  }
402
- }),
445
+ })
446
+ },
403
447
  // The input is plain JSON Schema, hence `unknown`: report, never throw.
404
448
  setMode: async (input: unknown): Promise<ModeSwitchResult> => {
405
449
  const parsed = readModeAction(input)
package/src/rpc.ts CHANGED
@@ -38,11 +38,36 @@ const USAGE_COUNTS_SCHEMA = {
38
38
  additionalProperties: false,
39
39
  } as const
40
40
 
41
+ // Account balance snapshot in `status.balance`. `currency` deliberately has no
42
+ // enum: a value the server could not normalize must not fail the whole payload
43
+ // at output-schema validation time.
44
+ const BALANCE_SCHEMA = {
45
+ type: "object",
46
+ properties: {
47
+ isAvailable: { type: "boolean" },
48
+ currency: { type: "string" },
49
+ totalBalance: { type: "string" },
50
+ grantedBalance: { type: "string" },
51
+ toppedUpBalance: { type: "string" },
52
+ fetchedAt: { type: "number" },
53
+ },
54
+ required: ["isAvailable", "currency", "totalBalance", "grantedBalance", "toppedUpBalance", "fetchedAt"],
55
+ additionalProperties: false,
56
+ } as const
57
+
41
58
  export const DeepSeekRpc = {
42
59
  id: DEEPSEEK_RPC_ID,
43
60
  methods: {
44
61
  status: {
45
- input: { type: "object", properties: {}, additionalProperties: false },
62
+ input: {
63
+ type: "object",
64
+ properties: {
65
+ // Only a manual refresh sends `{ force: true }`; timer paths MUST send
66
+ // `{}`, because older servers reject unknown input properties.
67
+ force: { type: "boolean" },
68
+ },
69
+ additionalProperties: false,
70
+ },
46
71
  output: {
47
72
  type: "object",
48
73
  properties: {
@@ -67,6 +92,9 @@ export const DeepSeekRpc = {
67
92
  required: ["today", "month", "total"],
68
93
  additionalProperties: false,
69
94
  },
95
+ // Optional like `counts`: absent while the first probe is in flight, on
96
+ // servers without the endpoint, or when the API key is missing.
97
+ balance: BALANCE_SCHEMA,
70
98
  },
71
99
  required: ["mode", "source", "patched", "hasKey", "pricePerThousand", "today", "month", "total"],
72
100
  additionalProperties: false,
@@ -107,6 +135,24 @@ export interface UsageCounts {
107
135
  costMicro: number
108
136
  }
109
137
 
138
+ /**
139
+ * DeepSeek account balance as reported by `GET /user/balance`.
140
+ *
141
+ * `fetchedAt` is epoch milliseconds attached by the server when it caches the
142
+ * snapshot, so sidebars can show staleness without trusting client clocks.
143
+ */
144
+ export interface BalanceStatus {
145
+ isAvailable: boolean
146
+ currency: string
147
+ totalBalance: string
148
+ grantedBalance: string
149
+ toppedUpBalance: string
150
+ fetchedAt: number
151
+ }
152
+
153
+ /** The wire shape of a balance probe, before the cache stamps `fetchedAt`. */
154
+ export type BalanceRead = Omit<BalanceStatus, "fetchedAt">
155
+
110
156
  export interface SearchStatus {
111
157
  mode: "deepseek" | "native"
112
158
  source: "override" | "config"
@@ -124,6 +170,8 @@ export interface SearchStatus {
124
170
  language: Language
125
171
  /** Structured counters, so each surface can format for its own width. Optional for older servers. */
126
172
  counts?: { today: UsageCounts; month: UsageCounts; total: UsageCounts }
173
+ /** Last cached balance probe. Absent until the first successful fetch. */
174
+ balance?: BalanceStatus
127
175
  }
128
176
 
129
177
  export interface ModeSwitchResult {
@@ -151,10 +199,23 @@ function readUsageCounts(value: unknown): UsageCounts | undefined {
151
199
  return { search, fetch, empty, failed, costMicro } as UsageCounts
152
200
  }
153
201
 
202
+ /** Strict read of a cached balance snapshot: all six fields, currency normalized. */
203
+ function readBalance(value: unknown): BalanceStatus | undefined {
204
+ if (!isRecord(value)) return undefined
205
+ const { isAvailable, currency, totalBalance, grantedBalance, toppedUpBalance, fetchedAt } = value
206
+ if (typeof isAvailable !== "boolean") return undefined
207
+ if (currency !== "CNY" && currency !== "USD") return undefined
208
+ for (const text of [totalBalance, grantedBalance, toppedUpBalance]) {
209
+ if (typeof text !== "string" || text.length === 0) return undefined
210
+ }
211
+ if (typeof fetchedAt !== "number" || !Number.isFinite(fetchedAt) || fetchedAt < 0) return undefined
212
+ return { isAvailable, currency, totalBalance, grantedBalance, toppedUpBalance, fetchedAt } as BalanceStatus
213
+ }
214
+
154
215
  /** Defensive read of an RPC `status` result. Never throws. */
155
216
  export function readStatus(raw: unknown): SearchStatus | undefined {
156
217
  if (!isRecord(raw)) return undefined
157
- const { mode, source, patched, hasKey, pricePerThousand, today, month, total, language, counts } = raw
218
+ const { mode, source, patched, hasKey, pricePerThousand, today, month, total, language, counts, balance } = raw
158
219
  if (!isMode(mode)) return undefined
159
220
  if (source !== "override" && source !== "config") return undefined
160
221
  if (typeof patched !== "boolean" || typeof hasKey !== "boolean") return undefined
@@ -180,6 +241,10 @@ export function readStatus(raw: unknown): SearchStatus | undefined {
180
241
  const totalCounts = readUsageCounts(counts.total)
181
242
  if (todayCounts && monthCounts && totalCounts) status.counts = { today: todayCounts, month: monthCounts, total: totalCounts }
182
243
  }
244
+ // A missing balance stays absent (no probe yet, no key, older server); a
245
+ // malformed one is dropped without failing the rest of the payload.
246
+ const balanceStatus = readBalance(balance)
247
+ if (balanceStatus) status.balance = balanceStatus
183
248
  return status
184
249
  }
185
250