dsh-ds-balance 2.3.0 → 3.0.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.
Files changed (48) hide show
  1. package/README.md +84 -177
  2. package/README_zh.md +140 -0
  3. package/lib/adapters/domain-core-store.d.ts +1 -1
  4. package/lib/adapters/domain-core-store.js +1 -1
  5. package/lib/adapters/host-account.d.ts +22 -1
  6. package/lib/adapters/host-account.js +13 -0
  7. package/lib/adapters/salt-file.d.ts +1 -1
  8. package/lib/adapters/salt-file.js +1 -1
  9. package/lib/client.js +619 -461
  10. package/lib/config.d.ts +28 -19
  11. package/lib/config.js +27 -19
  12. package/lib/domain/credential.d.ts +41 -0
  13. package/lib/domain/credential.js +52 -0
  14. package/lib/http/handlers.d.ts +34 -3
  15. package/lib/http/handlers.js +125 -32
  16. package/lib/http/routes.d.ts +6 -2
  17. package/lib/http/routes.js +3 -2
  18. package/lib/http/wire.d.ts +59 -2
  19. package/lib/http/wire.js +4 -2
  20. package/lib/index.d.ts +1 -1
  21. package/lib/index.js +35 -6
  22. package/lib/ports/account.d.ts +25 -0
  23. package/lib/ports/core-store.d.ts +1 -1
  24. package/lib/ports/core-store.js +1 -1
  25. package/lib/services/key-resolver.d.ts +33 -3
  26. package/lib/services/key-resolver.js +41 -12
  27. package/lib/services/source-readers.js +3 -3
  28. package/lib/services/source-selector.d.ts +10 -0
  29. package/lib/services/source-selector.js +14 -4
  30. package/lib/services/sources-service.d.ts +148 -0
  31. package/lib/services/sources-service.js +209 -0
  32. package/lib/types/client/api-types.d.ts +8 -0
  33. package/lib/types/client/data.d.ts +72 -0
  34. package/lib/types/client/icons/currency.d.ts +26 -0
  35. package/lib/types/client/icons.d.ts +92 -0
  36. package/lib/types/client/locales.d.ts +15 -13
  37. package/lib/types/client/model.d.ts +10 -0
  38. package/lib/types/client/settings/AccountAvatar.d.ts +27 -0
  39. package/lib/types/client/settings/BalanceSettingsCard.d.ts +8 -1
  40. package/lib/types/client/settings/fields.d.ts +70 -39
  41. package/lib/types/client/settings/source-status.d.ts +43 -0
  42. package/lib/types/client/settings/use-config-form.d.ts +1 -27
  43. package/lib/types/client/settings/use-sources.d.ts +38 -0
  44. package/lib/version.d.ts +1 -1
  45. package/lib/version.js +1 -1
  46. package/package.json +32 -32
  47. package/README_en.md +0 -237
  48. package/lib/types/client/settings/use-credential-state.d.ts +0 -36
@@ -4,13 +4,13 @@
4
4
  * 三层职责分得很开:本文件只管**注册**,`handlers.ts` 管**怎么做**,
5
5
  * `wire.ts` 管**长什么样**。注册需要 `ctx`,所以只有本文件碰宿主。
6
6
  *
7
- * 契约要点(docs/backend-architecture.md §3.3 / §8.2):
7
+ * 契约要点(docs/BACKEND-CONTRACTS.md §3.3 / §8.2):
8
8
  * - `path` 写死精确值、不带尾随斜杠(实现是 Map 精确键匹配);
9
9
  * - `requestBody` 必填;
10
10
  * - 物理载体**已先做完信任与浏览器鉴权**,handler 不必再判一次。
11
11
  * @module dsh-ds-balance/http/routes
12
12
  */
