dsh-ds-balance 2.3.1 → 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 (42) hide show
  1. package/README.md +84 -178
  2. package/README_zh.md +140 -0
  3. package/lib/adapters/host-account.d.ts +22 -1
  4. package/lib/adapters/host-account.js +13 -0
  5. package/lib/client.js +619 -461
  6. package/lib/config.d.ts +28 -19
  7. package/lib/config.js +27 -19
  8. package/lib/domain/credential.d.ts +41 -0
  9. package/lib/domain/credential.js +52 -0
  10. package/lib/http/handlers.d.ts +33 -2
  11. package/lib/http/handlers.js +124 -31
  12. package/lib/http/routes.d.ts +5 -1
  13. package/lib/http/routes.js +2 -1
  14. package/lib/http/wire.d.ts +58 -1
  15. package/lib/http/wire.js +3 -1
  16. package/lib/index.d.ts +1 -1
  17. package/lib/index.js +35 -6
  18. package/lib/ports/account.d.ts +25 -0
  19. package/lib/services/key-resolver.d.ts +33 -3
  20. package/lib/services/key-resolver.js +41 -12
  21. package/lib/services/source-readers.js +3 -3
  22. package/lib/services/source-selector.d.ts +10 -0
  23. package/lib/services/source-selector.js +14 -4
  24. package/lib/services/sources-service.d.ts +148 -0
  25. package/lib/services/sources-service.js +209 -0
  26. package/lib/types/client/api-types.d.ts +8 -0
  27. package/lib/types/client/data.d.ts +72 -0
  28. package/lib/types/client/icons/currency.d.ts +26 -0
  29. package/lib/types/client/icons.d.ts +92 -0
  30. package/lib/types/client/locales.d.ts +15 -13
  31. package/lib/types/client/model.d.ts +10 -0
  32. package/lib/types/client/settings/AccountAvatar.d.ts +27 -0
  33. package/lib/types/client/settings/BalanceSettingsCard.d.ts +8 -1
  34. package/lib/types/client/settings/fields.d.ts +70 -39
  35. package/lib/types/client/settings/source-status.d.ts +43 -0
  36. package/lib/types/client/settings/use-config-form.d.ts +1 -27
  37. package/lib/types/client/settings/use-sources.d.ts +38 -0
  38. package/lib/version.d.ts +1 -1
  39. package/lib/version.js +1 -1
  40. package/package.json +32 -32
  41. package/README_en.md +0 -237
  42. package/lib/types/client/settings/use-credential-state.d.ts +0 -36
package/lib/config.d.ts CHANGED
@@ -1,8 +1,22 @@
1
1
  /**
2
2
  * 插件配置:schemastery schema、常量与派生值。
3
3
  *
4
- * 字段名与 [UI 侧契约与移交](../docs/UI-HANDOFF.md) 第六节逐字一致,共 **11** 个。
4
+ * 字段名与 [UI 侧契约与移交](../docs/UI-HANDOFF.md) 第六节逐字一致,共 **9** 个。
5
5
  * `timeoutMs` **不在 schema 里**:它是常量加环境变量覆盖,UI 不暴露。
6
+ *
7
+ * **本轮删掉两个字段(2.x → 3.0),理由都是「跟随官方」**:
8
+ * - `apiKey`:它让手改 `settings.yaml` 的人可以静默压过官方那份凭据,而界面读的
9
+ * `credentials.describe()` **看不见**它 —— 于是「凭据来源」展示会说假话。
10
+ * - `baseUrl`:本插件的职责就是**绑定官方**,端点没有第二个合法取值。
11
+ * **不能**跟着官方的 `DEEPSEEK_BASE_URL` 走:那个变量是 **Messages 根地址**
12
+ * (`https://api.deepseek.com/anthropic`),而余额端点在根域下
13
+ * (`GET {root}/user/balance`)—— 照抄会拼出 `.../anthropic/user/balance`,错。
14
+ * 代价是走内网镜像/自建代理的部署失去了唯一变通办法(契约 §3.2「端点独立」的由来)。
15
+ *
16
+ * `apiKeyRef` **保留但不上界面**:它是「读凭据存储里的哪一条」这个输入,
17
+ * 默认值就是官方那一个(`DEEPSEEK_API_KEY`),而状态展示是**跟着它读**的
18
+ * (不像 `apiKey` 那样绕开 `describe()`),所以留着一个不可见的逃生舱不构成假话。
19
+ * 见 [决策记录](../.agents/notes/2026-10-15-drop-apikey-field.md)。
6
20
  * @module dsh-ds-balance/config
7
21
  */
8
22
  import z from '@deepseek-ai/schemastery';
@@ -37,14 +51,17 @@ export declare const CURRENCY_AUTO = "auto";
37
51
  /** 官方凭据的默认引用名。与 `llm-deepseek` 的默认逐字相同。 */
38
52
  export declare const DEFAULT_API_KEY_REF = "DEEPSEEK_API_KEY";
