dsh-api-dashboard 1.1.2 → 1.4.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 (4) hide show
  1. package/README.md +138 -2
  2. package/client/client.js +716 -142
  3. package/package.json +1 -1
  4. package/src/index.js +1298 -182
package/src/index.js CHANGED
@@ -15,10 +15,10 @@
15
15
 
16
16
  import Schema from '@deepseek-ai/schemastery'
17
17
  import { z } from 'zod'
18
- import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, rmSync, cpSync, statSync, readdirSync, realpathSync } from 'node:fs'
18
+ import { readFileSync, writeFileSync, renameSync, chmodSync, existsSync, mkdirSync, rmSync, cpSync, statSync, readdirSync, realpathSync } from 'node:fs'
19
19
  import { fileURLToPath } from 'node:url'
20
20
  import { execFileSync } from 'node:child_process'
21
- import { tmpdir } from 'node:os'
21
+ import { tmpdir, homedir } from 'node:os'
22
22
  import { join, dirname, basename } from 'node:path'
23
23
 
24
24
  export const name = 'dsh-api-dashboard'
@@ -88,13 +88,13 @@ export async function applyUpdate({ targets = null, timeoutMs = 30000, remoteVer
88
88
  if (currentVersion && semverCompare(wantVersion, currentVersion) <= 0) {
89
89
  throw new Error(`already up to date (${currentVersion})`)
90
90
  }
91
- // 待写入目录: 运行实体优先; 若经典源码目录 (/root/dsha-api-dashboard) 存在
91
+ // 待写入目录: 运行实体优先; 若经典源码目录 (~/dsha-api-dashboard) 存在
92
92
  // 且是与运行实体不同的另一条真实路径, 一并同步, 避免链接形态下两边版本漂移.
93
93
  // (仅在缺省自动模式下探测; 显式注入 targets 的测试/调试调用不受影响)
94
94
  const dirs = Array.isArray(targets) && targets.length ? [...new Set(targets)] : [SELF_ROOT]
95
95
  if (!Array.isArray(targets)) {
96
96
  try {
97
- const legacyReal = realPathSafe('/root/dsha-api-dashboard')
97
+ const legacyReal = realPathSafe(join(homedir(), 'dsha-api-dashboard'))
98
98
  const selfReal = realPathSafe(SELF_ROOT)
99
99
  if (legacyReal && selfReal && legacyReal !== selfReal && existsSync(join(legacyReal, 'package.json'))) {
100
100
  dirs.push(legacyReal)
@@ -200,24 +200,400 @@ async function getUpdateStatus(force = false) {
200
200
  // 配置持久化: 设置面板保存的配置写入独立状态文件, 重启后恢复
201
201
  // (不写回 cordis.patch.yml, 避免 YAML 写坏导致 dsh 起不来)
202
202
  // ============================================================
203
- const STATE_FILE = '/root/.dsh/dsh-api-dashboard.json'
203
+ const STATE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-api-dashboard.json')
204
+
205
+ /**
206
+ * 状态文件结构版本。**改动已持久化字段的默认值时必须 +1 并补一段迁移**,
207
+ * 否则老用户的状态文件会把字段钉死在旧值上 —— 新默认值对老用户永远不生效。
208
+ * 1 → 2: v1.4.0 `overseasCurrency` 默认 'follow' → 'USD'。
209
+ * 老状态文件里那行 'follow' 是**旧默认值写下来的**, 不是用户的显式选择,
210
+ * 因此迁移时把它改成 'USD'; 迁移后用户再手动选 'follow' 就会被正常尊重。
211
+ */
212
+ const CONFIG_VERSION = 2
213
+
214
+ const migratePersistedState = (parsed) => {
215
+ const s = (parsed && typeof parsed === 'object') ? parsed : {}
216
+ const ver = Number.isFinite(s.configVersion) ? s.configVersion : 1
217
+ let out = s
218
+ if (ver < 2) {
219
+ if (out.overseasCurrency === 'follow' || out.overseasCurrency === undefined) {
220
+ out = { ...out, overseasCurrency: 'USD' }
221
+ }
222
+ }
223
+ if (out.configVersion !== CONFIG_VERSION) out = { ...out, configVersion: CONFIG_VERSION }
224
+ return out
225
+ }
204
226
 
205
227
  const loadPersistedState = () => {
206
228
  try {
207
229
  const raw = readFileSync(STATE_FILE, 'utf8')
208
- const parsed = JSON.parse(raw)
209
- return parsed && typeof parsed === 'object' ? parsed : {}
210
- } catch { return {} }
230
+ return migratePersistedState(JSON.parse(raw))
231
+ } catch { return migratePersistedState({}) }
211
232
  }
212
233
 
213
234
  const savePersistedState = (state) => {
214
235
  try {
215
- writeFileSync(STATE_FILE + '.tmp', JSON.stringify(state, null, 2), 'utf8')
236
+ const merged = { ...loadPersistedState(), ...state }
237
+ // mode 0o600: 状态文件含自定义中转站/模型的 API Key 明文, 必须限定本用户可读
238
+ // (不能依赖 umask —— 默认 umask 0022 的桌面机会落成 0644); chmod 兜底修正旧文件
239
+ writeFileSync(STATE_FILE + '.tmp', JSON.stringify(merged, null, 2), { encoding: 'utf8', mode: 0o600 })
216
240
  renameSync(STATE_FILE + '.tmp', STATE_FILE)
241
+ try { chmodSync(STATE_FILE, 0o600) } catch { /* 平台不支持或已是 0600, 忽略 */ }
217
242
  return true
218
243
  } catch { return false }
219
244
  }
220
245
 
246
+ // ============================================================
247
+ // provider 官方/中转判定 (开源化改造)
248
+ // ------------------------------------------------------------
249
+ // 用途: 判断当前对话走的是官方直连还是中转站。中转站没有余额接口,
250
+ // 状态条金额必须显示「—」, 而不是拿某个官方平台的余额顶上去。
251
+ //
252
+ // 三层判定 (优先级由高到低, 客户端 isRelayProvider 按同样顺序落地):
253
+ // 1) 用户在设置面板显式声明的「官方直连 provider」名单 (officialProviders)
254
+ // —— 最高优先级, 兜住下面两层的一切误判
255
+ // 2) 读 settings.yaml 里 llm-pi-ai.providers.<name>.baseURL, 按 **域名** 比对
256
+ // 官方端点白名单 (不是比对 provider 名 —— 别人的 provider 叫什么猜不到)
257
+ // 3) DSH 官方插件命名约定: `-official` / `_official` 后缀 (客户端兜底)
258
+ // 都不命中 → 按中转站处理 (保守: 宁可不显示余额, 也不显示错的余额)
259
+ //
260
+ // ⚠️ 只认 settings.yaml 里「显式写出」的 baseURL。provider 省略 baseURL 时靠
261
+ // llm-pi-ai 内置目录解析, 而内置目录里的官方域名并不代表用户这把 key 来自官方
262
+ // (实测: xiaomi 无 baseURL, 内置目录指向 api.xiaomimimo.com, 但用户的 key 实际
263
+ // 来自中转站) → 这种情况不表态, 交给第 3 层, 最终落到「按中转站」。
264
+ // ============================================================
265
+
266
+ /** 官方 API 端点主机名白名单 (精确匹配)。
267
+ * 取自各平台官方文档与 pi-ai 内置 provider 目录的 baseUrl。
268
+ * 拿不准的一律不列 —— 不列只是「不显示余额」, 列错会显示别家的余额。 */
269
+ const OFFICIAL_API_HOSTS = new Set([
270
+ // 国内
271
+ 'api.deepseek.com',
272
+ 'open.bigmodel.cn', 'api.z.ai',
273
+ 'api.moonshot.cn', 'api.moonshot.ai', 'api.kimi.com',
274
+ 'api.stepfun.com',
275
+ 'api.siliconflow.cn',
276
+ 'api.minimaxi.com', 'api.minimax.io', 'api.minimax.chat',
277
+ 'dashscope.aliyuncs.com', 'dashscope-intl.aliyuncs.com',
278
+ 'token-plan.cn-beijing.maas.aliyuncs.com', 'token-plan.ap-southeast-1.maas.aliyuncs.com',
279
+ 'api.ant-ling.com',
280
+ // 海外
281
+ 'api.openai.com', 'chatgpt.com',
282
+ 'api.anthropic.com',
283
+ 'generativelanguage.googleapis.com',
284
+ 'openrouter.ai',
285
+ 'api.novita.ai',
286
+ 'api.x.ai',
287
+ 'api.mistral.ai',
288
+ 'api.groq.com',
289
+ 'api.together.ai', 'api.together.xyz',
290
+ 'api.fireworks.ai',
291
+ 'api.cerebras.ai',
292
+ 'integrate.api.nvidia.com',
293
+ 'router.huggingface.co',
294
+ 'api.individual.githubcopilot.com',
295
+ ])
296
+
297
+ /** 官方端点域名后缀 (子域一律算官方; 只用于确实由厂商独占的注册域)。
298
+ * ⚠️ 通用云域名 (aliyuncs.com / cloudflare 之类) 绝不能进这里 —— 谁都能在上面开服务。 */
299
+ const OFFICIAL_API_SUFFIXES = [
300
+ '.xiaomimimo.com', // api / token-plan-cn / token-plan-ams / token-plan-sgp
301
+ ]
302
+
303
+ /** URL → 小写主机名 (去端口); 解析不了返回空串 */
304
+ export const hostOfUrl = (url) => {
305
+ try { return new URL(String(url)).hostname.toLowerCase() } catch { return '' }
306
+ }
307
+
308
+ /** 主机名是否属于官方 API 端点 */
309
+ export function isOfficialHost(host) {
310
+ if (typeof host !== 'string' || host === '') return false
311
+ const h = host.toLowerCase()
312
+ if (OFFICIAL_API_HOSTS.has(h)) return true
313
+ return OFFICIAL_API_SUFFIXES.some((suffix) => h.endsWith(suffix))
314
+ }
315
+
316
+ /**
317
+ * settings.yaml 里 `llm-pi-ai.providers.<name>.<field>` 的通用取值器 (v1.4.0 抽出,
318
+ * 原先只取 baseURL 一个字段, 「真自动」要连 apiKeyEnv 一起取)。
319
+ * 手写最小缩进解析器 —— 刻意不引 yaml 依赖: package.json 的 dependencies 保持为空,
320
+ * 引依赖会破坏零依赖安装。只认这一条路径, 别的 YAML 语法一概不管。
321
+ * @param {string} text settings.yaml 全文
322
+ * @param {string[]} wanted 想取的字段名 (provider 直接子字段那一层)
323
+ * @returns {Record<string,Record<string,string>>} { providerName: { field: value } }
324
+ */
325
+ const collectProviderFields = (text, wanted) => {
326
+ const out = {}
327
+ if (typeof text !== 'string' || text === '') return out
328
+ const indentOf = (line) => line.length - line.replace(/^[ \t]+/, '').length
329
+ // 取 `key: value` 的键与值; 列表项 (`- id: x`) 与非键值行返回 null
330
+ const keyOf = (line) => {
331
+ if (line.startsWith('-')) return null
332
+ const m = /^([^\s#][^:]*):(.*)$/.exec(line)
333
+ return m === null ? null : { key: m[1].trim(), value: m[2].trim() }
334
+ }
335
+ // 剥掉行内注释与引号
336
+ const cleanValue = (raw) => {
337
+ let v = raw.split(' #')[0].trim()
338
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
339
+ return v.trim()
340
+ }
341
+ let sectionIndent = -1 // `llm-pi-ai:` 的缩进
342
+ let sectionChildIndent = -1 // llm-pi-ai 直接子键的缩进 (只在这一层认 `providers`)
343
+ let providersIndent = -1 // `providers:` 的缩进
344
+ let nameIndent = -1 // `<providerName>:` 的缩进
345
+ let fieldIndent = -1 // provider 直接子字段的缩进 (只认这一层的 baseURL)
346
+ let current = ''
347
+ for (const raw of text.split(/\r?\n/)) {
348
+ const trimmed = raw.trim()
349
+ if (trimmed === '' || trimmed.startsWith('#')) continue
350
+ const indent = indentOf(raw)
351
+ // 退出比当前更浅的层级
352
+ if (current !== '' && nameIndent >= 0 && indent <= nameIndent) { current = ''; fieldIndent = -1 }
353
+ if (providersIndent >= 0 && indent <= providersIndent) { providersIndent = -1; nameIndent = -1 }
354
+ if (sectionIndent >= 0 && indent <= sectionIndent && providersIndent < 0) {
355
+ // 同级或更浅的另一个顶层键 → llm-pi-ai 段结束
356
+ const kv = keyOf(trimmed)
357
+ if (kv !== null && kv.key !== 'llm-pi-ai') { sectionIndent = -1; sectionChildIndent = -1 }
358
+ }
359
+ const kv = keyOf(trimmed)
360
+ if (kv === null) continue // 列表项 (`- id: x`) 等一概跳过
361
+ if (sectionIndent < 0) {
362
+ if (kv.key === 'llm-pi-ai' && kv.value === '') { sectionIndent = indent; sectionChildIndent = -1 }
363
+ continue
364
+ }
365
+ if (providersIndent < 0) {
366
+ if (indent <= sectionIndent) continue
367
+ if (sectionChildIndent < 0) sectionChildIndent = indent
368
+ // 只认 llm-pi-ai 的直接子键 providers, 不误吃更深层同名键
369
+ if (indent === sectionChildIndent && kv.key === 'providers' && kv.value === '') providersIndent = indent
370
+ continue
371
+ }
372
+ if (current === '') {
373
+ // provider 名: providers 的直接子键, 值为空 (dict 头)
374
+ if (indent > providersIndent && kv.value === '') {
375
+ if (nameIndent < 0) nameIndent = indent
376
+ if (indent === nameIndent) { current = kv.key; fieldIndent = -1 }
377
+ }
378
+ continue
379
+ }
380
+ if (indent <= nameIndent) continue
381
+ if (fieldIndent < 0) fieldIndent = indent // provider 下第一个字段定基准缩进
382
+ if (indent !== fieldIndent) continue // 更深的层 (models 项内部等) 不认
383
+ if (wanted.includes(kv.key)) {
384
+ const value = cleanValue(kv.value)
385
+ if (value !== '') {
386
+ if (out[current] === undefined) out[current] = {}
387
+ out[current][kv.key] = value
388
+ }
389
+ }
390
+ }
391
+ return out
392
+ }
393
+
394
+ /**
395
+ * 从 settings.yaml 文本里抓 `llm-pi-ai.providers.<name>.baseURL` (第 2 层判定用)。
396
+ * @param {string} text settings.yaml 全文
397
+ * @returns {Record<string,string>} { providerName: baseURL }
398
+ */
399
+ export function parseProviderBaseURLs(text) {
400
+ const out = {}
401
+ for (const [name, fields] of Object.entries(collectProviderFields(text, ['baseURL', 'baseUrl']))) {
402
+ const url = fields.baseURL !== undefined ? fields.baseURL : fields.baseUrl
403
+ if (url !== undefined) out[name] = url
404
+ }
405
+ return out
406
+ }
407
+
408
+ /**
409
+ * v1.4.0「真自动」: 抓 provider 的 baseURL **和 apiKeyEnv**。
410
+ * 插件据此把用户在 DSH 里配好的中转站直接变成可查余额的条目 ——
411
+ * 不必再去插件设置里手抄一遍 baseUrl + key。
412
+ * @param {string} text settings.yaml 全文
413
+ * @returns {Record<string,{baseURL:string,apiKeyEnv:string}>}
414
+ */
415
+ export function parseProviderEntries(text) {
416
+ const out = {}
417
+ for (const [name, fields] of Object.entries(collectProviderFields(text, ['baseURL', 'baseUrl', 'apiKeyEnv']))) {
418
+ out[name] = {
419
+ baseURL: fields.baseURL !== undefined ? fields.baseURL : (fields.baseUrl !== undefined ? fields.baseUrl : ''),
420
+ apiKeyEnv: fields.apiKeyEnv !== undefined ? fields.apiKeyEnv : '',
421
+ }
422
+ }
423
+ return out
424
+ }
425
+
426
+ /**
427
+ * v1.4.0「真自动」: 从 settings.yaml 派生数据里挑出「该自动去查余额」的 provider。
428
+ * 纯函数, 不碰文件/网络, 便于单测。**不在这里解析 key** —— 那步要访问 credentials 服务, 是异步的。
429
+ *
430
+ * 过滤规则 (三条, 每条都对应一条既有铁律):
431
+ * 1. 只收**写了 baseURL** 的 provider —— 没写的按铁律 9「不表态」, 交 `-official` 后缀兜底
432
+ * (本机 `xiaomi` 正是「内置目录指向官方域名、但 key 实际来自中转站」的反例);
433
+ * 2. 第 2 层判成 `official` 的跳过 —— 官方直连由预设平台负责, 别重复成一条中转站;
434
+ * 3. 用户关掉的 (`dshProviderOptOut`) 跳过 —— 关过不会被下次自动发现又打开。
435
+ * @param {Record<string,{baseURL?:string,apiKeyEnv?:string}>} entries parseProviderEntries 的结果
436
+ * @param {Record<string,string>} kinds computeProviderKinds 的结果
437
+ * @param {string[]} optOut 用户关掉的 provider 名 (大小写不敏感)
438
+ * @returns {{name:string,baseURL:string,apiKeyEnv:string}[]} 按 provider 名排序, baseURL 已剥尾斜杠
439
+ */
440
+ export const selectDshProviders = (entries, kinds, optOut) => {
441
+ const off = new Set((Array.isArray(optOut) ? optOut : []).map((x) => String(x).toLowerCase()))
442
+ const src = (entries && typeof entries === 'object') ? entries : {}
443
+ const kindMap = (kinds && typeof kinds === 'object') ? kinds : {}
444
+ const out = []
445
+ for (const name of Object.keys(src).sort()) {
446
+ const e = (src[name] && typeof src[name] === 'object') ? src[name] : {}
447
+ const baseURL = typeof e.baseURL === 'string' ? e.baseURL : ''
448
+ if (baseURL === '') continue
449
+ if (kindMap[name] === 'official') continue
450
+ if (off.has(String(name).toLowerCase())) continue
451
+ out.push({
452
+ name,
453
+ baseURL: baseURL.replace(/\/+$/, ''),
454
+ apiKeyEnv: typeof e.apiKeyEnv === 'string' ? e.apiKeyEnv : '',
455
+ })
456
+ }
457
+ return out
458
+ }
459
+
460
+ /**
461
+ * v1.4.0: `/api-dashboard/balances` 的取数策略 —— 纯函数, 便于单测 (策略很容易被"顺手改坏")。
462
+ *
463
+ * 背景: `force=1` 那条路底下是 `await refreshAll()` —— 一次全量轮询要等**最慢**的端点,
464
+ * 最长可以拖满 `timeoutMs`(默认 8s)。应用切回前台 / 页面重载时如果走 force,
465
+ * 用户看到的就是「插件加载很慢, 要等一段时间」。
466
+ *
467
+ * @returns {'wait'|'background'|'none'}
468
+ * wait = 阻塞刷新后返回新数据 (没有东西可显示, 或用户显式强刷)
469
+ * background = 立刻回手上有的, 刷新丢后台 (stale-while-revalidate)
470
+ * none = 缓存够新, 直接用
471
+ */
472
+ export const planBalancesFetch = ({ force = false, peek = false, hasData = false, age = 0, intervalMs = 5000 } = {}) => {
473
+ const stale = age > (intervalMs || 300000) // 兼容旧行为: intervalMs 缺失时用 5 分钟
474
+ if (!hasData) return 'wait' // 冷启动(服务端刚重启): 确实没东西可显示, 只能等
475
+ if (!force && !stale) return 'none'
476
+ if (peek) return age > 1000 ? 'background' : 'none' // 1 秒内刚拉过就不重复打
477
+ return age > 2000 ? 'wait' : 'none' // 显式强刷留 2 秒节流, 防连点打爆平台接口
478
+ }
479
+
480
+ /** v1.4.0: 刷新间隔白名单化 —— 1~60 秒 (下限由 5 秒放宽到 1 秒, 用户要求更快) */
481
+ export const clampRefreshSec = (v) => Math.min(Math.max(Math.round(Number(v) || 1), 1), 60)
482
+
483
+ /**
484
+ * 第 2 层自动判定的补充素材: 提取 settings.yaml 里 `llm-pi-ai.providers.<name>` 的
485
+ * **全部 provider 名**,包括没有写 baseURL 的 provider。这样即使别人没有手写 URL,
486
+ * 只要用的是已知官方 preset 名,也能自动判定为官方,而不必先手动补 settings。
487
+ * 注意:只提取 provider 名本身,不改变 parseProviderBaseURLs 的返回语义。
488
+ */
489
+ export function parseProviderNames(text) {
490
+ const names = []
491
+ if (typeof text !== 'string' || text === '') return names
492
+ const indentOf = (line) => line.length - line.replace(/^[ \t]+/, '').length
493
+ const keyOf = (line) => {
494
+ if (line.startsWith('-')) return null
495
+ const m = /^([^\s#][^:]*):(.*)$/.exec(line)
496
+ return m === null ? null : { key: m[1].trim(), value: m[2].trim() }
497
+ }
498
+ let sectionIndent = -1
499
+ let sectionChildIndent = -1
500
+ let providersIndent = -1
501
+ let nameIndent = -1
502
+ for (const raw of text.split(/\r?\n/)) {
503
+ const trimmed = raw.trim()
504
+ if (trimmed === '' || trimmed.startsWith('#')) continue
505
+ const indent = indentOf(raw)
506
+ if (nameIndent >= 0 && indent <= nameIndent) { nameIndent = -1 }
507
+ if (providersIndent >= 0 && indent <= providersIndent) { providersIndent = -1; nameIndent = -1 }
508
+ if (sectionIndent >= 0 && indent <= sectionIndent && providersIndent < 0) {
509
+ const kv = keyOf(trimmed)
510
+ if (kv !== null && kv.key !== 'llm-pi-ai') { sectionIndent = -1; sectionChildIndent = -1 }
511
+ }
512
+ const kv = keyOf(trimmed)
513
+ if (kv === null) continue
514
+ if (sectionIndent < 0) {
515
+ if (kv.key === 'llm-pi-ai' && kv.value === '') { sectionIndent = indent; sectionChildIndent = -1 }
516
+ continue
517
+ }
518
+ if (providersIndent < 0) {
519
+ if (indent <= sectionIndent) continue
520
+ if (sectionChildIndent < 0) sectionChildIndent = indent
521
+ if (indent === sectionChildIndent && kv.key === 'providers' && kv.value === '') providersIndent = indent
522
+ continue
523
+ }
524
+ if (indent > providersIndent && kv.value === '') {
525
+ if (nameIndent < 0) nameIndent = indent
526
+ if (indent === nameIndent) names.push(kv.key)
527
+ }
528
+ }
529
+ return names
530
+ }
531
+
532
+ /**
533
+ * 第 2 层判定结果: { providerName: 'official' | 'relay' }。
534
+ * 没有 baseURL / baseURL 解析不出主机名的 provider **不写进结果** (不表态, 交第 3 层)。
535
+ */
536
+ export function computeProviderKinds(text) {
537
+ const kinds = {}
538
+ for (const [name, url] of Object.entries(parseProviderBaseURLs(text))) {
539
+ const host = hostOfUrl(url)
540
+ if (host === '') continue
541
+ kinds[name] = isOfficialHost(host) ? 'official' : 'relay'
542
+ }
543
+ // v1.4.0 移除「按 provider 名字猜官方」的兜底。
544
+ // 旧代码把「名字恰好等于某个预设 id 且没写 baseURL」判成 official —— 这与本文件 232-240 行的政策
545
+ // 和 AGENTS.md 铁律 9 直接冲突:「没写 baseURL 的 provider 是不表态、交 `-official` 后缀兜底」。
546
+ // 理由(AGENTS.md 原话): 内置目录指向官方域名 ≠ 用户的 key 来自官方(`xiaomi` 就是反例)。
547
+ // 只看名字会把「恰好同名的中转站会话」顶上官方余额 —— 不表态比猜错安全。
548
+ return kinds
549
+ }
550
+
551
+ /** 用户填的官方直连名单规范化: 接受数组或「逗号/换行/空格分隔」的字符串。
552
+ * 上限 64 条 × 64 字符 —— 名单会持久化并随每次 /balances 下发, 防超大输入撑爆状态文件。 */
553
+ export const normalizeOfficialProviders = (input) => {
554
+ const list = Array.isArray(input)
555
+ ? input
556
+ : typeof input === 'string' ? input.split(/[,,、;;\s]+/) : []
557
+ const seen = new Set()
558
+ const out = []
559
+ for (const item of list) {
560
+ if (out.length >= 64) break
561
+ if (typeof item !== 'string') continue
562
+ const name = item.trim().slice(0, 64)
563
+ if (name === '' || seen.has(name.toLowerCase())) continue
564
+ seen.add(name.toLowerCase())
565
+ out.push(name)
566
+ }
567
+ return out
568
+ }
569
+
570
+ const SETTINGS_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'settings.yaml')
571
+ /**
572
+ * settings.yaml 派生数据缓存, 按 mtime 失效 (轮询每 5s 一次, 别每次都解析)。
573
+ * kinds = 第 2 层官方/中转判定素材
574
+ * entries = v1.4.0「真自动」: 各 provider 的 baseURL / apiKeyEnv,
575
+ * 用来把 DSH 里配好的中转站合成可查余额的条目
576
+ */
577
+ let settingsDerivedCache = { mtimeMs: -1, kinds: {}, entries: {} }
578
+
579
+ /** 读 settings.yaml 并算出全部派生数据 (mtime 缓存) */
580
+ const readSettingsDerived = () => {
581
+ try {
582
+ const mtimeMs = statSync(SETTINGS_FILE).mtimeMs
583
+ if (mtimeMs === settingsDerivedCache.mtimeMs) return settingsDerivedCache
584
+ const text = readFileSync(SETTINGS_FILE, 'utf8')
585
+ settingsDerivedCache = { mtimeMs, kinds: computeProviderKinds(text), entries: parseProviderEntries(text) }
586
+ return settingsDerivedCache
587
+ } catch {
588
+ // settings.yaml 不存在/读不动: 不表态, 全交给第 1、3 层, 也没有可自动发现的中转站
589
+ settingsDerivedCache = { mtimeMs: -1, kinds: {}, entries: {} }
590
+ return settingsDerivedCache
591
+ }
592
+ }
593
+
594
+ /** 读 settings.yaml 算第 2 层判定 */
595
+ const readProviderKinds = () => readSettingsDerived().kinds
596
+
221
597
  // ============================================================
222
598
  // 工具函数
223
599
  // ============================================================
@@ -226,75 +602,181 @@ const toAmount = (value) => {
226
602
  return Number.isFinite(n) ? n : 0
227
603
  }
228
604
 
605
+ /** FNV-1a 32bit 哈希, 用于按内容生成 ETag (数据没变才 304) */
606
+ const fnv1a = (str) => {
607
+ let h = 0x811c9dc5
608
+ for (let i = 0; i < str.length; i++) {
609
+ h ^= str.charCodeAt(i)
610
+ h = Math.imul(h, 0x01000193)
611
+ }
612
+ return (h >>> 0).toString(36)
613
+ }
614
+
229
615
  // ============================================================
230
616
  // DeepSeek 峰谷计费引擎 (学习 dsh-balance)
231
617
  // 北京时间 09:00~12:00 / 14:00~18:00 为峰时(100%), 其余时段谷时特惠(5折)
232
618
  // ============================================================
619
+ // v1.3.4 (2026-09-10): 官方同日 12:00 起调整 Flash 系列定价(最高降幅 60%), 并收敛模型名 ——
620
+ // deepseek-v4-flash → deepseek-flash (旧名仍可调用, 由 V4.1-Flash 服务并按 Flash 价计费, 定价页注 1);
621
+ // 2026-09-14 12:00 后 deepseek-v4-pro 的请求将全部路由到 V4.1-Flash 并按 Flash 价计费(官方计划下线 Pro, 注 2)。
622
+ // 来源: https://api-docs.deepseek.com/zh-cn/quick_start/pricing (CNY) 与 /quick_start/pricing (USD) ——
623
+ // USD 表为官方直发(非 ÷7 换算, 实际口径约 1 USD ≈ 6.67 CNY), pro 档与调整前一致, 未变动。
233
624
  export const V4_RATES = {
234
625
  CNY: {
235
- peak: { 'deepseek-v4-flash': { cacheHit: 0.1, cacheMiss: 3, output: 9 }, 'deepseek-v4-pro': { cacheHit: 0.3, cacheMiss: 9, output: 27 } },
236
- offPeak: { 'deepseek-v4-flash': { cacheHit: 0.05, cacheMiss: 1.5, output: 4.5 }, 'deepseek-v4-pro': { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 } },
626
+ peak: { 'deepseek-flash': { cacheHit: 0.04, cacheMiss: 2, output: 8 }, 'deepseek-v4-pro': { cacheHit: 0.3, cacheMiss: 9, output: 27 } },
627
+ offPeak: { 'deepseek-flash': { cacheHit: 0.02, cacheMiss: 1, output: 4 }, 'deepseek-v4-pro': { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 } },
237
628
  },
238
629
  USD: {
239
- peak: { 'deepseek-v4-flash': { cacheHit: 0.014, cacheMiss: 0.44, output: 1.32 }, 'deepseek-v4-pro': { cacheHit: 0.044, cacheMiss: 1.32, output: 3.96 } },
240
- offPeak: { 'deepseek-v4-flash': { cacheHit: 0.007, cacheMiss: 0.22, output: 0.66 }, 'deepseek-v4-pro': { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 } },
630
+ peak: { 'deepseek-flash': { cacheHit: 0.006, cacheMiss: 0.3, output: 1.2 }, 'deepseek-v4-pro': { cacheHit: 0.044, cacheMiss: 1.32, output: 3.96 } },
631
+ offPeak: { 'deepseek-flash': { cacheHit: 0.003, cacheMiss: 0.15, output: 0.6 }, 'deepseek-v4-pro': { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 } },
241
632
  },
242
633
  }
243
634
 
635
+ /**
636
+ * 通用表 USD → CNY 换算汇率 —— **近似值**, 只在「用户把币种设成非原生币种」时才用到。
637
+ * v1.4.0 起 MODEL_PRICES 按原生币种存储, 默认配置(国内 CNY / 海外 USD)下两边同币种, 根本不走换算;
638
+ * 且 DeepSeek 走 V4_RATES 自带的两套官方表, 本汇率对它无效。
639
+ * ⚠️ 官方 DeepSeek 的 USD 直发价口径约 1 USD ≈ 6.67 CNY, 与本值不同 —— 别拿它去"校正" V4_RATES.USD。
640
+ */
641
+ const USD_TO_CNY_RATE = 7
642
+
643
+ /** 北京时间(UTC+8)的星期与小时, 先 +8h 再取值, 避免跨日界(00:00~08:00)星期比北京时间早一天 */
644
+ const bjtParts = (timestamp) => {
645
+ const d = new Date(timestamp + 8 * 3600 * 1000)
646
+ return { weekday: d.getUTCDay(), hour: d.getUTCHours() }
647
+ }
648
+
244
649
  /**
245
650
  * 当前是否处于 DeepSeek 峰时.
246
651
  * 工作日(周一~周五): 北京时间 09-12 / 14-18 为峰时, 其余谷时。
247
652
  * 周末(周六日): 整天都是谷时特惠。
248
653
  */
249
654
  export const isPeakTime = (timestamp = Date.now()) => {
250
- const d = new Date(timestamp)
251
- const day = d.getUTCDay()
252
- const hourBJT = (d.getUTCHours() + 8) % 24
655
+ const { weekday, hour } = bjtParts(timestamp)
253
656
  // 周末(0=周日, 6=周六)整天谷时
254
- if (day === 0 || day === 6) return false
255
- return (hourBJT >= 9 && hourBJT < 12) || (hourBJT >= 14 && hourBJT < 18)
657
+ if (weekday === 0 || weekday === 6) return false
658
+ return (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18)
256
659
  }
257
660
 
258
661
  /** 当前是否周末 */
259
662
  export const isWeekend = (timestamp = Date.now()) => {
260
- const d = new Date(timestamp)
261
- const day = d.getUTCDay()
262
- return day === 0 || day === 6
663
+ const { weekday } = bjtParts(timestamp)
664
+ return weekday === 0 || weekday === 6
263
665
  }
264
666
 
265
667
 
266
668
  // ============================================================
267
- // 通用模型价格表 — 每百万token, 币种按各平台官方数字直接填入(多为 USD, 本表不做 CNY 换算,
268
- // 仅用于 balanceCost 投影中非 DeepSeek 模型的"估算", 不保证精确).
269
- // ⚠️ DeepSeek 计费请走上面 V4_RATES 峰谷 CNY 表(官方价, 准确); 本通用表覆盖 OpenAI/Claude/Gemini/国产等。
270
- // 来源: 现役主力(2026-08) 来自 NousResearch hermes-agent usage_pricing.py(追踪各官方文档, 逐条注明 source_url);
669
+ // 通用模型价格表 — 每百万token, **按「模型原生币种」存储** (v1.4.0)。
670
+ //
671
+ // 规则: 国内厂商官方定价页给的是 CNY 这里直接写官方 CNY 原值;
672
+ // 海外厂商官方定价页给的是 USD 这里直接写官方 USD 原值。
673
+ // 币种由 modelRegion(model) 判定 (与 currencyForModel 同源), resolveModelPrice 只在
674
+ // 「显示币种 ≠ 原生币种」时才换算 —— 默认配置(currency=CNY / overseasCurrency=USD)下
675
+ // 国内走 CNY、海外走 USD, 两边都是原样返回, 零换算误差。
676
+ //
677
+ // ⚠️ 为什么不继续用「统一存 USD 基准」(v1.3.4 及以前):
678
+ // CNY 官方价 ÷7 入库、显示时再 ×7, 既留舍入尾巴, 更容易把官方 CNY 直接填进 USD 槽位
679
+ // → 面板按 7 倍计费。历史上已被咬过两次:
680
+ // ① MiMo: ¥1 被写成 0.020 (等于又除了一次 7), 少算成 1/7;
681
+ // ② glm-4-plus / 整个「历史/参考」段: ¥2.5/¥5/¥5 被当 USD, 显示 ¥17.5/¥35/¥35。
682
+ // 原生币种存储让这类错误**无法表达** —— 改表时直接抄官方页数字, 不要再做任何 ÷7。
683
+ //
684
+ // ⚠️ DeepSeek 计费请走上面 V4_RATES 峰谷表(自带 CNY/USD 两套, 官方价, 准确); 本通用表覆盖 OpenAI/Claude/Gemini/国产等。
685
+ // 来源: ① 现役主力(2026-09-03) NousResearch hermes-agent usage_pricing.py + 各厂商官方定价页;
686
+ // v1.4.0 (2026-09-10) 复核抓取原文: api-docs.deepseek.com 中英双页 / platform.kimi.com /
687
+ // platform.minimaxi.com / platform.stepfun.com / docs.bigmodel.cn / help.aliyun.com(百炼) /
688
+ // MiMo 官方永久降价公告。逐条核对, 差异已就地注明。
271
689
  // ② 旧模型(2025-08) 为历史参考价. 仅做参考, 实际以平台为准.
272
690
  // ============================================================
273
691
  export const MODEL_PRICES = {
274
- // —— 现役主力 (2026-08, USD/百万tokens, 来源: hermes-agent 追踪官方文档) ——
275
- // OpenAI GPT-5.6 系列
276
- 'gpt-5.6-sol': { cacheHit: 0.50, cacheMiss: 5.00, output: 30.00 },
277
- 'gpt-5.6-terra': { cacheHit: 0.25, cacheMiss: 2.50, output: 15.00 },
278
- 'gpt-5.6-luna': { cacheHit: 0.10, cacheMiss: 1.00, output: 6.00 },
279
- // Anthropic Claude 4.x / 5
280
- 'claude-opus-4-8': { cacheHit: 0.50, cacheMiss: 5.00, output: 25.00 },
281
- 'claude-sonnet-5': { cacheHit: 0.20, cacheMiss: 2.00, output: 10.00 },
692
+ // —— 海外厂商: 单位 USD/百万tokens (原生) ——
693
+ // 来源: modelradar.cn 2026-09-03 快照 (各模型 sourceUrl 均指官方定价页)。
694
+ // 仅采纳与官方口径无分歧的条目; 与原表冲突时保留原值并注明 ——
695
+ // radar 的 GPT-5.6 系输出价全呈「输入×1.25」异常模式, 疑似抓错列, 未采纳。
696
+ // ⚠️ OpenAI / Anthropic / Gemini 官方定价页在本容器环境被 403 / 地域封锁, v1.4.0 未能取到原文复核,
697
+ // 下列海外条目仍为 radar/hermes-agent 二手源, 未逐条核实 —— 有账单单据时优先以单据为准。
698
+ // OpenAI GPT-5.6 系列 (radar 报 sol 输出 $5 / terra $2.5 / luna $0.25, 均为输入×1.25 异常模式, 未采纳)
699
+ 'gpt-5.6-sol': { cacheHit: 0.5, cacheMiss: 4.0, output: 20.0 }, // 临时促销价(至少到 2026-11-21)
700
+ 'gpt-5.6-terra': { cacheHit: 0.2, cacheMiss: 2.0, output: 12.0 }, // 2026-07-30 降价
701
+ 'gpt-5.6-luna': { cacheHit: 0.02, cacheMiss: 0.2, output: 1.2 }, // 2026-07-30 降价
702
+ 'gpt-5.3-codex': { cacheHit: 0.175, cacheMiss: 1.75, output: 14.0 }, // radar 2026-09-03, OpenAI 官方页
703
+ // Anthropic Claude 5
704
+ 'claude-opus-5': { cacheHit: 0.5, cacheMiss: 5.0, output: 25.0 }, // v1.2.0 修正缓存读价: Anthropic 缓存读=0.1×输入, radar 对照 claude.com/pricing (opus-4-8 亦 $0.5); 原误标"无缓存折扣"
705
+ 'claude-sonnet-5': { cacheHit: 0.2, cacheMiss: 2.0, output: 10.0 },
282
706
  'claude-sonnet-4-6': { cacheHit: 0.30, cacheMiss: 3.00, output: 15.00 },
283
707
  'claude-haiku-4-5': { cacheHit: 0.10, cacheMiss: 1.00, output: 5.00 },
284
708
  // Google Gemini 3.x
285
- 'gemini-3.6-flash': { cacheHit: 0.15, cacheMiss: 1.50, output: 7.50 },
286
- 'gemini-3.5-flash': { cacheHit: 0.15, cacheMiss: 1.50, output: 9.00 },
287
- 'gemini-3.1-pro': { cacheHit: 0.20, cacheMiss: 2.00, output: 12.00 },
709
+ 'gemini-3.7-flash': { cacheHit: 0.075, cacheMiss: 0.75, output: 3.75 }, // 促销至 2026-12-31, 之后翻倍
710
+ 'gemini-3.8-flash': { cacheHit: 0.075, cacheMiss: 0.75, output: 3.75 }, // radar 2026-09-02 新增, 与 3.7/3.6 同价
711
+ 'gemini-3.6-flash': { cacheHit: 0.075, cacheMiss: 0.75, output: 3.75 }, // 促销至 2026-12-31, 之后翻倍
712
+ 'gemini-3-flash-preview': { cacheHit: 0.025, cacheMiss: 0.5, output: 3.0 },
713
+ 'gemini-3.5-flash-lite': { cacheHit: 0.3, cacheMiss: 0.3, output: 2.5 }, // 无缓存折扣
714
+ 'gemini-3.1-pro': { cacheHit: 2.0, cacheMiss: 2.0, output: 12.0 }, // 无缓存折扣; 长上下文 $4/$24
288
715
  'gemini-2.5-pro': { cacheHit: 0.125, cacheMiss: 1.25, output: 10.00 },
289
- // —— 国产主力 (2026-08) ——
290
- // Kimi K3 (来源: benchlm.ai / morphllm.com 2026-08, 输入/输出 $3/$15; 缓存折扣未取到 → 暂按无折扣)
291
- 'kimi-k3': { cacheHit: 3.00, cacheMiss: 3.00, output: 15.00 },
292
- // 阶跃星辰 (来源: platform.stepfun.com 官方定价 2026; 列序=输入/缓存命中/输出, CNY1M)
293
- 'step-3.7-flash': { cacheHit: 0.27, cacheMiss: 1.35, output: 8.10 },
294
- 'step-3.5-flash': { cacheHit: 0.14, cacheMiss: 0.70, output: 2.10 },
295
- // ⚠️ 通义 Qwen3 / 智谱 GLM-5 的现役官方一手价未取到可信数字, 不虚构; 遇到这些模型会落 defaultPrices(未定价),
296
- // qwen-*/glm-4 条目仍在下方作历史参考.
716
+ 'gemini-2.5-flash': { cacheHit: 0.03, cacheMiss: 0.3, output: 2.5 }, // radar 2026-09-03, 1M ctx
717
+ // —— 国内厂商: 单位 CNY/百万tokens (原生官方价, 不要再 ÷7) ——
718
+ // 阿里云百炼 Qwen3 (华北2/北京; help.aliyun.com/zh/model-studio/model-pricing 2026-09-10 抓取)
719
+ // 官方上下文缓存规则: 命中按「标准输入单价 10%」计费。
720
+ // ⚠️ 官方明文例外: qwen3.8-max / qwen3.8-flash / qwen3.8-2.4t-a95b 的缓存命中价**不是 10%**,
721
+ // 且未在文档给数字(只写「参见百炼控制台」)→ 这两条 cacheHit 沿用中转站实测报价, 标为「例外价」。
722
+ 'qwen3.8-max': { cacheHit: 1.5, cacheMiss: 12, output: 36 }, // 官方 ¥12/¥36; cacheHit ¥1.5 为控制台例外价(非 10% 规则)
723
+ 'qwen3.7-max': { cacheHit: 1.2, cacheMiss: 12, output: 36 }, // v1.4.0: 官方页现为原价 ¥12/¥36 (旧「5 折促销值」官方页已不存在, 已废)
724
+ 'qwen3.7-plus': { cacheHit: 0.16, cacheMiss: 1.6, output: 6.4 }, // v1.4.0: 官方限时 8 折 (原价 ¥2/¥8)
725
+ 'qwen3.7-flash': { cacheHit: 0.02, cacheMiss: 0.2, output: 0.8 }, // v1.4.0: 官方 ¥0.2/¥0.8 (旧值 0.21/0.91 系中转站高档位, 已废)
726
+ 'qwen3.8-flash': { cacheHit: 0.1, cacheMiss: 0.8, output: 2.7 }, // 官方 ¥0.8/¥2.7; cacheHit ¥0.1 同 3.8-max 为控制台例外价
727
+ 'qwen3.8-27b': { cacheHit: 0.3, cacheMiss: 3, output: 12 }, // v1.4.0: 官方 ¥3/¥12; 缓存命中按官方 10% 规则 → ¥0.3 (旧值 ¥0.6 偏高 100%)
728
+ 'qwen3.6-plus': { cacheHit: 0.2, cacheMiss: 2, output: 12 }, // 官方 ¥2/¥12 (256K 档 ¥8/¥48 未做分档)
729
+ // 智谱 GLM (docs.bigmodel.cn/cn/guide/start/pricing 2026-09-10 抓取)
730
+ // ⚠️ GLM-5 系官方分档: 「[0,32K)」与「≥32K」两套价。本表按 ≥32K(更贵) 入库 —— 估算偏保守高估。
731
+ 'glm-5.3': { cacheHit: 2, cacheMiss: 8, output: 28 }, // 官方 ¥8/¥28/缓存 ¥2
732
+ 'glm-5.2': { cacheHit: 2, cacheMiss: 8, output: 28 }, // 官方 ¥8/¥28/缓存 ¥2
733
+ 'glm-5.1': { cacheHit: 2, cacheMiss: 8, output: 28 }, // 官方 ≥32K 档 ¥8/¥28/缓存 ¥2 ([0,32K) 档为 ¥6/¥24/¥1.3)
734
+ 'glm-5-turbo': { cacheHit: 1.8, cacheMiss: 7, output: 26 }, // v1.4.0: 官方 ≥32K 档 ¥7/¥26/缓存 ¥1.8 (旧值 1.68/8.4/28 两档都不符)
735
+ 'glm-5.3-flash': { cacheHit: 0.23, cacheMiss: 0.8, output: 2.8 }, // 官方 ¥0.8/¥2.8/缓存 ¥0.23
736
+ // Kimi / Moonshot (platform.kimi.com/docs/pricing/* 2026-09-10 抓取, 均 CNY)
737
+ 'kimi-k3': { cacheHit: 2, cacheMiss: 20, output: 100 }, // v1.4.0: 官方 ¥2/¥20/¥100 (旧值全线 +5%)
738
+ 'kimi-k2.7-code': { cacheHit: 1.3, cacheMiss: 6.5, output: 27 }, // 官方 ¥1.3/¥6.5/¥27
739
+ 'kimi-k2.7-code-highspeed': { cacheHit: 2.6, cacheMiss: 13, output: 54 },// v1.4.0 新增: 官方高速版 ¥2.6/¥13/¥54
740
+ 'kimi-k2.6': { cacheHit: 1.1, cacheMiss: 6.5, output: 27 }, // v1.4.0: 官方缓存命中 ¥1.1 (旧值误抄成 k2.7-code 的 ¥1.3)
741
+ 'kimi-k2.5': { cacheHit: 0.679, cacheMiss: 3.864, output: 20.279 }, // ⚠️ 未核实: 官方页未列(历史款), 由 v1.3.4 USD 值 ×7 保号迁移
742
+ // 字节豆包 Seed (火山方舟; ⚠️ 官方页是 SPA, v1.4.0 未能取到原文 → ×7 保号迁移, 未核实)
743
+ 'doubao-seed-2.0-pro-32k': { cacheHit: 0.616, cacheMiss: 3.087, output: 15.449 },
744
+ 'doubao-seed-2.0-pro-128k': { cacheHit: 0.924, cacheMiss: 4.634, output: 23.17 },
745
+ 'doubao-seed-2.0-pro-256k': { cacheHit: 1.855, cacheMiss: 9.268, output: 46.347 },
746
+ 'doubao-seed-2.0-lite-32k': { cacheHit: 0.119, cacheMiss: 0.581, output: 3.479 },
747
+ 'doubao-seed-2.0-lite-128k': { cacheHit: 0.175, cacheMiss: 0.868, output: 5.215 },
748
+ 'doubao-seed-2.0-lite-256k': { cacheHit: 0.35, cacheMiss: 1.736, output: 10.43 },
749
+ 'doubao-seed-2.0-mini-32k': { cacheHit: 0.042, cacheMiss: 0.196, output: 1.932 },
750
+ 'doubao-seed-2.0-mini-128k': { cacheHit: 0.077, cacheMiss: 0.385, output: 3.864 },
751
+ 'doubao-seed-2.0-mini-256k': { cacheHit: 0.154, cacheMiss: 0.77, output: 7.721 },
752
+ 'doubao-seed-2.0-code-32k': { cacheHit: 0.616, cacheMiss: 3.087, output: 15.449 },
753
+ 'doubao-seed-2.0-code-128k': { cacheHit: 0.924, cacheMiss: 4.634, output: 23.17 },
754
+ 'doubao-seed-2.0-code-256k': { cacheHit: 1.855, cacheMiss: 9.268, output: 46.347 },
755
+ // 字节 Seed 2.1 (中转站实测; 未分档, 按单一价入库)
756
+ 'seed-2.1-turbo': { cacheHit: 0.6, cacheMiss: 3, output: 15 }, // 实测 ¥3/¥15/缓存 ¥0.6
757
+ 'seed-2.1-pro': { cacheHit: 1.2, cacheMiss: 6, output: 30 }, // 实测 ¥6/¥30/缓存 ¥1.2
758
+ // MiniMax (platform.minimaxi.com/docs/guides/pricing-paygo 2026-09-10 抓取)
759
+ 'minimax-m2.7': { cacheHit: 0.42, cacheMiss: 2.1, output: 8.4 }, // v1.4.0 修复: 官方缓存读 ¥0.42 (旧值拿 cacheMiss ¥2.1 顶替 → 长会话高估 5 倍, 同 AGENTS.md 红线 4)
760
+ 'minimax-m2.7-highspeed': { cacheHit: 0.42, cacheMiss: 4.2, output: 16.8 }, // v1.4.0 新增: 官方高速版
761
+ // 美团 LongCat (中转站实测; 官方页未取到明文)
762
+ 'longcat-2.0': { cacheHit: 0.1, cacheMiss: 5, output: 20 }, // 实测 ¥5/¥20/缓存 ¥0.1
763
+ // 腾讯混元 (⚠️ 官方页是 SPA, v1.4.0 未能取到原文 → ×7 保号迁移, 未核实)
764
+ 'hunyuan-2.0-instruct-128k': { cacheHit: 4.347, cacheMiss: 4.347, output: 10.745 },
765
+ 'hunyuan-2.0-think-128k': { cacheHit: 5.117, cacheMiss: 5.117, output: 20.468 },
766
+ 'hunyuan-turbo-s': { cacheHit: 0.77, cacheMiss: 0.77, output: 1.932 },
767
+ // 阶跃星辰 (platform.stepfun.com/docs/zh/guides/pricing/details 2026-09-10 抓取)
768
+ 'step-3.7-flash': { cacheHit: 0.27, cacheMiss: 1.35, output: 8.1 }, // 官方 ¥1.35/¥8.1/缓存 ¥0.27
769
+ 'step-3.5-flash': { cacheHit: 0.14, cacheMiss: 0.7, output: 2.1 }, // 官方 ¥0.7/¥2.1/缓存 ¥0.14
770
+ // 小米 MiMo — 官方 2026-05-27 起「永久降价」(最高降幅 99%), 取消上下文分档; 与中转站 tokenrhythm 实时报价一致。
771
+ // v1.3.4 修的「除两次 7」结论正确, v1.4.0 起改为直接存官方 CNY 原值, 不再有 ÷7 环节。
772
+ 'mimo-v2.5': { cacheHit: 0.02, cacheMiss: 1, output: 2 }, // 官方 ¥1/¥2/缓存 ¥0.02
773
+ 'mimo-v2.5-pro': { cacheHit: 0.025, cacheMiss: 3, output: 6 }, // 官方 ¥3/¥6/缓存 ¥0.025
297
774
  // —— 以下为历史/参考模型 (2025-08, 实际以平台为准) ——
775
+ // 币种规则同上: 海外的写 USD, 国内的写 CNY。
776
+ // 🔴 v1.4.0 重要修复: 本段「国内」条目历来填的是**官方 CNY 原值**(不是 ÷7 后的 USD),
777
+ // 在旧的「统一 USD 基准」口径下被又 ×7 了一次 → 面板把这些模型高估 7 倍。
778
+ // 已核对的样本: glm-4-plus ¥2.5/¥5/¥5、qwen-plus ¥0.8/¥2、qwen-turbo ¥0.3/¥0.6、
779
+ // qwen2.5-72b ¥4/¥12 均与官方页逐项吻合 → 全段按 CNY 原值解读, 未再 ×7。
298
780
  'gpt-4o': { cacheHit: 1.25, cacheMiss: 2.5, output: 10 },
299
781
  'gpt-4o-mini': { cacheHit: 0.075, cacheMiss: 0.15, output: 0.6 },
300
782
  'gpt-4-turbo': { cacheHit: 5, cacheMiss: 10, output: 30 },
@@ -302,66 +784,152 @@ export const MODEL_PRICES = {
302
784
  'o1': { cacheHit: 7.5, cacheMiss: 15, output: 60 },
303
785
  'o1-mini': { cacheHit: 0.55, cacheMiss: 1.1, output: 4.4 },
304
786
  'o3-mini': { cacheHit: 0.55, cacheMiss: 1.1, output: 4.4 },
305
- // Claude
306
- 'claude-3-5-sonnet': { cacheHit: 1.5, cacheMiss: 3, output: 15 },
307
- 'claude-3-5-haiku': { cacheHit: 0.4, cacheMiss: 0.8, output: 4 },
308
- 'claude-3-opus': { cacheHit: 7.5, cacheMiss: 15, output: 75 },
309
- // Gemini
310
- 'gemini-2.0-flash': { cacheHit: 0.05, cacheMiss: 0.1, output: 0.4 },
311
- 'gemini-2.0-pro': { cacheHit: 1.25, cacheMiss: 2.5, output: 10 },
312
- 'gemini-1.5-pro': { cacheHit: 1.75, cacheMiss: 3.5, output: 10.5 },
787
+ // Claude — v1.4.0 修正: 缓存读 = 输入 ×10% (Anthropic 官方规则)。旧值用的是 OpenAI 的 50% 口径,
788
+ // 会让老 Claude 模型的长会话消耗高估 5 倍 (同表新条目 claude-opus-5 等已是 10%, 口径原本就不一致)。
789
+ 'claude-3-5-sonnet': { cacheHit: 0.3, cacheMiss: 3, output: 15 },
790
+ 'claude-3-5-haiku': { cacheHit: 0.08, cacheMiss: 0.8, output: 4 },
791
+ 'claude-3-opus': { cacheHit: 1.5, cacheMiss: 15, output: 75 },
792
+ // Gemini — v1.4.0 修正: 缓存读 = 输入 ×25% (Gemini 官方 75% off 口径)。旧值 50% 偏高。
793
+ 'gemini-2.0-flash': { cacheHit: 0.025, cacheMiss: 0.1, output: 0.4 },
794
+ 'gemini-2.0-pro': { cacheHit: 0.625, cacheMiss: 2.5, output: 10 },
795
+ 'gemini-1.5-pro': { cacheHit: 0.875, cacheMiss: 3.5, output: 10.5 },
313
796
  // DeepSeek (标准价兜底) — ⚠️ 2026-07-24 起 deepseek-chat / deepseek-reasoner / deepseek-r1 已 RETIRED,
314
- // 官方 API 调用会直接报错(不再重定向到 V4)。现役仅 deepseek-v4-flash / deepseek-v4-pro / deepseek-v4-flash-vision-exp
315
- // 保留这三条仅作为「若仍在用的旧配置」的估算占位, 真实计费请走上面 V4 峰谷表。
797
+ // 官方 API 调用会直接报错(不再重定向到 V4)。现役为 deepseek-flash (旧名 deepseek-v4-flash / -vision-exp 仍可调用)
798
+ // deepseek-v4-pro。⚠️ 2026-09-14 12:00 后 deepseek-v4-pro 的请求将全部路由到 V4.1-Flash 并按 Flash 价计费。
799
+ // 保留这三条仅作为「若仍在用的旧配置」的估算占位(单位 CNY), 真实计费请走上面 V4 峰谷表。
800
+ // ⚠️ 未核实: 与 DeepSeek 官方历史价(¥2/¥8 一档)对不上, 暂时原样保留待重新取证。
316
801
  'deepseek-chat': { cacheHit: 0.1, cacheMiss: 1, output: 2 },
317
802
  'deepseek-reasoner': { cacheHit: 0.2, cacheMiss: 2, output: 8 },
318
803
  'deepseek-r1': { cacheHit: 0.2, cacheMiss: 2, output: 8 },
319
804
  // 智谱
320
- 'glm-4-plus': { cacheHit: 2.5, cacheMiss: 5, output: 5 },
321
- 'glm-4-flash': { cacheHit: 0.05, cacheMiss: 0.1, output: 0.1 },
322
- // 通义千问
323
- 'qwen-plus': { cacheHit: 0.4, cacheMiss: 0.8, output: 2 },
324
- 'qwen-max': { cacheHit: 10, cacheMiss: 20, output: 60 },
325
- 'qwen-turbo': { cacheHit: 0.15, cacheMiss: 0.3, output: 0.6 },
326
- 'qwen2.5-72b-instruct': { cacheHit: 2, cacheMiss: 4, output: 12 },
327
- // Kimi
805
+ 'glm-4-plus': { cacheHit: 2.5, cacheMiss: 5, output: 5 }, // ✅ v1.4.0 修复: 官方 ¥2.5/¥5/¥5 (旧口径下显示 ¥17.5/¥35/¥35, 高 7 倍)
806
+ 'glm-4-flash': { cacheHit: 0.05, cacheMiss: 0.1, output: 0.1 }, // ⚠️ 官方 GLM-4-Flash-250414 现为免费; 此处保留历史 ¥0.1 档(宁高不低, 中转站可能仍计费)
807
+ // 通义千问 (官方 CNY; 2026-09-10 抓取)
808
+ 'qwen-plus': { cacheHit: 0.4, cacheMiss: 0.8, output: 2 }, // 官方 ¥0.8/¥2 ✅; ⚠️ cacheHit 0.4(=50%) 未核实
809
+ 'qwen-max': { cacheHit: 0.24, cacheMiss: 2.4, output: 9.6 },// v1.4.0: 官方现价 ¥2.4/¥9.6 (旧值 20/60 是远古价)
810
+ 'qwen-turbo': { cacheHit: 0.15, cacheMiss: 0.3, output: 0.6 },// 官方 ¥0.3/¥0.6 ✅; ⚠️ cacheHit 未核实
811
+ 'qwen2.5-72b-instruct': { cacheHit: 2, cacheMiss: 4, output: 12 }, // 官方 ¥4/¥12 ✅
812
+ // Kimi — ⚠️ 未核实: 官方页未列旧款, 且这些值与 Moonshot 官方历史价(¥12/¥12 一档)对不上, 待重新取证
328
813
  'moonshot-v1-8k': { cacheHit: 0.6, cacheMiss: 1.2, output: 2.4 },
329
814
  'moonshot-v1-32k': { cacheHit: 1.2, cacheMiss: 2.4, output: 4.8 },
330
815
  'moonshot-v1-128k': { cacheHit: 3, cacheMiss: 6, output: 12 },
331
- // 阶跃星辰
816
+ // 阶跃星辰 — ⚠️ 未核实: 官方页未列旧款
332
817
  'step-1-flash': { cacheHit: 0.5, cacheMiss: 1, output: 2 },
333
818
  'step-1-8k': { cacheHit: 2, cacheMiss: 4, output: 8 },
334
819
  'step-1-32k': { cacheHit: 4, cacheMiss: 8, output: 15 },
335
- // 其他
820
+ // 其他 (海外 USD)
336
821
  'mistral-large': { cacheHit: 1.5, cacheMiss: 3, output: 9 },
337
822
  'groq-llama-3.3-70b': { cacheHit: 0.29, cacheMiss: 0.59, output: 0.79 },
338
823
  'openrouter-auto': { cacheHit: 0.5, cacheMiss: 1, output: 2 },
339
824
  }
340
825
 
341
- /** 解析模型单价, deepseek-v4-* 支持峰谷自动切换; chat/reasoner 等走通用价格表 */
826
+ // v1.3.2: 模型产地判定 —— 供「海外模型独立计价货币」使用。
827
+ // 海外厂商官方定价页本来就是 USD, ×7 折人民币只是近似且容易被误读成美元
828
+ // (用户实测: 面板 ¥1285 被看成 $1285, 实为 $183.7)。
829
+ // v1.4.0 起本判定还兼任 MODEL_PRICES 的「存储币种」判定 (见 nativeCurrencyOf), 见下方注释。
830
+ // 判定按前缀, 与 MODEL_PRICES 的键同源; 未命中 → null (不表态, 走主货币, 保守)。
831
+ const OVERSEAS_MODEL_PREFIXES = ['gpt-', 'gpt', 'o1', 'o3', 'o4', 'chatgpt', 'claude', 'gemini', 'grok', 'mistral', 'groq-', 'llama', 'command-', 'openrouter-']
832
+ const DOMESTIC_MODEL_PREFIXES = ['deepseek', 'glm', 'kimi', 'moonshot', 'step-', 'qwen', 'mimo', 'doubao', 'seed-', 'hunyuan', 'minimax', 'longcat', 'abab', 'ernie', 'spark', 'yi-']
833
+
834
+ /** 判定模型产地: '海外' | '国内' | null(未知, 不表态)。前缀匹配取最长, 避免短前缀误命中。 */
835
+ export const modelRegion = (model) => {
836
+ if (typeof model !== 'string' || model === '') return null
837
+ const m = model.toLowerCase()
838
+ const hit = (list) => list.filter(p => m.startsWith(p)).sort((a, b) => b.length - a.length)[0] ?? null
839
+ const dom = hit(DOMESTIC_MODEL_PREFIXES)
840
+ const sea = hit(OVERSEAS_MODEL_PREFIXES)
841
+ if (dom !== null && sea !== null) return dom.length >= sea.length ? '国内' : '海外'
842
+ if (dom !== null) return '国内'
843
+ if (sea !== null) return '海外'
844
+ return null
845
+ }
846
+
847
+ /**
848
+ * v1.4.0: MODEL_PRICES 条目的**存储币种** —— 国内厂商官方页是 CNY, 海外厂商是 USD。
849
+ * 与 modelRegion 同源, 因此「写表的人抄官方页数字」即为正确, 不需要任何人工 ÷7。
850
+ * 未命中产地的模型不表态 → 按 CNY (国内口径), 与 defaultPrices 的 USD 基准无关。
851
+ */
852
+ export const nativeCurrencyOf = (model) => (modelRegion(model) === '海外' ? 'USD' : 'CNY')
853
+
854
+ /**
855
+ * 把一份单价从 from 币种换算到 to 币种。同币种原样返回(浅拷贝, 不泄露表内对象引用)。
856
+ * 汇率是**近似值**(USD_TO_CNY_RATE), 仅用于「用户自定义了非原生币种」这种少数情况;
857
+ * 默认配置(国内 CNY / 海外 USD)下两边同币种, 根本不走换算 —— 这正是 v1.4.0 想达到的效果。
858
+ */
859
+ const convertPrice = (price, from, to) => {
860
+ if (from === to) return { cacheHit: price.cacheHit, cacheMiss: price.cacheMiss, output: price.output }
861
+ const k = from === 'USD' ? USD_TO_CNY_RATE : 1 / USD_TO_CNY_RATE
862
+ return { cacheHit: price.cacheHit * k, cacheMiss: price.cacheMiss * k, output: price.output * k }
863
+ }
864
+
865
+ /**
866
+ * v1.3.2: 算出某模型实际该用哪种计价货币。
867
+ * 海外模型且 overseasCurrency 不是 'follow' 时用它, 其余一律跟主货币 currency。
868
+ * v1.4.0: 默认值由 'follow' 改为 'USD' —— 即「国内的用国内价(CNY), 海外的用海外价(USD)」。
869
+ * 想要 v1.2.6 的老行为(全部跟主货币), 显式设成 'follow' 即可。
870
+ */
871
+ export const currencyForModel = (config, model) => {
872
+ const main = (config?.currency ?? 'CNY').toUpperCase()
873
+ const over = String(config?.overseasCurrency ?? 'USD').toLowerCase()
874
+ if (over === 'follow' || over === '') return main
875
+ if (modelRegion(model) !== '海外') return main
876
+ return over.toUpperCase() === 'USD' ? 'USD' : 'CNY'
877
+ }
878
+
879
+ /**
880
+ * v1.4.0: 前缀兜底匹配 —— 只接受「安全后缀」。
881
+ *
882
+ * 旧实现是「取最长前缀」,只保证同族内选最长,模型名比某个**老键**长且不属同族时会被老键吞掉:
883
+ * gpt-4.1 → 命中 gpt-4 键 → $15/$30/$60 (真价 $0.40/$1.60, 输出虚高约 37 倍)
884
+ * gpt-4.5-preview→ 命中 gpt-4 键 → 同上
885
+ * gemini-2.5-flash-lite → 命中 gemini-2.5-flash 键 (真价 $0.10/$0.40)
886
+ * 而 gpt-4o-mini-2024-07-18 / claude-3-5-sonnet-20241022 这类**日期后缀**才是设计意图。
887
+ * 因此: 只有当剩余部分是日期/版本/预览标记时才认前缀, 其余一律落 defaultPrices。
888
+ */
889
+ const SAFE_SUFFIX_RE = /^[-_](?:v?\d[\w.-]*|latest|preview|exp|experimental)$/i
890
+
891
+ /** 精确命中优先; 否则按「安全后缀」前缀兜底; 都不中返回 null。 */
892
+ const matchModelPrice = (model) => {
893
+ const exact = MODEL_PRICES[model]
894
+ if (exact) return exact
895
+ const hits = Object.keys(MODEL_PRICES).filter(k => model.startsWith(k)).sort((a, b) => b.length - a.length)
896
+ for (const k of hits) {
897
+ if (SAFE_SUFFIX_RE.test(model.slice(k.length))) return MODEL_PRICES[k]
898
+ }
899
+ return null
900
+ }
901
+
902
+ /** 解析模型单价, 仅 deepseek-flash / deepseek-v4-* 支持峰谷自动切换; chat/reasoner 等走通用价格表 */
342
903
  export const resolveModelPrice = (configOrGetter, model, timestamp = Date.now()) => {
343
904
  const config = typeof configOrGetter === 'function' ? configOrGetter() : configOrGetter
344
905
  const peak = isPeakTime(timestamp)
906
+ const display = currencyForModel(config, model)
345
907
 
346
- // 自定义价格优先
347
- if (config?.prices && Object.prototype.hasOwnProperty.call(config.prices, model) && config.prices[model]) {
908
+ // 自定义价格优先 (用户自填, 币种由用户自己把握, 不做换算)
909
+ if (typeof model === 'string' && config?.prices && Object.prototype.hasOwnProperty.call(config.prices, model) && config.prices[model]) {
348
910
  return config.prices[model]
349
911
  }
350
912
 
351
913
  // v0.5.3 修复: 原 startsWith('deepseek') 会把 deepseek-chat/reasoner 劫持进 V4 峰谷表,
352
- // 导致其按 v4-flash 价格计费 (output 虚高至 4.5 倍)。仅精确匹配 v4 系列。
353
- if (model === 'deepseek-v4-pro' || model === 'deepseek-v4-flash' || model.startsWith('deepseek-v4')) {
354
- const currency = (config?.currency ?? 'CNY').toUpperCase() === 'USD' ? 'USD' : 'CNY'
355
- const table = V4_RATES[currency] ?? V4_RATES.CNY
356
- const key = model === 'deepseek-v4-pro' || model.startsWith('deepseek-v4-pro') ? 'deepseek-v4-pro' : 'deepseek-v4-flash'
357
- return (peak ? table.peak[key] : table.offPeak[key]) ?? config?.defaultPrices
914
+ // 导致其按 v4-flash 价格计费 (output 虚高至 4.5 倍)。仅匹配现役 v4 系列 + 收敛后的 deepseek-flash。
915
+ // v1.3.4: 官方收敛模型名后, deepseek-flash 与旧名 deepseek-v4-flash / -vision-exp 同档
916
+ // (旧名仍可调用, V4.1-Flash 服务并按 Flash 价计费) 一律映射到 flash 档位。
917
+ // DeepSeek v4 V4_RATES 峰谷表 (自带 CNY/USD 两套, 按显示币种选表)
918
+ if (typeof model === 'string' && (model.startsWith('deepseek-v4') || model.startsWith('deepseek-flash'))) {
919
+ const table = V4_RATES[display] ?? V4_RATES.CNY
920
+ const key = model.startsWith('deepseek-v4-pro') ? 'deepseek-v4-pro' : 'deepseek-flash'
921
+ const hit = (peak ? table.peak[key] : table.offPeak[key])
922
+ if (hit) return { ...hit }
358
923
  }
359
- // 查询 MODEL_PRICES 表兜底 (优先匹配完整模型名, 再试前缀匹配)
360
- const exact = MODEL_PRICES[model]
361
- if (exact) return exact
362
- const prefix = Object.keys(MODEL_PRICES).find(k => model.startsWith(k) || k.startsWith(model))
363
- if (prefix) return MODEL_PRICES[prefix]
364
- return config?.defaultPrices ?? { cacheHit: 0.1, cacheMiss: 1, output: 2 }
924
+
925
+ // 查通用表 (精确名 → 安全后缀前缀兜底)。条目按原生币种存储, 换算到显示币种。
926
+ if (typeof model === 'string' && model !== '') {
927
+ const entry = matchModelPrice(model)
928
+ if (entry) return convertPrice(entry, nativeCurrencyOf(model), display)
929
+ }
930
+
931
+ // 都未命中: 落 defaultPrices。⚠️ defaultPrices 的单位是 **USD** (与 v1.2.x 一致, 未随 v1.4.0 改动)。
932
+ return convertPrice(config?.defaultPrices ?? { cacheHit: 0.1, cacheMiss: 1, output: 2 }, 'USD', display)
365
933
  }
366
934
 
367
935
  /** 通用 fetch 请求, 带超时。 */
@@ -375,6 +943,24 @@ async function fetchWithTimeout(url, headers, timeoutMs, method = 'GET') {
375
943
  }
376
944
  }
377
945
 
946
+ /** 读取请求体并限制大小 (默认 256KB) —— 防持有 token 者灌大包打爆内存。超限抛错。 */
947
+ async function readBody(req, limit = 256 * 1024) {
948
+ let body = ''
949
+ for await (const chunk of req) {
950
+ body += chunk
951
+ if (body.length > limit) throw Object.assign(new Error('request body too large'), { statusCode: 413 })
952
+ }
953
+ return body
954
+ }
955
+
956
+ /** 字符串清洗: 截断到 max 长度 (设置面板传入的任意字段统一过这里) */
957
+ const cleanStr = (value, max) => String(value ?? '').trim().slice(0, max)
958
+ /** URL 清洗: 只接受 http/https 协议 (防 file: 等混淆 scheme 进配置), 失败返回空串 */
959
+ const cleanUrl = (value) => {
960
+ const s = cleanStr(value, 512)
961
+ return /^https?:\/\//i.test(s) ? s : ''
962
+ }
963
+
378
964
  // ============================================================
379
965
  // 平台预设 (完整清单)
380
966
  // ============================================================
@@ -397,7 +983,7 @@ const PLATFORM_PRESETS = [
397
983
  baseUrl: 'https://api.stepfun.com', queryType: 'stepfun', envKeys: ['STEPFUN_API_KEY'] },
398
984
  { id: 'siliconflow', label: '硅基流动', icon: 'siliconflow', color: '#6E29F6', category: '国内',
399
985
  baseUrl: 'https://api.siliconflow.cn', queryType: 'siliconflow', envKeys: ['SILICONFLOW_API_KEY', 'SILICON_API_KEY'] },
400
- { id: 'minimax', label: 'MiniMax', icon: 'together', color: '#1E40AF', category: '国内',
986
+ { id: 'minimax', label: 'MiniMax', icon: 'minimax', color: '#E73562', category: '国内',
401
987
  baseUrl: 'https://api.minimaxi.com', queryType: 'minimax', envKeys: ['MINIMAX_API_KEY'] },
402
988
 
403
989
  // ===== 海外平台(有公开余额/配额查询接口)=====
@@ -405,8 +991,18 @@ const PLATFORM_PRESETS = [
405
991
  baseUrl: 'https://openrouter.ai', queryType: 'openrouter', envKeys: ['OPENROUTER_API_KEY'] },
406
992
  { id: 'novita', label: 'Novita AI', icon: 'together', color: '#FA520F', category: '海外',
407
993
  baseUrl: 'https://api.novita.ai', queryType: 'novita', envKeys: ['NOVITA_API_KEY'] },
408
- { id: 'xai', label: 'xAI Grok', icon: 'mistral', color: '#000000', category: '海外',
994
+ { id: 'xai', label: 'xAI Grok', icon: 'xai', color: '#000000', category: '海外',
409
995
  baseUrl: 'https://api.x.ai', queryType: 'openai', envKeys: ['XAI_API_KEY'] },
996
+
997
+ // ===== 著名模型品牌 (无公开余额接口, 仅显示模型 + 按价格表估算消耗) =====
998
+ { id: 'openai', label: 'OpenAI', icon: 'openai', color: '#10A37F', category: '海外', noBalance: true },
999
+ { id: 'claude', label: 'Anthropic Claude', icon: 'claude', color: '#D97757', category: '海外', noBalance: true },
1000
+ { id: 'gemini', label: 'Google Gemini', icon: 'gemini', color: '#4285F4', category: '海外', noBalance: true },
1001
+ { id: 'qwen', label: '通义千问 Qwen', icon: 'qwen', color: '#623AE7', category: '国内', noBalance: true },
1002
+ { id: 'mimo', label: '小米 MiMo', icon: 'mimo', color: '#FF6900', category: '国内', noBalance: true },
1003
+ // v1.2.1: 豆包/混元入列模型品牌分组 (价格表 v1.2.0 已覆盖, 此前只算价不显示)
1004
+ { id: 'doubao', label: '豆包 Seed', icon: 'doubao', color: '#3C8CFF', category: '国内', noBalance: true },
1005
+ { id: 'hunyuan', label: '腾讯混元', icon: 'hunyuan', color: '#0052D9', category: '国内', noBalance: true },
410
1006
  ]
411
1007
 
412
1008
  // ============================================================
@@ -431,6 +1027,13 @@ export const Config = Schema.object({
431
1027
  warnThreshold: Schema.number().min(0).default(10),
432
1028
  /** 计价货币 */
433
1029
  currency: Schema.string().default('CNY'),
1030
+ /**
1031
+ * v1.3.2: 海外模型独立计价货币 —— 'USD'(默认, 见 v1.4.0) | 'CNY' | 'follow'(跟随 currency)。
1032
+ * 海外厂商官方价本来就是 USD, 选 'USD' 可免掉 ×7 折算带来的误差与「¥ 被看成 $」的误读。
1033
+ * v1.4.0: 默认值从 'follow' 改为 'USD' —— 即「国内的用国内价(CNY)、海外的用海外价(USD)」。
1034
+ * 想要旧行为(所有模型都跟主货币)请显式设成 'follow'。
1035
+ */
1036
+ overseasCurrency: Schema.string().default('USD'),
434
1037
  prices: Schema.dict(Schema.object({
435
1038
  cacheHit: Schema.number().min(0).default(0.2),
436
1039
  cacheMiss: Schema.number().min(0).default(2),
@@ -443,6 +1046,16 @@ export const Config = Schema.object({
443
1046
  }).default({}),
444
1047
  /** 收养大肥鱼: 屏幕侧边互动宠物挂件 (v1.1.0, 纯互动不含余额, 移植自 MeteorNOX/DeepSeek-Balance-Whale-Widget, MIT) */
445
1048
  whaleEnabled: Schema.boolean().default(false),
1049
+ /** 显示无余额模型品牌 (OpenAI/Claude/Gemini/Qwen/MiMo), 默认关闭 */
1050
+ showNoBalanceBrands: Schema.boolean().default(false),
1051
+ /** 官方直连 provider 名单 (第 1 层判定, 最高优先级)。
1052
+ * 写在这里的 provider 名一律按「官方直连」处理, 状态条显示官方余额;
1053
+ * 没写的按 baseURL 域名 / `-official` 后缀自动判定, 都不命中则按中转站显示「—」。 */
1054
+ officialProviders: Schema.array(Schema.string()).default([]),
1055
+ /** v1.4.0「真自动」: 用户主动关掉的 DSH provider 名 (来自 settings.yaml llm-pi-ai.providers)。
1056
+ * 默认空数组 = 全部启用。关过的记在这里, 下次自动发现不会再打开 (除非用户又点开)。
1057
+ * ⚠️ 这与 officialProviders 是**两回事**: 那个决定「按官方显示」, 这个决定「要不要去查余额」。 */
1058
+ dshProviderOptOut: Schema.array(Schema.string()).default([]),
446
1059
  /** 大肥鱼挂件设置: 大小/音效/音量/气泡/峰谷文案/吸附/位置记忆 */
447
1060
  whaleSettings: Schema.object({
448
1061
  scale: Schema.number().min(0.6).max(2.5).default(1),
@@ -474,9 +1087,9 @@ function checkAlerts(balances, config, ctx) {
474
1087
  const val = b.percent != null ? b.percent : b.total
475
1088
  const prev = lastAlertState[id]
476
1089
  let level = val > safe ? 'ok' : val > warn ? 'warn' : 'err'
1090
+ newState[id] = level
477
1091
 
478
1092
  if (level === 'warn' && prev !== 'warn') {
479
- newState[id] = 'warn'
480
1093
  try {
481
1094
  const name = b.name || id
482
1095
  const msg = `🔔 ${name} 余额偏低: ${val}${b.percent != null ? '%' : (b.currency || '')}`
@@ -487,7 +1100,6 @@ function checkAlerts(balances, config, ctx) {
487
1100
  }
488
1101
  } catch { /* 静默 */ }
489
1102
  } else if (level === 'err' && prev !== 'err') {
490
- newState[id] = 'err'
491
1103
  try {
492
1104
  const name = b.name || id
493
1105
  const msg = `🚨 ${name} 余额不足: ${val}${b.percent != null ? '%' : (b.currency || '')}`
@@ -507,11 +1119,15 @@ function checkAlerts(balances, config, ctx) {
507
1119
 
508
1120
  // 纯函数, 导出便于单测 (不影响对外行为)
509
1121
  export function parseResponse(queryType, json) {
1122
+ if (!json || typeof json !== 'object' || Array.isArray(json)) return null
510
1123
  switch (queryType) {
511
1124
  case 'deepseek': {
512
1125
  const infos = Array.isArray(json?.balance_infos) ? json.balance_infos : []
513
1126
  const p = infos[0]
514
1127
  if (!p) return null
1128
+ // v1.4.0 修复: total_balance 缺失时 toAmount(null)=0 会伪造「余额 0」。
1129
+ // 与 AGENTS.md「字段存在性校验」一致 —— 缺关键字段即返回 null, 交给上层显示「未开放」。
1130
+ if (p.total_balance == null) return null
515
1131
  // total_balance 当前余额, granted_balance 赠送, topped_up_balance 充值
516
1132
  const total = toAmount(p.total_balance)
517
1133
  const grant = toAmount(p.granted_balance)
@@ -526,8 +1142,9 @@ export function parseResponse(queryType, json) {
526
1142
  if (!hasAny) return null
527
1143
  const total = toAmount(json?.total_granted)
528
1144
  const used = toAmount(json?.total_used)
529
- const available = toAmount(json?.total_available)
530
- return { total: available || (total - used), currency: 'USD', available, used, note: 'OpenAI 兼容额度' }
1145
+ const hasAvail = json?.total_available != null
1146
+ const available = hasAvail ? toAmount(json.total_available) : null
1147
+ return { total: hasAvail ? available : (total - used), currency: 'USD', available, used, note: 'OpenAI 兼容额度' }
531
1148
  }
532
1149
  case 'siliconflow': {
533
1150
  const d = json?.data
@@ -540,8 +1157,10 @@ export function parseResponse(queryType, json) {
540
1157
  const d = json?.data
541
1158
  if (!d) return null
542
1159
  // 数据红线: total_credits / total_usage 字段名未用真实 key 实测, 可能不叫这个名。
543
- // 若两个预期字段都缺失 视为解析失败(返回 null, 前端标"无法解析/未开放"), 绝不显示假"余额0"。
544
- if (d.total_credits == null && d.total_usage == null) return null
1160
+ // v1.4.0 修复: 原守卫用 `&&`(两个都缺才放弃), 只缺 total_credits 时 toAmount(null)=0,
1161
+ // total 变成 `0 - usage` 的**负数**, 客户端渲染成红色「余额不足」—— 正好是这条红线要防的伪造数字。
1162
+ // 改为 `||`: 任一关键字段缺失即视为解析失败, 返回 null(前端显示「未开放」), 绝不编数。
1163
+ if (d.total_credits == null || d.total_usage == null) return null
545
1164
  return { total: toAmount(d.total_credits) - toAmount(d.total_usage), currency: 'USD', available: toAmount(d.total_credits), used: toAmount(d.total_usage), note: 'OpenRouter 余额(字段待实测)' }
546
1165
  }
547
1166
  case 'novita': {
@@ -556,7 +1175,7 @@ export function parseResponse(queryType, json) {
556
1175
  }
557
1176
  case 'quota': {
558
1177
  const q = json?.data
559
- if (!q || (q.quota == null && q.username == null)) return null
1178
+ if (!q || q.quota == null) return null
560
1179
  const quota = toAmount(q.quota)
561
1180
  return { total: quota / 500000, currency: 'USD', available: null, used: null, note: 'one-api quota (÷500000, 系数待实测)' }
562
1181
  }
@@ -601,11 +1220,11 @@ export function parseResponse(queryType, json) {
601
1220
  const l = usedUp[0]
602
1221
  return { total: 0, currency: 'tokens', available: 0, used: null, percent: null, note: '智谱配额已用完(0)' + (l.nextResetTime ? ', 待重置' : ''), resetAt: l?.nextResetTime ?? null }
603
1222
  }
604
- // 兜底: 只有 percentage 无 remaining → 限流填充度(非余额)
605
- if (limits.length > 0) {
606
- const p = pctOf(limits[0])
607
- if (p !== null) return { total: p, currency: '%', available: null, used: null, percent: true, note: '智谱限流填充度%(非余额)', resetAt: limits[0]?.nextResetTime ?? null }
608
- }
1223
+ // 兜底: 只有 percentage 无 remaining
1224
+ // v1.4.0 修复: percentage 是**已用/填充度**(100=用完), 不是余额, 方向还是反的 ——
1225
+ // 旧代码把它当 total 下发, 客户端 `percent ?? total` 取到 88 → getLevel 与 50 阈值比 → 判「绿灯」,
1226
+ // 于是「快用完」显示成「余额充足」, 同时踩 AGENTS.md 红线 4 README:9「查不到就如实显示未开放」。
1227
+ // 这里不再冒充余额, 直接返回 null, 交给 classifyBizError 走中性的 no-balance-api。
609
1228
  return null
610
1229
  }
611
1230
 
@@ -614,7 +1233,14 @@ export function parseResponse(queryType, json) {
614
1233
  if (!u) return null
615
1234
  if (u.limit == null && u.remaining == null) return null
616
1235
  const limit = toAmount(u.limit), remaining = toAmount(u.remaining)
617
- return { total: limit, currency: 'tokens', available: remaining, used: limit - remaining, note: 'Kimi 套餐剩余 tokens', percent: limit > 0 ? (remaining / limit) * 100 : null }
1236
+ // v1.4.0 修复: `limit` 是限流窗口的**上限**, 不是可用余额。旧代码把它当 total 下发, 而客户端
1237
+ // 的状态条/卡片/详情大数字都取 `b.total`(从不看 available) → 配额耗尽也显示满额 + 绿灯。
1238
+ // 与同文件 glm 适配器语义对齐: total = 剩余量; 上限与用量放在 note/used 里。
1239
+ return {
1240
+ total: remaining, currency: 'tokens', available: remaining, used: limit - remaining,
1241
+ note: `Kimi 套餐剩余 tokens (窗口上限 ${limit})`,
1242
+ percent: limit > 0 ? (remaining / limit) * 100 : null,
1243
+ }
618
1244
  }
619
1245
  case 'minimax': {
620
1246
  const models = Array.isArray(json?.model_remains) ? json.model_remains : []
@@ -628,10 +1254,44 @@ export function parseResponse(queryType, json) {
628
1254
  }
629
1255
 
630
1256
  /** 某些类型无法用普通 API key 查询余额 (需 OAuth 等) */
1257
+ // ============================================================
1258
+ // 业务层错误分类 (v1.2.6 抽出为可测函数)
1259
+ // ============================================================
1260
+ /**
1261
+ * 解析失败时对「接口 HTTP 200 但业务层报错」做分类。
1262
+ * ⚠️ 仅在 parseResponse 返回 null 时调用 —— 能解析出配额/余额的账户(如智谱 Coding Plan 套餐用户)
1263
+ * 根本不会走到这里, 本函数不影响他们。
1264
+ * @returns {{status: string, error: string}} 供 queryPreset 直接摊进返回体
1265
+ */
1266
+ export function classifyBizError(queryType, json) {
1267
+ // 业务层错误消息: success:false 或 code!=200 且带 msg (JSON 解析失败 json=null 时不适用)
1268
+ const bizMsg = (json && typeof json === 'object' && typeof json.msg === 'string' && json.msg
1269
+ && (json.success === false || (json.code !== undefined && json.code !== 200))) ? json.msg : null
1270
+
1271
+ // 智谱: 按量付费账户无公开余额接口 (实测 2026-09-03: /api/monitor/account/balance、
1272
+ // /api/paas/v4/dashboard/billing/{subscription,credit_grants,usage}、/api/paas/v4/users/me
1273
+ // 等候选端点全部 404; 唯一公开的 /api/monitor/usage/quota/limit 是 Coding Plan 套餐专用)。
1274
+ // 该情形属「平台未开放」而非「插件解析坏了」, 按中性状态展示, 不标红。
1275
+ if (queryType === 'glm' && bizMsg && /coding\s*plan/i.test(bizMsg)) {
1276
+ // ⚠️ 措辞不替平台断言账户类型: 按量付费用户与套餐已过期用户拿到的是【同一条】返回,
1277
+ // 接口层无法区分, 所以只说"无 Coding Plan 套餐", 不硬说成"按量付费"。
1278
+ return { status: 'no-balance-api', error: '无 Coding Plan 套餐,无余额接口 (按量付费 / 套餐已过期均返回此结果;套餐用户可正常显示配额)' }
1279
+ }
1280
+
1281
+ // 其余业务错误: 透传原始 msg, 便于用户/维护者定位真实原因 (套餐过期、无权限、接口改名…)
1282
+ return { status: 'parse-error', error: bizMsg ? `无法解析余额数据 (接口返回: ${bizMsg})` : '无法解析余额数据' }
1283
+ }
1284
+
631
1285
  // ============================================================
632
1286
  // 查询单个预设平台
633
1287
  // ============================================================
634
1288
  async function queryPreset(platform, apiKey, config) {
1289
+ if (platform.noBalance) {
1290
+ return {
1291
+ platform: platform.id, name: platform.label, icon: platform.icon, color: platform.color,
1292
+ category: platform.category, status: 'no-balance-api', error: '该平台未开放余额查询', noBalance: true,
1293
+ }
1294
+ }
635
1295
  if (!apiKey) {
636
1296
  return {
637
1297
  platform: platform.id, name: platform.label, icon: platform.icon, color: platform.color,
@@ -678,9 +1338,12 @@ async function queryPreset(platform, apiKey, config) {
678
1338
  const parsed = parseResponse(queryType, json)
679
1339
 
680
1340
  if (!parsed) {
1341
+ // v1.2.6: 业务错误分类抽到 classifyBizError (可单测)。
1342
+ // 注意: 智谱 Coding Plan 套餐用户能解析出配额 → parsed 非空 → 不会走到这里。
1343
+ const { status, error } = classifyBizError(queryType, json)
681
1344
  return {
682
1345
  platform: platform.id, name: platform.label, icon: platform.icon, color: platform.color,
683
- category: platform.category, status: 'parse-error', error: '无法解析余额数据', noBalance: true,
1346
+ category: platform.category, status, error, noBalance: true,
684
1347
  }
685
1348
  }
686
1349
 
@@ -696,7 +1359,7 @@ async function queryPreset(platform, apiKey, config) {
696
1359
  return {
697
1360
  platform: platform.id, name: platform.label, icon: platform.icon, color: platform.color,
698
1361
  category: platform.category,
699
- status: 'error', error: message.includes('abort') ? '请求超时' : '网络错误', noBalance,
1362
+ status: 'error', error: message.includes('abort') ? '请求超时' : '网络错误', noBalance: false,
700
1363
  }
701
1364
  }
702
1365
  }
@@ -716,8 +1379,10 @@ function relayTypePath(queryType) {
716
1379
 
717
1380
  async function queryCustomRelay(relay, config) {
718
1381
  const { id, name, baseUrl, apiKey, queryType } = relay
1382
+ // v1.4.0: 这条中转站是不是从 DSH settings.yaml 自动发现的 (客户端据此显示「DSH」标)
1383
+ const fromDsh = relay.fromDsh === true
719
1384
  if (!apiKey) {
720
- return { platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站', status: 'no-key', error: '未配置 API Key', noBalance: true }
1385
+ return { platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站', status: 'no-key', error: '未配置 API Key', noBalance: true, fromDsh }
721
1386
  }
722
1387
  const base = (baseUrl || '').replace(/\/+$/, '')
723
1388
 
@@ -748,7 +1413,7 @@ async function queryCustomRelay(relay, config) {
748
1413
  platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站',
749
1414
  status: 'ok', total: parsed.total, currency: parsed.currency, available: parsed.available,
750
1415
  used: parsed.used, note: parsed.note || cand.type, percent: parsed.percent,
751
- noBalance: false, queryType: cand.type, fetchedAt: Date.now(),
1416
+ noBalance: false, queryType: cand.type, fetchedAt: Date.now(), fromDsh,
752
1417
  }
753
1418
  }
754
1419
  } catch { /* 尝试下一个 */ }
@@ -756,7 +1421,7 @@ async function queryCustomRelay(relay, config) {
756
1421
 
757
1422
  return {
758
1423
  platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站',
759
- status: 'no-balance-api', error: '该平台未开放余额查询', noBalance: true,
1424
+ status: 'no-balance-api', error: '该平台未开放余额查询', noBalance: true, fromDsh,
760
1425
  }
761
1426
  }
762
1427
 
@@ -789,8 +1454,9 @@ export async function queryCustomModel(model, config) {
789
1454
 
790
1455
  // 1) 手动映射优先 (totalPath 点分路径, 如 data.balance)
791
1456
  if (totalPath) {
792
- const total = toAmount(dotGet(json, totalPath))
793
- if (Number.isFinite(total) && (total !== 0 || json != null)) {
1457
+ const raw = dotGet(json, totalPath)
1458
+ const total = toAmount(raw)
1459
+ if (raw != null && Number.isFinite(Number(raw))) {
794
1460
  const used = usedPath ? toAmount(dotGet(json, usedPath)) : null
795
1461
  return {
796
1462
  ...base, status: 'ok', total, currency: currency || 'CNY',
@@ -825,14 +1491,201 @@ export async function queryCustomModel(model, config) {
825
1491
  // ============================================================
826
1492
  // 会话消耗投影 (学习 dsh-balance queryBalanceCost)
827
1493
  // ============================================================
828
- export function makeCostProjection(configOrGetter) {
1494
+ /**
1495
+ * v1.4.0: 子代理消耗汇总。
1496
+ *
1497
+ * 背景: 本投影只折叠**本会话**的事件, 而子代理(subagent)跑在自己的子会话里 —— 手机会话
1498
+ * 开了子代理后, 子代理烧的 token 完全不在主板数字里。
1499
+ *
1500
+ * 数据源分两条, 因为子代理会话会「由热转冷」:
1501
+ * ① **热路径(首选)**: `ctx.sessions.get(id)` + `sessionProjections.snapshot/stateOf`。
1502
+ * 父会话的 `subagentCatalog` 投影给出**按创建顺序**的直接子会话; 每个子会话的
1503
+ * `queryBalanceCost` 投影(就是本插件注册的同一个 unit)给出它的消耗。
1504
+ * ② **冷路径(兜底)**: 读持久化投影缓存文件 `storages/session_projcache/sessions/<id>.json`。
1505
+ * ⚠️ 为什么必须要这条: 框架的 `SubagentListEntry.activity` 只有 `'running' | 'inactive'`,
1506
+ * **inactive = 只存在于持久化里** —— 子代理跑完(或其 turn 结束)后就不在 `ctx.sessions`
1507
+ * 的常驻表里了, `sessions.get()` 取不到 → 面板显示 `~—`(实测踩到)。框架自己的
1508
+ * `listChildren()` 走"投影缓存读"解决这件事, 但它是 **async**, 而投影的 `view()`
1509
+ * 契约要求**同步** —— 所以这里同步读缓存文件。形状取自实测, 读不到/形状不符一律静默返回 null。
1510
+ *
1511
+ * 递归展开孙代理并把金额**向上汇总**到直接子代理那一条 (深度 / 行数都有封顶)。
1512
+ *
1513
+ * @param services 惰性取服务: () => ({ sessions, projections }) | null。取不到就静默返回空数组
1514
+ * (老框架 / 单测环境), 绝不让子代理汇总拖垮主投影。
1515
+ */
1516
+ const SUBAGENT_MAX_DEPTH = 4
1517
+ const SUBAGENT_MAX_ROWS = 12
1518
+ /** 冷路径的文件读缓存 TTL —— view() 会随每次投影变化被调用, 不能每次都去读盘。 */
1519
+ const SUBAGENT_FILE_TTL_MS = 3000
1520
+ const sessionCacheFiles = new Map()
1521
+ let subagentCostSummarize = null
1522
+
1523
+ const safeSessionId = (id) => typeof id === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(id) ? id : null
1524
+
1525
+ /** 读某会话的投影缓存记录(带 TTL 内存缓存)。任何异常 → null。 */
1526
+ const readSessionCacheRecord = (sessionId) => {
1527
+ const id = safeSessionId(sessionId)
1528
+ if (id === null) return null
1529
+ const now = Date.now()
1530
+ const hit = sessionCacheFiles.get(id)
1531
+ if (hit !== undefined && now - hit.at < SUBAGENT_FILE_TTL_MS) return hit.rows
1532
+ let rows = null
1533
+ try {
1534
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
1535
+ const file = join(home, 'storages', 'session_projcache', 'sessions', `${id}.json`)
1536
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
1537
+ const r = parsed?.record?.rows
1538
+ if (r !== null && typeof r === 'object') rows = r
1539
+ } catch { rows = null }
1540
+ // 只缓存成功结果, 避免一次读失败被 TTL 钉住 3 秒
1541
+ if (rows !== null) sessionCacheFiles.set(id, { at: now, rows })
1542
+ if (sessionCacheFiles.size > 256) sessionCacheFiles.clear()
1543
+ return rows
1544
+ }
1545
+
1546
+ /** 冷路径: 从缓存文件里取该会话的 queryBalanceCost **状态**(不是 wire 视图)。 */
1547
+ const cachedCostState = (sessionId) => {
1548
+ const val = readSessionCacheRecord(sessionId)?.queryBalanceCost?.val
1549
+ return (val !== null && typeof val === 'object' && Array.isArray(val.modelOrder) && val.byModel !== null && typeof val.byModel === 'object') ? val : null
1550
+ }
1551
+
1552
+ /** 冷路径: 从缓存文件里取该会话的 subagentCatalog 条目。 */
1553
+ const cachedCatalog = (sessionId) => {
1554
+ const st = readSessionCacheRecord(sessionId)?.subagentCatalog?.val
1555
+ const values = st?.head?.values
1556
+ if (!Array.isArray(values)) return []
1557
+ return values
1558
+ .filter((v) => v !== null && typeof v === 'object' && typeof v.childId === 'string')
1559
+ .map((v) => ({
1560
+ id: v.childId,
1561
+ createdAt: typeof v.childCreatedAt === 'number' ? v.childCreatedAt : 0,
1562
+ mode: v.mode === 'continuable' ? 'continuable' : 'one-shot',
1563
+ label: typeof v.label === 'string' ? v.label : undefined,
1564
+ }))
1565
+ }
1566
+
1567
+ export function collectSubagentCosts(services, rootSessionId, summarize) {
1568
+ const out = []
1569
+ if (typeof rootSessionId !== 'string' || rootSessionId === '') return out
1570
+ let sessions = null, projections = null
1571
+ try {
1572
+ const svc = typeof services === 'function' ? services() : services
1573
+ sessions = svc?.sessions ?? null
1574
+ projections = svc?.projections ?? null
1575
+ } catch { /* 服务取不到 → 只能走冷路径 */ }
1576
+ subagentCostSummarize = typeof summarize === 'function' ? summarize : null
1577
+
1578
+ /** 取常驻会话对象。任何异常一律当成"取不到"(宿主服务在极端情况下可能抛)。 */
1579
+ const getSession = (id) => {
1580
+ try { return sessions?.get?.(id) ?? null } catch { return null }
1581
+ }
1582
+
1583
+ /** 某会话的直接子会话(按创建顺序): 热路径优先, 空则回落到缓存文件。 */
1584
+ const childrenOf = (sessionId) => {
1585
+ const s = getSession(sessionId)
1586
+ if (s !== null && projections !== null) {
1587
+ try {
1588
+ const list = projections.snapshot(s, ['subagentCatalog'])?.values?.subagentCatalog
1589
+ if (Array.isArray(list) && list.length > 0) return list
1590
+ } catch { /* 落到冷路径 */ }
1591
+ }
1592
+ return cachedCatalog(sessionId)
1593
+ }
1594
+
1595
+ /** 某会话的消耗投影状态: 热路径优先(更新鲜), 无数据则回落到缓存文件。 */
1596
+ const costStateOf = (sessionId) => {
1597
+ const s = getSession(sessionId)
1598
+ if (s !== null && projections !== null) {
1599
+ try {
1600
+ const st = projections.stateOf(s, 'queryBalanceCost')
1601
+ if (st !== null && st !== undefined && Array.isArray(st.modelOrder) && st.modelOrder.length > 0) return st
1602
+ } catch { /* 落到冷路径 */ }
1603
+ }
1604
+ return cachedCostState(sessionId)
1605
+ }
1606
+
1607
+ /** 递归汇总: 自身 + 后代, 返回与 summarize 同形的汇总。 */
1608
+ const rollup = (sessionId, depth) => {
1609
+ const st = costStateOf(sessionId)
1610
+ let acc = (st !== null && subagentCostSummarize !== null) ? subagentCostSummarize(st) : null
1611
+ if (depth >= SUBAGENT_MAX_DEPTH) return acc
1612
+ for (const entry of childrenOf(sessionId)) {
1613
+ acc = mergeSummary(acc, rollup(entry.id, depth + 1))
1614
+ }
1615
+ return acc
1616
+ }
1617
+
1618
+ for (const entry of childrenOf(rootSessionId)) {
1619
+ if (out.length >= SUBAGENT_MAX_ROWS) break
1620
+ const s = rollup(entry.id, 1) ?? emptySummary()
1621
+ out.push({
1622
+ id: String(entry.id),
1623
+ label: typeof entry.label === 'string' && entry.label !== '' ? entry.label : String(entry.id).slice(0, 12),
1624
+ mode: entry.mode === 'continuable' ? 'continuable' : 'one-shot',
1625
+ createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : 0,
1626
+ cost: s.cost,
1627
+ costByCurrency: s.costByCurrency,
1628
+ currencyByModel: s.currencyByModel,
1629
+ mixedCurrency: s.mixedCurrency,
1630
+ tokens: s.tokens,
1631
+ models: s.models,
1632
+ })
1633
+ }
1634
+ return out
1635
+ }
1636
+
1637
+ /** 一份空的汇总 (与 summarize 同形)。 */
1638
+ export const emptySummary = () => ({
1639
+ cost: 0, costByModel: {}, costByCurrency: {}, currencyByModel: {}, mixedCurrency: false, models: [],
1640
+ tokens: { uncachedInput: 0, cacheRead: 0, cacheWrite: 0, output: 0 }, tokensByModel: {},
1641
+ })
1642
+
1643
+ /** 把两份汇总按币种/模型/token 相加 (子代理树向上汇总用)。 */
1644
+ export const mergeSummary = (a, b) => {
1645
+ const x = a ?? emptySummary(), y = b ?? emptySummary()
1646
+ const round6 = (n) => Math.round(n * 1e6) / 1e6
1647
+ const sumMap = (p, q) => {
1648
+ const out = { ...(p || {}) }
1649
+ for (const [k, v] of Object.entries(q || {})) out[k] = round6((out[k] ?? 0) + v)
1650
+ return out
1651
+ }
1652
+ const costByCurrency = sumMap(x.costByCurrency, y.costByCurrency)
1653
+ const costByModel = sumMap(x.costByModel, y.costByModel)
1654
+ const tokens = {
1655
+ uncachedInput: x.tokens.uncachedInput + y.tokens.uncachedInput,
1656
+ cacheRead: x.tokens.cacheRead + y.tokens.cacheRead,
1657
+ cacheWrite: x.tokens.cacheWrite + y.tokens.cacheWrite,
1658
+ output: x.tokens.output + y.tokens.output,
1659
+ }
1660
+ const tokensByModel = { ...(x.tokensByModel || {}) }
1661
+ for (const [m, t] of Object.entries(y.tokensByModel || {})) {
1662
+ const p = tokensByModel[m] ?? { uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }
1663
+ tokensByModel[m] = {
1664
+ uncachedInputTokens: p.uncachedInputTokens + (t?.uncachedInputTokens ?? 0),
1665
+ cacheReadTokens: p.cacheReadTokens + (t?.cacheReadTokens ?? 0),
1666
+ cacheWriteTokens: p.cacheWriteTokens + (t?.cacheWriteTokens ?? 0),
1667
+ outputTokens: p.outputTokens + (t?.outputTokens ?? 0),
1668
+ }
1669
+ }
1670
+ const models = [...new Set([...(x.models || []), ...(y.models || [])])]
1671
+ const mainCur = Object.keys(costByCurrency)[0]
1672
+ return {
1673
+ cost: mainCur === undefined ? 0 : costByCurrency[mainCur],
1674
+ costByModel, costByCurrency,
1675
+ currencyByModel: { ...x.currencyByModel, ...y.currencyByModel },
1676
+ mixedCurrency: Object.keys(costByCurrency).length > 1,
1677
+ models, tokens, tokensByModel,
1678
+ }
1679
+ }
1680
+
1681
+ export function makeCostProjection(configOrGetter, services) {
829
1682
  const getConfig = () => typeof configOrGetter === 'function' ? configOrGetter() : configOrGetter
830
1683
  const zero = () => ({ uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 })
831
1684
  const bucketsOf = (usage) => ({
832
- uncachedInputTokens: usage.inputTokens,
1685
+ uncachedInputTokens: usage.inputTokens ?? 0,
833
1686
  cacheReadTokens: usage.cacheReadTokens ?? 0,
834
1687
  cacheWriteTokens: usage.cacheWriteTokens ?? 0,
835
- outputTokens: usage.outputTokens,
1688
+ outputTokens: usage.outputTokens ?? 0,
836
1689
  })
837
1690
  const bucketsEqual = (a, b) =>
838
1691
  a.uncachedInputTokens === b.uncachedInputTokens && a.cacheReadTokens === b.cacheReadTokens &&
@@ -851,12 +1704,80 @@ export function makeCostProjection(configOrGetter) {
851
1704
  })
852
1705
  const round6 = (n) => Math.round(n * 1e6) / 1e6
853
1706
 
1707
+ /**
1708
+ * v1.4.0: 从一条 assistant stream 里取**最后一次** usage chunk。
1709
+ * 与 dsh-llm 的 `lastAssistantStreamChunk(stream, 'usage')` 同语义, 本地实现以免给插件引入额外依赖
1710
+ * (`dependencies` 必须保持为空是硬约束)。
1711
+ */
1712
+ const lastUsageFromStream = (stream) => {
1713
+ if (!Array.isArray(stream)) return undefined
1714
+ for (let i = stream.length - 1; i >= 0; i -= 1) {
1715
+ const rec = stream[i]
1716
+ if (rec !== null && typeof rec === 'object' && rec.type === 'chunk' && rec.chunk?.type === 'usage') return rec.chunk.usage
1717
+ }
1718
+ return undefined
1719
+ }
1720
+
1721
+ /**
1722
+ * v1.4.0: 一个事件所携带的用量样本。对齐 dsh-token-meter 的 usage-projection:
1723
+ * - `assistant/message` 优先用自带 `usage`, 没有则回落到 stream 里的 usage chunk;
1724
+ * - `assistant/attempt`(失败/重试/取消/流错误、没有产出可见消息的尝试) 从 stream 里取。
1725
+ * ⚠️ 旧代码读的是 `assistant/chunk` —— 该事件名**不在**框架 `KNOWN_SESSION_EVENT_TYPES` 里, 是死分支,
1726
+ * 导致上面两种真实事件里 `assistant/attempt` 的 token 被整段漏计。
1727
+ */
1728
+ const usageOfEvent = (event) => {
1729
+ if (event.type === 'assistant/message' && event.data.usage !== undefined) return event.data.usage
1730
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined
1731
+ return lastUsageFromStream(event.data.stream)
1732
+ }
1733
+
1734
+ /**
1735
+ * v1.4.0: 把一份投影状态折成金额汇总 —— 主视图与子代理汇总**共用同一套口径**,
1736
+ * 保证「子代理那行」与「主板数字」算法完全一致 (含峰谷、币种、缓存读写分桶)。
1737
+ */
1738
+ const summarize = (state) => {
1739
+ const cfg = getConfig()
1740
+ const mainCurrency = (cfg.currency ?? 'CNY').toUpperCase()
1741
+ const tokens = { uncachedInput: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
1742
+ const costByModel = {}
1743
+ const costByCurrency = {}
1744
+ const currencyByModel = {}
1745
+ let cost = 0
1746
+ const order = Array.isArray(state?.modelOrder) ? state.modelOrder : []
1747
+ for (const model of order) {
1748
+ const b = state.byModel?.[model] ?? zero()
1749
+ tokens.uncachedInput += b.uncachedInputTokens
1750
+ tokens.cacheRead += b.cacheReadTokens
1751
+ tokens.cacheWrite += b.cacheWriteTokens
1752
+ tokens.output += b.outputTokens
1753
+ // 支持 DeepSeek 谷峰自动计费
1754
+ const price = resolveModelPrice(cfg, model)
1755
+ const c = ((b.uncachedInputTokens + b.cacheWriteTokens) * price.cacheMiss + b.cacheReadTokens * price.cacheHit + b.outputTokens * price.output) / 1e6
1756
+ // v1.3.2: 该模型实际币种 (海外模型可能与主货币不同)
1757
+ const cur = currencyForModel(cfg, model)
1758
+ if (c > 0) {
1759
+ costByModel[model] = round6(c)
1760
+ currencyByModel[model] = cur
1761
+ costByCurrency[cur] = round6((costByCurrency[cur] ?? 0) + c)
1762
+ }
1763
+ // cost 仍只汇总「主货币」那一份, 保持字段语义单一 (混合时另一半在 costByCurrency 里)。
1764
+ // overseasCurrency='follow' 时所有模型都是主货币, cost === 全部合计, 与 v1.2.6 一致。
1765
+ if (cur === mainCurrency) cost += c
1766
+ }
1767
+ return {
1768
+ cost: round6(cost), costByModel, costByCurrency, currencyByModel,
1769
+ mixedCurrency: Object.keys(costByCurrency).length > 1,
1770
+ tokens, tokensByModel: state?.byModel ?? {}, models: order, mainCurrency,
1771
+ }
1772
+ }
1773
+
854
1774
  return {
855
1775
  key: 'queryBalanceCost',
856
1776
  // 框架要求的投影定义 API: stateSchema(内部状态) + wire.{viewSchema,view}(客户端可见视图)。
857
1777
  // 旧版误用顶层 schema+view, 导致 wire 缺失, 服务端 drive 永不通知、客户端永远拿不到值。
858
1778
  stateSchema: z.object({
859
1779
  currentModel: z.string().nullable(),
1780
+ currentProvider: z.string().nullable(),
860
1781
  last: z.object({
861
1782
  turn: z.number(),
862
1783
  step: z.number(),
@@ -875,76 +1796,115 @@ export function makeCostProjection(configOrGetter) {
875
1796
  outputTokens: z.number(),
876
1797
  })),
877
1798
  modelOrder: z.array(z.string()),
1799
+ /** v1.4.0: 本投影所属会话 id —— 子代理汇总要拿它去查 `subagentCatalog`。空串表示未知。 */
1800
+ sessionId: z.string(),
1801
+ }),
1802
+ init: (header) => ({
1803
+ currentModel: null, currentProvider: null, last: null, byModel: {}, modelOrder: [],
1804
+ sessionId: typeof header?.id === 'string' ? header.id : '',
878
1805
  }),
879
- init: () => ({ currentModel: null, last: null, byModel: {}, modelOrder: [] }),
880
1806
  apply: (state, event) => {
1807
+ // v1.4.0: `llm/retry-started` 关闭「替换槽位」—— 被重试的那次 attempt 的用量要**留在总量里**,
1808
+ // 下一次 attempt 是**新增**而不是替换。与 dsh-token-meter 的 usage-projection 对齐。
1809
+ if (event.type === 'llm/retry-started') {
1810
+ const turn = event.data?.turn
1811
+ const step = event.data?.step
1812
+ return state.last !== null && state.last.turn === turn && state.last.step === step
1813
+ ? { ...state, last: null }
1814
+ : state
1815
+ }
881
1816
  let nextModel = state.currentModel
1817
+ let nextProvider = state.currentProvider
882
1818
  if (event.type === 'request/header') {
883
1819
  const model = event.data.header?.config?.model
884
1820
  if (typeof model === 'string' && model !== '') nextModel = model
1821
+ const prov = event.data.header?.config?.provider
1822
+ if (typeof prov === 'string' && prov !== '') nextProvider = prov
885
1823
  } else if (event.type === 'request/context') {
886
1824
  const model = event.data.model
887
1825
  if (typeof model === 'string' && model !== '') nextModel = model
1826
+ const prov = event.data.provider
1827
+ if (typeof prov === 'string' && prov !== '') nextProvider = prov
888
1828
  }
889
1829
  let usage = null, turn = 0, step = 0
890
- if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
891
- ({ turn, step } = event.data); usage = event.data.chunk.usage
892
- } else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
893
- ({ turn, step, usage } = event.data)
1830
+ const sample = usageOfEvent(event)
1831
+ if (sample !== undefined && sample !== null) {
1832
+ turn = event.data.turn
1833
+ step = event.data.step
1834
+ usage = sample
894
1835
  }
895
- if (usage === null) return nextModel === state.currentModel ? state : { ...state, currentModel: nextModel }
1836
+ const unchanged = nextModel === state.currentModel && nextProvider === state.currentProvider
1837
+ if (usage === null) return unchanged ? state : { ...state, currentModel: nextModel, currentProvider: nextProvider }
896
1838
  const model = nextModel ?? 'unknown'
897
1839
  const buckets = bucketsOf(usage)
898
1840
  const prev = state.last !== null && state.last.turn === turn && state.last.step === step ? state.last : null
899
1841
  if (prev !== null && prev.model === model && bucketsEqual(prev.buckets, buckets)) {
900
- return nextModel === state.currentModel ? state : { ...state, currentModel: nextModel }
1842
+ return unchanged ? state : { ...state, currentModel: nextModel, currentProvider: nextProvider }
901
1843
  }
902
1844
  const isNewModel = !(model in state.byModel)
903
1845
  let byModel = state.byModel
904
1846
  if (prev !== null) byModel = { ...byModel, [prev.model]: subBuckets(byModel[prev.model] ?? zero(), prev.buckets) }
905
1847
  byModel = { ...byModel, [model]: addBuckets(byModel[model] ?? zero(), buckets) }
906
- return { ...state, currentModel: nextModel, last: { turn, step, model, buckets }, byModel, modelOrder: isNewModel ? [...state.modelOrder, model] : state.modelOrder }
1848
+ return { ...state, currentModel: nextModel, currentProvider: nextProvider, last: { turn, step, model, buckets }, byModel, modelOrder: isNewModel ? [...state.modelOrder, model] : state.modelOrder }
907
1849
  },
908
1850
  wire: {
909
1851
  viewSchema: z.object({
910
1852
  models: z.array(z.string()),
911
1853
  // v0.5.3: 暴露当前会话正在使用的模型, 客户端据此自动切换选中平台
912
1854
  currentModel: z.string().nullable(),
1855
+ // 当前 provider (中转站/官方), 客户端据此判断是否走官方余额
1856
+ currentProvider: z.string().nullable().optional(),
913
1857
  cost: z.number(),
914
1858
  costByModel: z.record(z.string(), z.number().nonnegative()),
1859
+ // v1.3.2: 海外模型可独立走 USD, 于是一个会话可能同时产生两种货币的消耗。
1860
+ // 不做汇率折算合并 (折算=再引入 ×7 误差), 客户端两段拼接显示。
1861
+ costByCurrency: z.record(z.string(), z.number().nonnegative()).optional(),
1862
+ currencyByModel: z.record(z.string(), z.string()).optional(),
1863
+ mixedCurrency: z.boolean().optional(),
915
1864
  tokens: z.object({ uncachedInput: z.number().int().nonnegative(), cacheRead: z.number().int().nonnegative(), cacheWrite: z.number().int().nonnegative(), output: z.number().int().nonnegative() }).strict(),
916
1865
  tokensByModel: z.record(z.string(), z.object({ uncachedInputTokens: z.number().int().nonnegative(), cacheReadTokens: z.number().int().nonnegative(), cacheWriteTokens: z.number().int().nonnegative(), outputTokens: z.number().int().nonnegative() }).strict()).optional(),
917
1866
  currency: z.string(),
918
1867
  isPeak: z.boolean().optional(),
919
1868
  waiting: z.boolean().optional(),
1869
+ // v1.4.0: 子代理消耗, 按父会话 catalog 事件顺序 (= 创建顺序) 从左到右展示。
1870
+ // 金额是「该子代理 + 其后代」的向上汇总; 取不到会话服务时为空数组。
1871
+ subagents: z.array(z.object({
1872
+ id: z.string(),
1873
+ label: z.string(),
1874
+ mode: z.enum(['one-shot', 'continuable']),
1875
+ createdAt: z.number(),
1876
+ cost: z.number(),
1877
+ costByCurrency: z.record(z.string(), z.number().nonnegative()),
1878
+ currencyByModel: z.record(z.string(), z.string()),
1879
+ mixedCurrency: z.boolean(),
1880
+ tokens: z.object({
1881
+ uncachedInput: z.number().int().nonnegative(),
1882
+ cacheRead: z.number().int().nonnegative(),
1883
+ cacheWrite: z.number().int().nonnegative(),
1884
+ output: z.number().int().nonnegative(),
1885
+ }).strict(),
1886
+ models: z.array(z.string()),
1887
+ }).strict()).optional(),
920
1888
  }).strict(),
921
1889
  view: (state) => {
922
1890
  const cfg = getConfig()
1891
+ const mainCurrency = (cfg.currency ?? 'CNY').toUpperCase()
1892
+ // v1.4.0: 子代理消耗 (换行单独展示)。取不到服务/没有子代理 → 空数组。
1893
+ const subagents = collectSubagentCosts(services, state.sessionId, summarize)
923
1894
  // 无事件时返回 waiting 标记, 客户端据此显示 "~—" 而非 "~¥0"
924
1895
  if (state.modelOrder.length === 0) {
925
- return { models: [], currentModel: state.currentModel ?? null, cost: -1, costByModel: {}, tokens: { uncachedInput: 0, cacheRead: 0, cacheWrite: 0, output: 0 }, tokensByModel: {}, currency: cfg.currency ?? 'CNY', isPeak: isPeakTime(), waiting: true }
1896
+ return { models: [], currentModel: state.currentModel ?? null, currentProvider: state.currentProvider ?? null, cost: -1, costByModel: {}, costByCurrency: {}, currencyByModel: {}, mixedCurrency: false, tokens: { uncachedInput: 0, cacheRead: 0, cacheWrite: 0, output: 0 }, tokensByModel: {}, currency: mainCurrency, isPeak: isPeakTime(), waiting: true, subagents }
926
1897
  }
927
- const tokens = { uncachedInput: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
928
- const costByModel = {}
929
- let cost = 0
930
- const defaultPrice = cfg.defaultPrices ?? { cacheHit: 0.1, cacheMiss: 1, output: 2 }
931
- const peak = isPeakTime()
932
- for (const model of state.modelOrder) {
933
- const b = state.byModel[model] ?? zero()
934
- tokens.uncachedInput += b.uncachedInputTokens
935
- tokens.cacheRead += b.cacheReadTokens
936
- tokens.cacheWrite += b.cacheWriteTokens
937
- tokens.output += b.outputTokens
938
- // 支持 DeepSeek 谷峰自动计费
939
- const price = resolveModelPrice(cfg, model)
940
- const c = ((b.uncachedInputTokens + b.cacheWriteTokens) * price.cacheMiss + b.cacheReadTokens * price.cacheHit + b.outputTokens * price.output) / 1e6
941
- if (c > 0) costByModel[model] = round6(c)
942
- cost += c
1898
+ const s = summarize(state)
1899
+ return {
1900
+ models: s.models, currentModel: state.currentModel ?? null, currentProvider: state.currentProvider ?? null,
1901
+ cost: s.cost, costByModel: s.costByModel, costByCurrency: s.costByCurrency, currencyByModel: s.currencyByModel,
1902
+ mixedCurrency: s.mixedCurrency, tokens: s.tokens, tokensByModel: s.tokensByModel,
1903
+ currency: mainCurrency, isPeak: isPeakTime(), waiting: false, subagents,
943
1904
  }
944
- return { models: state.modelOrder, currentModel: state.currentModel ?? null, cost: round6(cost), costByModel, tokens, tokensByModel: state.byModel, currency: cfg.currency ?? 'CNY', isPeak: peak, waiting: false }
945
1905
  },
946
1906
  },
947
- stateVersion: 1,
1907
+ stateVersion: 2,
948
1908
  }
949
1909
  }
950
1910
 
@@ -964,9 +1924,15 @@ export function apply(ctx, config) {
964
1924
  prices: config.prices ?? { 'deepseek-chat': { cacheHit: 0.1, cacheMiss: 1, output: 2 } },
965
1925
  defaultPrices: config.defaultPrices ?? { cacheHit: 0.1, cacheMiss: 1, output: 2 },
966
1926
  currency: persisted.currency ?? config.currency ?? 'CNY',
1927
+ // v1.3.2: 海外模型独立计价货币 ('follow' = 跟随主货币, 默认, 行为同 v1.2.6)
1928
+ overseasCurrency: persisted.overseasCurrency ?? config.overseasCurrency ?? 'follow',
967
1929
  safeThreshold: persisted.safeThreshold ?? config.safeThreshold ?? 50,
968
1930
  warnThreshold: persisted.warnThreshold ?? config.warnThreshold ?? 10,
969
1931
  whaleEnabled: persisted.whaleEnabled ?? config.whaleEnabled ?? false,
1932
+ showNoBalanceBrands: persisted.showNoBalanceBrands ?? config.showNoBalanceBrands ?? false,
1933
+ officialProviders: normalizeOfficialProviders(persisted.officialProviders ?? config.officialProviders ?? []),
1934
+ // v1.4.0「真自动」: 被用户关掉的 DSH provider (默认空 = 全部启用)。复用同一个名单规范化器。
1935
+ dshProviderOptOut: normalizeOfficialProviders(persisted.dshProviderOptOut ?? config.dshProviderOptOut ?? []),
970
1936
  whaleSettings: {
971
1937
  scale: 1, soundOn: true, soundSet: 'duck', volume: 0.5, bubbleOn: true,
972
1938
  peakMode: 'default', snapOn: true, peekRatio: 0.5, left: null, top: null, side: 'right',
@@ -977,16 +1943,56 @@ export function apply(ctx, config) {
977
1943
 
978
1944
  const getConfig = () => runtimeConfig
979
1945
 
1946
+ /** 直接读 ~/.dsh/.credentials.yaml 的 refs: 段 —— 拿不到 credentials 服务时的兜底。
1947
+ * @param {string[]} names 要找的 ref 名 (遇到第一个有值的就返回) */
1948
+ const readCredentialRefs = (names) => {
1949
+ const wanted = Array.isArray(names) ? names : []
1950
+ if (wanted.length === 0) return ''
1951
+ try {
1952
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
1953
+ const raw = readFileSync(join(home, '.credentials.yaml'), 'utf8')
1954
+ let inRefs = false
1955
+ for (const line of raw.split('\n')) {
1956
+ if (line === 'refs:') { inRefs = true; continue }
1957
+ if (!inRefs) continue
1958
+ if (!line.startsWith(' ')) { inRefs = false; continue }
1959
+ const idx = line.indexOf(':')
1960
+ if (idx === -1) continue
1961
+ const key = line.slice(0, idx).trim()
1962
+ const val = line.slice(idx + 1).trim()
1963
+ if (key && val && wanted.includes(key)) return val
1964
+ }
1965
+ } catch { /* 忽略 */ }
1966
+ return ''
1967
+ }
1968
+
1969
+ /** 解析一个 apiKeyEnv 名 → 真实 key (环境变量 → credentials 服务 → 凭据文件)。
1970
+ * v1.4.0「真自动」用它取 DSH provider 的 key, 与预设平台同一套三层兜底。 */
1971
+ const resolveApiKeyRef = async (ref) => {
1972
+ const name = typeof ref === 'string' ? ref.trim() : ''
1973
+ if (name === '') return ''
1974
+ if (process.env[name]) return process.env[name]
1975
+ const creds = ctx.get('credentials')
1976
+ if (creds !== undefined) {
1977
+ try {
1978
+ const hit = await creds.resolve(name)
1979
+ if (hit !== undefined) return hit.value
1980
+ } catch { /* 忽略 */ }
1981
+ }
1982
+ return readCredentialRefs([name])
1983
+ }
1984
+
980
1985
  /** 解析预设平台的 API key (从环境变量、credentials 系统或直接读凭据文件) */
981
1986
  const resolvePresetKey = async (platform) => {
1987
+ const refs = platform.envKeys || []
982
1988
  // 1) 环境变量
983
- for (const name of platform.envKeys || []) {
1989
+ for (const name of refs) {
984
1990
  if (process.env[name]) return process.env[name]
985
1991
  }
986
1992
  // 2) DSH credentials 服务
987
1993
  const creds = ctx.get('credentials')
988
1994
  if (creds !== undefined) {
989
- for (const ref of (platform.envKeys || [])) {
1995
+ for (const ref of refs) {
990
1996
  try {
991
1997
  const hit = await creds.resolve(ref)
992
1998
  if (hit !== undefined) return hit.value
@@ -994,27 +2000,48 @@ export function apply(ctx, config) {
994
2000
  }
995
2001
  }
996
2002
  // 3) 直接读 ~/.dsh/.credentials.yaml 文件兜底
997
- try {
998
- const { readFileSync } = await import('node:fs')
999
- const { homedir } = await import('node:os')
1000
- const { join } = await import('node:path')
1001
- const home = process.env.DSH_HOME || join(homedir(), '.dsh')
1002
- const raw = readFileSync(join(home, '.credentials.yaml'), 'utf8')
1003
- const lines = raw.split('\n')
1004
- let inRefs = false
1005
- for (const line of lines) {
1006
- if (line === 'refs:') { inRefs = true; continue }
1007
- if (inRefs) {
1008
- if (!line.startsWith(' ')) { inRefs = false; continue }
1009
- const idx = line.indexOf(':')
1010
- if (idx === -1) continue
1011
- const key = line.slice(0, idx).trim()
1012
- const val = line.slice(idx + 1).trim()
1013
- if (key && val && (platform.envKeys || []).includes(key)) return val
1014
- }
2003
+ return readCredentialRefs(refs)
2004
+ }
2005
+
2006
+ /**
2007
+ * v1.4.0「真自动」: DSH settings.yaml 里的 provider 合成为可查余额的中转站条目。
2008
+ * 挑选规则见模块级纯函数 `selectDshProviders` (可单测)
2009
+ * 这里只多一步: 解析 key —— 要访问 credentials 服务, 所以必须是异步的。
2010
+ * 与手填的 customRelays 合并时**手填优先** (同 id / 同 baseUrl 都算重复), 见 refreshAll。
2011
+ */
2012
+ const listDshProviderRelays = async () => {
2013
+ const { entries, kinds } = readSettingsDerived()
2014
+ const picked = selectDshProviders(entries, kinds, runtimeConfig.dshProviderOptOut)
2015
+ const out = []
2016
+ for (const p of picked) {
2017
+ out.push({
2018
+ id: 'dsh:' + p.name,
2019
+ name: p.name + ' (DSH)',
2020
+ baseUrl: p.baseURL,
2021
+ apiKey: await resolveApiKeyRef(p.apiKeyEnv),
2022
+ queryType: 'auto',
2023
+ fromDsh: true,
2024
+ })
2025
+ }
2026
+ return out
2027
+ }
2028
+
2029
+ /** 设置面板用: DSH provider 自动发现结果 (只读展示 + 开关状态)。**绝不下发 key**。 */
2030
+ const readDshProviderStatus = () => {
2031
+ const { entries, kinds } = readSettingsDerived()
2032
+ const on = new Set(selectDshProviders(entries, kinds, runtimeConfig.dshProviderOptOut).map((p) => p.name))
2033
+ return Object.keys(entries).sort().map((name) => {
2034
+ const e = entries[name]
2035
+ const kind = e.baseURL ? (kinds[name] || 'unknown') : 'no-base-url'
2036
+ return {
2037
+ name,
2038
+ baseURL: e.baseURL,
2039
+ apiKeyEnv: e.apiKeyEnv,
2040
+ // official | relay | unknown(主机名解析不出) | no-base-url(没写 baseURL, 不表态)
2041
+ kind,
2042
+ enabled: on.has(name),
1015
2043
  }
1016
- } catch { /* 忽略 */ }
1017
- return ''
2044
+ })
1018
2045
  }
1019
2046
 
1020
2047
  let cache = { balances: [], fetchedAt: 0, error: null }
@@ -1024,7 +2051,16 @@ export function apply(ctx, config) {
1024
2051
  if (inflight !== null) return inflight
1025
2052
  inflight = (async () => {
1026
2053
  const presetList = PLATFORM_PRESETS.filter(p => runtimeConfig.presets.includes(p.id))
1027
- const relayList = runtimeConfig.customRelays
2054
+ // v1.4.0「真自动」: 手填的 customRelays + 从 settings.yaml 自动发现的 DSH provider。
2055
+ // 手填优先 —— 同 id 或同 baseUrl 时不重复查一遍 (用户手填的那条口径由他自己定)。
2056
+ const manualRelays = runtimeConfig.customRelays
2057
+ const dshRelays = await listDshProviderRelays()
2058
+ const manualIds = new Set(manualRelays.map(r => String(r.id)))
2059
+ const manualUrls = new Set(manualRelays.map(r => String(r.baseUrl || '').replace(/\/+$/, '')))
2060
+ const relayList = [
2061
+ ...manualRelays,
2062
+ ...dshRelays.filter(r => !manualIds.has(r.id) && !manualUrls.has(r.baseUrl)),
2063
+ ]
1028
2064
  const modelList = runtimeConfig.customModels
1029
2065
  const tasks = [
1030
2066
  ...presetList.map(async (p) => queryPreset(p, await resolvePresetKey(p), runtimeConfig)),
@@ -1054,11 +2090,19 @@ export function apply(ctx, config) {
1054
2090
  safeThreshold: runtimeConfig.safeThreshold,
1055
2091
  warnThreshold: runtimeConfig.warnThreshold,
1056
2092
  currency: runtimeConfig.currency,
2093
+ overseasCurrency: runtimeConfig.overseasCurrency,
1057
2094
  isPeak: isPeakTime(),
1058
2095
  isWeekend: isWeekend(),
1059
2096
  whaleEnabled: !!runtimeConfig.whaleEnabled,
2097
+ showNoBalanceBrands: !!runtimeConfig.showNoBalanceBrands,
2098
+ // provider 官方/中转判定素材下发给客户端 (第 1 层: 用户名单; 第 2 层: baseURL 域名判定)
2099
+ officialProviders: runtimeConfig.officialProviders,
2100
+ providerKinds: readProviderKinds(),
2101
+ // v1.4.0「真自动」: DSH provider 发现结果 (只读, 不含 key)
2102
+ dshProviders: readDshProviderStatus(),
1060
2103
  },
1061
2104
  }
2105
+ cache.etag = '"' + fnv1a(JSON.stringify(cache.balances) + '|' + JSON.stringify(cache.config)) + '"'
1062
2106
  // A3: 告警检测
1063
2107
  checkAlerts(balances, runtimeConfig, ctx)
1064
2108
  })().finally(() => { inflight = null })
@@ -1068,7 +2112,7 @@ export function apply(ctx, config) {
1068
2112
  let loopTimer = null
1069
2113
  const resetLoop = () => {
1070
2114
  if (loopTimer !== null) { clearTimeout(loopTimer); loopTimer = null }
1071
- const run = () => { void refreshAll().then(() => { loopTimer = setTimeout(run, runtimeConfig.refreshIntervalMs) }) }
2115
+ const run = () => { refreshAll().catch(() => {}).finally(() => { loopTimer = setTimeout(run, runtimeConfig.refreshIntervalMs) }) }
1072
2116
  loopTimer = setTimeout(run, 0)
1073
2117
  }
1074
2118
 
@@ -1089,12 +2133,27 @@ export function apply(ctx, config) {
1089
2133
  kind: 'exact', path: '/api-dashboard/balances',
1090
2134
  async handler(req, res) {
1091
2135
  if (!['GET', 'HEAD', 'POST'].includes(req.method)) { res.writeHead(405, { Allow: 'GET, HEAD, POST' }); res.end(); return }
1092
- const force = req.method === 'POST' || new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('force') === '1'
1093
- // 自动刷新: 缓存为空 缓存超过 refreshIntervalMs 时自动拉取最新 (解决进入页面要手动刷新)
1094
- const stale = Date.now() - cache.fetchedAt > (runtimeConfig.refreshIntervalMs || 300000)
1095
- if ((force || stale || cache.balances.length === 0) && (Date.now() - cache.fetchedAt > 2000 || cache.balances.length === 0)) await refreshAll()
2136
+ const params = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams
2137
+ const force = req.method === 'POST' || params.get('force') === '1'
2138
+ /**
2139
+ * v1.4.0 `?stale=1` = stale-while-revalidate:
2140
+ * 「把手上有的先给我」。应用切回前台 / 页面重载时用它打首屏。
2141
+ * 为什么需要它: `force=1` 是**阻塞**的 —— 底下 `await refreshAll()` 要等最慢的那个
2142
+ * 端点(最长 timeoutMs=8s)。首屏卡这么久, 用户看到的就是「插件加载很慢」。
2143
+ * 有缓存时改成「立刻回旧数据 + 后台刷新」, 由下一次轮询把新数据带上来。
2144
+ * 没有缓存(服务端刚重启)时仍然只能等 —— 那时确实没有东西可显示。
2145
+ */
2146
+ const peek = params.get('stale') === '1'
2147
+ const plan = planBalancesFetch({
2148
+ force, peek,
2149
+ hasData: cache.balances.length > 0,
2150
+ age: Date.now() - cache.fetchedAt,
2151
+ intervalMs: runtimeConfig.refreshIntervalMs,
2152
+ })
2153
+ if (plan === 'wait') await refreshAll() // 冷启动/显式强刷: 等新数据
2154
+ else if (plan === 'background') refreshAll().catch(() => {}) // 有缓存: 立刻回旧的, 刷新丢后台
1096
2155
  // v0.5.0: ETag 协商缓存 — 轮询期间数据没变就 304 空响应, 省 JSON 序列化与流量
1097
- const etag = '"' + Number(cache.fetchedAt || 0).toString(36) + '"'
2156
+ const etag = cache.etag || '"' + Number(cache.fetchedAt || 0).toString(36) + '"'
1098
2157
  if (req.method === 'HEAD') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ETag: etag }); res.end(); return }
1099
2158
  if (!force && req.headers['if-none-match'] === etag && cache.balances.length > 0) {
1100
2159
  res.writeHead(304, { ETag: etag })
@@ -1121,11 +2180,12 @@ export function apply(ctx, config) {
1121
2180
  const cur = (cfg.currency ?? 'CNY').toUpperCase() === 'USD' ? 'USD' : 'CNY'
1122
2181
  const table = V4_RATES[cur] ?? V4_RATES.CNY
1123
2182
  const mk = (p) => p ? { cacheHit: p.cacheHit, cacheMiss: p.cacheMiss, output: p.output } : null
1124
- // v4 峰谷系列
2183
+ // v1.3.4: 官方定价页与 GET https://api.deepseek.com/models (2026-09-10 实测) 一致 ——
2184
+ // 现役仅 deepseek-flash / deepseek-v4-pro 两个模型。旧名 deepseek-v4-flash / -vision-exp
2185
+ // 仍可调用但由 V4.1-Flash 服务、按 Flash 价计费, 解析层已映射到 flash 档, 故此处不再单列免误导。
1125
2186
  const models = []
1126
- for (const key of ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4-flash-vision-exp']) {
1127
- // v4-flash-vision-exp flash 同价 (vision 不加价), 复用 flash 费率
1128
- const src = key === 'deepseek-v4-flash-vision-exp' ? 'deepseek-v4-flash' : key
2187
+ for (const key of ['deepseek-flash', 'deepseek-v4-pro']) {
2188
+ const src = key.startsWith('deepseek-v4-pro') ? 'deepseek-v4-pro' : 'deepseek-flash'
1129
2189
  models.push({ model: key, peak: mk(table.peak[src]), offPeak: mk(table.offPeak[src]), peakValley: true })
1130
2190
  }
1131
2191
  sendJson(res, 200, { ok: true, currency: cfg.currency ?? 'CNY', peakNow: isPeakTime(), weekend: isWeekend(), models })
@@ -1170,7 +2230,8 @@ export function apply(ctx, config) {
1170
2230
  const msg = /already up to date/.test(String(err?.message)) ? `already up to date`
1171
2231
  : /GitHub API|download|remote version/.test(String(err?.message)) ? 'network failed'
1172
2232
  : 'update failed'
1173
- sendJson(res, 500, { ok: false, error: msg })
2233
+ const code = /already up to date/.test(String(err?.message)) ? 200 : 500
2234
+ sendJson(res, code, { ok: false, error: msg })
1174
2235
  }
1175
2236
  },
1176
2237
  }), 'dsh-api-dashboard: update install route')
@@ -1264,8 +2325,7 @@ export function apply(ctx, config) {
1264
2325
  if (req.method === 'GET') { sendJson(res, 200, { ok: true, settings: runtimeConfig.whaleSettings }); return }
1265
2326
  if (req.method === 'PUT' || req.method === 'POST') {
1266
2327
  try {
1267
- let raw = ''
1268
- for await (const chunk of req) { raw += chunk }
2328
+ let raw = await readBody(req)
1269
2329
  const body = raw ? JSON.parse(raw) : {}
1270
2330
  const cur = runtimeConfig.whaleSettings
1271
2331
  const num = (v, lo, hi, dflt) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : dflt)
@@ -1290,19 +2350,25 @@ export function apply(ctx, config) {
1290
2350
  customRelays: runtimeConfig.customRelays,
1291
2351
  customModels: runtimeConfig.customModels,
1292
2352
  currency: runtimeConfig.currency,
2353
+ overseasCurrency: runtimeConfig.overseasCurrency,
1293
2354
  safeThreshold: runtimeConfig.safeThreshold,
1294
2355
  warnThreshold: runtimeConfig.warnThreshold,
1295
2356
  whaleEnabled: runtimeConfig.whaleEnabled,
2357
+ showNoBalanceBrands: runtimeConfig.showNoBalanceBrands,
1296
2358
  whaleSettings: runtimeConfig.whaleSettings,
1297
2359
  })
1298
2360
  sendJson(res, 200, { ok: true, settings: runtimeConfig.whaleSettings })
1299
- } catch (err) { sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) }) }
2361
+ } catch (err) {
2362
+ const code = err && err.statusCode === 413 ? 413 : 400
2363
+ sendJson(res, code, { ok: false, error: err instanceof Error ? err.message : String(err) })
2364
+ }
1300
2365
  return
1301
2366
  }
1302
2367
  res.writeHead(405, { Allow: 'GET, PUT, POST' }); res.end()
1303
2368
  },
1304
2369
  }), 'dsh-api-dashboard: whale settings route')
1305
2370
 
2371
+
1306
2372
  webCtx.effect(() => webCtx.webServer.register({
1307
2373
  kind: 'exact', path: '/api-dashboard/config',
1308
2374
  async handler(req, res) {
@@ -1313,38 +2379,52 @@ export function apply(ctx, config) {
1313
2379
  customModels: runtimeConfig.customModels.map(m => ({ ...m, apiKey: m.apiKey ? '***' : '' })),
1314
2380
  presets: runtimeConfig.presets,
1315
2381
  refreshIntervalSec: Math.round(runtimeConfig.refreshIntervalMs / 1000),
2382
+ currency: runtimeConfig.currency,
2383
+ overseasCurrency: runtimeConfig.overseasCurrency,
1316
2384
  whaleEnabled: !!runtimeConfig.whaleEnabled,
2385
+ showNoBalanceBrands: !!runtimeConfig.showNoBalanceBrands,
2386
+ officialProviders: runtimeConfig.officialProviders,
2387
+ // 只读: 供设置面板显示「自动判定结果」, 让用户知道哪些还需要手填
2388
+ providerKinds: readProviderKinds(),
2389
+ // v1.4.0「真自动」: 从 settings.yaml 自动发现的 provider 及启用状态 (不含 key)
2390
+ dshProviders: readDshProviderStatus(),
2391
+ dshProviderOptOut: runtimeConfig.dshProviderOptOut,
1317
2392
  })
1318
2393
  return
1319
2394
  }
1320
2395
  if (req.method === 'POST') {
1321
2396
  try {
1322
- let body = ''
1323
- for await (const chunk of req) { body += chunk }
2397
+ let body = await readBody(req)
1324
2398
  body = body ? JSON.parse(body) : {}
2399
+ // 数组规模上限: 状态文件与每次轮询都要带它们, 防垃圾数据无限膨胀
2400
+ const MAX_ITEMS = 64
2401
+ const cleanId = (v) => cleanStr(v, 64).replace(/[^a-zA-Z0-9_-]/g, '')
2402
+ const cleanQueryType = (v) => { const s = cleanStr(v, 32); return /^[a-zA-Z0-9_-]+$/.test(s) ? s : 'auto' }
1325
2403
  if (Array.isArray(body.customRelays)) {
1326
- runtimeConfig.customRelays = body.customRelays.map(r => {
2404
+ runtimeConfig.customRelays = body.customRelays.slice(0, MAX_ITEMS).map(r => {
1327
2405
  const prev = runtimeConfig.customRelays.find(x => x.id === r.id)
2406
+ const rk = cleanStr(r.apiKey, 256) // '***' / 空 = 保留旧 key (掩码回填约定)
1328
2407
  return {
1329
- id: r.id || Math.random().toString(36).slice(2), name: r.name || '中转站',
1330
- baseUrl: (r.baseUrl || '').replace(/\/+$/, ''), apiKey: r.apiKey && r.apiKey !== '***' ? r.apiKey : (prev?.apiKey || ''), queryType: r.queryType || 'auto',
2408
+ id: cleanId(r.id) || Math.random().toString(36).slice(2), name: cleanStr(r.name, 128) || '中转站',
2409
+ baseUrl: cleanUrl(r.baseUrl).replace(/\/+$/, ''), apiKey: (rk && rk !== '***') ? rk : (prev?.apiKey || ''), queryType: cleanQueryType(r.queryType),
1331
2410
  }
1332
2411
  })
1333
2412
  }
1334
2413
  if (Array.isArray(body.customModels)) {
1335
- runtimeConfig.customModels = body.customModels.map(m => {
2414
+ runtimeConfig.customModels = body.customModels.slice(0, MAX_ITEMS).map(m => {
1336
2415
  const prev = runtimeConfig.customModels.find(x => x.id === m.id)
2416
+ const mk = cleanStr(m.apiKey, 256)
1337
2417
  return {
1338
- id: m.id || Math.random().toString(36).slice(2), name: m.name || '自定义模型',
1339
- apiUrl: (m.apiUrl || '').trim(), apiKey: m.apiKey && m.apiKey !== '***' ? m.apiKey : (prev?.apiKey || ''),
1340
- queryType: m.queryType || 'auto', totalPath: (m.totalPath || '').trim(), usedPath: (m.usedPath || '').trim(),
1341
- currency: (m.currency || '').trim() || 'CNY',
2418
+ id: cleanId(m.id) || Math.random().toString(36).slice(2), name: cleanStr(m.name, 128) || '自定义模型',
2419
+ apiUrl: cleanUrl(m.apiUrl), apiKey: mk !== '***' && mk !== '' ? mk : (prev?.apiKey || ''),
2420
+ queryType: cleanQueryType(m.queryType), totalPath: cleanStr(m.totalPath, 128), usedPath: cleanStr(m.usedPath, 128),
2421
+ currency: cleanStr(m.currency, 8) || 'CNY',
1342
2422
  }
1343
2423
  })
1344
2424
  }
1345
- // 自定义刷新时间 (5~60 秒, 最高一分钟)
2425
+ // 自定义刷新时间 (1~60 秒; v1.4.0 下限由 5 秒放宽到 1 秒)
1346
2426
  if (typeof body.refreshIntervalSec === 'number' && Number.isFinite(body.refreshIntervalSec)) {
1347
- const sec = Math.min(Math.max(Math.round(body.refreshIntervalSec), 5), 60)
2427
+ const sec = clampRefreshSec(body.refreshIntervalSec)
1348
2428
  runtimeConfig.refreshIntervalMs = sec * 1000
1349
2429
  runtimeConfig.clientPollIntervalMs = sec * 1000
1350
2430
  }
@@ -1352,8 +2432,23 @@ export function apply(ctx, config) {
1352
2432
  if (typeof body.safeThreshold === 'number' && body.safeThreshold >= 0) runtimeConfig.safeThreshold = body.safeThreshold
1353
2433
  if (typeof body.warnThreshold === 'number' && body.warnThreshold >= 0) runtimeConfig.warnThreshold = body.warnThreshold
1354
2434
  if (typeof body.currency === 'string' && body.currency.trim()) runtimeConfig.currency = body.currency.trim().toUpperCase()
2435
+ // v1.3.2: 海外模型独立计价货币, 只接受白名单三值 (脏值一律落回 follow, 不放行任意字符串)
2436
+ if (typeof body.overseasCurrency === 'string') {
2437
+ const v = body.overseasCurrency.trim().toLowerCase()
2438
+ runtimeConfig.overseasCurrency = v === 'usd' ? 'USD' : v === 'cny' ? 'CNY' : 'follow'
2439
+ }
1355
2440
  // v1.1.0: 收养大肥鱼开关
1356
2441
  if (typeof body.whaleEnabled === 'boolean') runtimeConfig.whaleEnabled = body.whaleEnabled
2442
+ // 显示无余额模型品牌
2443
+ if (typeof body.showNoBalanceBrands === 'boolean') runtimeConfig.showNoBalanceBrands = body.showNoBalanceBrands
2444
+ // 官方直连 provider 名单 (第 1 层判定); 接受数组或逗号/换行分隔的字符串
2445
+ if (Array.isArray(body.officialProviders) || typeof body.officialProviders === 'string') {
2446
+ runtimeConfig.officialProviders = normalizeOfficialProviders(body.officialProviders)
2447
+ }
2448
+ // v1.4.0「真自动」: 被关掉的 DSH provider 名单 (默认空 = 全部启用)
2449
+ if (Array.isArray(body.dshProviderOptOut) || typeof body.dshProviderOptOut === 'string') {
2450
+ runtimeConfig.dshProviderOptOut = normalizeOfficialProviders(body.dshProviderOptOut)
2451
+ }
1357
2452
  // 持久化: 写入状态文件, 重启后恢复 (用户配置优先)
1358
2453
  savePersistedState({
1359
2454
  refreshIntervalMs: runtimeConfig.refreshIntervalMs,
@@ -1362,9 +2457,13 @@ export function apply(ctx, config) {
1362
2457
  customRelays: runtimeConfig.customRelays,
1363
2458
  customModels: runtimeConfig.customModels,
1364
2459
  currency: runtimeConfig.currency,
2460
+ overseasCurrency: runtimeConfig.overseasCurrency,
1365
2461
  safeThreshold: runtimeConfig.safeThreshold,
1366
2462
  warnThreshold: runtimeConfig.warnThreshold,
1367
2463
  whaleEnabled: runtimeConfig.whaleEnabled,
2464
+ showNoBalanceBrands: runtimeConfig.showNoBalanceBrands,
2465
+ officialProviders: runtimeConfig.officialProviders,
2466
+ dshProviderOptOut: runtimeConfig.dshProviderOptOut,
1368
2467
  })
1369
2468
  resetLoop(); await refreshAll()
1370
2469
  sendJson(res, 200, {
@@ -1372,9 +2471,19 @@ export function apply(ctx, config) {
1372
2471
  customRelays: runtimeConfig.customRelays.map(r => ({ ...r, apiKey: r.apiKey ? '***' : '' })),
1373
2472
  customModels: runtimeConfig.customModels.map(m => ({ ...m, apiKey: m.apiKey ? '***' : '' })),
1374
2473
  refreshIntervalSec: Math.round(runtimeConfig.refreshIntervalMs / 1000),
2474
+ currency: runtimeConfig.currency,
2475
+ overseasCurrency: runtimeConfig.overseasCurrency,
1375
2476
  whaleEnabled: !!runtimeConfig.whaleEnabled,
2477
+ showNoBalanceBrands: !!runtimeConfig.showNoBalanceBrands,
2478
+ officialProviders: runtimeConfig.officialProviders,
2479
+ providerKinds: readProviderKinds(),
2480
+ dshProviders: readDshProviderStatus(),
2481
+ dshProviderOptOut: runtimeConfig.dshProviderOptOut,
1376
2482
  })
1377
- } catch (err) { sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) }) }
2483
+ } catch (err) {
2484
+ const code = err && err.statusCode === 413 ? 413 : 400
2485
+ sendJson(res, code, { ok: false, error: err instanceof Error ? err.message : String(err) })
2486
+ }
1378
2487
  return
1379
2488
  }
1380
2489
  res.writeHead(405, { Allow: 'GET, POST' })
@@ -1385,6 +2494,13 @@ export function apply(ctx, config) {
1385
2494
 
1386
2495
  // 会话消耗投影
1387
2496
  ctx.inject(['sessionProjections'], (projectionCtx) => {
1388
- projectionCtx.sessionProjections.register(makeCostProjection(getConfig))
2497
+ // v1.4.0: 把 session 存储与投影注册表惰性交给投影, 用于汇总子代理消耗。
2498
+ // 惰性 (每次读取时才 get) 是为了不把服务解析绑死在注册时刻 —— 服务可能后挂载。
2499
+ const services = () => {
2500
+ let sessions = null
2501
+ try { sessions = ctx.get('sessions') ?? projectionCtx.get('sessions') ?? null } catch { sessions = null }
2502
+ return { sessions, projections: projectionCtx.sessionProjections }
2503
+ }
2504
+ projectionCtx.sessionProjections.register(makeCostProjection(getConfig, services))
1389
2505
  })
1390
2506
  }