13
- import { handleBalance, handleConfigGet, handleConfigUpdate, handleHealthz, handleRefresh, handleTestConnection, } from './handlers.js';
13
+ import { handleBalance, handleConfigGet, handleConfigUpdate, handleHealthz, handleRefresh, handleSources, handleTestConnection, } from './handlers.js';
14
14
  /**
15
15
  * `/api/v1/config` 的方法分发。
16
16
  *
@@ -35,6 +35,7 @@ export const HTTP_ROUTES = [
35
35
  { path: '/api/v1/balance', methods: ['GET'], handle: handleBalance },
36
36
  { path: '/api/v1/balance/refresh', methods: ['POST'], handle: handleRefresh },
37
37
  { path: '/api/v1/config', methods: ['GET', 'POST'], handle: handleConfigRoute },
38
+ { path: '/api/v1/sources', methods: ['GET'], handle: handleSources },
38
39
  { path: '/api/v1/test-connection', methods: ['POST'], handle: handleTestConnection },
39
40
  { path: '/api/v1/healthz', methods: ['GET'], handle: handleHealthz },
40
41
  ];
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 线上形状与序列化。
3
3
  *
4
- * 契约见 docs/backend-architecture.md §8:**金额一律八位小数字符串**,
4
+ * 契约见 docs/BACKEND-CONTRACTS.md §8:**金额一律八位小数字符串**,
5
5
  * `bigint` 最小单位只活在进程内。本模块是纯函数,不碰 `ctx`、不发请求。
6
6
  * @module dsh-ds-balance/http/wire
7
7
  */
@@ -43,12 +43,68 @@ export interface WireCredentialInfo {
43
43
  source: string | null;
44
44
  writable: boolean;
45
45
  }
46
+ /**
47
+ * 一条取数途径的只读状态。
48
+ *
49
+ * **这是「连接」组唯一的事实出口**:界面不再自己拼四档判据,也不再跨端点拼两半。
50
+ * 判据住在后端(它本来就持有 `credentials` 与 `deepseekAccount`),客户端只渲染。
51
+ *
52
+ * `masked` 是**首尾片段掩码**(`sk-cf08d*****3b84`),与平台控制台
53
+ * `platform.deepseek.com/api_keys` 同形 —— **完整密钥永不跨这条线**,
54
+ * 后端解析出明文后立刻算掩码并丢掉明文。见 [AGENTS.md](AGENTS.md) 的红线。
55
+ */
56
+ export interface WireKeySource {
57
+ /** 生效的凭据引用名。 */
58
+ ref: string;
59
+ /** 解析链此刻能不能拿到值(`credentials` 或环境变量任一有)。 */
60
+ configured: boolean;
61
+ /** 生效值的来源层:`file` / `env` / `project-env` / `user-env`;未知为 `null`。 */
62
+ source: string | null;
63
+ /** 首尾片段掩码;未配置或拿不到明文时是空串。 */
64
+ masked: string;
65
+ /** 账本标识前 8 位(`HMAC(salt, key)`);未配置时是空串。 */
66
+ tag8: string;
67
+ }
68
+ /** 账号登录那条途径的只读状态。 */
69
+ export interface WireAccountSource {
70
+ /** 宿主是否装了账号接缝。缺席时整条途径不存在,界面据此显示「不可用」而不是消失。 */
71
+ available: boolean;
72
+ /** 本地存着登录凭据(存储态,不代表服务端验过)。 */
73
+ signedIn: boolean;
74
+ /** 账号显示名;读不到时为 `null`。 */
75
+ name: string | null;
76
+ /** 头像 URL;没有头像时为 `null`。 */
77
+ avatarUrl: string | null;
78
+ /** 服务端已脱敏的联系方式;读不到时为 `null`。 */
79
+ contact: string | null;
80
+ }
81
+ /**
82
+ * `GET /api/v1/sources` 的响应体。
83
+ *
84
+ * `revision` 是**凭据世代**:凭据库变了、或账号登录态变了,它就 +1。
85
+ * 界面拿它当「有没有东西换了」的判据 —— 不必把密钥本身放进 React 的依赖串。
86
+ */
87
+ export interface WireSourcesResponse {
88
+ requestId: string;
89
+ schemaVersion: number;
90
+ revision: number;
91
+ key: WireKeySource;
92
+ account: WireAccountSource;
93
+ }
46
94
  /** `GET /api/v1/balance` 的响应体。 */
