pi-multi-quota 0.1.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/src/index.ts ADDED
@@ -0,0 +1,440 @@
1
+ /**
2
+ * pi-multi-quota 扩展入口。
3
+ *
4
+ * 职责:
5
+ * - 订阅 session_start / model_select / session_shutdown,维护 footer 状态
6
+ * - 注册 /quota 命令族(详情 · 全量 · 配置 cookie · 列出账号)
7
+ *
8
+ * 硬性约束(SPEC.md §4.1 刷新时机 · §12 Never):
9
+ * - 定时器**只在 session_start 启动**、session_shutdown 清理(factory 可能在无会话的调用中运行)
10
+ * - 任何输出路径都不得包含 cookie 原文
11
+ * - 额度数据不写入 session、不发送给模型
12
+ */
13
+ import type {
14
+ ExtensionAPI,
15
+ ExtensionCommandContext,
16
+ ExtensionContext,
17
+ } from "@earendil-works/pi-coding-agent";
18
+ import type { AccountReport, SourceId } from "./types.js";
19
+ import {
20
+ findArkAccountById,
21
+ findArkAccountByProvider,
22
+ loadConfig,
23
+ redact,
24
+ saveConfig,
25
+ upsertArkAccount,
26
+ type QuotaConfig,
27
+ } from "./config.js";
28
+ import { describeExpiry, parseArkCookie } from "./cookie.js";
29
+ import {
30
+ createCache,
31
+ dedupe,
32
+ getFresh,
33
+ getLastKnown,
34
+ noteFailure,
35
+ noteSuccess,
36
+ put,
37
+ shouldQuery,
38
+ type QuotaCache,
39
+ } from "./cache.js";
40
+ import { renderDetail, renderFooter } from "./footer.js";
41
+ import { sourceForBaseUrl, sourceLabel, targetsForSource } from "./registry.js";
42
+ import { fetchArkUsage } from "./sources/ark.js";
43
+
44
+ const STATUS_KEY = "multi-quota";
45
+ const WIDGET_KEY = "multi-quota-detail";
46
+ const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
47
+ /** cookie 剩余寿命低于此值时在 footer 挂提醒。 */
48
+ const EXPIRY_WARN_MS = 2 * 60 * 60 * 1000;
49
+
50
+ export default function (pi: ExtensionAPI): void {
51
+ const cache: QuotaCache = createCache();
52
+ let timer: ReturnType<typeof setInterval> | undefined;
53
+ let reports: AccountReport[] = [];
54
+ let currentAccountId: string | undefined;
55
+ let warnedExpiry = false;
56
+
57
+ // ---------------------------------------------------------------- 工具
58
+
59
+ /** 读取配置;损坏时返回空配置并提示,绝不让扩展整体崩掉。 */
60
+ function safeConfig(ctx: ExtensionContext): QuotaConfig | undefined {
61
+ try {
62
+ return loadConfig();
63
+ } catch (err) {
64
+ const message = redact(err instanceof Error ? err.message : String(err));
65
+ ctx.ui.notify(`多额度:配置文件读取失败 —— ${message}`, "error");
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ /** 当前 provider 对应的 Ark 账号 id(多账号时决定 footer 裁剪优先级)。 */
71
+ function resolveCurrentAccountId(config: QuotaConfig, providerId: string | undefined): string | undefined {
72
+ if (!providerId) return undefined;
73
+ return findArkAccountByProvider(config, providerId)?.id;
74
+ }
75
+
76
+ function publish(ctx: ExtensionContext): void {
77
+ if (!ctx.hasUI) return;
78
+ const text = renderFooter(reports, { currentAccountId });
79
+ ctx.ui.setStatus(STATUS_KEY, text.length > 0 ? text : undefined);
80
+ }
81
+
82
+ function clear(ctx: ExtensionContext): void {
83
+ reports = [];
84
+ currentSource = undefined;
85
+ if (ctx.hasUI) {
86
+ ctx.ui.setStatus(STATUS_KEY, undefined);
87
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
88
+ }
89
+ }
90
+
91
+ let currentSource: SourceId | undefined;
92
+
93
+ // ------------------------------------------------------------ 核心刷新
94
+
95
+ /**
96
+ * 刷新当前数据源的全部账号。
97
+ * - 命中新鲜缓存 → 直接用,不打网络
98
+ * - 处于退避窗口 → 用上次已知数据(保留 stale 语义)
99
+ * - 否则查询;失败时保留上次数据并标记 stale
100
+ */
101
+ async function refresh(ctx: ExtensionContext, force: boolean): Promise<AccountReport[]> {
102
+ const model = ctx.model;
103
+ const source = sourceForBaseUrl(model?.baseUrl);
104
+
105
+ if (!model || !source) {
106
+ clear(ctx);
107
+ return [];
108
+ }
109
+
110
+ const config = safeConfig(ctx);
111
+ if (!config) {
112
+ clear(ctx);
113
+ return [];
114
+ }
115
+
116
+ currentSource = source;
117
+ currentAccountId = resolveCurrentAccountId(config, model.provider);
118
+
119
+ const targets = targetsForSource(source, model.provider, {
120
+ auth: { getProviderAuth: (providerId) => ctx.modelRegistry.getProviderAuth(providerId) },
121
+ config,
122
+ });
123
+
124
+ if (targets.length === 0) {
125
+ clear(ctx);
126
+ return [];
127
+ }
128
+
129
+ const settled = await Promise.all(
130
+ targets.map(async (target): Promise<AccountReport> => {
131
+ const fresh = getFresh(cache, target.accountId);
132
+ if (fresh && !force) return fresh;
133
+
134
+ if (!force && !shouldQuery(cache, target.accountId)) {
135
+ const lastKnown = getLastKnown(cache, target.accountId);
136
+ if (lastKnown) return { ...lastKnown, stale: true };
137
+ }
138
+
139
+ try {
140
+ const report = await dedupe(cache, target.accountId, () => target.query());
141
+ if (report.error) {
142
+ noteFailure(cache, target.accountId);
143
+ const lastKnown = getLastKnown(cache, target.accountId);
144
+ return lastKnown ? { ...report, stale: true } : report;
145
+ }
146
+ noteSuccess(cache, target.accountId);
147
+ put(cache, report);
148
+ return report;
149
+ } catch (err) {
150
+ noteFailure(cache, target.accountId);
151
+ // 外部 pi API 的异常文本不在本项目控制内,统一脱敏后再入报告
152
+ const message = redact(err instanceof Error ? err.message : "查询失败");
153
+ const lastKnown = getLastKnown(cache, target.accountId);
154
+ return {
155
+ accountId: target.accountId,
156
+ displayName: target.displayName,
157
+ sourceId: target.sourceId,
158
+ kind: source === "deepseek" ? "balance" : "windows",
159
+ fetchedAt: Date.now(),
160
+ error: { code: "unknown-shape", message },
161
+ ...(lastKnown ? { stale: true, windows: lastKnown.windows, balances: lastKnown.balances } : {}),
162
+ };
163
+ }
164
+ }),
165
+ );
166
+
167
+ reports = settled;
168
+ publish(ctx);
169
+ return settled;
170
+ }
171
+
172
+ /** 检查 Ark cookie 剩余寿命,必要时提醒一次。 */
173
+ function checkExpiry(ctx: ExtensionContext, config: QuotaConfig): void {
174
+ if (!ctx.hasUI) return;
175
+ const soon: string[] = [];
176
+ for (const account of config.ark.accounts) {
177
+ const parsed = parseArkCookie(account.cookie);
178
+ if (!parsed.ok) continue;
179
+ const remaining = parsed.value.expiresInMs;
180
+ if (remaining === undefined) continue;
181
+ if (remaining < EXPIRY_WARN_MS) {
182
+ soon.push(`${account.id}(${describeExpiry(remaining)})`);
183
+ }
184
+ }
185
+ if (soon.length > 0 && !warnedExpiry) {
186
+ warnedExpiry = true;
187
+ ctx.ui.notify(
188
+ `多额度:Ark cookie 即将失效 —— ${soon.join("、")}。用 /quota set <账号> 更新。`,
189
+ "warning",
190
+ );
191
+ }
192
+ }
193
+
194
+ // ---------------------------------------------------------------- 定时器
195
+
196
+ function stopTimer(): void {
197
+ if (timer !== undefined) {
198
+ clearInterval(timer);
199
+ timer = undefined;
200
+ }
201
+ }
202
+
203
+ function startTimer(ctx: ExtensionContext): void {
204
+ stopTimer();
205
+ timer = setInterval(() => {
206
+ void refresh(ctx, false);
207
+ }, REFRESH_INTERVAL_MS);
208
+ // 不阻止进程退出
209
+ if (typeof timer === "object" && timer !== null && "unref" in timer) {
210
+ (timer as { unref(): void }).unref();
211
+ }
212
+ }
213
+
214
+ // ---------------------------------------------------------------- 事件
215
+
216
+ pi.on("session_start", async (_event, ctx) => {
217
+ warnedExpiry = false;
218
+ const config = safeConfig(ctx);
219
+ await refresh(ctx, false);
220
+ if (config) checkExpiry(ctx, config);
221
+ startTimer(ctx);
222
+ });
223
+
224
+ pi.on("model_select", async (_event, ctx) => {
225
+ // 换了模型 → 可能换了数据源,强制刷新一次
226
+ await refresh(ctx, true);
227
+ });
228
+
229
+ pi.on("session_shutdown", () => {
230
+ stopTimer();
231
+ });
232
+
233
+ // ---------------------------------------------------------------- 命令
234
+
235
+ /** 查询指定数据源(供详情命令复用)。 */
236
+ async function collect(ctx: ExtensionCommandContext, source: SourceId): Promise<AccountReport[]> {
237
+ const config = safeConfig(ctx);
238
+ if (!config) return [];
239
+ const providerId = ctx.model?.provider;
240
+ if (!providerId) return [];
241
+
242
+ const targets = targetsForSource(source, providerId, {
243
+ auth: { getProviderAuth: (p) => ctx.modelRegistry.getProviderAuth(p) },
244
+ config,
245
+ });
246
+
247
+ const settled = await Promise.all(
248
+ targets.map(async (target): Promise<AccountReport> => {
249
+ try {
250
+ const report = await dedupe(cache, target.accountId, () => target.query());
251
+ if (!report.error) {
252
+ noteSuccess(cache, target.accountId);
253
+ put(cache, report);
254
+ } else {
255
+ noteFailure(cache, target.accountId);
256
+ }
257
+ return report;
258
+ } catch (err) {
259
+ noteFailure(cache, target.accountId);
260
+ const message = redact(err instanceof Error ? err.message : "查询失败");
261
+ return {
262
+ accountId: target.accountId,
263
+ displayName: target.displayName,
264
+ sourceId: target.sourceId,
265
+ kind: "windows",
266
+ fetchedAt: Date.now(),
267
+ error: { code: "unknown-shape", message },
268
+ };
269
+ }
270
+ }),
271
+ );
272
+ return settled;
273
+ }
274
+
275
+ function show(ctx: ExtensionCommandContext, title: string, lines: string[]): void {
276
+ if (!ctx.hasUI) {
277
+ // 非 TUI:退化为 notify,避免静默无输出
278
+ ctx.ui.notify([title, ...lines].join("\n"), "info");
279
+ return;
280
+ }
281
+ ctx.ui.setWidget(WIDGET_KEY, [title, ...lines, "", "(/quota close 关闭此面板)"]);
282
+ }
283
+
284
+ pi.registerCommand("quota", {
285
+ description: "查看当前供应商额度明细(all=查全部 · set <账号>=更新 cookie · list=账号与有效期 · close=关闭面板)",
286
+ getArgumentCompletions: (prefix: string) => {
287
+ const options = ["all", "set", "list", "close"];
288
+ const hits = options.filter((o) => o.startsWith(prefix));
289
+ return hits.length > 0 ? hits.map((value) => ({ value, label: value })) : null;
290
+ },
291
+ handler: async (args, ctx) => {
292
+ const input = (args ?? "").trim();
293
+ const [head, ...rest] = input.split(/\s+/);
294
+
295
+ if (head === "close") {
296
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
297
+ return;
298
+ }
299
+
300
+ if (head === "list") {
301
+ const config = safeConfig(ctx);
302
+ if (!config) return;
303
+ if (config.ark.accounts.length === 0) {
304
+ show(ctx, "已配置账号:无", [
305
+ "先在 ~/.pi/agent/multi-quota.json 里创建账号槽位(含 provider 字段)",
306
+ "再用 /quota set ark-a 粘贴这个账号的控制台 cookie",
307
+ ]);
308
+ return;
309
+ }
310
+ const lines = config.ark.accounts.map((account) => {
311
+ const parsed = parseArkCookie(account.cookie);
312
+ const life = parsed.ok
313
+ ? parsed.value.expiresInMs !== undefined
314
+ ? describeExpiry(parsed.value.expiresInMs)
315
+ : "无过期信息"
316
+ : `解析失败(${parsed.code})`;
317
+ return ` ${account.id} provider=${account.provider} cookie ${life}`;
318
+ });
319
+ show(ctx, "已配置账号:", lines);
320
+ return;
321
+ }
322
+
323
+ if (head === "set") {
324
+ const accountId = rest[0];
325
+ const config = safeConfig(ctx);
326
+ if (!config) return;
327
+ if (!accountId) {
328
+ show(ctx, "用法:/quota set <账号 id>", [
329
+ "例如 /quota set ark-a",
330
+ `当前已配置:${config.ark.accounts.map((a) => a.id).join("、") || "(无)"}`,
331
+ ]);
332
+ return;
333
+ }
334
+
335
+ const slot = findArkAccountById(config, accountId);
336
+ if (!slot) {
337
+ show(ctx, `未找到账号 ${accountId}`, [
338
+ `当前已配置:${config.ark.accounts.map((a) => a.id).join("、") || "(无)"}`,
339
+ "提示:先在 ~/.pi/agent/multi-quota.json 里创建该账号槽位(含 provider 字段)",
340
+ ]);
341
+ return;
342
+ }
343
+
344
+ const pasted = await ctx.ui.input(`粘贴 ${accountId} 的控制台 cookie:`, "整段 cookie,不是单个值");
345
+ if (pasted === undefined || pasted.trim().length === 0) {
346
+ ctx.ui.notify("已取消,未做任何修改。", "info");
347
+ return;
348
+ }
349
+
350
+ const parsed = parseArkCookie(pasted.trim());
351
+ if (!parsed.ok) {
352
+ const hint =
353
+ parsed.code === "missing-csrf"
354
+ ? "cookie 不完整,请复制整段 cookie(含 csrfToken)"
355
+ : parsed.code === "missing-digest"
356
+ ? "cookie 不完整,请确认已登录控制台后复制整段 cookie"
357
+ : "digest 无法解析,请重新登录控制台后复制整段 cookie";
358
+ ctx.ui.notify(`校验失败:${hint}`, "error");
359
+ return;
360
+ }
361
+
362
+ // 账号防呆:新 cookie 的 AccountID 不能与其它槽位指向同一个火山账号
363
+ const incomingAccount = parsed.value.accountId;
364
+ if (incomingAccount !== undefined) {
365
+ const clash = config.ark.accounts.find((other) => {
366
+ if (other.id === accountId) return false;
367
+ const otherParsed = parseArkCookie(other.cookie);
368
+ return otherParsed.ok && otherParsed.value.accountId === incomingAccount;
369
+ });
370
+ if (clash) {
371
+ ctx.ui.notify(
372
+ `拒绝保存:该 cookie 属于账号 ${incomingAccount},与 ${clash.id} 是同一个火山账号。请确认是否登错了账号。`,
373
+ "error",
374
+ );
375
+ return;
376
+ }
377
+ }
378
+
379
+ // 当场发真实请求校验
380
+ const candidate = { ...slot, cookie: pasted.trim() };
381
+ const result = await fetchArkUsage(candidate);
382
+ if (result.error) {
383
+ ctx.ui.notify(
384
+ `校验失败(${result.error.code}):${result.error.message}。未保存。`,
385
+ "error",
386
+ );
387
+ return;
388
+ }
389
+
390
+ const next = upsertArkAccount(config, candidate);
391
+ try {
392
+ saveConfig(next);
393
+ } catch (err) {
394
+ const message = redact(err instanceof Error ? err.message : String(err));
395
+ ctx.ui.notify(`保存失败:${message}`, "error");
396
+ return;
397
+ }
398
+
399
+ warnedExpiry = false;
400
+ const summary = (result.windows ?? [])
401
+ .map((w) => `${w.level} ${w.percent.toFixed(1)}%`)
402
+ .join(" · ");
403
+ ctx.ui.notify(`${accountId} 已更新${summary.length > 0 ? `:${summary}` : ""}`, "info");
404
+ await refresh(ctx, true);
405
+ return;
406
+ }
407
+
408
+ if (head === "all") {
409
+ const sources: SourceId[] = ["ark", "opencode", "deepseek"];
410
+ const groups = await Promise.all(
411
+ sources.map(async (source) => ({ source, list: await collect(ctx, source) })),
412
+ );
413
+ const lines: string[] = [];
414
+ for (const group of groups) {
415
+ lines.push(`【${sourceLabel(group.source)}】`);
416
+ if (group.list.length === 0) {
417
+ lines.push(" (当前 provider 不匹配此数据源)");
418
+ } else {
419
+ lines.push(...renderDetail(group.list));
420
+ }
421
+ lines.push("");
422
+ }
423
+ show(ctx, "全部供应商额度", lines);
424
+ return;
425
+ }
426
+
427
+ // 默认:当前数据源
428
+ const source = currentSource ?? sourceForBaseUrl(ctx.model?.baseUrl);
429
+ if (!source) {
430
+ show(ctx, "当前模型不属于已支持的供应商", [
431
+ "支持:火山方舟 Ark · OpenCode Go · DeepSeek",
432
+ "用 /quota all 查看全部(需切换到对应 provider)",
433
+ ]);
434
+ return;
435
+ }
436
+ const list = await collect(ctx, source);
437
+ show(ctx, `${sourceLabel(source)} 额度明细`, renderDetail(list));
438
+ },
439
+ });
440
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * 数据源分派层:把「当前模型的 provider + baseUrl」映射到数据源与查询目标。
3
+ *
4
+ * 职责边界:
5
+ * - 只做分派与凭据解析,**不发网络请求**(网络在 sources/*)
6
+ * - 只做组装,**不做缓存**(缓存在 cache.ts,由 index.ts 编排)
7
+ *
8
+ * 为什么按 host 匹配而不是 provider id:本机 provider id 是自定义的
9
+ * (例如 opencode-go-ds),且可能随时改名,而 baseUrl 的 host 是稳定标识。
10
+ */
11
+ import type { AccountReport, SourceId } from "./types.js";
12
+ import type { ArkAccountConfig, QuotaConfig } from "./config.js";
13
+ import { OPENCODE_ACCOUNT_ID, fetchOpenCodeUsage } from "./sources/opencode.js";
14
+ import { DEEPSEEK_ACCOUNT_ID, fetchDeepSeekBalance } from "./sources/deepseek.js";
15
+ import { fetchArkUsage } from "./sources/ark.js";
16
+
17
+ /** host → 数据源映射表。改这里之前先读 SPEC §3.1。 */
18
+ const HOST_TO_SOURCE: ReadonlyArray<readonly [string, SourceId]> = [
19
+ ["ark.cn-beijing.volces.com", "ark"],
20
+ ["opencode.ai", "opencode"],
21
+ ["api.deepseek.com", "deepseek"],
22
+ ];
23
+
24
+ /** 从 baseUrl 判定数据源;无法判定时返回 undefined(调用方据此清空 footer)。 */
25
+ export function sourceForBaseUrl(baseUrl: string | undefined): SourceId | undefined {
26
+ if (!baseUrl) return undefined;
27
+ let host: string;
28
+ try {
29
+ host = new URL(baseUrl).hostname;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ for (const entry of HOST_TO_SOURCE) {
34
+ if (entry[0] === host) return entry[1];
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ /**
40
+ * pi 凭据解析器的最小结构契约。
41
+ * 刻意不 import @earendil-works/pi-ai 的 AuthResult —— 该包不在本项目的
42
+ * node_modules 里(由 pi 运行时提供),结构化类型可以避免依赖它的类型路径。
43
+ */
44
+ export interface ProviderAuthResolver {
45
+ getProviderAuth(providerId: string): Promise<{ auth?: { apiKey?: string } } | undefined>;
46
+ }
47
+
48
+ /** 一个可执行的查询目标。query() 失败时应返回带 error 的报告,而不是抛异常。 */
49
+ export interface QueryTarget {
50
+ accountId: string;
51
+ displayName: string;
52
+ sourceId: SourceId;
53
+ query(): Promise<AccountReport>;
54
+ }
55
+
56
+ export interface ResolveDeps {
57
+ auth: ProviderAuthResolver;
58
+ config: QuotaConfig;
59
+ }
60
+
61
+ /** "ark-a" → "Ark-A";"ark-b2" → "Ark-B2"。 */
62
+ export function arkDisplayName(accountId: string): string {
63
+ const suffix = accountId.replace(/^ark[-_]?/i, "");
64
+ if (suffix.length === 0) return "Ark";
65
+ return `Ark-${suffix.charAt(0).toUpperCase()}${suffix.slice(1)}`;
66
+ }
67
+
68
+ /** 构造「未配置凭据」的报告。用于 config 里存在但 cookie 为空的 Ark 槽位。 */
69
+ function missingCredentialReport(
70
+ accountId: string,
71
+ displayName: string,
72
+ sourceId: SourceId,
73
+ kind: AccountReport["kind"],
74
+ ): AccountReport {
75
+ return {
76
+ accountId,
77
+ displayName,
78
+ sourceId,
79
+ kind,
80
+ fetchedAt: Date.now(),
81
+ error: { code: "missing-credential", message: "未配置" },
82
+ };
83
+ }
84
+
85
+ /**
86
+ * 解析某个数据源下的**全部**查询目标。
87
+ *
88
+ * Ark 会返回配置中的所有账号(这才让「两个账号并排显示」成为可能);
89
+ * OpenCode 与 DeepSeek 各返回一个目标。
90
+ *
91
+ * 凭据解析刻意延迟到 query() 内部执行:这样一次 `/quota all` 不会因为
92
+ * 某个 provider 没登录而在组装阶段就整体失败。
93
+ */
94
+ export function targetsForSource(
95
+ source: SourceId,
96
+ providerId: string,
97
+ deps: ResolveDeps,
98
+ ): QueryTarget[] {
99
+ if (source === "ark") return arkTargets(deps.config);
100
+ if (source === "opencode") return [openCodeTarget(providerId, deps.auth)];
101
+ if (source === "deepseek") return [deepSeekTarget(providerId, deps.auth)];
102
+ return [];
103
+ }
104
+
105
+ function arkTargets(config: QuotaConfig): QueryTarget[] {
106
+ return config.ark.accounts.map((account: ArkAccountConfig) => {
107
+ const displayName = arkDisplayName(account.id);
108
+ return {
109
+ accountId: account.id,
110
+ displayName,
111
+ sourceId: "ark" as const,
112
+ query: (): Promise<AccountReport> => {
113
+ if (account.cookie.trim().length === 0) {
114
+ return Promise.resolve(
115
+ missingCredentialReport(account.id, displayName, "ark", "windows"),
116
+ );
117
+ }
118
+ return fetchArkUsage(account, { fetchImpl: undefined });
119
+ },
120
+ };
121
+ });
122
+ }
123
+
124
+ function openCodeTarget(providerId: string, auth: ProviderAuthResolver): QueryTarget {
125
+ return {
126
+ accountId: OPENCODE_ACCOUNT_ID,
127
+ displayName: "Zen",
128
+ sourceId: "opencode",
129
+ query: async (): Promise<AccountReport> => {
130
+ const resolved = await auth.getProviderAuth(providerId);
131
+ const apiKey = resolved?.auth?.apiKey;
132
+ if (typeof apiKey !== "string" || apiKey.length === 0) {
133
+ return missingCredentialReport(OPENCODE_ACCOUNT_ID, "Zen", "opencode", "windows");
134
+ }
135
+ return fetchOpenCodeUsage(apiKey);
136
+ },
137
+ };
138
+ }
139
+
140
+ function deepSeekTarget(providerId: string, auth: ProviderAuthResolver): QueryTarget {
141
+ return {
142
+ accountId: DEEPSEEK_ACCOUNT_ID,
143
+ displayName: "DeepSeek",
144
+ sourceId: "deepseek",
145
+ query: async (): Promise<AccountReport> => {
146
+ const resolved = await auth.getProviderAuth(providerId);
147
+ const apiKey = resolved?.auth?.apiKey;
148
+ if (typeof apiKey !== "string" || apiKey.length === 0) {
149
+ return missingCredentialReport(DEEPSEEK_ACCOUNT_ID, "DeepSeek", "deepseek", "balance");
150
+ }
151
+ return fetchDeepSeekBalance(apiKey);
152
+ },
153
+ };
154
+ }
155
+
156
+ /** 数据源的中文展示名(详情视图用)。 */
157
+ export function sourceLabel(source: SourceId): string {
158
+ if (source === "ark") return "火山方舟 Ark";
159
+ if (source === "opencode") return "OpenCode Go";
160
+ return "DeepSeek";
161
+ }