@konnga/dsh-hud 0.9.2 → 0.10.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.
@@ -0,0 +1,531 @@
1
+ /**
2
+ * 「当前模型是谁家的,就查谁家的余额」——宿主半。
3
+ *
4
+ * 为什么要这么绕:DSH 的账号接口(`remote.account`)只认 DeepSeek 平台账号那一条路。
5
+ * 会话换了提供方(`ur` / `api404` / 官方 API Key ……)之后,那枚余额胶囊显示的是**另一个
6
+ * 账本**上的钱,看着像 bug。这里按会话当前的模型选择解析出提供方,再用**它自己的协议**
7
+ * 取一次余额。
8
+ *
9
+ * 实测出来的三套协议(2026-10-27 用本机真实配置验过,见 README 的"提供方余额"一节):
10
+ *
11
+ * 1. `deepseek-account` —— DeepSeek 平台账号。**不在这里取**:客户端已经用
12
+ * `remote.account.getBalance` 拿到了同一份数,宿主再取一次只会多一条路径。
13
+ * 2. `official` —— DeepSeek 官方 API Key。`GET https://api.deepseek.com/user/balance`
14
+ * 回 `{is_available, balance_infos: [{currency, total_balance, granted_balance, ...}]}`。
15
+ * 3. `relay` —— 第三方中转站(one-api / new-api 家族)。一对 OpenAI 兼容接口:
16
+ * `GET {base}/dashboard/billing/subscription` 给上限(`hard_limit_usd`),
17
+ * `GET {base}/dashboard/billing/usage` 给已用(`total_usage`,单位是**分**)。
18
+ *
19
+ * 三个必须如实交代的约束(都在载荷里,不靠文案遮掩):
20
+ *
21
+ * - **占位上限**:不少中转站把上限写成 `1e8` 当"无限"。这时剩余额算不出来,只能报已用
22
+ * (`placeholderLimit: true`),别拿 `1e8 - 已用` 冒充一个天文数字余额。
23
+ * - **币种口径**:中转站这套接口的字段名是 `*_usd`,但很多站内部按人民币 1:1 记账。
24
+ * 我们只转述站点自己的数,**不做任何汇率换算**,`currency` 就标站点口径。
25
+ * - **没有统一标准**:有的站(本机的 `api404`)两条路都 404/502。这种情况报
26
+ * `unsupported`,界面不画胶囊 —— 与"读不到就不画"同一条规矩。
27
+ *
28
+ * 凭据只在这个进程里用:按提供方自己的 `apiKeyEnv` 引用名向 `credentials` 服务要一次,
29
+ * 只发给**这个提供方自己的 baseURL**,不进日志、不落盘、不回传给浏览器。
30
+ */
31
+ /** 单个提供方的余额缓存时长:中转站被轮询打脸会被限流,这里挡一层。 */
32
+ export const PROVIDER_CACHE_MS = 60000;
33
+ /** 一次取数的超时(中转站偶尔很慢,超过就不等了)。 */
34
+ export const PROVIDER_TIMEOUT_MS = 8000;
35
+ /**
36
+ * 上限的"占位值"门限:实测有站点把 `hard_limit_usd` 写成 `1e8`(等于"无限")。
37
+ * 超过这个数就认为它不是真实余额,只报已用。
38
+ */
39
+ export const PLACEHOLDER_LIMIT_USD = 1e7;
40
+ /** 官方 API Key 那条插件配置的命名空间最后一段。 */
41
+ const OFFICIAL_NS = 'llm-deepseek-api-key';
42
+ /** 官方端点(`baseURL` 未配置时上游自己用的那个默认值)。 */
43
+ const OFFICIAL_DEFAULT_BASE_URL = 'https://api.deepseek.com/anthropic';
44
+ /** DeepSeek 官方 API 的 origin(只有它走 `/user/balance`)。 */
45
+ const OFFICIAL_ORIGIN = 'https://api.deepseek.com';
46
+ /**
47
+ * 把一个 JSON 值里的字符串金额原样取出。
48
+ *
49
+ * 为什么不转成 `number`:余额是账目数字,`1e-2` 这种经过浮点会变成 `0.010000000000000002`。
50
+ * 站点给什么字符串就用什么字符串;给数字的先 `String()` 再走同一条路(也只在这一处容忍)。
51
+ */
52
+ function amountOf(value) {
53
+ if (typeof value === 'string') {
54
+ var raw = value.trim();
55
+ return /^-?\d+(?:\.\d+)?$/.test(raw) ? raw : null;
56
+ }
57
+ if (typeof value === 'number' && Number.isFinite(value)) {
58
+ // 站点把小数位写进浮点时,`toFixed` 到 8 位足以还原它的意图,且不会再出现科学计数法。
59
+ return value.toFixed(8);
60
+ }
61
+ return null;
62
+ }
63
+ /**
64
+ * 解析 DeepSeek 官方 `/user/balance` 的响应。
65
+ *
66
+ * @param raw - 已 JSON.parse 的响应体。
67
+ * @returns 钱包与赠送额度;形状不对回 `null`(不猜)。
68
+ */
69
+ export function parseOfficialBalance(raw) {
70
+ if (raw === null || typeof raw !== 'object')
71
+ return null;
72
+ var infos = raw.balance_infos;
73
+ if (!Array.isArray(infos))
74
+ return null;
75
+ var wallets = [];
76
+ var bonus = [];
77
+ for (var index = 0; index < infos.length; index += 1) {
78
+ var item = infos[index];
79
+ if (item === null || typeof item !== 'object')
80
+ continue;
81
+ var row = item;
82
+ if (typeof row.currency !== 'string' || row.currency === '')
83
+ continue;
84
+ var total = amountOf(row.total_balance);
85
+ if (total === null)
86
+ continue;
87
+ wallets.push({ currency: row.currency, balance: total });
88
+ // 赠送额度单独一栏,与账号那一路的口径一致(`granted_balance` 是"总额里有多少是赠送")。
89
+ var granted = amountOf(row.granted_balance);
90
+ if (granted !== null && granted !== '0' && granted !== '0.00') {
91
+ bonus.push({ currency: row.currency, balance: granted });
92
+ }
93
+ }
94
+ return wallets.length === 0 ? null : { wallets: wallets, bonus: bonus };
95
+ }
96
+ /** 小数点后保留两位(站点给的是浮点,钱的展示以分为准)。 */
97
+ function centsRounded(value) {
98
+ return value.toFixed(2);
99
+ }
100
+ /**
101
+ * 解析中转站的那一对账单响应。
102
+ *
103
+ * 两件事必须说清楚,否则数字会错得看不出来:
104
+ *
105
+ * 1. **`hard_limit_usd` 是剩余额度,不是总额度**(one-api / new-api 的账单接口按这个
106
+ * 语义发字段,`soft_limit_usd` 与它同值)。所以**不能再减一次 `total_usage`** ——
107
+ * 减了就是把余额算少一截。`total_usage` 是"另一件事":已用量。
108
+ * 2. **`total_usage` 的单位是分**(one-api 的 `GetUsage` 把额度换算成
109
+ * `quota / QuotaPerUnit * 100` 发出来)。实测 `total_usage: 21274.26` 即 $212.74。
110
+ *
111
+ * 「无限」占位:额度是 `1e8` 的站(实测 `ur` / `ccwu` / `happycoding` 两次探测都恰好
112
+ * 是 100000000,期间真实用量在涨而它一动不动)——这不是余额,是"无限额度"的哨兵值。
113
+ * 这时剩余额**算不出来**,只能报已用,并用 `placeholderLimit` 把这个事实传给界面。
114
+ *
115
+ * @param subscription - `/dashboard/billing/subscription` 的响应体。
116
+ * @param usage - `/dashboard/billing/usage` 的响应体。
117
+ * @returns 剩余/已用;两个形状都对不上时回 `null`。
118
+ */
119
+ export function parseRelayBilling(subscription, usage) {
120
+ var reported = null;
121
+ if (subscription !== null && typeof subscription === 'object') {
122
+ var sub = subscription;
123
+ // `hard_limit_usd` 优先(语义更贴"可用到哪");站点没给才退到 `soft_limit_usd`。
124
+ for (var candidate of [sub.hard_limit_usd, sub.soft_limit_usd]) {
125
+ if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0) {
126
+ reported = candidate;
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ var usedCents = null;
132
+ if (usage !== null && typeof usage === 'object') {
133
+ var total = usage.total_usage;
134
+ if (typeof total === 'number' && Number.isFinite(total) && total >= 0)
135
+ usedCents = total;
136
+ }
137
+ if (reported === null && usedCents === null)
138
+ return null;
139
+ var usedUsd = usedCents === null ? null : usedCents / 100;
140
+ var placeholder = reported === null || reported >= PLACEHOLDER_LIMIT_USD;
141
+ return {
142
+ // 字段名是 `*_usd`,但站点口径不一定真是美元;这里只转述,不做任何汇率换算。
143
+ currency: 'USD',
144
+ // 剩余 = 站点报的那个数本身(不减已用,见上面第 1 点)。
145
+ remaining: placeholder ? null : centsRounded(reported),
146
+ used: usedUsd === null ? null : centsRounded(usedUsd),
147
+ placeholderLimit: placeholder,
148
+ };
149
+ }
150
+ /**
151
+ * 从设置投影里认出提供方 →(baseURL、凭据引用)。
152
+ *
153
+ * 只认两个已知命名空间,别的一律不看:
154
+ * - `llm-pi-ai`:`providers` 字典,键就是提供方 id(`ur` / `muyuan` / …);
155
+ * - `llm-deepseek-api-key`:单提供方 `deepseek-official`,字段就在顶层。
156
+ *
157
+ * 为什么从设置读而不是自己存一份:用户改中转站地址/密钥引用是在「设置 → 模型」里改的,
158
+ * 我们另存一份必然漂移。这里的读取只发生在宿主进程内,不经过浏览器。
159
+ *
160
+ * @param descriptors - `settings.describe()` 的结果。
161
+ * @returns 提供方 id → 事实。缺字段的条目直接跳过(不猜默认值)。
162
+ */
163
+ export function factsFromSettings(descriptors) {
164
+ var out = new Map();
165
+ for (var index = 0; index < descriptors.length; index += 1) {
166
+ var descriptor = descriptors[index];
167
+ if (descriptor === null || typeof descriptor !== 'object')
168
+ continue;
169
+ var ns = typeof descriptor.ns === 'string' ? descriptor.ns : '';
170
+ var value = descriptor.value;
171
+ if (value === null || typeof value !== 'object')
172
+ continue;
173
+ var tail = nsTailOf(ns);
174
+ // ① 字典形状(`llm-pi-ai`):一个提供方一个 key,每个都自报 baseURL 与凭据引用名。
175
+ // 按**形状**认,不按命名空间字符串认 —— 命名空间是 loader 那边拼出来的
176
+ // (这条 profile 里就是 `include:llm-pi-ai`),写死会静默失配:
177
+ // 余额读不到、胶囊只剩一个 `—`,而没有任何一处会报错。
178
+ var dict = value.providers;
179
+ if (dict !== null && typeof dict === 'object' && !Array.isArray(dict)) {
180
+ for (var [id, entry] of Object.entries(dict)) {
181
+ var fromDict = factsOf(id, entry);
182
+ if (fromDict !== null)
183
+ out.set(id, fromDict);
184
+ }
185
+ continue;
186
+ }
187
+ // ② 单条形状(`llm-deepseek-api-key`):顶层就是 baseURL + apiKeyEnv。
188
+ // 它的 `baseURL` 没有 schema 默认值(不填就是 undefined),而运行时等价于官方端点,
189
+ // 所以这里补上同一个默认值 —— 补的是**上游自己的默认**,不是我们编的地址。
190
+ var single = value;
191
+ if (tail === OFFICIAL_NS && typeof single.apiKeyEnv === 'string') {
192
+ var official = factsOf('deepseek-official', {
193
+ baseURL: typeof single.baseURL === 'string' ? single.baseURL : OFFICIAL_DEFAULT_BASE_URL,
194
+ apiKeyEnv: single.apiKeyEnv,
195
+ });
196
+ if (official !== null)
197
+ out.set('deepseek-official', official);
198
+ continue;
199
+ }
200
+ var standalone = factsOf(tail, single);
201
+ if (standalone !== null)
202
+ out.set(tail, standalone);
203
+ }
204
+ return out;
205
+ }
206
+ /** 命名空间最后一段(loader 会拼成 `include:llm-pi-ai` 这种带前缀的形状)。 */
207
+ function nsTailOf(ns) {
208
+ var parts = ns.split(':');
209
+ return parts.length === 0 ? ns : parts[parts.length - 1];
210
+ }
211
+ /** 一个条目 → 事实;`baseURL` 与 `apiKeyEnv` 都必须是字符串,否则跳过。 */
212
+ function factsOf(provider, entry) {
213
+ if (entry === null || typeof entry !== 'object')
214
+ return null;
215
+ if (typeof entry.baseURL !== 'string' || entry.baseURL === '')
216
+ return null;
217
+ if (typeof entry.apiKeyEnv !== 'string' || entry.apiKeyEnv === '')
218
+ return null;
219
+ return {
220
+ provider: provider,
221
+ kind: kindOf(entry.baseURL),
222
+ baseURL: entry.baseURL,
223
+ apiKeyEnv: entry.apiKeyEnv,
224
+ // 账号余额那条路要的凭据,从 API Key 的引用名**推**出来(零配置):
225
+ // UR_API_KEY → UR_ACCESS_TOKEN (网页访问令牌)
226
+ // UR_API_KEY → UR_USER_ID (部分部署要求的 New-Api-User)
227
+ // 不符合 `*_API_KEY` 形状时就不推导 —— 宁可没有,也不去猜别的引用名。
228
+ accessTokenEnv: refSiblingOf(entry.apiKeyEnv, 'ACCESS_TOKEN'),
229
+ userIdEnv: refSiblingOf(entry.apiKeyEnv, 'USER_ID'),
230
+ };
231
+ }
232
+ /** `UR_API_KEY` + `ACCESS_TOKEN` → `UR_ACCESS_TOKEN`;形状不符回 `null`。 */
233
+ function refSiblingOf(apiKeyEnv, suffix) {
234
+ var tail = '_API_KEY';
235
+ if (!apiKeyEnv.endsWith(tail))
236
+ return null;
237
+ return apiKeyEnv.slice(0, apiKeyEnv.length - tail.length) + '_' + suffix;
238
+ }
239
+ /**
240
+ * 按 baseURL 的 origin 判断走哪套协议。
241
+ *
242
+ * 官方域名就是 `/user/balance`,其余一律当 one-api 家族试 `/dashboard/billing/*` ——
243
+ * 这是"猜协议"唯一允许的地方,猜错了会明确地报 `unsupported`,不会给出假数字。
244
+ *
245
+ * @param baseURL - 提供方 baseURL。
246
+ * @returns `official` / `relay`。
247
+ */
248
+ export function kindOf(baseURL) {
249
+ try {
250
+ return new URL(baseURL).origin === OFFICIAL_ORIGIN ? 'official' : 'relay';
251
+ }
252
+ catch (error) {
253
+ return 'relay';
254
+ }
255
+ }
256
+ /** 把 baseURL 归一成"没有结尾斜杠"的形式(拼路径前用)。 */
257
+ function trimSlash(url) {
258
+ return url.endsWith('/') ? url.slice(0, -1) : url;
259
+ }
260
+ /**
261
+ * 一次 GET + JSON 解析;非 2xx 或非 JSON 都算失败。
262
+ *
263
+ * @param deps - 取数依赖。
264
+ * @param url - 完整地址。
265
+ * @param apiKey - `Bearer` 令牌;传 `null` 表示这条请求不需要鉴权(例如 `/api/status`)。
266
+ * @param extra - 额外请求头(`New-Api-User` 这类站点要求的头)。
267
+ * @returns 解析后的响应体,或失败与状态码。
268
+ */
269
+ async function getJson(deps, url, apiKey, extra) {
270
+ try {
271
+ var headers = { Accept: 'application/json' };
272
+ if (apiKey !== null)
273
+ headers.Authorization = 'Bearer ' + apiKey;
274
+ if (extra !== undefined)
275
+ Object.assign(headers, extra);
276
+ var response = await deps.fetch(url, {
277
+ headers: headers,
278
+ signal: deps.signal === undefined ? undefined : deps.signal(),
279
+ });
280
+ if (!response.ok)
281
+ return { ok: false, status: response.status };
282
+ var text = await response.text();
283
+ try {
284
+ return { ok: true, body: JSON.parse(text) };
285
+ }
286
+ catch (error) {
287
+ return { ok: false, status: response.status };
288
+ }
289
+ }
290
+ catch (error) {
291
+ return { ok: false, status: 0 };
292
+ }
293
+ }
294
+ /**
295
+ * 按提供方自己的协议取一次余额。
296
+ *
297
+ * @param facts - 提供方事实(baseURL / kind / 凭据引用)。
298
+ * @param apiKey - 已解析出来的密钥值(调用方负责取,本函数只发出去)。
299
+ * @param deps - 取数与时钟(测试注入)。
300
+ * @returns 余额载荷;取不到或协议不认时回 `null`(调用方再决定报什么原因)。
301
+ */
302
+ export async function fetchProviderBalance(facts, apiKey, deps) {
303
+ var at = deps.now();
304
+ if (facts.kind === 'official') {
305
+ var origin = OFFICIAL_ORIGIN;
306
+ try {
307
+ origin = new URL(facts.baseURL).origin;
308
+ }
309
+ catch (error) {
310
+ return null;
311
+ }
312
+ var official = await getJson(deps, origin + '/user/balance', apiKey);
313
+ if (official.ok !== true)
314
+ return null;
315
+ var parsed = parseOfficialBalance(official.body);
316
+ if (parsed === null)
317
+ return null;
318
+ return {
319
+ provider: facts.provider,
320
+ kind: 'official',
321
+ currency: parsed.wallets[0].currency,
322
+ remaining: parsed.wallets[0].balance,
323
+ // `/user/balance` 只报钱包余额,没有"已用"这个数 —— 两条都留空,不拿别处的数顶上。
324
+ usedAccount: null,
325
+ usedKey: null,
326
+ placeholderLimit: false,
327
+ wallets: parsed.wallets,
328
+ bonus: parsed.bonus,
329
+ at: at,
330
+ };
331
+ }
332
+ var base = trimSlash(facts.baseURL);
333
+ // 区间给"全历史",不是"本月":实测 ur / muyuan / happycoding / ccwu **都忽略**这两个参数,
334
+ // 回的始终是这枚 Key 的累计用量。既然区间不生效,就别写一个"本月"的标签去暗示它按月算 ——
335
+ // 标签跟着"这个数到底是什么"走,落在 `usedKey` 这个字段上。
336
+ var end = new Date(at + 86400000).toISOString().slice(0, 10);
337
+ var subscription = await getJson(deps, base + '/dashboard/billing/subscription', apiKey);
338
+ if (subscription.ok !== true)
339
+ return null;
340
+ var usage = await getJson(deps, base + '/dashboard/billing/usage?start_date=1970-01-01&end_date=' + end, apiKey);
341
+ var billing = parseRelayBilling(subscription.body, usage.ok === true ? usage.body : null);
342
+ if (billing === null)
343
+ return null;
344
+ return {
345
+ provider: facts.provider,
346
+ kind: 'relay',
347
+ currency: billing.currency,
348
+ remaining: billing.remaining,
349
+ // 这个"已用"是**这枚 API Key** 的累计量,不是账号总量(也不是本月)。
350
+ usedKey: billing.used,
351
+ usedAccount: null,
352
+ placeholderLimit: billing.placeholderLimit,
353
+ wallets: [],
354
+ bonus: [],
355
+ at: at,
356
+ };
357
+ }
358
+ /* ------------------------------------------- 中转站「账号余额」(网页令牌那条路) */
359
+ /**
360
+ * new-api 的默认换算率:`quota` 是整数,除以它才是站点自己的货币金额。
361
+ *
362
+ * 站点会把真实值公开在 `GET /api/status` 的 `quota_per_unit` 里(实测 ur = 500000),
363
+ * 这里是它取不到时用的兜底 —— new-api 上游的默认值,不是我们编的系数。
364
+ */
365
+ export const QUOTA_PER_UNIT_DEFAULT = 500000;
366
+ /**
367
+ * 解析 `/api/status` 的金额口径。
368
+ *
369
+ * @param raw - 已 JSON.parse 的响应体。
370
+ * @returns 换算口径;字段缺失或非法时回 `null`(调用方用默认值)。
371
+ */
372
+ export function parseAmountScale(raw) {
373
+ if (raw === null || typeof raw !== 'object')
374
+ return null;
375
+ var data = raw.data;
376
+ if (data === null || typeof data !== 'object')
377
+ return null;
378
+ var fields = data;
379
+ var rawPerUnit = fields.quota_per_unit;
380
+ if (typeof rawPerUnit !== 'number' || !Number.isFinite(rawPerUnit) || rawPerUnit <= 0)
381
+ return null;
382
+ var perUnit = rawPerUnit;
383
+ var display = typeof fields.quota_display_type === 'string' ? fields.quota_display_type : '';
384
+ var rate = fields.usd_exchange_rate;
385
+ return {
386
+ quotaPerUnit: perUnit,
387
+ currency: display === '' ? 'USD' : display.toUpperCase(),
388
+ exchangeRate: typeof rate === 'number' && Number.isFinite(rate) && rate > 0 ? rate : 1,
389
+ };
390
+ }
391
+ /** 一个 `quota` 整数 → 站点自己口径的十进制金额字符串。 */
392
+ function amountFromQuota(quota, scale) {
393
+ if (!Number.isFinite(quota))
394
+ return null;
395
+ var amount = quota / scale.quotaPerUnit;
396
+ // 站点用非美元显示时,按**它自己公布的汇率**换算(不是我编的汇率)。
397
+ if (scale.currency !== 'USD')
398
+ amount = amount * scale.exchangeRate;
399
+ // 向零截断到两位:余额是钱,不该出现第三位小数(与账号那一路的规则一致)。
400
+ var truncated = amount >= 0 ? Math.floor(amount * 100) / 100 : Math.ceil(amount * 100) / 100;
401
+ // `-0` 归一成 `0`:`-0.000006` 截断后是"没有钱",不是"负的零钱"。
402
+ if (truncated === 0)
403
+ truncated = 0;
404
+ return truncated.toFixed(2);
405
+ }
406
+ /**
407
+ * 解析 new-api `GET /api/user/self` 的响应(**账号级**余额)。
408
+ *
409
+ * 这是唯一能拿到"当前余额"的接口 —— 它挂在 `UserAuth()` 下,认后台凭据
410
+ * (PAT / 登录会话),不认 `sk-` 开头的 API Key。所以调用方得先拿到一枚后台凭据。
411
+ *
412
+ * @param raw - 已 JSON.parse 的响应体。
413
+ * @param scale - 金额口径(来自 `/api/status`)。
414
+ * @returns 余额、账号累计已用、请求数;形状不对回 `null`。
415
+ */
416
+ export function parseUserSelfBalance(raw, scale) {
417
+ if (raw === null || typeof raw !== 'object')
418
+ return null;
419
+ var envelope = raw;
420
+ // new-api 用 `success: true`;个别 fork 只给 `data`。没有 `data` 就是没读到。
421
+ if (envelope.data === null || typeof envelope.data !== 'object')
422
+ return null;
423
+ if (envelope.success === false)
424
+ return null;
425
+ var user = envelope.data;
426
+ var remaining = typeof user.quota === 'number' ? amountFromQuota(user.quota, scale) : null;
427
+ var usedAccount = typeof user.used_quota === 'number' ? amountFromQuota(user.used_quota, scale) : null;
428
+ if (remaining === null && usedAccount === null)
429
+ return null;
430
+ // 站点后台「API 请求」那个数(截图里 3685);同一个响应里就有,顺手带上。
431
+ var requests = typeof user.request_count === 'number' && Number.isFinite(user.request_count)
432
+ ? user.request_count
433
+ : null;
434
+ return {
435
+ remaining: remaining,
436
+ usedAccount: usedAccount,
437
+ currency: scale.currency,
438
+ requests: requests,
439
+ };
440
+ }
441
+ /**
442
+ * 用**后台凭据**读中转站的账号余额(`GET /api/user/self`)。
443
+ *
444
+ * 为什么单独一条路:new-api 把 `/api/user/self` 挂在 `UserAuth()` 下,认的是后台凭据
445
+ * (PAT 或登录会话令牌);`sk-` API Key 走的是另一套 `TokenAuth`,只能读
446
+ * `/dashboard/billing/*`(那里往往只给"无限额度"占位值与用量)。
447
+ * 拿不到就不要假装拿得到 —— 回 `null` 让调用方退回用量那一路。
448
+ *
449
+ * @param facts - 提供方事实(用它的 origin)。
450
+ * @param accessToken - 后台凭据(PAT 或登录会话令牌),放进 `Authorization: Bearer`。
451
+ * @param userId - 可选的 `New-Api-User`(只有老版本 / 部分 fork 校验)。
452
+ * @param deps - 取数与时钟。
453
+ * @returns 账号级余额;令牌无效 / 形状不对时回 `null`。
454
+ */
455
+ export async function fetchUserBalance(facts, accessToken, userId, deps) {
456
+ var origin = originOf(facts.baseURL);
457
+ if (origin === null)
458
+ return null;
459
+ // 换算率是公开的(不需要鉴权);拿不到就用 new-api 的默认值。
460
+ var status = await getJson(deps, origin + '/api/status', null);
461
+ var scale = (status.ok === true ? parseAmountScale(status.body) : null) ?? {
462
+ quotaPerUnit: QUOTA_PER_UNIT_DEFAULT,
463
+ currency: 'USD',
464
+ exchangeRate: 1,
465
+ };
466
+ var extra = {};
467
+ if (userId !== null && userId !== '')
468
+ extra['New-Api-User'] = userId;
469
+ var self = await getJson(deps, origin + '/api/user/self', accessToken, extra);
470
+ if (self.ok !== true)
471
+ return null;
472
+ var parsed = parseUserSelfBalance(self.body, scale);
473
+ if (parsed === null)
474
+ return null;
475
+ return {
476
+ provider: facts.provider,
477
+ kind: 'relay',
478
+ currency: parsed.currency,
479
+ remaining: parsed.remaining,
480
+ // 账号 `/api/user/self` 的 `used_quota` 是**整个账号**的累计量。
481
+ usedAccount: parsed.usedAccount,
482
+ usedKey: null,
483
+ requests: parsed.requests,
484
+ // 账号余额是站点自己算出来的确定值,不是"无限额度"占位。
485
+ placeholderLimit: false,
486
+ wallets: [],
487
+ bonus: [],
488
+ at: deps.now(),
489
+ };
490
+ }
491
+ /** 从 baseURL 取 origin;不是合法 URL 就回 `null`。 */
492
+ function originOf(baseURL) {
493
+ try {
494
+ return new URL(baseURL).origin;
495
+ }
496
+ catch (error) {
497
+ return null;
498
+ }
499
+ }
500
+ /**
501
+ * 真实取数依赖:`fetch` + 单次超时。
502
+ *
503
+ * 超时用 `AbortSignal.timeout`(每次请求一个新的信号):中转站偶尔会挂住不返回,
504
+ * 不设上限就会把余额胶囊那条链一直吊着。
505
+ *
506
+ * @returns 供 `createProviderBalanceReader` 用的依赖。
507
+ */
508
+ export function defaultFetchDeps() {
509
+ return {
510
+ fetch: async function (url, init) {
511
+ var timeout = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
512
+ ? AbortSignal.timeout(PROVIDER_TIMEOUT_MS)
513
+ : undefined;
514
+ var response = await fetch(url, { headers: init.headers, signal: timeout });
515
+ return {
516
+ ok: response.ok,
517
+ status: response.status,
518
+ text: function () {
519
+ return response.text();
520
+ },
521
+ };
522
+ },
523
+ now: function () {
524
+ return Date.now();
525
+ },
526
+ };
527
+ }
528
+ /** 没取到时给界面的原因(`unsupported` = 两套协议都不认,别当网络错误)。 */
529
+ export function failureOf(provider, kind, reason, message) {
530
+ return { provider: provider, kind: kind, reason: reason, message: message ?? null };
531
+ }
package/lib/host/rpc.js CHANGED
@@ -10,6 +10,28 @@ export const MAX_BODY_BYTES = 256 * 1024;
10
10
  /** 一次最多给多少个改动文件算"这一版"的签名(超出的那些只显示"未审",不报错)。 */
11
11
  export const REVIEW_SIGNATURE_MAX = 200;
12
12
  const toArgs = (args) => args !== null && typeof args === 'object' ? args : {};
13
+ /**
14
+ * 「按会话模型来源查余额」的读取器。
15
+ *
16
+ * 端点表是静态对象(没有 ctx),所以由插件 `apply` 在启动时装进来 ——
17
+ * 与 `services.ts` 里那几个软依赖读数器同一个套路。没装就是"这个宿主不支持"。
18
+ */
19
+ let providerBalanceReader = null;
20
+ /**
21
+ * 「把后台凭据写进凭据存储」的写入器。
22
+ *
23
+ * 与读取器同一个套路:端点表拿不到 ctx,由 `apply` 装进来。
24
+ * 这是本插件**唯一**会改状态的动作,所以收得很窄 —— 只写 `*_ACCESS_TOKEN` 那一个引用。
25
+ */
26
+ let providerTokenWriter = null;
27
+ /** 装/卸读取器(`apply` 与它的清理函数各调一次)。 */
28
+ export function setProviderBalanceReader(reader) {
29
+ providerBalanceReader = reader;
30
+ }
31
+ /** 装/卸写入器(同上)。 */
32
+ export function setProviderTokenWriter(writer) {
33
+ providerTokenWriter = writer;
34
+ }
13
35
  /** 端点表:全部只读;新增端点时在这里登记。 */
14
36
  export const ENDPOINTS = {
15
37
  async status(args) {
@@ -24,6 +46,62 @@ export const ENDPOINTS = {
24
46
  }
25
47
  return status;
26
48
  },
49
+ /**
50
+ * 这个会话**现在用的提供方**的余额。
51
+ *
52
+ * 与 `balance`(账号那一路)分开的理由:这一条要读**用户自己的**中转站,凭据与地址都来自
53
+ * 「设置 → 模型」,请求只发给他自己填的 baseURL。放在同一条通道上只是复用围栏,语义不同。
54
+ */
55
+ async 'provider-balance'(args) {
56
+ const input = toArgs(args);
57
+ const sessionId = typeof input.sessionId === 'string' && input.sessionId !== '' ? input.sessionId : null;
58
+ if (providerBalanceReader === null) {
59
+ return {
60
+ available: false,
61
+ provider: null,
62
+ kind: null,
63
+ reason: 'unsupported',
64
+ message: 'provider balance reader is not installed',
65
+ token: null,
66
+ };
67
+ }
68
+ const outcome = await providerBalanceReader(sessionId, input.force === true);
69
+ // `token` 顶层透传:客户端据此决定点胶囊是"弹设置面板"还是"刷新一下"。
70
+ // 只给引用名与是否已配 —— 令牌本身永远不出宿主。
71
+ const token = outcome.token ?? null;
72
+ if (outcome.ok === true) {
73
+ // `account: true` = 这个提供方的账本就是 DSH 账号那一路,客户端自己去取。
74
+ if (outcome.value === null)
75
+ return { available: true, account: true, token };
76
+ return { available: true, balance: outcome.value, token };
77
+ }
78
+ return {
79
+ available: false,
80
+ provider: outcome.failure.provider,
81
+ kind: outcome.failure.kind,
82
+ reason: outcome.failure.reason,
83
+ message: outcome.failure.message,
84
+ token,
85
+ };
86
+ },
87
+ /**
88
+ * 写入某个提供方的后台凭据(胶囊上的设置面板调这个)。
89
+ *
90
+ * 只接受"这个提供方自己的 `*_ACCESS_TOKEN`"——引用名由宿主从设置投影推导,
91
+ * 客户端连引用名都不用传。令牌不回显,也不进任何日志。
92
+ */
93
+ async 'provider-credential'(args) {
94
+ const input = toArgs(args);
95
+ const provider = typeof input.provider === 'string' ? input.provider.trim() : '';
96
+ if (provider === '')
97
+ return { saved: false, reason: 'unknown-provider' };
98
+ if (providerTokenWriter === null)
99
+ return { saved: false, reason: 'unsupported' };
100
+ const result = await providerTokenWriter(provider, input.token);
101
+ if (result.ok === true)
102
+ return { saved: true, ref: result.ref };
103
+ return { saved: false, reason: result.reason, message: result.message ?? null };
104
+ },
27
105
  async 'session-tools'(args) {
28
106
  const input = toArgs(args);
29
107
  const sessionId = typeof input.sessionId === 'string' && input.sessionId.length > 0 ? input.sessionId : null;