47
95
  export interface WireBalanceResponse {
48
96
  requestId: string;
49
97
  schemaVersion: number;
50
98
  /** 这一份数字是哪条取数路给的;界面据此在标题里标来源。 */
51
99
  source: BalanceSource;
100
+ /**
101
+ * **这条路上用的凭据是哪一份**:掩码 key(首尾片段、不可逆)或账号名。
102
+ *
103
+ * 它跟着数字一起走,而不是让界面另外读一次 `/api/v1/sources` —— 两个缓存各自刷新时
104
+ * 标签会先跳、数字还停在旧的,那就是**标错来源**。读不到时 `null`,
105
+ * 界面退回「(API Key)」那种路名标签。
106
+ */
107
+ sourceIdentity: string | null;
52
108
  /**
53
109
  * 处境:界面**唯一**的分支入口。
54
110
  *
@@ -86,8 +142,9 @@ export declare function toWireError(info: ErrorInfo): WireError;
86
142
  * - 没有快照时 `isAvailable` 记 `false`。
87
143
  * @param view - 服务层给出的视图。
88
144
  * @param accountTag8 - 当前账本标识前 8 位;没有快照时传空串。
145
+ * @param sourceIdentity - 这条路上那份凭据的身份(掩码 key / 账号名);读不到传 null。
89
146
  * @param requestId - 本次请求的标识。
90
147
  * @returns 契约 §8.3 的响应体。
91
148
  */
92
- export declare function toWireBalanceView(view: BalanceView, accountTag8: string, requestId: string): WireBalanceResponse;
149
+ export declare function toWireBalanceView(view: BalanceView, accountTag8: string, sourceIdentity: string | null, requestId: string): WireBalanceResponse;
93
150
  //# sourceMappingURL=wire.d.ts.map
package/lib/http/wire.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 线上形状与序列化。
3
3
  *
4
- * 契约见 docs/backend-architecture.md §8:**金额一律八位小数字符串**,
4
+ * 契约见 docs/BACKEND-CONTRACTS.md §8:**金额一律八位小数字符串**,
5
5
  * `bigint` 最小单位只活在进程内。本模块是纯函数,不碰 `ctx`、不发请求。
6
6
  * @module dsh-ds-balance/http/wire
7
7
  */
@@ -33,10 +33,11 @@ function thresholdPair(pair) {
33
33
  * - 没有快照时 `isAvailable` 记 `false`。
34
34
  * @param view - 服务层给出的视图。
35
35
  * @param accountTag8 - 当前账本标识前 8 位;没有快照时传空串。
36
+ * @param sourceIdentity - 这条路上那份凭据的身份(掩码 key / 账号名);读不到传 null。
36
37
  * @param requestId - 本次请求的标识。
37
38
  * @returns 契约 §8.3 的响应体。
38
39
  */
39
- export function toWireBalanceView(view, accountTag8, requestId) {
40
+ export function toWireBalanceView(view, accountTag8, sourceIdentity, requestId) {
40
41
  const thresholds = {};
41
42
  for (const [currency, pair] of Object.entries(view.thresholds)) {
42
43
  thresholds[currency] = thresholdPair(pair);
@@ -45,6 +46,7 @@ export function toWireBalanceView(view, accountTag8, requestId) {
45
46
  requestId,
46
47
  schemaVersion: SCHEMA_VERSION,
47
48
  source: view.source,
49
+ sourceIdentity,
48
50
  situation: view.situation,
49
51
  state: view.state,
50
52
  stale: view.stale,
package/lib/index.d.ts CHANGED
@@ -31,7 +31,7 @@ export declare const SALT_FILE_NAME = ".salt";
31
31
  *
32
32
  * 异步是因为盐要从磁盘读;读失败会降级,不阻断挂载。
33
33
  *
34
- * `refs` 是**引用面**不是值:schema 里 11 个字段全是 `.volatile()` 的,Loader 把
34
+ * `refs` 是**引用面**不是值:schema 里 9 个字段全是 `.volatile()` 的,Loader 把
35
35
  * 值提交进这些引用而不重挂本插件,所以 `apply` 只跑一次,读值一律走
36
36
  * `createConfigSource` 的现读路径。
37
37
  * @param ctx - 宿主上下文。
package/lib/index.js CHANGED
@@ -22,6 +22,7 @@ import { BalanceService } from './services/balance-service.js';
22
22
  import { ConfigService } from './services/config-service.js';
23
23
  import { KeyResolver } from './services/key-resolver.js';
24
24
  import { Scheduler } from './services/scheduler.js';
25
+ import { SourcesService } from './services/sources-service.js';
25
26
  import { createAccountSource } from './adapters/host-account.js';
26
27
  import { SourceLedger } from './services/balance-source.js';
27
28
  import { accountReader, keyReader } from './services/source-readers.js';
@@ -49,8 +50,11 @@ export const SALT_FILE_NAME = '.salt';
49
50
  * **阈值与展示币种不在里面**:它们不改变上游取数节奏,而 severity 是后端按缓存快照
50
51
  * 现算的。把它们也算进去,用户改一次阈值就会顺带打一次官方接口 ——
51
52
  * 界面只要重新读一次缓存就够了。
53
+ *
54
+ * **`apiKey` 已随字段一起删(3.0)**:它曾是「用户显式覆盖密钥」的逃生舱,
55
+ * 现在解析链只剩引用名与环境变量,所以改引用名才是需要重排的那件事。
52
56
  */
53
- const SCHEDULE_FIELDS = ['serverRefreshSeconds', 'baseUrl', 'apiKey', 'apiKeyRef'];
57
+ const SCHEDULE_FIELDS = ['serverRefreshSeconds', 'apiKeyRef'];
54
58
  /** 系统时钟。 */
55
59
  const systemClock = {
56
60
  now: () => Date.now(),
@@ -268,7 +272,7 @@ function connectRoute(ctx) {
268
272
  *
269
273
  * 异步是因为盐要从磁盘读;读失败会降级,不阻断挂载。
270
274
  *
271
- * `refs` 是**引用面**不是值:schema 里 11 个字段全是 `.volatile()` 的,Loader 把
275
+ * `refs` 是**引用面**不是值:schema 里 9 个字段全是 `.volatile()` 的,Loader 把
272
276
  * 值提交进这些引用而不重挂本插件,所以 `apply` 只跑一次,读值一律走
273
277
  * `createConfigSource` 的现读路径。
274
278
  * @param ctx - 宿主上下文。
@@ -281,10 +285,7 @@ export async function apply(ctx, refs) {
281
285
  // 端口只建一次:KeyResolver 与 HTTP 端点读的是同一份「可不可写」的事实。
282
286
  const credentials = credentialsPort(ctx);
283
287
  const keys = new KeyResolver({
284
- readConfig: () => {
285
- const current = configService.current();
286
- return { apiKey: current.apiKey, apiKeyRef: current.apiKeyRef };
287
- },
288
+ readConfig: () => ({ apiKeyRef: configService.current().apiKeyRef }),
288
289
  credentials,
289
290
  logger,
290
291
  });
@@ -328,6 +329,14 @@ export async function apply(ctx, refs) {
328
329
  logger,
329
330
  metrics,
330
331
  });
332
+ // 两条取数途径的状态:界面「连接」组的唯一事实出口。
333
+ const sources = new SourcesService({
334
+ keys,
335
+ credentials,
336
+ account: () => account.current(),
337
+ salt,
338
+ logger,
339
+ });
331
340
  const scheduler = new Scheduler({ target: service, logger });
332
341
  /**
333
342
  * 可选服务到位时把来源判据重定一次。
@@ -346,9 +355,26 @@ export async function apply(ctx, refs) {
346
355
  // 所以改阈值、改展示币种都不会顺带打一次官方接口。
347
356
  const stopWatching = ctx.on('loader/volatile-update', (paths) => {
348
357
  if (paths.some((path) => path.length > 0 && SCHEDULE_FIELDS.some((field) => field === path[0]))) {
358
+ // 引用名改了 = 换了另一份凭据:两条途径的状态要重读,缓存也失效。
359
+ sources.invalidate();
349
360
  scheduler.reset();
350
361
  }
351
362
  });
363
+ /**
364
+ * 凭据库变了就作废状态缓存。
365
+ *
366
+ * **这是对 [2026-09-30 那条决策](../../.agents/notes/2026-09-30-credential-change-refresh-boundary.md)
367
+ * 的有意反转**,理由写在新记录里:那一轮「不订阅」是因为订阅只买到「把等一个周期压到秒级」,
368
+ * 而现在界面要**展示**凭据状态 —— 不订阅的话,在官方模型页换了 key 之后,
369
+ * 卡片会一直显示旧的那把(同一个引用名,值换了),那是**说假话**,不是「慢一点」。
370
+ *
371
+ * 只作废缓存,不重排调度:取数节奏仍由 `SCHEDULE_FIELDS` 与轮询决定,
372
+ * 免得同一引用名重写成同一个值也白抓一次上游。
373
+ */
374
+ const stopCredentialWatch = ctx.on('credentials/reference-updated', (ref) => {
375
+ if (ref === sources.keyRef())
376
+ sources.invalidate();
377
+ });
352
378
  let stopped = false;
353
379
  // 先恢复落盘快照再起调度,否则首拉之前的窗口里界面会闪一次空态。
354
380
  void service.restore().then(() => {
@@ -358,6 +384,7 @@ export async function apply(ctx, refs) {
358
384
  return () => {
359
385
  stopped = true;
360
386
  stopWatching();
387
+ stopCredentialWatch();
361
388
  scheduler.stop();
362
389
  // 句柄由调用方关闭:facility 不绑消费方 fiber(契约 §10.0)。
363
390
  void store.close();
@@ -375,6 +402,8 @@ export async function apply(ctx, refs) {
375
402
  logger,
376
403
  metrics,
377
404
  credentials,
405
+ sources,
406
+ account: () => account.current(),
378
407
  });
379
408
  return () => {
380
409
  void disposeRoutes();
@@ -6,6 +6,23 @@
6
6
  * @module dsh-ds-balance/ports/account
7
7
  */
8
8
  import type { AccountBalance } from '../domain/account.js';
9
+ /**
10
+ * 账号的显示身份。
11
+ *
12
+ * **只有账号那条路有身份**:API Key 是一串没有身份的字符串,官方 `AccountProfile`
13
+ * 也只存在于账号侧。所以「连接」组的两行不是对称的 —— 账号那行有头像与名字,
14
+ * Key 那行只有引用名与掩码。
15
+ *
16
+ * `contact` 由**服务端脱敏**后给出(如 `138****5678`),本插件不再加工。
17
+ */
18
+ export interface AccountIdentity {
19
+ /** 显示名;平台没给时为 `null`。 */
20
+ readonly name: string | null;
21
+ /** 头像 URL;没有头像时为 `null`。 */
22
+ readonly avatarUrl: string | null;
23
+ /** 已脱敏的联系方式;没有时为 `null`。 */
24
+ readonly contact: string | null;
25
+ }
9
26
  /** 账号来源的只读面。没有写入口:余额只是读。 */
10
27
  export interface AccountSource {
11
28
  /** 本地存着登录凭据(宿主原文:存储态,不代表服务端验过)。 */
@@ -16,6 +33,14 @@ export interface AccountSource {
16
33
  * 拿不到时回 `null`(宿主本次进程还没读过 profile),调用方按「未知账号」处理。
17
34
  */
18
35
  accountId(): Promise<string | null>;
36
+ /**
37
+ * 读一次账号身份(名字 / 头像 / 脱敏联系方式)。
38
+ *
39
+ * **这是一次真上游请求**(宿主 `getProfile` 每次都打 Platform,只有上一次失败时才用
40
+ * 同一 grant 的缓存兜底),所以调用方必须缓存它,不许每次渲染都问。
41
+ * 未登录或资料读不到时回 `null`。
42
+ */
43
+ profile(): Promise<AccountIdentity | null>;
19
44
  /**
20
45
  * 读一次钱包。
21
46
  *
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 快照存储端口。实现走 dsh 官方存储接缝,见 docs/backend-architecture.md §10。
2
+ * 快照存储端口。实现走 dsh 官方存储接缝,见 docs/BACKEND-CONTRACTS.md §10。
3
3
  * @module dsh-ds-balance/ports/core-store
4
4
  */
5
5
  import type { BalanceSnapshot } from '../domain/balance.js';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 快照存储端口。实现走 dsh 官方存储接缝,见 docs/backend-architecture.md §10。
2
+ * 快照存储端口。实现走 dsh 官方存储接缝,见 docs/BACKEND-CONTRACTS.md §10。
3
3
  * @module dsh-ds-balance/ports/core-store
4
4
  */
5
5
  export {};
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * 密钥解析。
3
3
  *
4
- * 解析链(优先级从高到低):配置 `apiKey` → `credentials.resolve(apiKeyRef)` →
4
+ * 解析链(优先级从高到低):`credentials.resolve(apiKeyRef)` →
5
5
  * `process.env[apiKeyRef]` → 抛 {@link NoKeyError}。
6
6
  *
7
+ * **第一档「配置 `apiKey`」已删(3.0)**:它让手改 `settings.yaml` 的人可以静默压过
8
+ * 官方那份凭据,而界面读的 `credentials.describe()` 看不见它 —— 于是「凭据来源」展示
9
+ * 会说假话。删掉后这条链就是「跟随官方」的字面实现。见
10
+ * [决策记录](../../.agents/notes/2026-10-15-drop-apikey-field.md)。
11
+ *
7
12
  * **无 credentials seam 时不抛错**:吞掉后继续往下走,让 env 层有机会接手。
8
13
  * @module dsh-ds-balance/services/key-resolver
9
14
  */
@@ -11,7 +16,6 @@ import type { Credentials } from '../ports/credentials.js';
11
16
  import type { Logger } from '../ports/logger.js';
12
17
  /** 每次解析时现读的配置切片。 */
13
18
  export interface KeyResolverConfig {
14
- apiKey: string;
15
19
  apiKeyRef: string;
16
20
  }
17
21
  /** 构造参数。 */
@@ -28,12 +32,38 @@ export interface KeyResolverOptions {
28
32
  }
29
33
  /** 契约要求的引用名形状。 */
30
34
  export declare const CREDENTIAL_REF_PATTERN: RegExp;
35
+ /** 环境变量那一档在 `describe()` 口径下的来源名(官方也用 `env`)。 */
36
+ export declare const ENV_SOURCE = "env";
37
+ /** 解析结果:明文值,以及它**从哪一档来的**。 */
38
+ export interface ResolvedKey {
39
+ /** 明文密钥。**调用方负责不把它写进日志或消息。** */
40
+ readonly value: string;
41
+ /** 来源层:凭据服务给的那个(`file` / `env` / …),或 `env`(我们自己的环境变量那一档)。 */
42
+ readonly source: string;
43
+ }
31
44
  /** 解析当前可用的明文密钥。 */
32
45
  export declare class KeyResolver {
33
46
  private readonly options;
34
47
  constructor(options: KeyResolverOptions);
35
48
  /**
36
- * 走完整条解析链。
49
+ * 现读生效的凭据引用名(已 trim)。
50
+ *
51
+ * 「连接」组的 Key 那行要显示它,所以读引用名也走这里 —— 免得展示路径另读一份配置。
52
+ * @returns 引用名;配置读不到时回空串。
53
+ */
54
+ describeRef(): string;
55
+ /**
56
+ * 走完整条解析链,并**一并回报值是从哪一档来的**。
57
+ *
58
+ * 为什么要回报来源:界面要展示「这把 key 是已保存的凭据还是环境变量」。
59
+ * 判据只能有一份 —— 就是这条链本身。让消费方自己再读一遍环境变量是**第二份判据**,
60
+ * 两者迟早会漂(本仓有一条注释说得很清楚:判据不许另抄一份)。
61
+ * @returns 明文值与来源层。
62
+ * @throws {NoKeyError} 整条链都没取到值。
63
+ */
64
+ resolveWithSource(): Promise<ResolvedKey>;
65
+ /**
66
+ * 走完整条解析链,只要值。
37
67
  * @returns 明文密钥。**调用方负责不把它写进日志或消息。**
38
68
  * @throws {NoKeyError} 整条链都没取到值。
39
69
  */
@@ -1,15 +1,22 @@
1
1
  /**
2
2
  * 密钥解析。
3
3
  *
4
- * 解析链(优先级从高到低):配置 `apiKey` → `credentials.resolve(apiKeyRef)` →
4
+ * 解析链(优先级从高到低):`credentials.resolve(apiKeyRef)` →
5
5
  * `process.env[apiKeyRef]` → 抛 {@link NoKeyError}。
6
6
  *
7
+ * **第一档「配置 `apiKey`」已删(3.0)**:它让手改 `settings.yaml` 的人可以静默压过
8
+ * 官方那份凭据,而界面读的 `credentials.describe()` 看不见它 —— 于是「凭据来源」展示
9
+ * 会说假话。删掉后这条链就是「跟随官方」的字面实现。见
10
+ * [决策记录](../../.agents/notes/2026-10-15-drop-apikey-field.md)。
11
+ *
7
12
  * **无 credentials seam 时不抛错**:吞掉后继续往下走,让 env 层有机会接手。
8
13
  * @module dsh-ds-balance/services/key-resolver
9
14
  */
10
15
  import { NoKeyError, describeError } from '../domain/errors.js';
11
16
  /** 契约要求的引用名形状。 */
12
17
  export const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
18
+ /** 环境变量那一档在 `describe()` 口径下的来源名(官方也用 `env`)。 */
19
+ export const ENV_SOURCE = 'env';
13
20
  /** 解析当前可用的明文密钥。 */
14
21
  export class KeyResolver {
15
22
  options;
@@ -17,18 +24,32 @@ export class KeyResolver {
17
24
  this.options = options;
18
25
  }
19
26
  /**
20
- * 走完整条解析链。
21
- * @returns 明文密钥。**调用方负责不把它写进日志或消息。**
27
+ * 现读生效的凭据引用名(已 trim)。
28
+ *
29
+ * 「连接」组的 Key 那行要显示它,所以读引用名也走这里 —— 免得展示路径另读一份配置。
30
+ * @returns 引用名;配置读不到时回空串。
31
+ */
32
+ describeRef() {
33
+ try {
34
+ return this.options.readConfig().apiKeyRef.trim();
35
+ }
36
+ catch {
37
+ return '';
38
+ }
39
+ }
40
+ /**
41
+ * 走完整条解析链,并**一并回报值是从哪一档来的**。
42
+ *
43
+ * 为什么要回报来源:界面要展示「这把 key 是已保存的凭据还是环境变量」。
44
+ * 判据只能有一份 —— 就是这条链本身。让消费方自己再读一遍环境变量是**第二份判据**,
45
+ * 两者迟早会漂(本仓有一条注释说得很清楚:判据不许另抄一份)。
46
+ * @returns 明文值与来源层。
22
47
  * @throws {NoKeyError} 整条链都没取到值。
23
48
  */
24
- async resolve() {
25
- const config = this.options.readConfig();
26
- const override = config.apiKey.trim();
27
- if (override !== '')
28
- return override;
29
- const ref = config.apiKeyRef.trim();
49
+ async resolveWithSource() {
50
+ const ref = this.describeRef();
30
51
  if (ref === '')
31
- throw new NoKeyError('no API key: both apiKey and apiKeyRef are empty');
52
+ throw new NoKeyError('no API key: apiKeyRef is empty');
32
53
  const isValid = this.options.isValidRef ?? ((value) => CREDENTIAL_REF_PATTERN.test(value));
33
54
  if (!isValid(ref)) {
34
55
  throw new NoKeyError(`no API key: apiKeyRef ${JSON.stringify(ref)} is not a valid credential reference`);
@@ -38,7 +59,7 @@ export class KeyResolver {
38
59
  const resolved = await this.options.credentials.resolve(ref);
39
60
  const value = (resolved?.value ?? '').trim();
40
61
  if (value !== '')
41
- return value;
62
+ return { value, source: resolved.source ?? 'credentials' };
42
63
  }
43
64
  catch (error) {
44
65
  // 没有 seam、引用名不存在、远程拒绝 —— 都只是「这一档没取到」,继续往下走。
@@ -51,8 +72,16 @@ export class KeyResolver {
51
72
  const env = this.options.env ?? process.env;
52
73
  const fromEnv = (env[ref] ?? '').trim();
53
74
  if (fromEnv !== '')
54
- return fromEnv;
75
+ return { value: fromEnv, source: ENV_SOURCE };
55
76
  throw new NoKeyError(`no API key: ${ref} is not configured`);
56
77
  }
78
+ /**
79
+ * 走完整条解析链,只要值。
80
+ * @returns 明文密钥。**调用方负责不把它写进日志或消息。**
81
+ * @throws {NoKeyError} 整条链都没取到值。
82
+ */
83
+ async resolve() {
84
+ return (await this.resolveWithSource()).value;
85
+ }
57
86
  }
58
87
  //# sourceMappingURL=key-resolver.js.map
@@ -5,7 +5,7 @@
5
5
  * 三个方法都是**本地读**,只有 `read()` 会打上游。
6
6
  * @module dsh-ds-balance/services/source-readers
7
7
  */
8
- import { endpointOf } from '../config.js';
8
+ import { ENDPOINT } from '../config.js';
9
9
  import { projectAccountBalance } from '../domain/account.js';
10
10
  import { NoKeyError } from '../domain/errors.js';
11
11
  import { computeAccountTag } from './account-tag.js';
@@ -28,9 +28,9 @@ export function keyReader(options) {
28
28
  },
29
29
  async read() {
30
30
  const apiKey = await keys.resolve();
31
- const current = config.current();
32
31
  const raw = await client.fetchBalance({
33
- baseUrl: endpointOf(current),
32
+ // 端点固定为官方根地址(3.0 起不再可配),见 config.ts 的 ENDPOINT。
33
+ baseUrl: ENDPOINT,
34
34
  apiKey,
35
35
  timeoutMs: config.timeoutMs(),
36
36
  });
@@ -19,6 +19,16 @@ import type { BalanceSource } from '../domain/balance.js';
19
19
  export declare const KEY_PROVIDER = "deepseek-official";
20
20
  /** 账号登录那条路的宿主 provider id。 */
21
21
  export declare const ACCOUNT_PROVIDER = "deepseek-account";
22
+ /**
23
+ * 两条取数路的 id。
24
+ *
25
+ * 它们是**闭集的两个成员**({@link BalanceSource}),所以给名字、不再到处写裸字面量:
26
+ * 「这条路的凭据是谁」那条判据([sources-service.ts](sources-service.ts) 的 `identityOf`)
27
+ * 与回落顺序都要按名字比对,散着写迟早有一处漏改。
28
+ */
29
+ export declare const HTTP_SOURCE: BalanceSource;
30
+ /** @see HTTP_SOURCE */
31
+ export declare const ACCOUNT_SOURCE: BalanceSource;
22
32
  /**
23
33
  * 路由都指不出该走哪条时的固定顺序:**排在前的先被选中**。
24
34
  *
@@ -18,12 +18,22 @@
18
18
  export const KEY_PROVIDER = 'deepseek-official';
19
19
  /** 账号登录那条路的宿主 provider id。 */
20
20
  export const ACCOUNT_PROVIDER = 'deepseek-account';
21
+ /**
22
+ * 两条取数路的 id。
23
+ *
24
+ * 它们是**闭集的两个成员**({@link BalanceSource}),所以给名字、不再到处写裸字面量:
25
+ * 「这条路的凭据是谁」那条判据([sources-service.ts](sources-service.ts) 的 `identityOf`)
26
+ * 与回落顺序都要按名字比对,散着写迟早有一处漏改。
27
+ */
28
+ export const HTTP_SOURCE = 'deepseek-http';
29
+ /** @see HTTP_SOURCE */
30
+ export const ACCOUNT_SOURCE = 'deepseek-account';
21
31
  /** 宿主 provider id → 本插件的取数来源。**认得的路只有这里列出的**。 */
22
32
  const ROUTED = {
23
33
  // 官方余额端点那条:凭据是 API Key(解析链见 KeyResolver)。
24
- [KEY_PROVIDER]: 'deepseek-http',
34
+ [KEY_PROVIDER]: HTTP_SOURCE,
25
35
  // 宿主账号服务那条:凭据是账号登录态。
26
- [ACCOUNT_PROVIDER]: 'deepseek-account',
36
+ [ACCOUNT_PROVIDER]: ACCOUNT_SOURCE,
27
37
  };
28
38
  /**
29
39
  * 路由都指不出该走哪条时的固定顺序:**排在前的先被选中**。
@@ -31,9 +41,9 @@ const ROUTED = {
31
41
  * Key 优先是刻意的:它让「今天能用的用户」数字一字不变 —— 账号那条路是补上断掉的那条,
32
42
  * 不是抢走已经在用的那条。
33
43
  */
34
- export const FALLBACK_ORDER = ['deepseek-http', 'deepseek-account'];
44
+ export const FALLBACK_ORDER = [HTTP_SOURCE, ACCOUNT_SOURCE];
35
45
  /** 一条都不可用时回落到谁。挑 Key 那条,因为它的 `NO_KEY` 文案是既有用户认识的那句。 */
36
- export const DEFAULT_SOURCE = 'deepseek-http';
46
+ export const DEFAULT_SOURCE = HTTP_SOURCE;
37
47
  /**
38
48
  * 把宿主 provider id 翻成我们认的来源。
39
49
  *