@qthinker/opencode-deepseek-search 0.2.1 → 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/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,
@@ -24,7 +25,8 @@ import {
24
25
  validateUrl,
25
26
  type SearchResult,
26
27
  } from "./deepseek.ts"
27
- import { DeepSeekRpc, type ModeSwitchResult, type SearchStatus } from "./rpc.ts"
28
+ import { resolveLanguage, t } from "./i18n/index.ts"
29
+ import { DeepSeekRpc, type BalanceRead, type ModeSwitchResult, type SearchStatus } from "./rpc.ts"
28
30
  import {
29
31
  USAGE_KEY,
30
32
  applyTally,
@@ -56,11 +58,13 @@ const SEARCH_TOOL_DESCRIPTION =
56
58
  "Search the web using DeepSeek Search. Use this for current information beyond knowledge cutoff." +
57
59
  `\n\nThe current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
58
60
 
59
- function readModeAction(input: unknown): ModeAction {
61
+ // Pure parser: the caller decides how to surface an invalid value (tool error
62
+ // vs. RPC result), so the message can be localized at that call site.
63
+ function readModeAction(input: unknown): { action: ModeAction } | { invalid: string } {
60
64
  const raw = (input as { mode?: unknown } | undefined)?.mode
61
- if (raw === undefined || raw === null || raw === "") return "status"
62
- if (raw === "status" || raw === "deepseek" || raw === "native" || raw === "config") return raw
63
- throw new Error(`mode 只能是 status / deepseek / native / config,收到:${String(raw)}`)
65
+ if (raw === undefined || raw === null || raw === "") return { action: "status" }
66
+ if (raw === "status" || raw === "deepseek" || raw === "native" || raw === "config") return { action: raw }
67
+ return { invalid: String(raw) }
64
68
  }
65
69
 
66
70
 
@@ -95,6 +99,18 @@ export default Plugin.define({
95
99
  id: "deepseek-search",
96
100
  async setup(ctx) {
97
101
  const opt = (ctx.options ?? {}) as Record<string, unknown>
102
+ // Resolved once per setup: every tool output and the RPC status payload use
103
+ // this single language, so surfaces can never disagree.
104
+ const pick = resolveLanguage(opt.language, {
105
+ LC_ALL: readEnvValue("LC_ALL"),
106
+ LANG: readEnvValue("LANG"),
107
+ })
108
+ const language = pick.language
109
+ if (pick.problem !== undefined)
110
+ console.error(
111
+ `[deepseek-search] invalid language option ${JSON.stringify(pick.problem)}; ` +
112
+ `valid values: en / ja / zh-CN. Using ${language}.`,
113
+ )
98
114
  const configMode: Mode = str(opt.mode) === "native" ? "native" : "deepseek"
99
115
  // Precedence: a persisted `deepseek_mode` switch wins over the config file
100
116
  // until it is cleared with `mode: "config"`.
@@ -143,10 +159,10 @@ export default Plugin.define({
143
159
  }
144
160
 
145
161
  const runSearch = async (query: string, signal: AbortSignal): Promise<SearchResult[]> => {
146
- const problem = validateQuery(query)
162
+ const problem = validateQuery(query, language)
147
163
  if (problem) throw new Error(problem)
148
164
  try {
149
- const results = await deepseekSearch(query, apiKey, count, signal)
165
+ const results = await deepseekSearch(query, apiKey, count, language, signal)
150
166
  tally(results.length > 0 ? "search" : "empty")
151
167
  return results
152
168
  } catch (e) {
@@ -176,7 +192,7 @@ export default Plugin.define({
176
192
 
177
193
  const deepseekSearchExecute = async (input: unknown, context: ToolContext): Promise<ToolResult> => {
178
194
  const query = readQuery(input)
179
- const problem = validateQuery(query)
195
+ const problem = validateQuery(query, language)
180
196
  if (problem) throw new Error(problem)
181
197
  await ctx.permission.assert({
182
198
  sessionID: context.sessionID,
@@ -217,38 +233,38 @@ export default Plugin.define({
217
233
  return true
218
234
  }
219
235
 
220
- const describeMode = (): string =>
221
- `当前搜索后端: ${mode}(来源: ${overrideMode ? "deepseek_mode 开关,已持久化" : "配置文件 options.mode"})` +
222
- (mode === "deepseek"
223
- ? searchToolPatched
224
- ? " · 内置 websearch 已由 DeepSeek 接管"
225
- : " · 未找到内置 websearch 工具,实际未能接管"
226
- : " · 内置 websearch 保持原生")
236
+ const describeMode = (): string => {
237
+ const source = overrideMode ? t(language, "mode.source.override") : t(language, "mode.source.config")
238
+ const suffix =
239
+ mode === "deepseek"
240
+ ? searchToolPatched
241
+ ? t(language, "mode.suffix.patched")
242
+ : t(language, "mode.suffix.notPatched")
243
+ : t(language, "mode.suffix.native")
244
+ return t(language, "mode.report", { mode, source, suffix })
245
+ }
227
246
 
228
247
  // Shared by the `deepseek_mode` tool and the RPC `setMode` method. The
229
248
  // returned strings are the tool's documented output, word for word.
230
249
  const switchMode = async (action: Exclude<ModeAction, "status">): Promise<string> => {
231
250
  if (!pristine) {
232
- return (
233
- `未找到内置 ${SEARCH_TOOL} 工具,无法在运行时切换。` +
234
- `请改用配置文件把 options.mode 设为 "${action === "config" ? configMode : action}"。`
235
- )
251
+ return t(language, "mode.notFound", {
252
+ tool: SEARCH_TOOL,
253
+ mode: action === "config" ? configMode : action,
254
+ })
236
255
  }
237
256
  if (action === "config") {
238
257
  await ctx.storage.remove(MODE_KEY)
239
258
  overrideMode = undefined
240
259
  await applySearchMode(configMode)
241
260
  notifyChanged()
242
- return `${describeMode()}\n\n已清除持久化开关,回到配置文件设置(${configMode})。`
261
+ return `${describeMode()}\n\n${t(language, "mode.configCleared", { mode: configMode })}`
243
262
  }
244
263
  await ctx.storage.set(MODE_KEY, action)
245
264
  overrideMode = action
246
265
  await applySearchMode(action)
247
266
  notifyChanged()
248
- return (
249
- `${describeMode()}\n\n已持久化:重启 OpenCode 后仍为 ${action}。` +
250
- '要恢复配置文件设置,调用 deepseek_mode 并传 mode: "config"。'
251
- )
267
+ return `${describeMode()}\n\n${t(language, "mode.persisted", { mode: action })}`
252
268
  }
253
269
 
254
270
  // ---------- tools ----------
@@ -289,11 +305,11 @@ export default Plugin.define({
289
305
  },
290
306
  execute: async (input, context) => {
291
307
  const url = String((input as { url?: unknown } | undefined)?.url ?? "").trim()
292
- const problem = validateUrl(url)
308
+ const problem = validateUrl(url, language)
293
309
  if (problem) throw new Error(problem)
294
310
  try {
295
- await context.progress({ status: `DeepSeek Fetch 抓取中: ${url}` })
296
- const result = await deepseekFetch(url, apiKey, fetchMaxChars, context.signal)
311
+ await context.progress({ status: t(language, "progress.fetch", { url }) })
312
+ const result = await deepseekFetch(url, apiKey, fetchMaxChars, language, context.signal)
297
313
  tally(result.empty ? "empty" : "fetch")
298
314
  return {
299
315
  content: result.markdown,
@@ -322,10 +338,10 @@ export default Plugin.define({
322
338
  execute: async (input) => {
323
339
  const reset = Boolean((input as { reset?: unknown } | undefined)?.reset)
324
340
  const report = await withLedger(async (ledger) => {
325
- const text = formatUsage(ledger, pricePerThousand, mode)
341
+ const text = formatUsage(ledger, pricePerThousand, mode, language)
326
342
  if (!reset) return text
327
343
  await ctx.storage.set(USAGE_KEY, emptyLedger())
328
- return `${text}\n\n已清零本地统计。`
344
+ return `${text}\n\n${t(language, "usage.reset")}`
329
345
  })
330
346
  return { content: report }
331
347
  },
@@ -351,20 +367,61 @@ export default Plugin.define({
351
367
  additionalProperties: false,
352
368
  },
353
369
  execute: async (input) => {
354
- const action = readModeAction(input)
370
+ const parsed = readModeAction(input)
371
+ if ("invalid" in parsed) throw new Error(t(language, "mode.invalid", { raw: parsed.invalid }))
372
+ const action = parsed.action
355
373
  if (action === "status") return { content: describeMode() }
356
374
  return { content: await switchMode(action) }
357
375
  },
358
376
  })
359
377
  })
360
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
+
361
413
  // ---------- RPC contract (TUI / desktop sidebars) ----------
362
414
  // Both sidebars read `status` and call `setMode`; nothing else crosses the
363
415
  // process boundary, so the plugin's state stays the single source of truth.
364
416
  rpcRegistration = await ctx.rpc.register(DeepSeekRpc, {
365
417
  // Reads through `withLedger` so it observes the same serialized chain.
366
- status: async (): Promise<SearchStatus> =>
367
- 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 => {
368
425
  const todayKey = dayKey()
369
426
  return {
370
427
  mode,
@@ -372,7 +429,8 @@ export default Plugin.define({
372
429
  patched: searchToolPatched,
373
430
  hasKey: Boolean(apiKey),
374
431
  pricePerThousand,
375
- ...usageSummaryLines(ledger, todayKey),
432
+ language,
433
+ ...usageSummaryLines(ledger, language, todayKey),
376
434
  // Same ledger read as the strings above, exposed as numbers so the
377
435
  // sidebars can lay them out for their own width.
378
436
  counts: {
@@ -380,17 +438,20 @@ export default Plugin.define({
380
438
  month: sumDays(ledger, (k) => k.startsWith(todayKey.slice(0, 7))),
381
439
  total: sumDays(ledger, () => true),
382
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,
383
444
  }
384
- }),
445
+ })
446
+ },
385
447
  // The input is plain JSON Schema, hence `unknown`: report, never throw.
386
448
  setMode: async (input: unknown): Promise<ModeSwitchResult> => {
387
- let action: ModeAction
388
- try {
389
- action = readModeAction(input)
390
- } catch (e) {
391
- return { ok: false, mode, message: e instanceof Error ? e.message : String(e) }
449
+ const parsed = readModeAction(input)
450
+ if ("invalid" in parsed) {
451
+ return { ok: false, mode, message: t(language, "mode.invalid", { raw: parsed.invalid }) }
392
452
  }
393
- if (action === "status") return { ok: false, mode, message: "status 只读取当前模式,不做切换。" }
453
+ const action = parsed.action
454
+ if (action === "status") return { ok: false, mode, message: t(language, "mode.statusOnly") }
394
455
  const message = await switchMode(action)
395
456
  // `!pristine` means switchMode could not apply the change; its message says why.
396
457
  return { ok: Boolean(pristine), mode, message }
@@ -403,16 +464,16 @@ export default Plugin.define({
403
464
  `apiKey=${apiKey ? "present" : "MISSING"} count=${count} ` +
404
465
  `searchTool=${searchToolPatched ? "deepseek" : "untouched"}`,
405
466
  )
406
- if (!apiKey) console.error(`[deepseek-search] ${missingKeyMessage()}`)
467
+ if (!apiKey) console.error(`[deepseek-search] ${missingKeyMessage("en")}`)
407
468
  if (overrideMode && overrideMode !== configMode)
408
469
  console.error(
409
- `[deepseek-search] 注意:存在持久化开关 mode=${overrideMode},已覆盖配置文件里的 ${configMode};` +
410
- `调用 deepseek_mode 传 "config" 可清除。`,
470
+ `[deepseek-search] note: a persisted mode=${overrideMode} overrides the config file's ${configMode}; ` +
471
+ `call deepseek_mode with "config" to clear it.`,
411
472
  )