39
53
  /**
40
- * 生效的端点基址。
54
+ * 生效的端点基址。**是一个常量,不再是配置项**(3.0 起删掉了 `baseUrl`)。
55
+ *
56
+ * 真源是 [ports/deepseek-client.ts](ports/deepseek-client.ts) 的 `DEFAULT_BASE_URL`;
57
+ * 这里转出去,是为了让「抓取」与「测连接」两条路都从一个名字取地址,
58
+ * 不必各自 import 端口层。
41
59
  *
42
- * **空串翻译成官方默认地址的唯一一处**:界面默认留空(官方「网页搜索」卡片同款),
43
- * 用户填了就用填的。抓取与测连接都必须走这里,不许各自判空。
44
- * @param config - 至少含 `baseUrl` 的配置。
45
- * @returns 可以直接拼路径的基址。
60
+ * **端点独立于对话适配器**(契约 §3.2):本插件打的是官方余额端点
61
+ * `GET {root}/user/balance`,而官方的 `DEEPSEEK_BASE_URL` 指的是 **Messages 根地址**
62
+ * (`https://api.deepseek.com/anthropic`)—— 两者拼法不同,所以**不读那个变量**。
46
63
  */
47
- export declare function endpointOf(config: Pick<Config, 'baseUrl'>): string;
64
+ export declare const ENDPOINT = "https://api.deepseek.com";
48
65
  /** 超时常量。UI 不暴露,改环境变量即可。 */
49
66
  export declare const DEFAULT_TIMEOUT_MS = 8000;
50
67
  /** 覆盖超时的环境变量名。 */
@@ -56,12 +73,8 @@ export declare const TIMEOUT_RANGE: {
56
73
  };
57
74
  /** 插件配置。 */
