@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/CHANGELOG.md +45 -0
- package/README.md +43 -37
- package/gui-experimental.cjs +1 -1
- package/package.json +86 -85
- package/src/deepseek.ts +146 -51
- package/src/gui/index.tsx +27 -14
- package/src/gui/panel.tsx +91 -20
- package/src/i18n/en.ts +170 -0
- package/src/i18n/index.ts +89 -0
- package/src/i18n/ja.ts +147 -0
- package/src/i18n/zh.ts +143 -0
- package/src/index.ts +106 -45
- package/src/rpc.ts +100 -3
- package/src/status-view.ts +189 -66
- package/src/tui/card.ts +75 -9
- package/src/tui/main.ts +29 -13
- package/src/usage.ts +32 -9
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
?
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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]
|
|
410
|
-
|
|
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]
|
|
415
|
-
|
|
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: {
|
|
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 = {
|
|
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
|
|