412
473
  if (mode === "deepseek" && !searchToolPatched)
413
474
  console.error(
414
- `[deepseek-search] 警告:未找到内置 ${SEARCH_TOOL} 工具,搜索仍走内置后端。` +
415
- `可在 opencode.jsonc 设置 "websearch": { "provider": "${PROVIDER_ID}" } 强制路由。`,
475
+ `[deepseek-search] warning: built-in ${SEARCH_TOOL} tool not found; search still uses the built-in backend. ` +
476
+ `Set "websearch": { "provider": "${PROVIDER_ID}" } in opencode.jsonc to force the route.`,
416
477
  )
417
478
  },
418
479
  })
package/src/rpc.ts CHANGED
@@ -13,6 +13,17 @@ import type { Rpc } from "@opencode/plugin/rpc"
13
13
 
14
14
  export const DEEPSEEK_RPC_ID = "deepseek-search"
15
15
 
16
+ /**
17
+ * UI languages the plugin ships. The server resolves one of them once and puts
18
+ * it in `status`, so the tools and both sidebars always speak the same language.
19
+ */
20
+ export type Language = "en" | "ja" | "zh-CN"
21
+ export const LANGUAGE_VALUES = ["en", "ja", "zh-CN"] as const
22
+
23
+ export function isLanguage(value: unknown): value is Language {
24
+ return value === "en" || value === "ja" || value === "zh-CN"
25
+ }
26
+
16
27
  // Shared by the today/month/total slots of `status.counts`.