58
75
  export interface Config {
59
- /** DeepSeek API Key。**用户显式覆盖**用;留空则走引用名解析。 */
60
- apiKey: string;
61
- /** 凭据引用名。必须匹配 `^[A-Za-z_][A-Za-z0-9_]*$`。 */
76
+ /** 凭据引用名。必须匹配 `^[A-Za-z_][A-Za-z0-9_]*$`。**不上界面**:默认即官方那一个。 */
62
77
  apiKeyRef: string;
63
- /** 端点基址。**端点独立**,不继承对话适配器;**留空表示用官方默认地址**。 */
64
- baseUrl: string;
65
78
  /** 服务端刷新频率(秒)。 */
66
79
  serverRefreshSeconds: number;
67
80
  /** 客户端轮询频率(秒)。 */
@@ -107,11 +120,11 @@ export declare function readConfig(refs: ConfigRefs): Config;
107
120
  * 悄悄写进用户层。**顺序与数量由 `test/config-fields.test.ts` 对着 schema 兜底**,
108
121
  * 不靠人记。
109
122
  */
110
- export declare const CONFIG_FIELDS: readonly ["apiKey", "apiKeyRef", "baseUrl", "serverRefreshSeconds", "clientPollSeconds", "manualRefreshCooldownSeconds", "displayCurrency", "cnyWarn", "cnyCritical", "usdWarn", "usdCritical"];
123
+ export declare const CONFIG_FIELDS: readonly ["apiKeyRef", "serverRefreshSeconds", "clientPollSeconds", "manualRefreshCooldownSeconds", "displayCurrency", "cnyWarn", "cnyCritical", "usdWarn", "usdCritical"];
111
124
  /**
112
125
  * 配置 schema。加载期校验,非法配置 fail loud。
113
126
  *
114
- * **11 个字段全部 `.volatile()`**,两条独立理由,缺一不可:
127
+ * **10 个字段全部 `.volatile()`**,两条独立理由,缺一不可:
115
128
  * 1. 只有 volatile 字段进得了表单 —— 宿主的 `volatileForm()` 在没有 volatile
116
129
  * 字段时返回 `undefined`,该行整条退出 `describe()`,于是
117
130
  * `ctx.configForms.get(ENTRY_ID)` 拿到的快照永远不是 `ready`,
@@ -119,7 +132,7 @@ export declare const CONFIG_FIELDS: readonly ["apiKey", "apiKeyRef", "baseUrl",
119
132
  * 漏加一个字段不会报错,只会让那一个字段在表单里消失。
120
133
  * 2. 写入路径按 volatile 逐路径放行:非 volatile 路径直接抛
121
134
  * `Config field "..." is not volatile`(宿主 `settings/src/index.ts` 的
122
- * `write()`)。`apiKey` 不给它加,`POST /api/v1/config` 就会被宿主拒掉。
135
+ * `write()`)。所以每一个要能改的字段都得加。
123
136
  *
124
137
  * 副作用是好的:全字段 volatile ⇒ Loader 永远判「只有 volatile 变了」⇒
125
138
  * 改配置**永不重挂**,`apply` 只跑一次。
@@ -127,9 +140,7 @@ export declare const CONFIG_FIELDS: readonly ["apiKey", "apiKeyRef", "baseUrl",
127
140
  * 阈值只在这里存储,前端不做金额比较:颜色由后端的 `severity` 决定。
128
141
  */
129
142
  export declare const Config: z<Schemastery.ObjectS<NoInfer<{
130
- apiKey: z<string, string, "volatile-defined">;
131
143
  apiKeyRef: z<string, string, "volatile-defined">;
132
- baseUrl: z<string, string, "volatile-defined">;
133
144
  serverRefreshSeconds: z<number, number, "volatile-defined">;
134
145
  clientPollSeconds: z<number, number, "volatile-defined">;
135
146
  manualRefreshCooldownSeconds: z<number, number, "volatile-defined">;
@@ -139,9 +150,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
139
150
  usdWarn: z<number, number, "volatile-defined">;
140
151
  usdCritical: z<number, number, "volatile-defined">;
141
152
  }>>, Schemastery.ObjectT<NoInfer<{
142
- apiKey: z<string, string, "volatile-defined">;
143
153
  apiKeyRef: z<string, string, "volatile-defined">;
144
- baseUrl: z<string, string, "volatile-defined">;
145
154
  serverRefreshSeconds: z<number, number, "volatile-defined">;
146
155
  clientPollSeconds: z<number, number, "volatile-defined">;
147
156
  manualRefreshCooldownSeconds: z<number, number, "volatile-defined">;
package/lib/config.js CHANGED
@@ -1,8 +1,22 @@
1
1
  /**
2
2
  * 插件配置:schemastery schema、常量与派生值。
3
3
  *
4
- * 字段名与 [UI 侧契约与移交](../docs/UI-HANDOFF.md) 第六节逐字一致,共 **11** 个。
4
+ * 字段名与 [UI 侧契约与移交](../docs/UI-HANDOFF.md) 第六节逐字一致,共 **9** 个。
5
5
  * `timeoutMs` **不在 schema 里**:它是常量加环境变量覆盖,UI 不暴露。
6
+ *
7
+ * **本轮删掉两个字段(2.x → 3.0),理由都是「跟随官方」**:
8
+ * - `apiKey`:它让手改 `settings.yaml` 的人可以静默压过官方那份凭据,而界面读的
9
+ * `credentials.describe()` **看不见**它 —— 于是「凭据来源」展示会说假话。
10
+ * - `baseUrl`:本插件的职责就是**绑定官方**,端点没有第二个合法取值。
11
+ * **不能**跟着官方的 `DEEPSEEK_BASE_URL` 走:那个变量是 **Messages 根地址**
12
+ * (`https://api.deepseek.com/anthropic`),而余额端点在根域下
13
+ * (`GET {root}/user/balance`)—— 照抄会拼出 `.../anthropic/user/balance`,错。
14
+ * 代价是走内网镜像/自建代理的部署失去了唯一变通办法(契约 §3.2「端点独立」的由来)。
15
+ *
16
+ * `apiKeyRef` **保留但不上界面**:它是「读凭据存储里的哪一条」这个输入,
17
+ * 默认值就是官方那一个(`DEEPSEEK_API_KEY`),而状态展示是**跟着它读**的
18
+ * (不像 `apiKey` 那样绕开 `describe()`),所以留着一个不可见的逃生舱不构成假话。
19
+ * 见 [决策记录](../.agents/notes/2026-10-15-drop-apikey-field.md)。
6
20
  * @module dsh-ds-balance/config
7
21
  */
8
22
  import z from '@deepseek-ai/schemastery';
@@ -37,17 +51,17 @@ export const CURRENCY_AUTO = 'auto';
37
51
  /** 官方凭据的默认引用名。与 `llm-deepseek` 的默认逐字相同。 */
38
52
  export const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY';
39
53
  /**
40
- * 生效的端点基址。
54
+ * 生效的端点基址。**是一个常量,不再是配置项**(3.0 起删掉了 `baseUrl`)。
55
+ *
56
+ * 真源是 [ports/deepseek-client.ts](ports/deepseek-client.ts) 的 `DEFAULT_BASE_URL`;
57
+ * 这里转出去,是为了让「抓取」与「测连接」两条路都从一个名字取地址,
58
+ * 不必各自 import 端口层。
41
59
  *
42
- * **空串翻译成官方默认地址的唯一一处**:界面默认留空(官方「网页搜索」卡片同款),
43
- * 用户填了就用填的。抓取与测连接都必须走这里,不许各自判空。
44
- * @param config - 至少含 `baseUrl` 的配置。
45
- * @returns 可以直接拼路径的基址。
60
+ * **端点独立于对话适配器**(契约 §3.2):本插件打的是官方余额端点
61
+ * `GET {root}/user/balance`,而官方的 `DEEPSEEK_BASE_URL` 指的是 **Messages 根地址**
62
+ * (`https://api.deepseek.com/anthropic`)—— 两者拼法不同,所以**不读那个变量**。
46
63
  */
47
- export function endpointOf(config) {
48
- const url = config.baseUrl.trim();
49
- return url === '' ? DEFAULT_BASE_URL : url;
50
- }
64
+ export const ENDPOINT = DEFAULT_BASE_URL;
51
65
  /** 超时常量。UI 不暴露,改环境变量即可。 */
52
66
  export const DEFAULT_TIMEOUT_MS = 8000;
53
67
  /** 覆盖超时的环境变量名。 */
@@ -63,9 +77,7 @@ export const TIMEOUT_RANGE = { min: 1000, max: 60_000 };
63
77
  */
64
78
  export function readConfig(refs) {
65
79
  return {
66
- apiKey: refs.apiKey.get(),
67
80
  apiKeyRef: refs.apiKeyRef.get(),
68
- baseUrl: refs.baseUrl.get(),
69
81
  serverRefreshSeconds: refs.serverRefreshSeconds.get(),
70
82
  clientPollSeconds: refs.clientPollSeconds.get(),
71
83
  manualRefreshCooldownSeconds: refs.manualRefreshCooldownSeconds.get(),
@@ -84,9 +96,7 @@ export function readConfig(refs) {
84
96
  * 不靠人记。
85
97
  */
86
98
  export const CONFIG_FIELDS = [
87
- 'apiKey',
88
99
  'apiKeyRef',
89
- 'baseUrl',
90
100
  'serverRefreshSeconds',
91
101
  'clientPollSeconds',
92
102
  'manualRefreshCooldownSeconds',
@@ -99,7 +109,7 @@ export const CONFIG_FIELDS = [
99
109
  /**
100
110
  * 配置 schema。加载期校验,非法配置 fail loud。
101
111
  *
102
- * **11 个字段全部 `.volatile()`**,两条独立理由,缺一不可:
112
+ * **10 个字段全部 `.volatile()`**,两条独立理由,缺一不可:
103
113
  * 1. 只有 volatile 字段进得了表单 —— 宿主的 `volatileForm()` 在没有 volatile
104
114
  * 字段时返回 `undefined`,该行整条退出 `describe()`,于是
105
115
  * `ctx.configForms.get(ENTRY_ID)` 拿到的快照永远不是 `ready`,
@@ -107,7 +117,7 @@ export const CONFIG_FIELDS = [
107
117
  * 漏加一个字段不会报错,只会让那一个字段在表单里消失。
108
118
  * 2. 写入路径按 volatile 逐路径放行:非 volatile 路径直接抛
109
119
  * `Config field "..." is not volatile`(宿主 `settings/src/index.ts` 的
110
- * `write()`)。`apiKey` 不给它加,`POST /api/v1/config` 就会被宿主拒掉。
120
+ * `write()`)。所以每一个要能改的字段都得加。
111
121
  *
112
122
  * 副作用是好的:全字段 volatile ⇒ Loader 永远判「只有 volatile 变了」⇒
113
123
  * 改配置**永不重挂**,`apply` 只跑一次。
@@ -115,9 +125,7 @@ export const CONFIG_FIELDS = [
115
125
  * 阈值只在这里存储,前端不做金额比较:颜色由后端的 `severity` 决定。
116
126
  */
117
127
  export const Config = z.object({
118
- apiKey: z.string().role('secret').default('').volatile(),
119
128
  apiKeyRef: z.string().role('credential-ref').default(DEFAULT_API_KEY_REF).volatile(),
120
- baseUrl: z.string().default('').volatile(),
121
129
  serverRefreshSeconds: z.natural().min(10).max(3600).default(60).volatile(),
122
130
  clientPollSeconds: z.natural().min(5).max(600).default(30).volatile(),
123
131
  manualRefreshCooldownSeconds: z.natural().min(0).max(600).default(30).volatile(),
@@ -198,7 +206,7 @@ export function resolveThresholdPairs(value) {
198
206
  violations.push(pair.currency);
199
207
  if (config === value)
200
208
  config = { ...value };
201
- // 字段名是 `keyof Config`(值类型是 11 个字段的并集),逐键赋值 TS 推不出来,
209
+ // 字段名是 `keyof Config`(值类型是 9 个字段的并集),逐键赋值 TS 推不出来,
202
210
  // 所以走一次数值视图 —— 这对字段在 schema 里本来就是数字。
203
211
  const numeric = config;
204
212
  numeric[pair.warn] = pair.defaultWarn;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 凭据的**展示形态**:把明文密钥折成首尾片段掩码。
3
+ *
4
+ * **完整密钥永不跨这条线**:调用方解析出明文、当场折成掩码、立刻丢掉明文。
5
+ * 这是 [http/AGENTS.md](../http/AGENTS.md) 那条红线的落点 —— 放宽到「只回首尾掩码」,
6
+ * 而不是「回固定长度的星号」。
7
+ *
8
+ * 形态与 DeepSeek 平台控制台 `platform.deepseek.com/api_keys` 一致:
9
+ * `sk-cf08d*****3b84`。选它的理由是**辨识度**:用户要能一眼看出「我现在用的是哪一把
10
+ * key」——固定长度的 `********` 回答不了这个问题,而「换 key 之后余额不对」正是这个
11
+ * 插件最容易遇到的困惑。
12
+ *
13
+ * **星号个数是固定的 5 个**:真实长度不进掩码,否则等于把密钥长度也送出去。
14
+ * @module dsh-ds-balance/domain/credential
15
+ */
16
+ /** 掩码里保留的头部长字符数。`sk-cf08d` 正好是 8 个字符,与平台控制台同形。 */
17
+ export declare const MASK_HEAD = 8;
18
+ /** 掩码里保留的尾部字符数。 */
19
+ export declare const MASK_TAIL = 4;
20
+ /** 中间固定的星号个数。**不随密钥长度变化** —— 长度本身也是信息。 */
21
+ export declare const MASK_STARS = "*****";
22
+ /**
23
+ * 把明文密钥折成首尾片段掩码。
24
+ *
25
+ * 长度不足以同时露首尾时**整串打码**:`sk-abc` 这种短值露 8 个头就等于露了全串。
26
+ * 空值回空串 —— 调用方据此显示「未配置」而不是显示一串星号。
27
+ * @param value - 明文密钥;调用方负责在此之后不再使用它。
28
+ * @returns `sk-cf08d*****3b84` 形态的掩码,或空串。
29
+ */
30
+ export declare function maskKey(value: string): string;
31
+ /**
32
+ * 生效值的来源层是否算「已保存的凭据」。
33
+ *
34
+ * 判据只有一处(这里),界面与后端都用它 —— 来源层的取值来自官方
35
+ * `credentialProvider.describe()` 的 `source`,本仓见
36
+ * [ports/credentials.ts](../ports/credentials.ts)。
37
+ * @param source - `describe()` 给的来源层;未知或读不到时传 `null`。
38
+ * @returns 是否来自凭据存储(`.credentials.yaml`)。
39
+ */
40
+ export declare function isSavedSource(source: string | null): boolean;
41
+ //# sourceMappingURL=credential.d.ts.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 凭据的**展示形态**:把明文密钥折成首尾片段掩码。
3
+ *
4
+ * **完整密钥永不跨这条线**:调用方解析出明文、当场折成掩码、立刻丢掉明文。
5
+ * 这是 [http/AGENTS.md](../http/AGENTS.md) 那条红线的落点 —— 放宽到「只回首尾掩码」,
6
+ * 而不是「回固定长度的星号」。
7
+ *
8
+ * 形态与 DeepSeek 平台控制台 `platform.deepseek.com/api_keys` 一致:
9
+ * `sk-cf08d*****3b84`。选它的理由是**辨识度**:用户要能一眼看出「我现在用的是哪一把
10
+ * key」——固定长度的 `********` 回答不了这个问题,而「换 key 之后余额不对」正是这个
11
+ * 插件最容易遇到的困惑。
12
+ *
13
+ * **星号个数是固定的 5 个**:真实长度不进掩码,否则等于把密钥长度也送出去。
14
+ * @module dsh-ds-balance/domain/credential
15
+ */
16
+ /** 掩码里保留的头部长字符数。`sk-cf08d` 正好是 8 个字符,与平台控制台同形。 */
17
+ export const MASK_HEAD = 8;
18
+ /** 掩码里保留的尾部字符数。 */
19
+ export const MASK_TAIL = 4;
20
+ /** 中间固定的星号个数。**不随密钥长度变化** —— 长度本身也是信息。 */
21
+ export const MASK_STARS = '*****';
22
+ /** 能安全露首尾的最短长度:头 + 尾。再短就只能整串打码。 */
23
+ const MASK_MIN_LENGTH = MASK_HEAD + MASK_TAIL;
24
+ /**
25
+ * 把明文密钥折成首尾片段掩码。
26
+ *
27
+ * 长度不足以同时露首尾时**整串打码**:`sk-abc` 这种短值露 8 个头就等于露了全串。
28
+ * 空值回空串 —— 调用方据此显示「未配置」而不是显示一串星号。
29
+ * @param value - 明文密钥;调用方负责在此之后不再使用它。
30
+ * @returns `sk-cf08d*****3b84` 形态的掩码,或空串。
31
+ */
32
+ export function maskKey(value) {
33
+ const text = value.trim();
34
+ if (text === '')
35
+ return '';
36
+ if (text.length < MASK_MIN_LENGTH)
37
+ return MASK_STARS;
38
+ return text.slice(0, MASK_HEAD) + MASK_STARS + text.slice(-MASK_TAIL);
39
+ }
40
+ /**
41
+ * 生效值的来源层是否算「已保存的凭据」。
42
+ *
43
+ * 判据只有一处(这里),界面与后端都用它 —— 来源层的取值来自官方
44
+ * `credentialProvider.describe()` 的 `source`,本仓见
45
+ * [ports/credentials.ts](../ports/credentials.ts)。
46
+ * @param source - `describe()` 给的来源层;未知或读不到时传 `null`。
47
+ * @returns 是否来自凭据存储(`.credentials.yaml`)。
48
+ */
49
+ export function isSavedSource(source) {
50
+ return source === 'file';
51
+ }
52
+ //# sourceMappingURL=credential.js.map
@@ -18,6 +18,8 @@ import type { BalanceService } from '../services/balance-service.js';
18
18
  import type { ConfigService } from '../services/config-service.js';
19
19
  import type { KeyResolver } from '../services/key-resolver.js';
20
20
  import type { Scheduler } from '../services/scheduler.js';
21
+ import type { SourcesService } from '../services/sources-service.js';
22
+ import type { AccountSource } from '../ports/account.js';
21
23
  /** 一套 handler 需要的全部依赖。 */
22
24
  export interface HttpDeps {
23
25
  service: BalanceService;
@@ -31,16 +33,45 @@ export interface HttpDeps {
31
33
  metrics?: ReadableMetrics | undefined;
32
34
  /** 凭据端口。只用来读「配没配 / 可不可写」,**永远不读值**。 */
33
35
  credentials?: Credentials | undefined;
36
+ /** 两条取数途径的状态。`GET /api/v1/sources` 的唯一来源。 */
37
+ sources: SourcesService;
38
+ /**
39
+ * 取当前账号端口实现;服务可能晚到,所以是函数而不是实例。
40
+ * `POST /api/v1/test-connection` 的 `source: 'deepseek-account'` 那条路用它。
41
+ */
42
+ account: () => AccountSource | undefined;
34
43
  }
35
44
  /** `GET /api/v1/balance`。**状态码始终 200**,业务错误走 `state` + `error`。 */
36
45
  export declare function handleBalance(request: Request, deps: HttpDeps): Promise<Response>;
37
46
  /** `POST /api/v1/balance/refresh`。请求体 `{ reason, provider }`,都可省略。 */
38
47
  export declare function handleRefresh(request: Request, deps: HttpDeps): Promise<Response>;
39
- /** `GET /api/v1/config`。**掩码是手动的** —— 本响应自己构造,不走 settings 读取。 */
48
+ /** `GET /api/v1/config`。 */
40
49
  export declare function handleConfigGet(_request: Request, deps: HttpDeps): Promise<Response>;
50
+ /**
51
+ * `GET /api/v1/sources`。
52
+ *
53
+ * 「连接」组的唯一事实出口:两条途径的状态一次给全。`?refresh=1` 强制重读
54
+ * (卡片打开 / 窗口重新获得焦点 / 点测试按钮时用),否则走缓存。
55
+ *
56
+ * **失败也回 200 + 一份保守快照**:界面读不到状态时该显示「未确认」,
57
+ * 而不是让整张卡片消失(与 `credential` 段同一策略)。
58
+ */
59
+ export declare function handleSources(request: Request, deps: HttpDeps): Promise<Response>;
41
60
  /** `POST /api/v1/config`。请求是任意字段子集;形状不合法或校验失败一律 `422`。 */
42
61
  export declare function handleConfigUpdate(request: Request, deps: HttpDeps): Promise<Response>;
43
- /** `POST /api/v1/test-connection`。**不动活动缓存。** */
62
+ /**
63
+ * `POST /api/v1/test-connection`。**不动活动缓存。**
64
+ *
65
+ * 请求体 `{ source?, apiKey?, timeoutMs? }`。
66
+ * - `source` 省略或 `deepseek-http`(默认):走官方余额端点,行为与从前逐字相同;
67
+ * - `source: 'deepseek-account'`:走账号那条路真读一次钱包。
68
+ *
69
+ * **真连一次上游**,回真实的 `latencyMs`。这正是它存在的意义 ——
70
+ * 界面上的 **Test** 按钮读的就是这里,而不是本地字符串检查。
71
+ *
72
+ * **余额预览不在这里**:卡片按 [settings/AGENTS.md](../client/settings/AGENTS.md)
73
+ * 不许出现任何额度信息,所以账号那条只回成败与延迟,不回钱包内容。
74
+ */
44
75
  export declare function handleTestConnection(request: Request, deps: HttpDeps): Promise<Response>;
45
76
  /** `GET /api/v1/healthz`。 */
46
77
  export declare function handleHealthz(_request: Request, deps: HttpDeps): Promise<Response>;
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { classify, describeError } from '../domain/errors.js';
13
13
  import { situationOf } from '../domain/situation.js';
14
- import { CONFIG_FIELDS, endpointOf, validateThresholds } from '../config.js';
14
+ import { CONFIG_FIELDS, ENDPOINT, validateThresholds } from '../config.js';
15
15
  import { PLUGIN_VERSION, SCHEMA_VERSION } from '../version.js';
16
16
  import { newRequestId, toWireBalanceView, toWireError, } from './wire.js';
17
17
  /** `testConnection` 超时的可接受区间;越界一律回落默认值。 */
@@ -63,6 +63,9 @@ function failureView(requestId, error, state = 'error', source = 'deepseek-http'
63
63
  requestId,
64
64
  schemaVersion: SCHEMA_VERSION,
65
65
  source,
66
+ // 进程级失败时**不声称知道凭据身份**:那份身份是异步读的,而这里必须同步返回。
67
+ // 界面据此退回路名标签,而路名仍然为真。
68
+ sourceIdentity: null,
66
69
  situation: 'internal-error',
67
70
  state,
68
71
  stale: false,
@@ -78,22 +81,6 @@ function failureView(requestId, error, state = 'error', source = 'deepseek-http'
78
81
  error: toWireError(classify(error)),
79
82
  };
80
83
  }
81
- /** 从配置里挑出 `GET /api/v1/config` 要回传的字段。**`apiKey` 永不出现在这里。** */
82
- function publicConfig(config) {
83
- const { apiKey: _secret, ...rest } = config;
84
- return rest;
85
- }
86
- /**
87
- * `apiKey` 的掩码。
88
- *
89
- * **不回传密钥的任何片段**:只回答「配没配」。回一个固定长度的星号串是为了让
90
- * 字段名 `apiKeyMasked` 名副其实,同时不给任何爆破线索。
91
- * @param value - 当前生效的 `apiKey`。
92
- * @returns 已配置时是固定长度的掩码串,未配置时是空串。
93
- */
94
- function maskApiKey(value) {
95
- return value.trim() === '' ? '' : '********';
96
- }
97
84
  /** `GET /api/v1/balance`。**状态码始终 200**,业务错误走 `state` + `error`。 */
98
85
  export async function handleBalance(request, deps) {
99
86
  const requestId = newRequestId();
@@ -104,7 +91,9 @@ export async function handleBalance(request, deps) {
104
91
  ...(currency === null || currency === '' ? {} : { currency }),
105
92
  providerHint,
106
93
  });
107
- return json(toWireBalanceView(view, deps.service.accountTag8() ?? '', requestId));
94
+ // 凭据身份跟着这份数字一起回去:界面拿它当标题里的来源标签(「是哪一把」而不是「哪条路」)。
95
+ const sourceIdentity = await deps.sources.identityOf(view.source);
96
+ return json(toWireBalanceView(view, deps.service.accountTag8() ?? '', sourceIdentity, requestId));
108
97
  }
109
98
  catch (error) {
110
99
  deps.logger?.error('ds-balance: balance handler failed', {
@@ -171,20 +160,24 @@ async function credentialInfo(deps, ref) {
171
160
  return null;
172
161
  }
173
162
  }
174
- /** 组装配置响应体。**`apiKey` 永不出现;掩码是手动的。** */
163
+ /**
164
+ * 组装配置响应体。
165
+ *
166
+ * **配置整份回传**:9 个字段里没有一个是秘密(密钥在 3.0 随 `apiKey` 字段一起删了),
167
+ * 所以不再需要从前那个「挑字段 + 手动掩码」的包装。掩码现在归 `GET /api/v1/sources`。
168
+ */
175
169
  async function configBody(requestId, deps) {
176
170
  const config = deps.config.current();
177
171
  return {
178
172
  requestId,
179
173
  schemaVersion: SCHEMA_VERSION,
180
- config: publicConfig(config),
181
- apiKeyMasked: maskApiKey(config.apiKey),
174
+ config,
182
175
  credential: await credentialInfo(deps, config.apiKeyRef),
183
176
  timeoutMs: deps.config.timeoutMs(),
184
177
  error: null,
185
178
  };
186
179
  }
187
- /** `GET /api/v1/config`。**掩码是手动的** —— 本响应自己构造,不走 settings 读取。 */
180
+ /** `GET /api/v1/config`。 */
188
181
  export async function handleConfigGet(_request, deps) {
189
182
  const requestId = newRequestId();
190
183
  try {
@@ -198,6 +191,44 @@ export async function handleConfigGet(_request, deps) {
198
191
  return json({ requestId, schemaVersion: SCHEMA_VERSION, error: toWireError(classify(error)) });
199
192
  }
200
193
  }
194
+ /**
195
+ * `GET /api/v1/sources`。
196
+ *
197
+ * 「连接」组的唯一事实出口:两条途径的状态一次给全。`?refresh=1` 强制重读
198
+ * (卡片打开 / 窗口重新获得焦点 / 点测试按钮时用),否则走缓存。
199
+ *
200
+ * **失败也回 200 + 一份保守快照**:界面读不到状态时该显示「未确认」,
201
+ * 而不是让整张卡片消失(与 `credential` 段同一策略)。
202
+ */
203
+ export async function handleSources(request, deps) {
204
+ const requestId = newRequestId();
205
+ const force = queryParam(request, 'refresh') === '1';
206
+ try {
207
+ const snapshot = force ? await deps.sources.refresh() : await deps.sources.snapshot();
208
+ return json({
209
+ requestId,
210
+ schemaVersion: SCHEMA_VERSION,
211
+ revision: snapshot.revision,
212
+ key: snapshot.key,
213
+ account: snapshot.account,
214
+ });
215
+ }
216
+ catch (error) {
217
+ deps.logger?.error('ds-balance: sources handler failed', {
218
+ requestId,
219
+ error: describeError(error),
220
+ });
221
+ // 保守快照:两条途径都说「读不到」。`revision: -1` 是一个界面认得出「这份不可信」的值。
222
+ return json({
223
+ requestId,
224
+ schemaVersion: SCHEMA_VERSION,
225
+ revision: -1,
226
+ key: { ref: '', configured: false, source: null, masked: '', tag8: '' },
227
+ account: { available: false, signedIn: false, name: null, avatarUrl: null, contact: null },
228
+ error: toWireError(classify(error)),
229
+ });
230
+ }
231
+ }
201
232
  /** `POST /api/v1/config`。请求是任意字段子集;形状不合法或校验失败一律 `422`。 */
202
233
  export async function handleConfigUpdate(request, deps) {
203
234
  const requestId = newRequestId();
@@ -250,16 +281,69 @@ export async function handleConfigUpdate(request, deps) {
250
281
  return json({ requestId, schemaVersion: SCHEMA_VERSION, error: toWireError(classify(error)) });
251
282
  }
252
283
  }
253
- /** `POST /api/v1/test-connection`。**不动活动缓存。** */
284
+ /**
285
+ * `POST /api/v1/test-connection`。**不动活动缓存。**
286
+ *
287
+ * 请求体 `{ source?, apiKey?, timeoutMs? }`。
288
+ * - `source` 省略或 `deepseek-http`(默认):走官方余额端点,行为与从前逐字相同;
289
+ * - `source: 'deepseek-account'`:走账号那条路真读一次钱包。
290
+ *
291
+ * **真连一次上游**,回真实的 `latencyMs`。这正是它存在的意义 ——
292
+ * 界面上的 **Test** 按钮读的就是这里,而不是本地字符串检查。
293
+ *
294
+ * **余额预览不在这里**:卡片按 [settings/AGENTS.md](../client/settings/AGENTS.md)
295
+ * 不许出现任何额度信息,所以账号那条只回成败与延迟,不回钱包内容。
296
+ */
254
297
  export async function handleTestConnection(request, deps) {
255
298
  const requestId = newRequestId();
256
299
  try {
257
300
  const body = await readJsonObject(request);
258
- const config = deps.config.current();
259
- const rawBaseUrl = body?.baseUrl;
260
- const baseUrl = typeof rawBaseUrl === 'string' && rawBaseUrl.trim() !== ''
261
- ? rawBaseUrl.trim()
262
- : endpointOf(config);
301
+ const rawSource = body?.source;
302
+ const source = rawSource === 'deepseek-account' ? 'deepseek-account' : 'deepseek-http';
303
+ if (source === 'deepseek-account') {
304
+ const account = deps.account();
305
+ if (account === undefined) {
306
+ return json({
307
+ requestId,
308
+ schemaVersion: SCHEMA_VERSION,
309
+ source,
310
+ ok: false,
311
+ latencyMs: 0,
312
+ code: 'NO_KEY',
313
+ message: 'account service is not available',
314
+ });
315
+ }
316
+ const startedAt = Date.now();
317
+ try {
318
+ const balance = await account.readBalance();
319
+ const latencyMs = Date.now() - startedAt;
320
+ if (balance === null) {
321
+ return json({
322
+ requestId,
323
+ schemaVersion: SCHEMA_VERSION,
324
+ source,
325
+ ok: false,
326
+ latencyMs,
327
+ code: 'NO_KEY',
328
+ message: 'account is not signed in',
329
+ });
330
+ }
331
+ return json({ requestId, schemaVersion: SCHEMA_VERSION, source, ok: true, latencyMs });
332
+ }
333
+ catch (error) {
334
+ const latencyMs = Date.now() - startedAt;
335
+ const info = classify(error);
336
+ return json({
337
+ requestId,
338
+ schemaVersion: SCHEMA_VERSION,
339
+ source,
340
+ ok: false,
341
+ latencyMs,
342
+ code: info.code,
343
+ message: info.message,
344
+ });
345
+ }
346
+ }
263
347
  const rawTimeout = Number(body?.timeoutMs);
264
348
  const timeoutMs = Number.isFinite(rawTimeout) &&
265
349
  rawTimeout >= TEST_TIMEOUT_RANGE.min &&
@@ -271,9 +355,17 @@ export async function handleTestConnection(request, deps) {
271
355
  ? rawApiKey.trim()
272
356
  : await deps.keys.resolve().catch(() => null);
273
357
  if (apiKey === null)
274
- return json({ requestId, ok: false, latencyMs: 0, code: 'NO_KEY', message: NO_KEY_MESSAGE });
275
- const result = await deps.client.testConnection({ baseUrl, apiKey, timeoutMs });
276
- return json({ requestId, schemaVersion: SCHEMA_VERSION, ...result });
358
+ return json({
359
+ requestId,
360
+ schemaVersion: SCHEMA_VERSION,
361
+ source,
362
+ ok: false,
363
+ latencyMs: 0,
364
+ code: 'NO_KEY',
365
+ message: NO_KEY_MESSAGE,
366
+ });
367
+ const result = await deps.client.testConnection({ baseUrl: ENDPOINT, apiKey, timeoutMs });
368
+ return json({ requestId, schemaVersion: SCHEMA_VERSION, source, ...result });
277
369
  }
278
370
  catch (error) {
279
371
  deps.logger?.warn('ds-balance: test-connection handler failed', {
@@ -284,6 +376,7 @@ export async function handleTestConnection(request, deps) {
284
376
  return json({
285
377
  requestId,
286
378
  schemaVersion: SCHEMA_VERSION,
379
+ source: 'deepseek-http',
287
380
  ok: false,
288
381
  latencyMs: 0,
289
382
  code: info.code,
@@ -11,7 +11,7 @@
11
11
  * @module dsh-ds-balance/http/routes
12
12
  */
13
13
  import type { Context } from '@deepseek-ai/cordis';
14
- import { handleBalance, handleHealthz, handleRefresh, handleTestConnection, type HttpDeps } from './handlers.js';
14
+ import { handleBalance, handleHealthz, handleRefresh, handleSources, handleTestConnection, type HttpDeps } from './handlers.js';
15
15
  /**
16
16
  * `/api/v1/config` 的方法分发。
17
17
  *
@@ -40,6 +40,10 @@ export declare const HTTP_ROUTES: readonly [{
40
40
  readonly path: "/api/v1/config";
41
41
  readonly methods: readonly ["GET", "POST"];
42
42
  readonly handle: typeof handleConfigRoute;
43
+ }, {
44
+ readonly path: "/api/v1/sources";
45
+ readonly methods: readonly ["GET"];
46
+ readonly handle: typeof handleSources;
43
47
  }, {
44
48
  readonly path: "/api/v1/test-connection";
45
49
  readonly methods: readonly ["POST"];
@@ -10,7 +10,7 @@
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
  ];