17
28
  const USAGE_COUNTS_SCHEMA = {
18
29
  type: "object",
@@ -27,11 +38,36 @@ const USAGE_COUNTS_SCHEMA = {
27
38
  additionalProperties: false,
28
39
  } as const
29
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
+
30
58
  export const DeepSeekRpc = {
31
59
  id: DEEPSEEK_RPC_ID,
32
60
  methods: {
33
61
  status: {
34
- 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
+ },
35
71
  output: {
36
72
  type: "object",
37
73
  properties: {
@@ -43,6 +79,9 @@ export const DeepSeekRpc = {
43
79
  today: { type: "string" },
44
80
  month: { type: "string" },
45
81
  total: { type: "string" },
82
+ // Declared but not required: older servers omit it, and readStatus
83
+ // falls back to English without failing the rest of the payload.
84
+ language: { type: "string", enum: ["en", "ja", "zh-CN"] },
46
85
  counts: {
47
86
  type: "object",
48
87
  properties: {
@@ -53,6 +92,9 @@ export const DeepSeekRpc = {
53
92
  required: ["today", "month", "total"],
54
93
  additionalProperties: false,
55
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,
56
98
  },
57
99
  required: ["mode", "source", "patched", "hasKey", "pricePerThousand", "today", "month", "total"],
58
100
  additionalProperties: false,
@@ -93,6 +135,24 @@ export interface UsageCounts {
93
135
  costMicro: number
94
136
  }
95
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
+
96
156
  export interface SearchStatus {
97
157
  mode: "deepseek" | "native"
98
158
  source: "override" | "config"
@@ -102,8 +162,16 @@ export interface SearchStatus {
102
162
  today: string
103
163
  month: string
104
164
  total: string
165
+ /**
166
+ * UI language resolved by the server. Required by this interface: `readStatus`
167
+ * always fills it (English when the server is older or sends garbage), so
168
+ * every surface has one value to render with.
169
+ */
170
+ language: Language
105
171
  /** Structured counters, so each surface can format for its own width. Optional for older servers. */
106
172
  counts?: { today: UsageCounts; month: UsageCounts; total: UsageCounts }
173
+ /** Last cached balance probe. Absent until the first successful fetch. */
174
+ balance?: BalanceStatus
107
175
  }
108
176
 
109
177
  export interface ModeSwitchResult {
@@ -131,16 +199,41 @@ function readUsageCounts(value: unknown): UsageCounts | undefined {
131
199
  return { search, fetch, empty, failed, costMicro } as UsageCounts
132
200
  }
133
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
+
134
215
  /** Defensive read of an RPC `status` result. Never throws. */
135
216
  export function readStatus(raw: unknown): SearchStatus | undefined {
136
217
  if (!isRecord(raw)) return undefined
137
- const { mode, source, patched, hasKey, pricePerThousand, today, month, total, counts } = raw
218
+ const { mode, source, patched, hasKey, pricePerThousand, today, month, total, language, counts, balance } = raw
138
219
  if (!isMode(mode)) return undefined
139
220
  if (source !== "override" && source !== "config") return undefined
140
221
  if (typeof patched !== "boolean" || typeof hasKey !== "boolean") return undefined
141
222
  if (typeof pricePerThousand !== "number" || !Number.isFinite(pricePerThousand)) return undefined
142
223
  if (typeof today !== "string" || typeof month !== "string" || typeof total !== "string") return undefined
143
- const status: SearchStatus = { mode, source, patched, hasKey, pricePerThousand, today, month, total }
224
+ const status: SearchStatus = {
225
+ mode,
226
+ source,
227
+ patched,
228
+ hasKey,
229
+ pricePerThousand,
230
+ today,
231
+ month,
232
+ total,
233
+ // An older server has no language; a malformed one is not worth failing the
234
+ // whole payload over. Both fall back to English.
235
+ language: isLanguage(language) ? language : "en",
236
+ }
144
237
  // Old servers omit `counts`; a malformed triple is dropped without failing the rest.
145
238
  if (isRecord(counts)) {
146
239
  const todayCounts = readUsageCounts(counts.today)
@@ -148,6 +241,10 @@ export function readStatus(raw: unknown): SearchStatus | undefined {
148
241
  const totalCounts = readUsageCounts(counts.total)
149
242
  if (todayCounts && monthCounts && totalCounts) status.counts = { today: todayCounts, month: monthCounts, total: totalCounts }
150
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
151
248
  return status
152
249
  }
153
250