dsh-api-dashboard 1.1.2 → 1.4.1

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 +191 -25
  2. package/client/client.js +847 -163
  3. package/package.json +2 -2
  4. package/src/index.js +1546 -194
package/src/index.js CHANGED
@@ -13,14 +13,53 @@
13
13
  * - sessionProjections 单元 queryBalanceCost 估算本会话消耗 (按模型单价)。
14
14
  */
15
15
 
16
- import Schema from '@deepseek-ai/schemastery'
17
- import { z } from 'zod'
18
- import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, rmSync, cpSync, statSync, readdirSync, realpathSync } from 'node:fs'
19
- import { fileURLToPath } from 'node:url'
16
+ import { createRequire } from 'node:module'
17
+ import { readFileSync, writeFileSync, renameSync, chmodSync, existsSync, mkdirSync, rmSync, cpSync, statSync, lstatSync, readdirSync, realpathSync } from 'node:fs'
18
+ import { fileURLToPath, pathToFileURL } from 'node:url'
20
19
  import { execFileSync } from 'node:child_process'
21
- import { tmpdir } from 'node:os'
20
+ import { tmpdir, homedir } from 'node:os'
22
21
  import { join, dirname, basename } from 'node:path'
23
22
 
23
+ /**
24
+ * peer 依赖加载器(v1.4.1)—— 这一条直接决定「别人下载后能不能用」。
25
+ *
26
+ * 病症(真机实测复现):用户按 README 执行 `dsh plugin --profile web add dsh-api-dashboard`,
27
+ * 安装成功,但启动时 **整个 dsh web 起不来**:
28
+ *
29
+ * Cannot find package '@deepseek-ai/schemastery' imported from
30
+ * /root/.local/share/pnpm/store/v10/files/42/e96689...
31
+ *
32
+ * 链路:profile 用 hoisted linker + autoInstallPeers:false(插件把它声明成 optional peer),
33
+ * DSHA 的 proot 带 `--link2symlink`,于是 pnpm 的硬链接被降级成**指向全局 store 的软链**;
34
+ * Node 的 ESM 会先把模块解析成 realpath,再从那开始向上找 node_modules ——
35
+ * 从 store 目录往上永远也走不到 `$DSH_HOME/profiles/node_modules`(DSH 放宿主依赖的地方)。
36
+ *
37
+ * 修法:先按常规 import;失败时改用宿主自己的模块回退目录做 CJS 解析
38
+ * (`createRequire` 用的是**给定路径**而不是 realpath,因此能穿透这层软链)。
39
+ * 这样 npm 安装、源码安装、软链安装三种布局都能加载。
40
+ */
41
+ const resolvePeer = async (spec) => {
42
+ let primary = null
43
+ try { return await import(spec) } catch (e) { primary = e }
44
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
45
+ const bases = [
46
+ join(home, 'profiles', 'node_modules', '__dshadb_resolver__.cjs'),
47
+ join(home, 'profiles', process.env.DSHA_STARTUP_PROFILE || 'web', 'node_modules', '__dshadb_resolver__.cjs'),
48
+ ]
49
+ for (const base of bases) {
50
+ try {
51
+ const entry = createRequire(base).resolve(spec)
52
+ return await import(pathToFileURL(entry).href)
53
+ } catch { /* 试下一个基准目录 */ }
54
+ }
55
+ throw primary
56
+ }
57
+
58
+ const schemaMod = await resolvePeer('@deepseek-ai/schemastery')
59
+ const Schema = schemaMod.default ?? schemaMod
60
+ const zodMod = await resolvePeer('zod')
61
+ const z = zodMod.z ?? zodMod.default?.z ?? zodMod
62
+
24
63
  export const name = 'dsh-api-dashboard'
25
64
 
26
65
  // ============================================================
@@ -36,7 +75,33 @@ const MANIFEST_API = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/co
36
75
  const TARBALL_URL = `https://codeload.github.com/${REPO_OWNER}/${REPO_NAME}/tar.gz/refs/heads/${REPO_BRANCH}`
37
76
 
38
77
  /** 插件运行实体的安装根目录 (src/index.js 上两级; ESM 默认按 realpath 加载) */
39
- const SELF_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
78
+ /**
79
+ * 插件自身的安装根目录(v1.4.1)。
80
+ *
81
+ * 不能只用 `dirname(dirname(import.meta.url))`:ESM 会把符号链接解析成 realpath,
82
+ * 而 npm 装进 profile 后文件常常是**指向 pnpm store 的软链** → 算出来的根目录是
83
+ * `.../store/v10/files`,于是 assets(图标 / 大肥鱼贴图 / 音效)全部 404、
84
+ * 版本号也读不到(一键更新会误判)。
85
+ * 这里按候选顺序找第一个「package.json 里 name 就是本插件」的目录。
86
+ */
87
+ const resolveSelfRoot = () => {
88
+ const fromUrl = dirname(dirname(fileURLToPath(import.meta.url)))
89
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
90
+ const profile = process.env.DSHA_STARTUP_PROFILE || 'web'
91
+ const candidates = [
92
+ fromUrl,
93
+ join(home, 'profiles', profile, 'node_modules', 'dsh-api-dashboard'),
94
+ join(home, 'profiles', 'node_modules', 'dsh-api-dashboard'),
95
+ ]
96
+ for (const dir of candidates) {
97
+ try {
98
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
99
+ if (pkg && pkg.name === 'dsh-api-dashboard') return dir
100
+ } catch { /* 试下一个 */ }
101
+ }
102
+ return fromUrl
103
+ }
104
+ const SELF_ROOT = resolveSelfRoot()
40
105
 
41
106
  /** 读取指定目录中 package.json 的 version, 异常返回 null */
42
107
  const readVersionAt = (dir) => {
@@ -88,15 +153,20 @@ export async function applyUpdate({ targets = null, timeoutMs = 30000, remoteVer
88
153
  if (currentVersion && semverCompare(wantVersion, currentVersion) <= 0) {
89
154
  throw new Error(`already up to date (${currentVersion})`)
90
155
  }
91
- // 待写入目录: 运行实体优先; 若经典源码目录 (/root/dsha-api-dashboard) 存在
156
+ // 待写入目录: 运行实体优先; 若经典源码目录 (~/dsha-api-dashboard) 存在
92
157
  // 且是与运行实体不同的另一条真实路径, 一并同步, 避免链接形态下两边版本漂移.
93
158
  // (仅在缺省自动模式下探测; 显式注入 targets 的测试/调试调用不受影响)
94
159
  const dirs = Array.isArray(targets) && targets.length ? [...new Set(targets)] : [SELF_ROOT]
95
160
  if (!Array.isArray(targets)) {
96
161
  try {
97
- const legacyReal = realPathSafe('/root/dsha-api-dashboard')
162
+ const legacyReal = realPathSafe(join(homedir(), 'dsha-api-dashboard'))
98
163
  const selfReal = realPathSafe(SELF_ROOT)
99
- if (legacyReal && selfReal && legacyReal !== selfReal && existsSync(join(legacyReal, 'package.json'))) {
164
+ // H-2 (v1.4.1): 自动同步 ~/dsha-api-dashboard 只对「非 git 工作区」生效。
165
+ // 以前不判断, 于是维护者/开发者的 git clone 会在一次"一键更新"后被 tarball 覆写 ——
166
+ // codeload 的 tarball 里**没有 .git**(已实测), 删掉就再也回不来。
167
+ if (legacyReal && selfReal && legacyReal !== selfReal
168
+ && existsSync(join(legacyReal, 'package.json'))
169
+ && !existsSync(join(legacyReal, '.git'))) {
100
170
  dirs.push(legacyReal)
101
171
  }
102
172
  } catch { /* 探测失败不影响主流程 */ }
@@ -104,6 +174,16 @@ export async function applyUpdate({ targets = null, timeoutMs = 30000, remoteVer
104
174
  for (const dir of dirs) {
105
175
  if (!existsSync(join(dir, 'package.json'))) throw new Error(`target missing: ${dir}`)
106
176
  }
177
+ // H-2 (v1.4.1): target 是符号链接时, 下面的 rmSync 会**穿透软链删光真实目录的内容**,
178
+ // 而 cpSync 对软链会抛 ERR_FS_CP_DIR_TO_NON_DIR; 备份 tar 里存的又只是软链本身 →
179
+ // 真实目录永久损坏且回滚不回来。遇到软链直接拒绝, 让用户改用真实路径。
180
+ for (const dir of dirs) {
181
+ let st = null
182
+ try { st = lstatSync(dir) } catch { st = null }
183
+ if (st && st.isSymbolicLink()) {
184
+ throw new Error(`refusing to update a symbolic-link target: ${dir} (use its real path instead)`)
185
+ }
186
+ }
107
187
 
108
188
  // 1) 下载 tarball 到临时文件
109
189
  const tmpBase = join(tmpdir(), `dshadb-update-${Date.now()}`)
@@ -138,7 +218,9 @@ export async function applyUpdate({ targets = null, timeoutMs = 30000, remoteVer
138
218
  // 5) 交换: 删旧内容 → 拷新内容 (node_modules 保留, 避免重装依赖)
139
219
  for (const dir of dirs) {
140
220
  swapped = true
141
- const keep = new Set(['node_modules'])
221
+ // H-2 (v1.4.1): 必须保留 .git —— codeload 的 tarball 里没有它(实测),
222
+ // 删掉就等于把用户的 git 历史抹了, 且没有任何恢复途径。
223
+ const keep = new Set(['node_modules', '.git'])
142
224
  for (const entry of readdirSafe(dir)) {
143
225
  if (!keep.has(entry)) rmSync(join(dir, entry), { recursive: true, force: true })
144
226
  }
@@ -200,24 +282,456 @@ async function getUpdateStatus(force = false) {
200
282
  // 配置持久化: 设置面板保存的配置写入独立状态文件, 重启后恢复
201
283
  // (不写回 cordis.patch.yml, 避免 YAML 写坏导致 dsh 起不来)
202
284
  // ============================================================
203
- const STATE_FILE = '/root/.dsh/dsh-api-dashboard.json'
285
+ const STATE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-api-dashboard.json')
286
+
287
+ /**
288
+ * A(v1.4.1): 每个中转站「上次探到的可用余额端点」的记忆表。
289
+ * `queryCustomRelay` 的 auto 探测是**串行**试 3 个候选端点, 每个都要跑满 timeoutMs ——
290
+ * 实测一次全量刷新因此要 8~13.6 秒。记住命中过的端点并优先试它, 稳态下每个中转站只打 1 个请求。
291
+ * 只做**排序提示**, 候选全表仍然会依次试, 所以某个中转站换了端点也能自动跟上。
292
+ * 持久化在状态文件 `relayEndpoints`(重启后依然生效)。
293
+ */
294
+ const relayEndpointHints = new Map()
295
+
296
+ /**
297
+ * 状态文件结构版本。**改动已持久化字段的默认值时必须 +1 并补一段迁移**,
298
+ * 否则老用户的状态文件会把字段钉死在旧值上 —— 新默认值对老用户永远不生效。
299
+ * 1 → 2: v1.4.0 `overseasCurrency` 默认 'follow' → 'USD'。
300
+ * 老状态文件里那行 'follow' 是**旧默认值写下来的**, 不是用户的显式选择,
301
+ * 因此迁移时把它改成 'USD'; 迁移后用户再手动选 'follow' 就会被正常尊重。
302
+ */
303
+ const CONFIG_VERSION = 2
304
+
305
+ /**
306
+ * 形状消毒 (v1.4.1): 状态文件是**我们自己的代码**写的, 但写盘可能被打断
307
+ * (磁盘满/进程被杀/UTF-8 截断), 用户也可能手改。字段形状一旦跑偏,
308
+ * 后面的 `(persisted.customRelays ?? []).map(...)` 会抛 TypeError —— 而 apply() 抛出
309
+ * 会让 **整个 dsh web 启动失败**(不是插件不显示, 是 GUI 打不开), 用户完全无从下手。
310
+ * 所以这里把所有"本该是数组/对象/数字"的字段统一消毒, 消毒不了就丢弃, 绝不让 apply() 抛。
311
+ * ⚠️ 新增持久化字段时, 记得同步登记到这里。
312
+ */
313
+ const ARRAY_FIELDS = ['customRelays', 'customModels', 'officialProviders', 'dshProviderOptOut', 'presets']
314
+ const OBJECT_FIELDS = ['whaleSettings', 'prices', 'relayEndpoints']
315
+ const NUMBER_FIELDS = [
316
+ ['refreshIntervalMs', 1000, 60000], // H-4b: 曾经能持久化成 -1 → 3 秒内 1794 次上游请求
317
+ ['clientPollIntervalMs', 1000, 60000],
318
+ ['timeoutMs', 1000, 60000],
319
+ ['safeThreshold', 0, Number.MAX_SAFE_INTEGER],
320
+ ['warnThreshold', 0, Number.MAX_SAFE_INTEGER],
321
+ ['configVersion', 0, Number.MAX_SAFE_INTEGER],
322
+ ]
323
+
324
+ const sanitizePersistedShape = (s) => {
325
+ const out = { ...s }
326
+ for (const k of ARRAY_FIELDS) {
327
+ if (k in out && !Array.isArray(out[k])) delete out[k]
328
+ }
329
+ for (const k of OBJECT_FIELDS) {
330
+ if (k in out && (out[k] === null || typeof out[k] !== 'object' || Array.isArray(out[k]))) delete out[k]
331
+ }
332
+ for (const [k, min, max] of NUMBER_FIELDS) {
333
+ if (!(k in out)) continue
334
+ const n = Number(out[k])
335
+ if (!Number.isFinite(n)) { delete out[k]; continue }
336
+ out[k] = Math.min(Math.max(Math.round(n), min), max)
337
+ }
338
+ return out
339
+ }
340
+
341
+ const migratePersistedState = (parsed) => {
342
+ const s = sanitizePersistedShape((parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {})
343
+ const ver = Number.isFinite(s.configVersion) ? s.configVersion : 1
344
+ let out = s
345
+ if (ver < 2) {
346
+ if (out.overseasCurrency === 'follow' || out.overseasCurrency === undefined) {
347
+ out = { ...out, overseasCurrency: 'USD' }
348
+ }
349
+ }
350
+ if (out.configVersion !== CONFIG_VERSION) out = { ...out, configVersion: CONFIG_VERSION }
351
+ return out
352
+ }
204
353
 
205
354
  const loadPersistedState = () => {
206
355
  try {
207
356
  const raw = readFileSync(STATE_FILE, 'utf8')
208
- const parsed = JSON.parse(raw)
209
- return parsed && typeof parsed === 'object' ? parsed : {}
210
- } catch { return {} }
357
+ return migratePersistedState(JSON.parse(raw))
358
+ } catch { return migratePersistedState({}) }
211
359
  }
212
360
 
213
361
  const savePersistedState = (state) => {
214
362
  try {
215
- writeFileSync(STATE_FILE + '.tmp', JSON.stringify(state, null, 2), 'utf8')
363
+ const merged = { ...loadPersistedState(), ...state }
364
+ // H-4c (v1.4.1): 以前这里从不建父目录, 且吞掉所有异常 → ~/.dsh 不存在时
365
+ // 接口照样回 ok:true, 用户配置"保存成功"却没落盘, 重启即丢。现在先建目录。
366
+ mkdirSync(dirname(STATE_FILE), { recursive: true })
367
+ // mode 0o600: 状态文件含自定义中转站/模型的 API Key 明文, 必须限定本用户可读
368
+ // (不能依赖 umask —— 默认 umask 0022 的桌面机会落成 0644); chmod 兜底修正旧文件
369
+ writeFileSync(STATE_FILE + '.tmp', JSON.stringify(merged, null, 2), { encoding: 'utf8', mode: 0o600 })
216
370
  renameSync(STATE_FILE + '.tmp', STATE_FILE)
371
+ try { chmodSync(STATE_FILE, 0o600) } catch { /* 平台不支持或已是 0600, 忽略 */ }
217
372
  return true
218
373
  } catch { return false }
219
374
  }
220
375
 
376
+ // ============================================================
377
+ // provider 官方/中转判定 (开源化改造)
378
+ // ------------------------------------------------------------
379
+ // 用途: 判断当前对话走的是官方直连还是中转站。中转站没有余额接口,
380
+ // 状态条金额必须显示「—」, 而不是拿某个官方平台的余额顶上去。
381
+ //
382
+ // 三层判定 (优先级由高到低, 客户端 isRelayProvider 按同样顺序落地):
383
+ // 1) 用户在设置面板显式声明的「官方直连 provider」名单 (officialProviders)
384
+ // —— 最高优先级, 兜住下面两层的一切误判
385
+ // 2) 读 settings.yaml 里 llm-pi-ai.providers.<name>.baseURL, 按 **域名** 比对
386
+ // 官方端点白名单 (不是比对 provider 名 —— 别人的 provider 叫什么猜不到)
387
+ // 3) DSH 官方插件命名约定: `-official` / `_official` 后缀 (客户端兜底)
388
+ // 都不命中 → 按中转站处理 (保守: 宁可不显示余额, 也不显示错的余额)
389
+ //
390
+ // ⚠️ 只认 settings.yaml 里「显式写出」的 baseURL。provider 省略 baseURL 时靠
391
+ // llm-pi-ai 内置目录解析, 而内置目录里的官方域名并不代表用户这把 key 来自官方
392
+ // (实测: xiaomi 无 baseURL, 内置目录指向 api.xiaomimimo.com, 但用户的 key 实际
393
+ // 来自中转站) → 这种情况不表态, 交给第 3 层, 最终落到「按中转站」。
394
+ // ============================================================
395
+
396
+ /** 官方 API 端点主机名白名单 (精确匹配)。
397
+ * 取自各平台官方文档与 pi-ai 内置 provider 目录的 baseUrl。
398
+ * 拿不准的一律不列 —— 不列只是「不显示余额」, 列错会显示别家的余额。 */
399
+ const OFFICIAL_API_HOSTS = new Set([
400
+ // 国内
401
+ 'api.deepseek.com',
402
+ 'open.bigmodel.cn', 'api.z.ai',
403
+ 'api.moonshot.cn', 'api.moonshot.ai', 'api.kimi.com',
404
+ 'api.stepfun.com',
405
+ 'api.siliconflow.cn',
406
+ 'api.minimaxi.com', 'api.minimax.io', 'api.minimax.chat',
407
+ 'dashscope.aliyuncs.com', 'dashscope-intl.aliyuncs.com',
408
+ 'token-plan.cn-beijing.maas.aliyuncs.com', 'token-plan.ap-southeast-1.maas.aliyuncs.com',
409
+ 'api.ant-ling.com',
410
+ // 海外
411
+ 'api.openai.com', 'chatgpt.com',
412
+ 'api.anthropic.com',
413
+ 'generativelanguage.googleapis.com',
414
+ 'openrouter.ai',
415
+ 'api.novita.ai',
416
+ 'api.x.ai',
417
+ 'api.mistral.ai',
418
+ 'api.groq.com',
419
+ 'api.together.ai', 'api.together.xyz',
420
+ 'api.fireworks.ai',
421
+ 'api.cerebras.ai',
422
+ 'integrate.api.nvidia.com',
423
+ 'router.huggingface.co',
424
+ 'api.individual.githubcopilot.com',
425
+ ])
426
+
427
+ /** 官方端点域名后缀 (子域一律算官方; 只用于确实由厂商独占的注册域)。
428
+ * ⚠️ 通用云域名 (aliyuncs.com / cloudflare 之类) 绝不能进这里 —— 谁都能在上面开服务。 */
429
+ const OFFICIAL_API_SUFFIXES = [
430
+ '.xiaomimimo.com', // api / token-plan-cn / token-plan-ams / token-plan-sgp
431
+ ]
432
+
433
+ /** URL → 小写主机名 (去端口); 解析不了返回空串 */
434
+ export const hostOfUrl = (url) => {
435
+ try { return new URL(String(url)).hostname.toLowerCase() } catch { return '' }
436
+ }
437
+
438
+ /** 主机名是否属于官方 API 端点 */
439
+ export function isOfficialHost(host) {
440
+ if (typeof host !== 'string' || host === '') return false
441
+ const h = host.toLowerCase()
442
+ if (OFFICIAL_API_HOSTS.has(h)) return true
443
+ return OFFICIAL_API_SUFFIXES.some((suffix) => h.endsWith(suffix))
444
+ }
445
+
446
+ /**
447
+ * settings.yaml 里 `llm-pi-ai.providers.<name>.<field>` 的通用取值器 (v1.4.0 抽出,
448
+ * 原先只取 baseURL 一个字段, 「真自动」要连 apiKeyEnv 一起取)。
449
+ * 手写最小缩进解析器 —— 刻意不引 yaml 依赖: package.json 的 dependencies 保持为空,
450
+ * 引依赖会破坏零依赖安装。只认这一条路径, 别的 YAML 语法一概不管。
451
+ * @param {string} text settings.yaml 全文
452
+ * @param {string[]} wanted 想取的字段名 (provider 直接子字段那一层)
453
+ * @returns {Record<string,Record<string,string>>} { providerName: { field: value } }
454
+ */
455
+ const collectProviderFields = (text, wanted) => {
456
+ const out = {}
457
+ if (typeof text !== 'string' || text === '') return out
458
+ const indentOf = (line) => line.length - line.replace(/^[ \t]+/, '').length
459
+ // 取 `key: value` 的键与值; 列表项 (`- id: x`) 与非键值行返回 null
460
+ const keyOf = (line) => {
461
+ if (line.startsWith('-')) return null
462
+ const m = /^([^\s#][^:]*):(.*)$/.exec(line)
463
+ return m === null ? null : { key: m[1].trim(), value: m[2].trim() }
464
+ }
465
+ // 剥掉行内注释与引号
466
+ const cleanValue = (raw) => {
467
+ let v = raw.split(' #')[0].trim()
468
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
469
+ return v.trim()
470
+ }
471
+ let sectionIndent = -1 // `llm-pi-ai:` 的缩进
472
+ let sectionChildIndent = -1 // llm-pi-ai 直接子键的缩进 (只在这一层认 `providers`)
473
+ let providersIndent = -1 // `providers:` 的缩进
474
+ let nameIndent = -1 // `<providerName>:` 的缩进
475
+ let fieldIndent = -1 // provider 直接子字段的缩进 (只认这一层的 baseURL)
476
+ let current = ''
477
+ for (const raw of text.split(/\r?\n/)) {
478
+ const trimmed = raw.trim()
479
+ if (trimmed === '' || trimmed.startsWith('#')) continue
480
+ const indent = indentOf(raw)
481
+ // 退出比当前更浅的层级
482
+ if (current !== '' && nameIndent >= 0 && indent <= nameIndent) { current = ''; fieldIndent = -1 }
483
+ if (providersIndent >= 0 && indent <= providersIndent) { providersIndent = -1; nameIndent = -1 }
484
+ if (sectionIndent >= 0 && indent <= sectionIndent && providersIndent < 0) {
485
+ // 同级或更浅的另一个顶层键 → llm-pi-ai 段结束
486
+ const kv = keyOf(trimmed)
487
+ if (kv !== null && kv.key !== 'llm-pi-ai') { sectionIndent = -1; sectionChildIndent = -1 }
488
+ }
489
+ const kv = keyOf(trimmed)
490
+ if (kv === null) continue // 列表项 (`- id: x`) 等一概跳过
491
+ if (sectionIndent < 0) {
492
+ if (kv.key === 'llm-pi-ai' && kv.value === '') { sectionIndent = indent; sectionChildIndent = -1 }
493
+ continue
494
+ }
495
+ if (providersIndent < 0) {
496
+ if (indent <= sectionIndent) continue
497
+ if (sectionChildIndent < 0) sectionChildIndent = indent
498
+ // 只认 llm-pi-ai 的直接子键 providers, 不误吃更深层同名键
499
+ if (indent === sectionChildIndent && kv.key === 'providers' && kv.value === '') providersIndent = indent
500
+ continue
501
+ }
502
+ if (current === '') {
503
+ // provider 名: providers 的直接子键, 值为空 (dict 头)
504
+ if (indent > providersIndent && kv.value === '') {
505
+ if (nameIndent < 0) nameIndent = indent
506
+ if (indent === nameIndent) { current = kv.key; fieldIndent = -1 }
507
+ }
508
+ continue
509
+ }
510
+ if (indent <= nameIndent) continue
511
+ if (fieldIndent < 0) fieldIndent = indent // provider 下第一个字段定基准缩进
512
+ if (indent !== fieldIndent) continue // 更深的层 (models 项内部等) 不认
513
+ if (wanted.includes(kv.key)) {
514
+ const value = cleanValue(kv.value)
515
+ if (value !== '') {
516
+ if (out[current] === undefined) out[current] = {}
517
+ out[current][kv.key] = value
518
+ }
519
+ }
520
+ }
521
+ return out
522
+ }
523
+
524
+ /**
525
+ * 从 settings.yaml 文本里抓 `llm-pi-ai.providers.<name>.baseURL` (第 2 层判定用)。
526
+ * @param {string} text settings.yaml 全文
527
+ * @returns {Record<string,string>} { providerName: baseURL }
528
+ */
529
+ export function parseProviderBaseURLs(text) {
530
+ const out = {}
531
+ for (const [name, fields] of Object.entries(collectProviderFields(text, ['baseURL', 'baseUrl']))) {
532
+ const url = fields.baseURL !== undefined ? fields.baseURL : fields.baseUrl
533
+ if (url !== undefined) out[name] = url
534
+ }
535
+ return out
536
+ }
537
+
538
+ /**
539
+ * v1.4.0「真自动」: 抓 provider 的 baseURL **和 apiKeyEnv**。
540
+ * 插件据此把用户在 DSH 里配好的中转站直接变成可查余额的条目 ——
541
+ * 不必再去插件设置里手抄一遍 baseUrl + key。
542
+ * @param {string} text settings.yaml 全文
543
+ * @returns {Record<string,{baseURL:string,apiKeyEnv:string}>}
544
+ */
545
+ export function parseProviderEntries(text) {
546
+ const out = {}
547
+ for (const [name, fields] of Object.entries(collectProviderFields(text, ['baseURL', 'baseUrl', 'apiKeyEnv']))) {
548
+ out[name] = {
549
+ baseURL: fields.baseURL !== undefined ? fields.baseURL : (fields.baseUrl !== undefined ? fields.baseUrl : ''),
550
+ apiKeyEnv: fields.apiKeyEnv !== undefined ? fields.apiKeyEnv : '',
551
+ }
552
+ }
553
+ return out
554
+ }
555
+
556
+ /**
557
+ * v1.4.0「真自动」: 从 settings.yaml 派生数据里挑出「该自动去查余额」的 provider。
558
+ * 纯函数, 不碰文件/网络, 便于单测。**不在这里解析 key** —— 那步要访问 credentials 服务, 是异步的。
559
+ *
560
+ * 过滤规则 (三条, 每条都对应一条既有铁律):
561
+ * 1. 只收**写了 baseURL** 的 provider —— 没写的按铁律 9「不表态」, 交 `-official` 后缀兜底
562
+ * (本机 `xiaomi` 正是「内置目录指向官方域名、但 key 实际来自中转站」的反例);
563
+ * 2. 第 2 层判成 `official` 的跳过 —— 官方直连由预设平台负责, 别重复成一条中转站;
564
+ * 3. 用户关掉的 (`dshProviderOptOut`) 跳过 —— 关过不会被下次自动发现又打开。
565
+ * @param {Record<string,{baseURL?:string,apiKeyEnv?:string}>} entries parseProviderEntries 的结果
566
+ * @param {Record<string,string>} kinds computeProviderKinds 的结果
567
+ * @param {string[]} optOut 用户关掉的 provider 名 (大小写不敏感)
568
+ * @returns {{name:string,baseURL:string,apiKeyEnv:string}[]} 按 provider 名排序, baseURL 已剥尾斜杠
569
+ */
570
+ export const selectDshProviders = (entries, kinds, optOut) => {
571
+ const off = new Set((Array.isArray(optOut) ? optOut : []).map((x) => String(x).toLowerCase()))
572
+ const src = (entries && typeof entries === 'object') ? entries : {}
573
+ const kindMap = (kinds && typeof kinds === 'object') ? kinds : {}
574
+ const out = []
575
+ for (const name of Object.keys(src).sort()) {
576
+ const e = (src[name] && typeof src[name] === 'object') ? src[name] : {}
577
+ const baseURL = typeof e.baseURL === 'string' ? e.baseURL : ''
578
+ if (baseURL === '') continue
579
+ if (kindMap[name] === 'official') continue
580
+ if (off.has(String(name).toLowerCase())) continue
581
+ out.push({
582
+ name,
583
+ baseURL: baseURL.replace(/\/+$/, ''),
584
+ apiKeyEnv: typeof e.apiKeyEnv === 'string' ? e.apiKeyEnv : '',
585
+ })
586
+ }
587
+ return out
588
+ }
589
+
590
+ /**
591
+ * v1.4.0: `/api-dashboard/balances` 的取数策略 —— 纯函数, 便于单测 (策略很容易被"顺手改坏")。
592
+ *
593
+ * 背景: `force=1` 那条路底下是 `await refreshAll()` —— 一次全量轮询要等**最慢**的端点,
594
+ * 最长可以拖满 `timeoutMs`(默认 8s)。应用切回前台 / 页面重载时如果走 force,
595
+ * 用户看到的就是「插件加载很慢, 要等一段时间」。
596
+ *
597
+ * @returns {'wait'|'background'|'none'}
598
+ * wait = 阻塞刷新后返回新数据 (没有东西可显示, 或用户显式强刷)
599
+ * background = 立刻回手上有的, 刷新丢后台 (stale-while-revalidate)
600
+ * none = 缓存够新, 直接用
601
+ */
602
+ export const planBalancesFetch = ({ force = false, peek = false, hasData = false, age = 0, intervalMs = 5000 } = {}) => {
603
+ const stale = age > (intervalMs || 300000) // 兼容旧行为: intervalMs 缺失时用 5 分钟
604
+ /**
605
+ * v1.4.1: 冷启动**不再阻塞首屏**。
606
+ * 旧行为是 `return 'wait'` —— 服务端刚重启时缓存为空, 第一个请求要 await 一次**全量**刷新,
607
+ * 而实测全量刷新要 8~13.6 秒(中转站 auto 探测是串行试 3 个端点), 用户看到的就是
608
+ * 「重启进来等半天」。现在改成: 立刻回「还在加载」+ 把刷新丢后台, 客户端保持骨架屏并**1.5 秒后重问**,
609
+ * 数据一到就上屏。注意这**不是假数据** —— 返回的是空列表 + loading 标记, 界面显示的是"加载中"而非 0。
610
+ * 只有显式强刷(force, 用户主动要新数据)才继续阻塞等。
611
+ */
612
+ if (!hasData) return force ? 'wait' : 'background'
613
+ if (!force && !stale) return 'none'
614
+ if (peek) return age > 1000 ? 'background' : 'none' // 1 秒内刚拉过就不重复打
615
+ return age > 2000 ? 'wait' : 'none' // 显式强刷留 2 秒节流, 防连点打爆平台接口
616
+ }
617
+
618
+ /** v1.4.0: 刷新间隔白名单化 —— 1~60 秒 (下限由 5 秒放宽到 1 秒, 用户要求更快) */
619
+ export const clampRefreshSec = (v) => Math.min(Math.max(Math.round(Number(v) || 1), 1), 60)
620
+
621
+ /**
622
+ * 第 2 层自动判定的补充素材: 提取 settings.yaml 里 `llm-pi-ai.providers.<name>` 的
623
+ * **全部 provider 名**,包括没有写 baseURL 的 provider。这样即使别人没有手写 URL,
624
+ * 只要用的是已知官方 preset 名,也能自动判定为官方,而不必先手动补 settings。
625
+ * 注意:只提取 provider 名本身,不改变 parseProviderBaseURLs 的返回语义。
626
+ */
627
+ export function parseProviderNames(text) {
628
+ const names = []
629
+ if (typeof text !== 'string' || text === '') return names
630
+ const indentOf = (line) => line.length - line.replace(/^[ \t]+/, '').length
631
+ const keyOf = (line) => {
632
+ if (line.startsWith('-')) return null
633
+ const m = /^([^\s#][^:]*):(.*)$/.exec(line)
634
+ return m === null ? null : { key: m[1].trim(), value: m[2].trim() }
635
+ }
636
+ let sectionIndent = -1
637
+ let sectionChildIndent = -1
638
+ let providersIndent = -1
639
+ let nameIndent = -1
640
+ for (const raw of text.split(/\r?\n/)) {
641
+ const trimmed = raw.trim()
642
+ if (trimmed === '' || trimmed.startsWith('#')) continue
643
+ const indent = indentOf(raw)
644
+ if (nameIndent >= 0 && indent <= nameIndent) { nameIndent = -1 }
645
+ if (providersIndent >= 0 && indent <= providersIndent) { providersIndent = -1; nameIndent = -1 }
646
+ if (sectionIndent >= 0 && indent <= sectionIndent && providersIndent < 0) {
647
+ const kv = keyOf(trimmed)
648
+ if (kv !== null && kv.key !== 'llm-pi-ai') { sectionIndent = -1; sectionChildIndent = -1 }
649
+ }
650
+ const kv = keyOf(trimmed)
651
+ if (kv === null) continue
652
+ if (sectionIndent < 0) {
653
+ if (kv.key === 'llm-pi-ai' && kv.value === '') { sectionIndent = indent; sectionChildIndent = -1 }
654
+ continue
655
+ }
656
+ if (providersIndent < 0) {
657
+ if (indent <= sectionIndent) continue
658
+ if (sectionChildIndent < 0) sectionChildIndent = indent
659
+ if (indent === sectionChildIndent && kv.key === 'providers' && kv.value === '') providersIndent = indent
660
+ continue
661
+ }
662
+ if (indent > providersIndent && kv.value === '') {
663
+ if (nameIndent < 0) nameIndent = indent
664
+ if (indent === nameIndent) names.push(kv.key)
665
+ }
666
+ }
667
+ return names
668
+ }
669
+
670
+ /**
671
+ * 第 2 层判定结果: { providerName: 'official' | 'relay' }。
672
+ * 没有 baseURL / baseURL 解析不出主机名的 provider **不写进结果** (不表态, 交第 3 层)。
673
+ */
674
+ export function computeProviderKinds(text) {
675
+ const kinds = {}
676
+ for (const [name, url] of Object.entries(parseProviderBaseURLs(text))) {
677
+ const host = hostOfUrl(url)
678
+ if (host === '') continue
679
+ kinds[name] = isOfficialHost(host) ? 'official' : 'relay'
680
+ }
681
+ // v1.4.0 移除「按 provider 名字猜官方」的兜底。
682
+ // 旧代码把「名字恰好等于某个预设 id 且没写 baseURL」判成 official —— 这与本文件 232-240 行的政策
683
+ // 和 AGENTS.md 铁律 9 直接冲突:「没写 baseURL 的 provider 是不表态、交 `-official` 后缀兜底」。
684
+ // 理由(AGENTS.md 原话): 内置目录指向官方域名 ≠ 用户的 key 来自官方(`xiaomi` 就是反例)。
685
+ // 只看名字会把「恰好同名的中转站会话」顶上官方余额 —— 不表态比猜错安全。
686
+ return kinds
687
+ }
688
+
689
+ /** 用户填的官方直连名单规范化: 接受数组或「逗号/换行/空格分隔」的字符串。
690
+ * 上限 64 条 × 64 字符 —— 名单会持久化并随每次 /balances 下发, 防超大输入撑爆状态文件。 */
691
+ export const normalizeOfficialProviders = (input) => {
692
+ const list = Array.isArray(input)
693
+ ? input
694
+ : typeof input === 'string' ? input.split(/[,,、;;\s]+/) : []
695
+ const seen = new Set()
696
+ const out = []
697
+ for (const item of list) {
698
+ if (out.length >= 64) break
699
+ if (typeof item !== 'string') continue
700
+ const name = item.trim().slice(0, 64)
701
+ if (name === '' || seen.has(name.toLowerCase())) continue
702
+ seen.add(name.toLowerCase())
703
+ out.push(name)
704
+ }
705
+ return out
706
+ }
707
+
708
+ const SETTINGS_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'settings.yaml')
709
+ /**
710
+ * settings.yaml 派生数据缓存, 按 mtime 失效 (轮询每 5s 一次, 别每次都解析)。
711
+ * kinds = 第 2 层官方/中转判定素材
712
+ * entries = v1.4.0「真自动」: 各 provider 的 baseURL / apiKeyEnv,
713
+ * 用来把 DSH 里配好的中转站合成可查余额的条目
714
+ */
715
+ let settingsDerivedCache = { mtimeMs: -1, kinds: {}, entries: {} }
716
+
717
+ /** 读 settings.yaml 并算出全部派生数据 (mtime 缓存) */
718
+ const readSettingsDerived = () => {
719
+ try {
720
+ const mtimeMs = statSync(SETTINGS_FILE).mtimeMs
721
+ if (mtimeMs === settingsDerivedCache.mtimeMs) return settingsDerivedCache
722
+ const text = readFileSync(SETTINGS_FILE, 'utf8')
723
+ settingsDerivedCache = { mtimeMs, kinds: computeProviderKinds(text), entries: parseProviderEntries(text) }
724
+ return settingsDerivedCache
725
+ } catch {
726
+ // settings.yaml 不存在/读不动: 不表态, 全交给第 1、3 层, 也没有可自动发现的中转站
727
+ settingsDerivedCache = { mtimeMs: -1, kinds: {}, entries: {} }
728
+ return settingsDerivedCache
729
+ }
730
+ }
731
+
732
+ /** 读 settings.yaml 算第 2 层判定 */
733
+ const readProviderKinds = () => readSettingsDerived().kinds
734
+
221
735
  // ============================================================
222
736
  // 工具函数
223
737
  // ============================================================
@@ -226,75 +740,181 @@ const toAmount = (value) => {
226
740
  return Number.isFinite(n) ? n : 0
227
741
  }
228
742
 
743
+ /** FNV-1a 32bit 哈希, 用于按内容生成 ETag (数据没变才 304) */
744
+ const fnv1a = (str) => {
745
+ let h = 0x811c9dc5
746
+ for (let i = 0; i < str.length; i++) {
747
+ h ^= str.charCodeAt(i)
748
+ h = Math.imul(h, 0x01000193)
749
+ }
750
+ return (h >>> 0).toString(36)
751
+ }
752
+
229
753
  // ============================================================
230
754
  // DeepSeek 峰谷计费引擎 (学习 dsh-balance)
231
755
  // 北京时间 09:00~12:00 / 14:00~18:00 为峰时(100%), 其余时段谷时特惠(5折)
232
756
  // ============================================================
757
+ // v1.3.4 (2026-09-10): 官方同日 12:00 起调整 Flash 系列定价(最高降幅 60%), 并收敛模型名 ——
758
+ // deepseek-v4-flash → deepseek-flash (旧名仍可调用, 由 V4.1-Flash 服务并按 Flash 价计费, 定价页注 1);
759
+ // 2026-09-14 12:00 后 deepseek-v4-pro 的请求将全部路由到 V4.1-Flash 并按 Flash 价计费(官方计划下线 Pro, 注 2)。
760
+ // 来源: https://api-docs.deepseek.com/zh-cn/quick_start/pricing (CNY) 与 /quick_start/pricing (USD) ——
761
+ // USD 表为官方直发(非 ÷7 换算, 实际口径约 1 USD ≈ 6.67 CNY), pro 档与调整前一致, 未变动。
233
762
  export const V4_RATES = {
234
763
  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 } },
764
+ peak: { 'deepseek-flash': { cacheHit: 0.04, cacheMiss: 2, output: 8 }, 'deepseek-v4-pro': { cacheHit: 0.3, cacheMiss: 9, output: 27 } },
765
+ offPeak: { 'deepseek-flash': { cacheHit: 0.02, cacheMiss: 1, output: 4 }, 'deepseek-v4-pro': { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 } },
237
766
  },
238
767
  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 } },
768
+ peak: { 'deepseek-flash': { cacheHit: 0.006, cacheMiss: 0.3, output: 1.2 }, 'deepseek-v4-pro': { cacheHit: 0.044, cacheMiss: 1.32, output: 3.96 } },
769
+ 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
770
  },
242
771
  }
243
772
 
773
+ /**
774
+ * 通用表 USD → CNY 换算汇率 —— **近似值**, 只在「用户把币种设成非原生币种」时才用到。
775
+ * v1.4.0 起 MODEL_PRICES 按原生币种存储, 默认配置(国内 CNY / 海外 USD)下两边同币种, 根本不走换算;
776
+ * 且 DeepSeek 走 V4_RATES 自带的两套官方表, 本汇率对它无效。
777
+ * ⚠️ 官方 DeepSeek 的 USD 直发价口径约 1 USD ≈ 6.67 CNY, 与本值不同 —— 别拿它去"校正" V4_RATES.USD。
778
+ */
779
+ const USD_TO_CNY_RATE = 7
780
+
781
+ /** 北京时间(UTC+8)的星期与小时, 先 +8h 再取值, 避免跨日界(00:00~08:00)星期比北京时间早一天 */
782
+ const bjtParts = (timestamp) => {
783
+ const d = new Date(timestamp + 8 * 3600 * 1000)
784
+ return { weekday: d.getUTCDay(), hour: d.getUTCHours() }
785
+ }
786
+
244
787
  /**
245
788
  * 当前是否处于 DeepSeek 峰时.
246
789
  * 工作日(周一~周五): 北京时间 09-12 / 14-18 为峰时, 其余谷时。
247
790
  * 周末(周六日): 整天都是谷时特惠。
248
791
  */
249
792
  export const isPeakTime = (timestamp = Date.now()) => {
250
- const d = new Date(timestamp)
251
- const day = d.getUTCDay()
252
- const hourBJT = (d.getUTCHours() + 8) % 24
793
+ const { weekday, hour } = bjtParts(timestamp)
253
794
  // 周末(0=周日, 6=周六)整天谷时
254
- if (day === 0 || day === 6) return false
255
- return (hourBJT >= 9 && hourBJT < 12) || (hourBJT >= 14 && hourBJT < 18)
795
+ if (weekday === 0 || weekday === 6) return false
796
+ return (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18)
256
797
  }
257
798
 
258
799
  /** 当前是否周末 */
259
800
  export const isWeekend = (timestamp = Date.now()) => {
260
- const d = new Date(timestamp)
261
- const day = d.getUTCDay()
262
- return day === 0 || day === 6
801
+ const { weekday } = bjtParts(timestamp)
802
+ return weekday === 0 || weekday === 6
263
803
  }
264
804
 
265
805
 
266
806
  // ============================================================
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);
807
+ // 通用模型价格表 — 每百万token, **按「模型原生币种」存储** (v1.4.0)。
808
+ //
809
+ // 规则: 国内厂商官方定价页给的是 CNY 这里直接写官方 CNY 原值;
810
+ // 海外厂商官方定价页给的是 USD 这里直接写官方 USD 原值。
811
+ // 币种由 modelRegion(model) 判定 (与 currencyForModel 同源), resolveModelPrice 只在
812
+ // 「显示币种 ≠ 原生币种」时才换算 —— 默认配置(currency=CNY / overseasCurrency=USD)下
813
+ // 国内走 CNY、海外走 USD, 两边都是原样返回, 零换算误差。
814
+ //
815
+ // ⚠️ 为什么不继续用「统一存 USD 基准」(v1.3.4 及以前):
816
+ // CNY 官方价 ÷7 入库、显示时再 ×7, 既留舍入尾巴, 更容易把官方 CNY 直接填进 USD 槽位
817
+ // → 面板按 7 倍计费。历史上已被咬过两次:
818
+ // ① MiMo: ¥1 被写成 0.020 (等于又除了一次 7), 少算成 1/7;
819
+ // ② glm-4-plus / 整个「历史/参考」段: ¥2.5/¥5/¥5 被当 USD, 显示 ¥17.5/¥35/¥35。
820
+ // 原生币种存储让这类错误**无法表达** —— 改表时直接抄官方页数字, 不要再做任何 ÷7。
821
+ //
822
+ // ⚠️ DeepSeek 计费请走上面 V4_RATES 峰谷表(自带 CNY/USD 两套, 官方价, 准确); 本通用表覆盖 OpenAI/Claude/Gemini/国产等。
823
+ // 来源: ① 现役主力(2026-09-03) NousResearch hermes-agent usage_pricing.py + 各厂商官方定价页;
824
+ // v1.4.0 (2026-09-10) 复核抓取原文: api-docs.deepseek.com 中英双页 / platform.kimi.com /
825
+ // platform.minimaxi.com / platform.stepfun.com / docs.bigmodel.cn / help.aliyun.com(百炼) /
826
+ // MiMo 官方永久降价公告。逐条核对, 差异已就地注明。
271
827
  // ② 旧模型(2025-08) 为历史参考价. 仅做参考, 实际以平台为准.
272
828
  // ============================================================
273
829
  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 },
830
+ // —— 海外厂商: 单位 USD/百万tokens (原生) ——
831
+ // 来源: modelradar.cn 2026-09-03 快照 (各模型 sourceUrl 均指官方定价页)。
832
+ // 仅采纳与官方口径无分歧的条目; 与原表冲突时保留原值并注明 ——
833
+ // radar 的 GPT-5.6 系输出价全呈「输入×1.25」异常模式, 疑似抓错列, 未采纳。
834
+ // ⚠️ OpenAI / Anthropic / Gemini 官方定价页在本容器环境被 403 / 地域封锁, v1.4.0 未能取到原文复核,
835
+ // 下列海外条目仍为 radar/hermes-agent 二手源, 未逐条核实 —— 有账单单据时优先以单据为准。
836
+ // OpenAI GPT-5.6 系列 (radar 报 sol 输出 $5 / terra $2.5 / luna $0.25, 均为输入×1.25 异常模式, 未采纳)
837
+ 'gpt-5.6-sol': { cacheHit: 0.5, cacheMiss: 4.0, output: 20.0 }, // 临时促销价(至少到 2026-11-21)
838
+ 'gpt-5.6-terra': { cacheHit: 0.2, cacheMiss: 2.0, output: 12.0 }, // 2026-07-30 降价
839
+ 'gpt-5.6-luna': { cacheHit: 0.02, cacheMiss: 0.2, output: 1.2 }, // 2026-07-30 降价
840
+ 'gpt-5.3-codex': { cacheHit: 0.175, cacheMiss: 1.75, output: 14.0 }, // radar 2026-09-03, OpenAI 官方页
841
+ // Anthropic Claude 5
842
+ '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); 原误标"无缓存折扣"
843
+ 'claude-sonnet-5': { cacheHit: 0.2, cacheMiss: 2.0, output: 10.0 },
282
844
  'claude-sonnet-4-6': { cacheHit: 0.30, cacheMiss: 3.00, output: 15.00 },
283
845
  'claude-haiku-4-5': { cacheHit: 0.10, cacheMiss: 1.00, output: 5.00 },
284
846
  // 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 },
847
+ 'gemini-3.7-flash': { cacheHit: 0.075, cacheMiss: 0.75, output: 3.75 }, // 促销至 2026-12-31, 之后翻倍
848
+ 'gemini-3.8-flash': { cacheHit: 0.075, cacheMiss: 0.75, output: 3.75 }, // radar 2026-09-02 新增, 与 3.7/3.6 同价
849
+ 'gemini-3.6-flash': { cacheHit: 0.075, cacheMiss: 0.75, output: 3.75 }, // 促销至 2026-12-31, 之后翻倍
850
+ 'gemini-3-flash-preview': { cacheHit: 0.025, cacheMiss: 0.5, output: 3.0 },
851
+ 'gemini-3.5-flash-lite': { cacheHit: 0.3, cacheMiss: 0.3, output: 2.5 }, // 无缓存折扣
852
+ 'gemini-3.1-pro': { cacheHit: 2.0, cacheMiss: 2.0, output: 12.0 }, // 无缓存折扣; 长上下文 $4/$24
288
853
  '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 条目仍在下方作历史参考.
854
+ 'gemini-2.5-flash': { cacheHit: 0.03, cacheMiss: 0.3, output: 2.5 }, // radar 2026-09-03, 1M ctx
855
+ // —— 国内厂商: 单位 CNY/百万tokens (原生官方价, 不要再 ÷7) ——
856
+ // 阿里云百炼 Qwen3 (华北2/北京; help.aliyun.com/zh/model-studio/model-pricing 2026-09-10 抓取)
857
+ // 官方上下文缓存规则: 命中按「标准输入单价 10%」计费。
858
+ // ⚠️ 官方明文例外: qwen3.8-max / qwen3.8-flash / qwen3.8-2.4t-a95b 的缓存命中价**不是 10%**,
859
+ // 且未在文档给数字(只写「参见百炼控制台」)→ 这两条 cacheHit 沿用中转站实测报价, 标为「例外价」。
860
+ 'qwen3.8-max': { cacheHit: 1.5, cacheMiss: 12, output: 36 }, // 官方 ¥12/¥36; cacheHit ¥1.5 为控制台例外价(非 10% 规则)
861
+ 'qwen3.7-max': { cacheHit: 1.2, cacheMiss: 12, output: 36 }, // v1.4.0: 官方页现为原价 ¥12/¥36 (旧「5 折促销值」官方页已不存在, 已废)
862
+ 'qwen3.7-plus': { cacheHit: 0.16, cacheMiss: 1.6, output: 6.4 }, // v1.4.0: 官方限时 8 折 (原价 ¥2/¥8)
863
+ 'qwen3.7-flash': { cacheHit: 0.02, cacheMiss: 0.2, output: 0.8 }, // v1.4.0: 官方 ¥0.2/¥0.8 (旧值 0.21/0.91 系中转站高档位, 已废)
864
+ 'qwen3.8-flash': { cacheHit: 0.1, cacheMiss: 0.8, output: 2.7 }, // 官方 ¥0.8/¥2.7; cacheHit ¥0.1 同 3.8-max 为控制台例外价
865
+ 'qwen3.8-27b': { cacheHit: 0.3, cacheMiss: 3, output: 12 }, // v1.4.0: 官方 ¥3/¥12; 缓存命中按官方 10% 规则 → ¥0.3 (旧值 ¥0.6 偏高 100%)
866
+ 'qwen3.6-plus': { cacheHit: 0.2, cacheMiss: 2, output: 12 }, // 官方 ¥2/¥12 (256K 档 ¥8/¥48 未做分档)
867
+ // 智谱 GLM (docs.bigmodel.cn/cn/guide/start/pricing 2026-09-10 抓取)
868
+ // ⚠️ GLM-5 系官方分档: 「[0,32K)」与「≥32K」两套价。本表按 ≥32K(更贵) 入库 —— 估算偏保守高估。
869
+ 'glm-5.3': { cacheHit: 2, cacheMiss: 8, output: 28 }, // 官方 ¥8/¥28/缓存 ¥2
870
+ 'glm-5.2': { cacheHit: 2, cacheMiss: 8, output: 28 }, // 官方 ¥8/¥28/缓存 ¥2
871
+ 'glm-5.1': { cacheHit: 2, cacheMiss: 8, output: 28 }, // 官方 ≥32K 档 ¥8/¥28/缓存 ¥2 ([0,32K) 档为 ¥6/¥24/¥1.3)
872
+ 'glm-5-turbo': { cacheHit: 1.8, cacheMiss: 7, output: 26 }, // v1.4.0: 官方 ≥32K 档 ¥7/¥26/缓存 ¥1.8 (旧值 1.68/8.4/28 两档都不符)
873
+ 'glm-5.3-flash': { cacheHit: 0.23, cacheMiss: 0.8, output: 2.8 }, // 官方 ¥0.8/¥2.8/缓存 ¥0.23
874
+ // Kimi / Moonshot (platform.kimi.com/docs/pricing/* 2026-09-10 抓取, 均 CNY)
875
+ 'kimi-k3': { cacheHit: 2, cacheMiss: 20, output: 100 }, // v1.4.0: 官方 ¥2/¥20/¥100 (旧值全线 +5%)
876
+ 'kimi-k2.7-code': { cacheHit: 1.3, cacheMiss: 6.5, output: 27 }, // 官方 ¥1.3/¥6.5/¥27
877
+ 'kimi-k2.7-code-highspeed': { cacheHit: 2.6, cacheMiss: 13, output: 54 },// v1.4.0 新增: 官方高速版 ¥2.6/¥13/¥54
878
+ 'kimi-k2.6': { cacheHit: 1.1, cacheMiss: 6.5, output: 27 }, // v1.4.0: 官方缓存命中 ¥1.1 (旧值误抄成 k2.7-code 的 ¥1.3)
879
+ 'kimi-k2.5': { cacheHit: 0.679, cacheMiss: 3.864, output: 20.279 }, // ⚠️ 未核实: 官方页未列(历史款), 由 v1.3.4 USD 值 ×7 保号迁移
880
+ // 字节豆包 Seed (火山方舟; ⚠️ 官方页是 SPA, v1.4.0 未能取到原文 → ×7 保号迁移, 未核实)
881
+ 'doubao-seed-2.0-pro-32k': { cacheHit: 0.616, cacheMiss: 3.087, output: 15.449 },
882
+ 'doubao-seed-2.0-pro-128k': { cacheHit: 0.924, cacheMiss: 4.634, output: 23.17 },
883
+ 'doubao-seed-2.0-pro-256k': { cacheHit: 1.855, cacheMiss: 9.268, output: 46.347 },
884
+ 'doubao-seed-2.0-lite-32k': { cacheHit: 0.119, cacheMiss: 0.581, output: 3.479 },
885
+ 'doubao-seed-2.0-lite-128k': { cacheHit: 0.175, cacheMiss: 0.868, output: 5.215 },
886
+ 'doubao-seed-2.0-lite-256k': { cacheHit: 0.35, cacheMiss: 1.736, output: 10.43 },
887
+ 'doubao-seed-2.0-mini-32k': { cacheHit: 0.042, cacheMiss: 0.196, output: 1.932 },
888
+ 'doubao-seed-2.0-mini-128k': { cacheHit: 0.077, cacheMiss: 0.385, output: 3.864 },
889
+ 'doubao-seed-2.0-mini-256k': { cacheHit: 0.154, cacheMiss: 0.77, output: 7.721 },
890
+ 'doubao-seed-2.0-code-32k': { cacheHit: 0.616, cacheMiss: 3.087, output: 15.449 },
891
+ 'doubao-seed-2.0-code-128k': { cacheHit: 0.924, cacheMiss: 4.634, output: 23.17 },
892
+ 'doubao-seed-2.0-code-256k': { cacheHit: 1.855, cacheMiss: 9.268, output: 46.347 },
893
+ // 字节 Seed 2.1 (中转站实测; 未分档, 按单一价入库)
894
+ 'seed-2.1-turbo': { cacheHit: 0.6, cacheMiss: 3, output: 15 }, // 实测 ¥3/¥15/缓存 ¥0.6
895
+ 'seed-2.1-pro': { cacheHit: 1.2, cacheMiss: 6, output: 30 }, // 实测 ¥6/¥30/缓存 ¥1.2
896
+ // MiniMax (platform.minimaxi.com/docs/guides/pricing-paygo 2026-09-10 抓取)
897
+ 'minimax-m2.7': { cacheHit: 0.42, cacheMiss: 2.1, output: 8.4 }, // v1.4.0 修复: 官方缓存读 ¥0.42 (旧值拿 cacheMiss ¥2.1 顶替 → 长会话高估 5 倍, 同 AGENTS.md 红线 4)
898
+ 'minimax-m2.7-highspeed': { cacheHit: 0.42, cacheMiss: 4.2, output: 16.8 }, // v1.4.0 新增: 官方高速版
899
+ // 美团 LongCat (中转站实测; 官方页未取到明文)
900
+ 'longcat-2.0': { cacheHit: 0.1, cacheMiss: 5, output: 20 }, // 实测 ¥5/¥20/缓存 ¥0.1
901
+ // 腾讯混元 (⚠️ 官方页是 SPA, v1.4.0 未能取到原文 → ×7 保号迁移, 未核实)
902
+ 'hunyuan-2.0-instruct-128k': { cacheHit: 4.347, cacheMiss: 4.347, output: 10.745 },
903
+ 'hunyuan-2.0-think-128k': { cacheHit: 5.117, cacheMiss: 5.117, output: 20.468 },
904
+ 'hunyuan-turbo-s': { cacheHit: 0.77, cacheMiss: 0.77, output: 1.932 },
905
+ // 阶跃星辰 (platform.stepfun.com/docs/zh/guides/pricing/details 2026-09-10 抓取)
906
+ 'step-3.7-flash': { cacheHit: 0.27, cacheMiss: 1.35, output: 8.1 }, // 官方 ¥1.35/¥8.1/缓存 ¥0.27
907
+ 'step-3.5-flash': { cacheHit: 0.14, cacheMiss: 0.7, output: 2.1 }, // 官方 ¥0.7/¥2.1/缓存 ¥0.14
908
+ // 小米 MiMo — 官方 2026-05-27 起「永久降价」(最高降幅 99%), 取消上下文分档; 与中转站 tokenrhythm 实时报价一致。
909
+ // v1.3.4 修的「除两次 7」结论正确, v1.4.0 起改为直接存官方 CNY 原值, 不再有 ÷7 环节。
910
+ 'mimo-v2.5': { cacheHit: 0.02, cacheMiss: 1, output: 2 }, // 官方 ¥1/¥2/缓存 ¥0.02
911
+ 'mimo-v2.5-pro': { cacheHit: 0.025, cacheMiss: 3, output: 6 }, // 官方 ¥3/¥6/缓存 ¥0.025
297
912
  // —— 以下为历史/参考模型 (2025-08, 实际以平台为准) ——
913
+ // 币种规则同上: 海外的写 USD, 国内的写 CNY。
914
+ // 🔴 v1.4.0 重要修复: 本段「国内」条目历来填的是**官方 CNY 原值**(不是 ÷7 后的 USD),
915
+ // 在旧的「统一 USD 基准」口径下被又 ×7 了一次 → 面板把这些模型高估 7 倍。
916
+ // 已核对的样本: glm-4-plus ¥2.5/¥5/¥5、qwen-plus ¥0.8/¥2、qwen-turbo ¥0.3/¥0.6、
917
+ // qwen2.5-72b ¥4/¥12 均与官方页逐项吻合 → 全段按 CNY 原值解读, 未再 ×7。
298
918
  'gpt-4o': { cacheHit: 1.25, cacheMiss: 2.5, output: 10 },
299
919
  'gpt-4o-mini': { cacheHit: 0.075, cacheMiss: 0.15, output: 0.6 },
300
920
  'gpt-4-turbo': { cacheHit: 5, cacheMiss: 10, output: 30 },
@@ -302,66 +922,152 @@ export const MODEL_PRICES = {
302
922
  'o1': { cacheHit: 7.5, cacheMiss: 15, output: 60 },
303
923
  'o1-mini': { cacheHit: 0.55, cacheMiss: 1.1, output: 4.4 },
304
924
  '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 },
925
+ // Claude — v1.4.0 修正: 缓存读 = 输入 ×10% (Anthropic 官方规则)。旧值用的是 OpenAI 的 50% 口径,
926
+ // 会让老 Claude 模型的长会话消耗高估 5 倍 (同表新条目 claude-opus-5 等已是 10%, 口径原本就不一致)。
927
+ 'claude-3-5-sonnet': { cacheHit: 0.3, cacheMiss: 3, output: 15 },
928
+ 'claude-3-5-haiku': { cacheHit: 0.08, cacheMiss: 0.8, output: 4 },
929
+ 'claude-3-opus': { cacheHit: 1.5, cacheMiss: 15, output: 75 },
930
+ // Gemini — v1.4.0 修正: 缓存读 = 输入 ×25% (Gemini 官方 75% off 口径)。旧值 50% 偏高。
931
+ 'gemini-2.0-flash': { cacheHit: 0.025, cacheMiss: 0.1, output: 0.4 },
932
+ 'gemini-2.0-pro': { cacheHit: 0.625, cacheMiss: 2.5, output: 10 },
933
+ 'gemini-1.5-pro': { cacheHit: 0.875, cacheMiss: 3.5, output: 10.5 },
313
934
  // 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 峰谷表。
935
+ // 官方 API 调用会直接报错(不再重定向到 V4)。现役为 deepseek-flash (旧名 deepseek-v4-flash / -vision-exp 仍可调用)
936
+ // deepseek-v4-pro。⚠️ 2026-09-14 12:00 后 deepseek-v4-pro 的请求将全部路由到 V4.1-Flash 并按 Flash 价计费。
937
+ // 保留这三条仅作为「若仍在用的旧配置」的估算占位(单位 CNY), 真实计费请走上面 V4 峰谷表。
938
+ // ⚠️ 未核实: 与 DeepSeek 官方历史价(¥2/¥8 一档)对不上, 暂时原样保留待重新取证。
316
939
  'deepseek-chat': { cacheHit: 0.1, cacheMiss: 1, output: 2 },
317
940
  'deepseek-reasoner': { cacheHit: 0.2, cacheMiss: 2, output: 8 },
318
941
  'deepseek-r1': { cacheHit: 0.2, cacheMiss: 2, output: 8 },
319
942
  // 智谱
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
943
+ 'glm-4-plus': { cacheHit: 2.5, cacheMiss: 5, output: 5 }, // ✅ v1.4.0 修复: 官方 ¥2.5/¥5/¥5 (旧口径下显示 ¥17.5/¥35/¥35, 高 7 倍)
944
+ 'glm-4-flash': { cacheHit: 0.05, cacheMiss: 0.1, output: 0.1 }, // ⚠️ 官方 GLM-4-Flash-250414 现为免费; 此处保留历史 ¥0.1 档(宁高不低, 中转站可能仍计费)
945
+ // 通义千问 (官方 CNY; 2026-09-10 抓取)
946
+ 'qwen-plus': { cacheHit: 0.4, cacheMiss: 0.8, output: 2 }, // 官方 ¥0.8/¥2 ✅; ⚠️ cacheHit 0.4(=50%) 未核实
947
+ 'qwen-max': { cacheHit: 0.24, cacheMiss: 2.4, output: 9.6 },// v1.4.0: 官方现价 ¥2.4/¥9.6 (旧值 20/60 是远古价)
948
+ 'qwen-turbo': { cacheHit: 0.15, cacheMiss: 0.3, output: 0.6 },// 官方 ¥0.3/¥0.6 ✅; ⚠️ cacheHit 未核实
949
+ 'qwen2.5-72b-instruct': { cacheHit: 2, cacheMiss: 4, output: 12 }, // 官方 ¥4/¥12 ✅
950
+ // Kimi — ⚠️ 未核实: 官方页未列旧款, 且这些值与 Moonshot 官方历史价(¥12/¥12 一档)对不上, 待重新取证
328
951
  'moonshot-v1-8k': { cacheHit: 0.6, cacheMiss: 1.2, output: 2.4 },
329
952
  'moonshot-v1-32k': { cacheHit: 1.2, cacheMiss: 2.4, output: 4.8 },
330
953
  'moonshot-v1-128k': { cacheHit: 3, cacheMiss: 6, output: 12 },
331
- // 阶跃星辰
954
+ // 阶跃星辰 — ⚠️ 未核实: 官方页未列旧款
332
955
  'step-1-flash': { cacheHit: 0.5, cacheMiss: 1, output: 2 },
333
956
  'step-1-8k': { cacheHit: 2, cacheMiss: 4, output: 8 },
334
957
  'step-1-32k': { cacheHit: 4, cacheMiss: 8, output: 15 },
335
- // 其他
958
+ // 其他 (海外 USD)
336
959
  'mistral-large': { cacheHit: 1.5, cacheMiss: 3, output: 9 },
337
960
  'groq-llama-3.3-70b': { cacheHit: 0.29, cacheMiss: 0.59, output: 0.79 },
338
961
  'openrouter-auto': { cacheHit: 0.5, cacheMiss: 1, output: 2 },
339
962
  }
340
963
 
341
- /** 解析模型单价, deepseek-v4-* 支持峰谷自动切换; chat/reasoner 等走通用价格表 */
964
+ // v1.3.2: 模型产地判定 —— 供「海外模型独立计价货币」使用。
965
+ // 海外厂商官方定价页本来就是 USD, ×7 折人民币只是近似且容易被误读成美元
966
+ // (用户实测: 面板 ¥1285 被看成 $1285, 实为 $183.7)。
967
+ // v1.4.0 起本判定还兼任 MODEL_PRICES 的「存储币种」判定 (见 nativeCurrencyOf), 见下方注释。
968
+ // 判定按前缀, 与 MODEL_PRICES 的键同源; 未命中 → null (不表态, 走主货币, 保守)。
969
+ const OVERSEAS_MODEL_PREFIXES = ['gpt-', 'gpt', 'o1', 'o3', 'o4', 'chatgpt', 'claude', 'gemini', 'grok', 'mistral', 'groq-', 'llama', 'command-', 'openrouter-']
970
+ const DOMESTIC_MODEL_PREFIXES = ['deepseek', 'glm', 'kimi', 'moonshot', 'step-', 'qwen', 'mimo', 'doubao', 'seed-', 'hunyuan', 'minimax', 'longcat', 'abab', 'ernie', 'spark', 'yi-']
971
+
972
+ /** 判定模型产地: '海外' | '国内' | null(未知, 不表态)。前缀匹配取最长, 避免短前缀误命中。 */
973
+ export const modelRegion = (model) => {
974
+ if (typeof model !== 'string' || model === '') return null
975
+ const m = model.toLowerCase()
976
+ const hit = (list) => list.filter(p => m.startsWith(p)).sort((a, b) => b.length - a.length)[0] ?? null
977
+ const dom = hit(DOMESTIC_MODEL_PREFIXES)
978
+ const sea = hit(OVERSEAS_MODEL_PREFIXES)
979
+ if (dom !== null && sea !== null) return dom.length >= sea.length ? '国内' : '海外'
980
+ if (dom !== null) return '国内'
981
+ if (sea !== null) return '海外'
982
+ return null
983
+ }
984
+
985
+ /**
986
+ * v1.4.0: MODEL_PRICES 条目的**存储币种** —— 国内厂商官方页是 CNY, 海外厂商是 USD。
987
+ * 与 modelRegion 同源, 因此「写表的人抄官方页数字」即为正确, 不需要任何人工 ÷7。
988
+ * 未命中产地的模型不表态 → 按 CNY (国内口径), 与 defaultPrices 的 USD 基准无关。
989
+ */
990
+ export const nativeCurrencyOf = (model) => (modelRegion(model) === '海外' ? 'USD' : 'CNY')
991
+
992
+ /**
993
+ * 把一份单价从 from 币种换算到 to 币种。同币种原样返回(浅拷贝, 不泄露表内对象引用)。
994
+ * 汇率是**近似值**(USD_TO_CNY_RATE), 仅用于「用户自定义了非原生币种」这种少数情况;
995
+ * 默认配置(国内 CNY / 海外 USD)下两边同币种, 根本不走换算 —— 这正是 v1.4.0 想达到的效果。
996
+ */
997
+ const convertPrice = (price, from, to) => {
998
+ if (from === to) return { cacheHit: price.cacheHit, cacheMiss: price.cacheMiss, output: price.output }
999
+ const k = from === 'USD' ? USD_TO_CNY_RATE : 1 / USD_TO_CNY_RATE
1000
+ return { cacheHit: price.cacheHit * k, cacheMiss: price.cacheMiss * k, output: price.output * k }
1001
+ }
1002
+
1003
+ /**
1004
+ * v1.3.2: 算出某模型实际该用哪种计价货币。
1005
+ * 海外模型且 overseasCurrency 不是 'follow' 时用它, 其余一律跟主货币 currency。
1006
+ * v1.4.0: 默认值由 'follow' 改为 'USD' —— 即「国内的用国内价(CNY), 海外的用海外价(USD)」。
1007
+ * 想要 v1.2.6 的老行为(全部跟主货币), 显式设成 'follow' 即可。
1008
+ */
1009
+ export const currencyForModel = (config, model) => {
1010
+ const main = (config?.currency ?? 'CNY').toUpperCase()
1011
+ const over = String(config?.overseasCurrency ?? 'USD').toLowerCase()
1012
+ if (over === 'follow' || over === '') return main
1013
+ if (modelRegion(model) !== '海外') return main
1014
+ return over.toUpperCase() === 'USD' ? 'USD' : 'CNY'
1015
+ }
1016
+
1017
+ /**
1018
+ * v1.4.0: 前缀兜底匹配 —— 只接受「安全后缀」。
1019
+ *
1020
+ * 旧实现是「取最长前缀」,只保证同族内选最长,模型名比某个**老键**长且不属同族时会被老键吞掉:
1021
+ * gpt-4.1 → 命中 gpt-4 键 → $15/$30/$60 (真价 $0.40/$1.60, 输出虚高约 37 倍)
1022
+ * gpt-4.5-preview→ 命中 gpt-4 键 → 同上
1023
+ * gemini-2.5-flash-lite → 命中 gemini-2.5-flash 键 (真价 $0.10/$0.40)
1024
+ * 而 gpt-4o-mini-2024-07-18 / claude-3-5-sonnet-20241022 这类**日期后缀**才是设计意图。
1025
+ * 因此: 只有当剩余部分是日期/版本/预览标记时才认前缀, 其余一律落 defaultPrices。
1026
+ */
1027
+ const SAFE_SUFFIX_RE = /^[-_](?:v?\d[\w.-]*|latest|preview|exp|experimental)$/i
1028
+
1029
+ /** 精确命中优先; 否则按「安全后缀」前缀兜底; 都不中返回 null。 */
1030
+ const matchModelPrice = (model) => {
1031
+ const exact = MODEL_PRICES[model]
1032
+ if (exact) return exact
1033
+ const hits = Object.keys(MODEL_PRICES).filter(k => model.startsWith(k)).sort((a, b) => b.length - a.length)
1034
+ for (const k of hits) {
1035
+ if (SAFE_SUFFIX_RE.test(model.slice(k.length))) return MODEL_PRICES[k]
1036
+ }
1037
+ return null
1038
+ }
1039
+
1040
+ /** 解析模型单价, 仅 deepseek-flash / deepseek-v4-* 支持峰谷自动切换; chat/reasoner 等走通用价格表 */
342
1041
  export const resolveModelPrice = (configOrGetter, model, timestamp = Date.now()) => {
343
1042
  const config = typeof configOrGetter === 'function' ? configOrGetter() : configOrGetter
344
1043
  const peak = isPeakTime(timestamp)
1044
+ const display = currencyForModel(config, model)
345
1045
 
346
- // 自定义价格优先
347
- if (config?.prices && Object.prototype.hasOwnProperty.call(config.prices, model) && config.prices[model]) {
1046
+ // 自定义价格优先 (用户自填, 币种由用户自己把握, 不做换算)
1047
+ if (typeof model === 'string' && config?.prices && Object.prototype.hasOwnProperty.call(config.prices, model) && config.prices[model]) {
348
1048
  return config.prices[model]
349
1049
  }
350
1050
 
351
1051
  // 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
358
- }
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 }
1052
+ // 导致其按 v4-flash 价格计费 (output 虚高至 4.5 倍)。仅匹配现役 v4 系列 + 收敛后的 deepseek-flash。
1053
+ // v1.3.4: 官方收敛模型名后, deepseek-flash 与旧名 deepseek-v4-flash / -vision-exp 同档
1054
+ // (旧名仍可调用, V4.1-Flash 服务并按 Flash 价计费) 一律映射到 flash 档位。
1055
+ // DeepSeek v4 V4_RATES 峰谷表 (自带 CNY/USD 两套, 按显示币种选表)
1056
+ if (typeof model === 'string' && (model.startsWith('deepseek-v4') || model.startsWith('deepseek-flash'))) {
1057
+ const table = V4_RATES[display] ?? V4_RATES.CNY
1058
+ const key = model.startsWith('deepseek-v4-pro') ? 'deepseek-v4-pro' : 'deepseek-flash'
1059
+ const hit = (peak ? table.peak[key] : table.offPeak[key])
1060
+ if (hit) return { ...hit }
1061
+ }
1062
+
1063
+ // 查通用表 (精确名 → 安全后缀前缀兜底)。条目按原生币种存储, 换算到显示币种。
1064
+ if (typeof model === 'string' && model !== '') {
1065
+ const entry = matchModelPrice(model)
1066
+ if (entry) return convertPrice(entry, nativeCurrencyOf(model), display)
1067
+ }
1068
+
1069
+ // 都未命中: 落 defaultPrices。⚠️ defaultPrices 的单位是 **USD** (与 v1.2.x 一致, 未随 v1.4.0 改动)。
1070
+ return convertPrice(config?.defaultPrices ?? { cacheHit: 0.1, cacheMiss: 1, output: 2 }, 'USD', display)
365
1071
  }
366
1072
 
367
1073
  /** 通用 fetch 请求, 带超时。 */
@@ -375,6 +1081,30 @@ async function fetchWithTimeout(url, headers, timeoutMs, method = 'GET') {
375
1081
  }
376
1082
  }
377
1083
 
1084
+ /** 读取请求体并限制大小 (默认 256KB) —— 防持有 token 者灌大包打爆内存。超限抛错。 */
1085
+ async function readBody(req, limit = 256 * 1024) {
1086
+ // H-4d (v1.4.1): 不能对每个 chunk 单独 toString —— 一个汉字的 3 个字节被 TCP 分到两个 chunk 时,
1087
+ // 两边都会解出替换字符 U+FFFD, 中文中转站名/自定义模型名会被写坏并持久化(实测)。
1088
+ // 改成先收集 Buffer 再整体解码。
1089
+ const chunks = []
1090
+ let size = 0
1091
+ for await (const chunk of req) {
1092
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
1093
+ size += buf.length
1094
+ if (size > limit) throw Object.assign(new Error('request body too large'), { statusCode: 413 })
1095
+ chunks.push(buf)
1096
+ }
1097
+ return Buffer.concat(chunks).toString('utf8')
1098
+ }
1099
+
1100
+ /** 字符串清洗: 截断到 max 长度 (设置面板传入的任意字段统一过这里) */
1101
+ const cleanStr = (value, max) => String(value ?? '').trim().slice(0, max)
1102
+ /** URL 清洗: 只接受 http/https 协议 (防 file: 等混淆 scheme 进配置), 失败返回空串 */
1103
+ const cleanUrl = (value) => {
1104
+ const s = cleanStr(value, 512)
1105
+ return /^https?:\/\//i.test(s) ? s : ''
1106
+ }
1107
+
378
1108
  // ============================================================
379
1109
  // 平台预设 (完整清单)
380
1110
  // ============================================================
@@ -397,7 +1127,7 @@ const PLATFORM_PRESETS = [
397
1127
  baseUrl: 'https://api.stepfun.com', queryType: 'stepfun', envKeys: ['STEPFUN_API_KEY'] },
398
1128
  { id: 'siliconflow', label: '硅基流动', icon: 'siliconflow', color: '#6E29F6', category: '国内',
399
1129
  baseUrl: 'https://api.siliconflow.cn', queryType: 'siliconflow', envKeys: ['SILICONFLOW_API_KEY', 'SILICON_API_KEY'] },
400
- { id: 'minimax', label: 'MiniMax', icon: 'together', color: '#1E40AF', category: '国内',
1130
+ { id: 'minimax', label: 'MiniMax', icon: 'minimax', color: '#E73562', category: '国内',
401
1131
  baseUrl: 'https://api.minimaxi.com', queryType: 'minimax', envKeys: ['MINIMAX_API_KEY'] },
402
1132
 
403
1133
  // ===== 海外平台(有公开余额/配额查询接口)=====
@@ -405,8 +1135,18 @@ const PLATFORM_PRESETS = [
405
1135
  baseUrl: 'https://openrouter.ai', queryType: 'openrouter', envKeys: ['OPENROUTER_API_KEY'] },
406
1136
  { id: 'novita', label: 'Novita AI', icon: 'together', color: '#FA520F', category: '海外',
407
1137
  baseUrl: 'https://api.novita.ai', queryType: 'novita', envKeys: ['NOVITA_API_KEY'] },
408
- { id: 'xai', label: 'xAI Grok', icon: 'mistral', color: '#000000', category: '海外',
1138
+ { id: 'xai', label: 'xAI Grok', icon: 'xai', color: '#000000', category: '海外',
409
1139
  baseUrl: 'https://api.x.ai', queryType: 'openai', envKeys: ['XAI_API_KEY'] },
1140
+
1141
+ // ===== 著名模型品牌 (无公开余额接口, 仅显示模型 + 按价格表估算消耗) =====
1142
+ { id: 'openai', label: 'OpenAI', icon: 'openai', color: '#10A37F', category: '海外', noBalance: true },
1143
+ { id: 'claude', label: 'Anthropic Claude', icon: 'claude', color: '#D97757', category: '海外', noBalance: true },
1144
+ { id: 'gemini', label: 'Google Gemini', icon: 'gemini', color: '#4285F4', category: '海外', noBalance: true },
1145
+ { id: 'qwen', label: '通义千问 Qwen', icon: 'qwen', color: '#623AE7', category: '国内', noBalance: true },
1146
+ { id: 'mimo', label: '小米 MiMo', icon: 'mimo', color: '#FF6900', category: '国内', noBalance: true },
1147
+ // v1.2.1: 豆包/混元入列模型品牌分组 (价格表 v1.2.0 已覆盖, 此前只算价不显示)
1148
+ { id: 'doubao', label: '豆包 Seed', icon: 'doubao', color: '#3C8CFF', category: '国内', noBalance: true },
1149
+ { id: 'hunyuan', label: '腾讯混元', icon: 'hunyuan', color: '#0052D9', category: '国内', noBalance: true },
410
1150
  ]
411
1151
 
412
1152
  // ============================================================
@@ -431,6 +1171,13 @@ export const Config = Schema.object({
431
1171
  warnThreshold: Schema.number().min(0).default(10),
432
1172
  /** 计价货币 */
433
1173
  currency: Schema.string().default('CNY'),
1174
+ /**
1175
+ * v1.3.2: 海外模型独立计价货币 —— 'USD'(默认, 见 v1.4.0) | 'CNY' | 'follow'(跟随 currency)。
1176
+ * 海外厂商官方价本来就是 USD, 选 'USD' 可免掉 ×7 折算带来的误差与「¥ 被看成 $」的误读。
1177
+ * v1.4.0: 默认值从 'follow' 改为 'USD' —— 即「国内的用国内价(CNY)、海外的用海外价(USD)」。
1178
+ * 想要旧行为(所有模型都跟主货币)请显式设成 'follow'。
1179
+ */
1180
+ overseasCurrency: Schema.string().default('USD'),
434
1181
  prices: Schema.dict(Schema.object({
435
1182
  cacheHit: Schema.number().min(0).default(0.2),
436
1183
  cacheMiss: Schema.number().min(0).default(2),
@@ -443,6 +1190,16 @@ export const Config = Schema.object({
443
1190
  }).default({}),
444
1191
  /** 收养大肥鱼: 屏幕侧边互动宠物挂件 (v1.1.0, 纯互动不含余额, 移植自 MeteorNOX/DeepSeek-Balance-Whale-Widget, MIT) */
445
1192
  whaleEnabled: Schema.boolean().default(false),
1193
+ /** 显示无余额模型品牌 (OpenAI/Claude/Gemini/Qwen/MiMo), 默认关闭 */
1194
+ showNoBalanceBrands: Schema.boolean().default(false),
1195
+ /** 官方直连 provider 名单 (第 1 层判定, 最高优先级)。
1196
+ * 写在这里的 provider 名一律按「官方直连」处理, 状态条显示官方余额;
1197
+ * 没写的按 baseURL 域名 / `-official` 后缀自动判定, 都不命中则按中转站显示「—」。 */
1198
+ officialProviders: Schema.array(Schema.string()).default([]),
1199
+ /** v1.4.0「真自动」: 用户主动关掉的 DSH provider 名 (来自 settings.yaml llm-pi-ai.providers)。
1200
+ * 默认空数组 = 全部启用。关过的记在这里, 下次自动发现不会再打开 (除非用户又点开)。
1201
+ * ⚠️ 这与 officialProviders 是**两回事**: 那个决定「按官方显示」, 这个决定「要不要去查余额」。 */
1202
+ dshProviderOptOut: Schema.array(Schema.string()).default([]),
446
1203
  /** 大肥鱼挂件设置: 大小/音效/音量/气泡/峰谷文案/吸附/位置记忆 */
447
1204
  whaleSettings: Schema.object({
448
1205
  scale: Schema.number().min(0.6).max(2.5).default(1),
@@ -474,9 +1231,9 @@ function checkAlerts(balances, config, ctx) {
474
1231
  const val = b.percent != null ? b.percent : b.total
475
1232
  const prev = lastAlertState[id]
476
1233
  let level = val > safe ? 'ok' : val > warn ? 'warn' : 'err'
1234
+ newState[id] = level
477
1235
 
478
1236
  if (level === 'warn' && prev !== 'warn') {
479
- newState[id] = 'warn'
480
1237
  try {
481
1238
  const name = b.name || id
482
1239
  const msg = `🔔 ${name} 余额偏低: ${val}${b.percent != null ? '%' : (b.currency || '')}`
@@ -487,7 +1244,6 @@ function checkAlerts(balances, config, ctx) {
487
1244
  }
488
1245
  } catch { /* 静默 */ }
489
1246
  } else if (level === 'err' && prev !== 'err') {
490
- newState[id] = 'err'
491
1247
  try {
492
1248
  const name = b.name || id
493
1249
  const msg = `🚨 ${name} 余额不足: ${val}${b.percent != null ? '%' : (b.currency || '')}`
@@ -507,11 +1263,15 @@ function checkAlerts(balances, config, ctx) {
507
1263
 
508
1264
  // 纯函数, 导出便于单测 (不影响对外行为)
509
1265
  export function parseResponse(queryType, json) {
1266
+ if (!json || typeof json !== 'object' || Array.isArray(json)) return null
510
1267
  switch (queryType) {
511
1268
  case 'deepseek': {
512
1269
  const infos = Array.isArray(json?.balance_infos) ? json.balance_infos : []
513
1270
  const p = infos[0]
514
1271
  if (!p) return null
1272
+ // v1.4.0 修复: total_balance 缺失时 toAmount(null)=0 会伪造「余额 0」。
1273
+ // 与 AGENTS.md「字段存在性校验」一致 —— 缺关键字段即返回 null, 交给上层显示「未开放」。
1274
+ if (p.total_balance == null) return null
515
1275
  // total_balance 当前余额, granted_balance 赠送, topped_up_balance 充值
516
1276
  const total = toAmount(p.total_balance)
517
1277
  const grant = toAmount(p.granted_balance)
@@ -524,10 +1284,22 @@ export function parseResponse(queryType, json) {
524
1284
  if (!json || typeof json !== 'object') return null
525
1285
  const hasAny = 'total_granted' in json || 'total_available' in json || 'total_used' in json
526
1286
  if (!hasAny) return null
527
- const total = toAmount(json?.total_granted)
528
- 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 兼容额度' }
1287
+ // H-4a (v1.4.1): 字段**存在但值无效**(null / 非数字)时, toAmount 会归 0,
1288
+ // 于是 total = 0 - used 得到一个**负数余额** —— 与 openrouter 那次是同一类漏洞
1289
+ // (红线: 不许把解析失败冒充成真实数字)。这里改成「值无效就不表态」。
1290
+ // ⚠️ Number(null) === 0、Number('') === 0 —— 必须先把「空值」挡掉, 否则等于没挡
1291
+ const numOrNull = (v) => {
1292
+ if (v === null || v === undefined || v === '') return null
1293
+ const n = Number(v)
1294
+ return Number.isFinite(n) ? n : null
1295
+ }
1296
+ const grantedN = numOrNull(json?.total_granted)
1297
+ const usedN = numOrNull(json?.total_used)
1298
+ const availN = numOrNull(json?.total_available)
1299
+ if (availN === null && (grantedN === null || usedN === null)) return null
1300
+ const used = usedN ?? 0
1301
+ const hasAvail = availN !== null
1302
+ return { total: hasAvail ? availN : (grantedN - used), currency: 'USD', available: hasAvail ? availN : null, used, note: 'OpenAI 兼容额度' }
531
1303
  }
532
1304
  case 'siliconflow': {
533
1305
  const d = json?.data
@@ -540,8 +1312,10 @@ export function parseResponse(queryType, json) {
540
1312
  const d = json?.data
541
1313
  if (!d) return null
542
1314
  // 数据红线: total_credits / total_usage 字段名未用真实 key 实测, 可能不叫这个名。
543
- // 若两个预期字段都缺失 视为解析失败(返回 null, 前端标"无法解析/未开放"), 绝不显示假"余额0"。
544
- if (d.total_credits == null && d.total_usage == null) return null
1315
+ // v1.4.0 修复: 原守卫用 `&&`(两个都缺才放弃), 只缺 total_credits 时 toAmount(null)=0,
1316
+ // total 变成 `0 - usage` 的**负数**, 客户端渲染成红色「余额不足」—— 正好是这条红线要防的伪造数字。
1317
+ // 改为 `||`: 任一关键字段缺失即视为解析失败, 返回 null(前端显示「未开放」), 绝不编数。
1318
+ if (d.total_credits == null || d.total_usage == null) return null
545
1319
  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
1320
  }
547
1321
  case 'novita': {
@@ -556,7 +1330,7 @@ export function parseResponse(queryType, json) {
556
1330
  }
557
1331
  case 'quota': {
558
1332
  const q = json?.data
559
- if (!q || (q.quota == null && q.username == null)) return null
1333
+ if (!q || q.quota == null) return null
560
1334
  const quota = toAmount(q.quota)
561
1335
  return { total: quota / 500000, currency: 'USD', available: null, used: null, note: 'one-api quota (÷500000, 系数待实测)' }
562
1336
  }
@@ -601,11 +1375,11 @@ export function parseResponse(queryType, json) {
601
1375
  const l = usedUp[0]
602
1376
  return { total: 0, currency: 'tokens', available: 0, used: null, percent: null, note: '智谱配额已用完(0)' + (l.nextResetTime ? ', 待重置' : ''), resetAt: l?.nextResetTime ?? null }
603
1377
  }
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
- }
1378
+ // 兜底: 只有 percentage 无 remaining
1379
+ // v1.4.0 修复: percentage 是**已用/填充度**(100=用完), 不是余额, 方向还是反的 ——
1380
+ // 旧代码把它当 total 下发, 客户端 `percent ?? total` 取到 88 → getLevel 与 50 阈值比 → 判「绿灯」,
1381
+ // 于是「快用完」显示成「余额充足」, 同时踩 AGENTS.md 红线 4 README:9「查不到就如实显示未开放」。
1382
+ // 这里不再冒充余额, 直接返回 null, 交给 classifyBizError 走中性的 no-balance-api。
609
1383
  return null
610
1384
  }
611
1385
 
@@ -614,7 +1388,14 @@ export function parseResponse(queryType, json) {
614
1388
  if (!u) return null
615
1389
  if (u.limit == null && u.remaining == null) return null
616
1390
  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 }
1391
+ // v1.4.0 修复: `limit` 是限流窗口的**上限**, 不是可用余额。旧代码把它当 total 下发, 而客户端
1392
+ // 的状态条/卡片/详情大数字都取 `b.total`(从不看 available) → 配额耗尽也显示满额 + 绿灯。
1393
+ // 与同文件 glm 适配器语义对齐: total = 剩余量; 上限与用量放在 note/used 里。
1394
+ return {
1395
+ total: remaining, currency: 'tokens', available: remaining, used: limit - remaining,
1396
+ note: `Kimi 套餐剩余 tokens (窗口上限 ${limit})`,
1397
+ percent: limit > 0 ? (remaining / limit) * 100 : null,
1398
+ }
618
1399
  }
619
1400
  case 'minimax': {
620
1401
  const models = Array.isArray(json?.model_remains) ? json.model_remains : []
@@ -628,10 +1409,44 @@ export function parseResponse(queryType, json) {
628
1409
  }
629
1410
 
630
1411
  /** 某些类型无法用普通 API key 查询余额 (需 OAuth 等) */
1412
+ // ============================================================
1413
+ // 业务层错误分类 (v1.2.6 抽出为可测函数)
1414
+ // ============================================================
1415
+ /**
1416
+ * 解析失败时对「接口 HTTP 200 但业务层报错」做分类。
1417
+ * ⚠️ 仅在 parseResponse 返回 null 时调用 —— 能解析出配额/余额的账户(如智谱 Coding Plan 套餐用户)
1418
+ * 根本不会走到这里, 本函数不影响他们。
1419
+ * @returns {{status: string, error: string}} 供 queryPreset 直接摊进返回体
1420
+ */
1421
+ export function classifyBizError(queryType, json) {
1422
+ // 业务层错误消息: success:false 或 code!=200 且带 msg (JSON 解析失败 json=null 时不适用)
1423
+ const bizMsg = (json && typeof json === 'object' && typeof json.msg === 'string' && json.msg
1424
+ && (json.success === false || (json.code !== undefined && json.code !== 200))) ? json.msg : null
1425
+
1426
+ // 智谱: 按量付费账户无公开余额接口 (实测 2026-09-03: /api/monitor/account/balance、
1427
+ // /api/paas/v4/dashboard/billing/{subscription,credit_grants,usage}、/api/paas/v4/users/me
1428
+ // 等候选端点全部 404; 唯一公开的 /api/monitor/usage/quota/limit 是 Coding Plan 套餐专用)。
1429
+ // 该情形属「平台未开放」而非「插件解析坏了」, 按中性状态展示, 不标红。
1430
+ if (queryType === 'glm' && bizMsg && /coding\s*plan/i.test(bizMsg)) {
1431
+ // ⚠️ 措辞不替平台断言账户类型: 按量付费用户与套餐已过期用户拿到的是【同一条】返回,
1432
+ // 接口层无法区分, 所以只说"无 Coding Plan 套餐", 不硬说成"按量付费"。
1433
+ return { status: 'no-balance-api', error: '无 Coding Plan 套餐,无余额接口 (按量付费 / 套餐已过期均返回此结果;套餐用户可正常显示配额)' }
1434
+ }
1435
+
1436
+ // 其余业务错误: 透传原始 msg, 便于用户/维护者定位真实原因 (套餐过期、无权限、接口改名…)
1437
+ return { status: 'parse-error', error: bizMsg ? `无法解析余额数据 (接口返回: ${bizMsg})` : '无法解析余额数据' }
1438
+ }
1439
+
631
1440
  // ============================================================
632
1441
  // 查询单个预设平台
633
1442
  // ============================================================
634
1443
  async function queryPreset(platform, apiKey, config) {
1444
+ if (platform.noBalance) {
1445
+ return {
1446
+ platform: platform.id, name: platform.label, icon: platform.icon, color: platform.color,
1447
+ category: platform.category, status: 'no-balance-api', error: '该平台未开放余额查询', noBalance: true,
1448
+ }
1449
+ }
635
1450
  if (!apiKey) {
636
1451
  return {
637
1452
  platform: platform.id, name: platform.label, icon: platform.icon, color: platform.color,
@@ -678,9 +1493,12 @@ async function queryPreset(platform, apiKey, config) {
678
1493
  const parsed = parseResponse(queryType, json)
679
1494
 
680
1495
  if (!parsed) {
1496
+ // v1.2.6: 业务错误分类抽到 classifyBizError (可单测)。
1497
+ // 注意: 智谱 Coding Plan 套餐用户能解析出配额 → parsed 非空 → 不会走到这里。
1498
+ const { status, error } = classifyBizError(queryType, json)
681
1499
  return {
682
1500
  platform: platform.id, name: platform.label, icon: platform.icon, color: platform.color,
683
- category: platform.category, status: 'parse-error', error: '无法解析余额数据', noBalance: true,
1501
+ category: platform.category, status, error, noBalance: true,
684
1502
  }
685
1503
  }
686
1504
 
@@ -696,7 +1514,7 @@ async function queryPreset(platform, apiKey, config) {
696
1514
  return {
697
1515
  platform: platform.id, name: platform.label, icon: platform.icon, color: platform.color,
698
1516
  category: platform.category,
699
- status: 'error', error: message.includes('abort') ? '请求超时' : '网络错误', noBalance,
1517
+ status: 'error', error: message.includes('abort') ? '请求超时' : '网络错误', noBalance: false,
700
1518
  }
701
1519
  }
702
1520
  }
@@ -716,8 +1534,10 @@ function relayTypePath(queryType) {
716
1534
 
717
1535
  async function queryCustomRelay(relay, config) {
718
1536
  const { id, name, baseUrl, apiKey, queryType } = relay
1537
+ // v1.4.0: 这条中转站是不是从 DSH settings.yaml 自动发现的 (客户端据此显示「DSH」标)
1538
+ const fromDsh = relay.fromDsh === true
719
1539
  if (!apiKey) {
720
- return { platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站', status: 'no-key', error: '未配置 API Key', noBalance: true }
1540
+ return { platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站', status: 'no-key', error: '未配置 API Key', noBalance: true, fromDsh }
721
1541
  }
722
1542
  const base = (baseUrl || '').replace(/\/+$/, '')
723
1543
 
@@ -733,6 +1553,13 @@ async function queryCustomRelay(relay, config) {
733
1553
  )
734
1554
  }
735
1555
 
1556
+ // A: 命中过的端点排到最前(只影响顺序, 不影响"全都会试一遍"的语义)
1557
+ if (candidates.length > 1 && relayEndpointHints.has(id)) {
1558
+ const hint = relayEndpointHints.get(id)
1559
+ const idx = candidates.findIndex((c) => c.type === hint)
1560
+ if (idx > 0) candidates.unshift(candidates.splice(idx, 1)[0])
1561
+ }
1562
+
736
1563
  for (const cand of candidates) {
737
1564
  const headers = { Accept: 'application/json', Authorization: `Bearer ${apiKey}` }
738
1565
  const method = cand.type === 'quota' ? 'POST' : 'GET'
@@ -744,11 +1571,16 @@ async function queryCustomRelay(relay, config) {
744
1571
  try { json = JSON.parse(text) } catch { continue }
745
1572
  const parsed = parseResponse(cand.type, json)
746
1573
  if (parsed) {
1574
+ // A: 记住这次命中的端点(变了才落盘, 避免每次刷新都写状态文件)
1575
+ if (relayEndpointHints.get(id) !== cand.type) {
1576
+ relayEndpointHints.set(id, cand.type)
1577
+ try { savePersistedState({ relayEndpoints: Object.fromEntries(relayEndpointHints) }) } catch { /* 落盘失败不影响本次结果 */ }
1578
+ }
747
1579
  return {
748
1580
  platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站',
749
1581
  status: 'ok', total: parsed.total, currency: parsed.currency, available: parsed.available,
750
1582
  used: parsed.used, note: parsed.note || cand.type, percent: parsed.percent,
751
- noBalance: false, queryType: cand.type, fetchedAt: Date.now(),
1583
+ noBalance: false, queryType: cand.type, fetchedAt: Date.now(), fromDsh,
752
1584
  }
753
1585
  }
754
1586
  } catch { /* 尝试下一个 */ }
@@ -756,7 +1588,7 @@ async function queryCustomRelay(relay, config) {
756
1588
 
757
1589
  return {
758
1590
  platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站',
759
- status: 'no-balance-api', error: '该平台未开放余额查询', noBalance: true,
1591
+ status: 'no-balance-api', error: '该平台未开放余额查询', noBalance: true, fromDsh,
760
1592
  }
761
1593
  }
762
1594
 
@@ -789,8 +1621,9 @@ export async function queryCustomModel(model, config) {
789
1621
 
790
1622
  // 1) 手动映射优先 (totalPath 点分路径, 如 data.balance)
791
1623
  if (totalPath) {
792
- const total = toAmount(dotGet(json, totalPath))
793
- if (Number.isFinite(total) && (total !== 0 || json != null)) {
1624
+ const raw = dotGet(json, totalPath)
1625
+ const total = toAmount(raw)
1626
+ if (raw != null && Number.isFinite(Number(raw))) {
794
1627
  const used = usedPath ? toAmount(dotGet(json, usedPath)) : null
795
1628
  return {
796
1629
  ...base, status: 'ok', total, currency: currency || 'CNY',
@@ -825,14 +1658,201 @@ export async function queryCustomModel(model, config) {
825
1658
  // ============================================================
826
1659
  // 会话消耗投影 (学习 dsh-balance queryBalanceCost)
827
1660
  // ============================================================
828
- export function makeCostProjection(configOrGetter) {
1661
+ /**
1662
+ * v1.4.0: 子代理消耗汇总。
1663
+ *
1664
+ * 背景: 本投影只折叠**本会话**的事件, 而子代理(subagent)跑在自己的子会话里 —— 手机会话
1665
+ * 开了子代理后, 子代理烧的 token 完全不在主板数字里。
1666
+ *
1667
+ * 数据源分两条, 因为子代理会话会「由热转冷」:
1668
+ * ① **热路径(首选)**: `ctx.sessions.get(id)` + `sessionProjections.snapshot/stateOf`。
1669
+ * 父会话的 `subagentCatalog` 投影给出**按创建顺序**的直接子会话; 每个子会话的
1670
+ * `queryBalanceCost` 投影(就是本插件注册的同一个 unit)给出它的消耗。
1671
+ * ② **冷路径(兜底)**: 读持久化投影缓存文件 `storages/session_projcache/sessions/<id>.json`。
1672
+ * ⚠️ 为什么必须要这条: 框架的 `SubagentListEntry.activity` 只有 `'running' | 'inactive'`,
1673
+ * **inactive = 只存在于持久化里** —— 子代理跑完(或其 turn 结束)后就不在 `ctx.sessions`
1674
+ * 的常驻表里了, `sessions.get()` 取不到 → 面板显示 `~—`(实测踩到)。框架自己的
1675
+ * `listChildren()` 走"投影缓存读"解决这件事, 但它是 **async**, 而投影的 `view()`
1676
+ * 契约要求**同步** —— 所以这里同步读缓存文件。形状取自实测, 读不到/形状不符一律静默返回 null。
1677
+ *
1678
+ * 递归展开孙代理并把金额**向上汇总**到直接子代理那一条 (深度 / 行数都有封顶)。
1679
+ *
1680
+ * @param services 惰性取服务: () => ({ sessions, projections }) | null。取不到就静默返回空数组
1681
+ * (老框架 / 单测环境), 绝不让子代理汇总拖垮主投影。
1682
+ */
1683
+ const SUBAGENT_MAX_DEPTH = 4
1684
+ const SUBAGENT_MAX_ROWS = 12
1685
+ /** 冷路径的文件读缓存 TTL —— view() 会随每次投影变化被调用, 不能每次都去读盘。 */
1686
+ const SUBAGENT_FILE_TTL_MS = 3000
1687
+ const sessionCacheFiles = new Map()
1688
+ let subagentCostSummarize = null
1689
+
1690
+ const safeSessionId = (id) => typeof id === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(id) ? id : null
1691
+
1692
+ /** 读某会话的投影缓存记录(带 TTL 内存缓存)。任何异常 → null。 */
1693
+ const readSessionCacheRecord = (sessionId) => {
1694
+ const id = safeSessionId(sessionId)
1695
+ if (id === null) return null
1696
+ const now = Date.now()
1697
+ const hit = sessionCacheFiles.get(id)
1698
+ if (hit !== undefined && now - hit.at < SUBAGENT_FILE_TTL_MS) return hit.rows
1699
+ let rows = null
1700
+ try {
1701
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
1702
+ const file = join(home, 'storages', 'session_projcache', 'sessions', `${id}.json`)
1703
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
1704
+ const r = parsed?.record?.rows
1705
+ if (r !== null && typeof r === 'object') rows = r
1706
+ } catch { rows = null }
1707
+ // 只缓存成功结果, 避免一次读失败被 TTL 钉住 3 秒
1708
+ if (rows !== null) sessionCacheFiles.set(id, { at: now, rows })
1709
+ if (sessionCacheFiles.size > 256) sessionCacheFiles.clear()
1710
+ return rows
1711
+ }
1712
+
1713
+ /** 冷路径: 从缓存文件里取该会话的 queryBalanceCost **状态**(不是 wire 视图)。 */
1714
+ const cachedCostState = (sessionId) => {
1715
+ const val = readSessionCacheRecord(sessionId)?.queryBalanceCost?.val
1716
+ return (val !== null && typeof val === 'object' && Array.isArray(val.modelOrder) && val.byModel !== null && typeof val.byModel === 'object') ? val : null
1717
+ }
1718
+
1719
+ /** 冷路径: 从缓存文件里取该会话的 subagentCatalog 条目。 */
1720
+ const cachedCatalog = (sessionId) => {
1721
+ const st = readSessionCacheRecord(sessionId)?.subagentCatalog?.val
1722
+ const values = st?.head?.values
1723
+ if (!Array.isArray(values)) return []
1724
+ return values
1725
+ .filter((v) => v !== null && typeof v === 'object' && typeof v.childId === 'string')
1726
+ .map((v) => ({
1727
+ id: v.childId,
1728
+ createdAt: typeof v.childCreatedAt === 'number' ? v.childCreatedAt : 0,
1729
+ mode: v.mode === 'continuable' ? 'continuable' : 'one-shot',
1730
+ label: typeof v.label === 'string' ? v.label : undefined,
1731
+ }))
1732
+ }
1733
+
1734
+ export function collectSubagentCosts(services, rootSessionId, summarize) {
1735
+ const out = []
1736
+ if (typeof rootSessionId !== 'string' || rootSessionId === '') return out
1737
+ let sessions = null, projections = null
1738
+ try {
1739
+ const svc = typeof services === 'function' ? services() : services
1740
+ sessions = svc?.sessions ?? null
1741
+ projections = svc?.projections ?? null
1742
+ } catch { /* 服务取不到 → 只能走冷路径 */ }
1743
+ subagentCostSummarize = typeof summarize === 'function' ? summarize : null
1744
+
1745
+ /** 取常驻会话对象。任何异常一律当成"取不到"(宿主服务在极端情况下可能抛)。 */
1746
+ const getSession = (id) => {
1747
+ try { return sessions?.get?.(id) ?? null } catch { return null }
1748
+ }
1749
+
1750
+ /** 某会话的直接子会话(按创建顺序): 热路径优先, 空则回落到缓存文件。 */
1751
+ const childrenOf = (sessionId) => {
1752
+ const s = getSession(sessionId)
1753
+ if (s !== null && projections !== null) {
1754
+ try {
1755
+ const list = projections.snapshot(s, ['subagentCatalog'])?.values?.subagentCatalog
1756
+ if (Array.isArray(list) && list.length > 0) return list
1757
+ } catch { /* 落到冷路径 */ }
1758
+ }
1759
+ return cachedCatalog(sessionId)
1760
+ }
1761
+
1762
+ /** 某会话的消耗投影状态: 热路径优先(更新鲜), 无数据则回落到缓存文件。 */
1763
+ const costStateOf = (sessionId) => {
1764
+ const s = getSession(sessionId)
1765
+ if (s !== null && projections !== null) {
1766
+ try {
1767
+ const st = projections.stateOf(s, 'queryBalanceCost')
1768
+ if (st !== null && st !== undefined && Array.isArray(st.modelOrder) && st.modelOrder.length > 0) return st
1769
+ } catch { /* 落到冷路径 */ }
1770
+ }
1771
+ return cachedCostState(sessionId)
1772
+ }
1773
+
1774
+ /** 递归汇总: 自身 + 后代, 返回与 summarize 同形的汇总。 */
1775
+ const rollup = (sessionId, depth) => {
1776
+ const st = costStateOf(sessionId)
1777
+ let acc = (st !== null && subagentCostSummarize !== null) ? subagentCostSummarize(st) : null
1778
+ if (depth >= SUBAGENT_MAX_DEPTH) return acc
1779
+ for (const entry of childrenOf(sessionId)) {
1780
+ acc = mergeSummary(acc, rollup(entry.id, depth + 1))
1781
+ }
1782
+ return acc
1783
+ }
1784
+
1785
+ for (const entry of childrenOf(rootSessionId)) {
1786
+ if (out.length >= SUBAGENT_MAX_ROWS) break
1787
+ const s = rollup(entry.id, 1) ?? emptySummary()
1788
+ out.push({
1789
+ id: String(entry.id),
1790
+ label: typeof entry.label === 'string' && entry.label !== '' ? entry.label : String(entry.id).slice(0, 12),
1791
+ mode: entry.mode === 'continuable' ? 'continuable' : 'one-shot',
1792
+ createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : 0,
1793
+ cost: s.cost,
1794
+ costByCurrency: s.costByCurrency,
1795
+ currencyByModel: s.currencyByModel,
1796
+ mixedCurrency: s.mixedCurrency,
1797
+ tokens: s.tokens,
1798
+ models: s.models,
1799
+ })
1800
+ }
1801
+ return out
1802
+ }
1803
+
1804
+ /** 一份空的汇总 (与 summarize 同形)。 */
1805
+ export const emptySummary = () => ({
1806
+ cost: 0, costByModel: {}, costByCurrency: {}, currencyByModel: {}, mixedCurrency: false, models: [],
1807
+ tokens: { uncachedInput: 0, cacheRead: 0, cacheWrite: 0, output: 0 }, tokensByModel: {},
1808
+ })
1809
+
1810
+ /** 把两份汇总按币种/模型/token 相加 (子代理树向上汇总用)。 */
1811
+ export const mergeSummary = (a, b) => {
1812
+ const x = a ?? emptySummary(), y = b ?? emptySummary()
1813
+ const round6 = (n) => Math.round(n * 1e6) / 1e6
1814
+ const sumMap = (p, q) => {
1815
+ const out = { ...(p || {}) }
1816
+ for (const [k, v] of Object.entries(q || {})) out[k] = round6((out[k] ?? 0) + v)
1817
+ return out
1818
+ }
1819
+ const costByCurrency = sumMap(x.costByCurrency, y.costByCurrency)
1820
+ const costByModel = sumMap(x.costByModel, y.costByModel)
1821
+ const tokens = {
1822
+ uncachedInput: x.tokens.uncachedInput + y.tokens.uncachedInput,
1823
+ cacheRead: x.tokens.cacheRead + y.tokens.cacheRead,
1824
+ cacheWrite: x.tokens.cacheWrite + y.tokens.cacheWrite,
1825
+ output: x.tokens.output + y.tokens.output,
1826
+ }
1827
+ const tokensByModel = { ...(x.tokensByModel || {}) }
1828
+ for (const [m, t] of Object.entries(y.tokensByModel || {})) {
1829
+ const p = tokensByModel[m] ?? { uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }
1830
+ tokensByModel[m] = {
1831
+ uncachedInputTokens: p.uncachedInputTokens + (t?.uncachedInputTokens ?? 0),
1832
+ cacheReadTokens: p.cacheReadTokens + (t?.cacheReadTokens ?? 0),
1833
+ cacheWriteTokens: p.cacheWriteTokens + (t?.cacheWriteTokens ?? 0),
1834
+ outputTokens: p.outputTokens + (t?.outputTokens ?? 0),
1835
+ }
1836
+ }
1837
+ const models = [...new Set([...(x.models || []), ...(y.models || [])])]
1838
+ const mainCur = Object.keys(costByCurrency)[0]
1839
+ return {
1840
+ cost: mainCur === undefined ? 0 : costByCurrency[mainCur],
1841
+ costByModel, costByCurrency,
1842
+ currencyByModel: { ...x.currencyByModel, ...y.currencyByModel },
1843
+ mixedCurrency: Object.keys(costByCurrency).length > 1,
1844
+ models, tokens, tokensByModel,
1845
+ }
1846
+ }
1847
+
1848
+ export function makeCostProjection(configOrGetter, services) {
829
1849
  const getConfig = () => typeof configOrGetter === 'function' ? configOrGetter() : configOrGetter
830
1850
  const zero = () => ({ uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 })
831
1851
  const bucketsOf = (usage) => ({
832
- uncachedInputTokens: usage.inputTokens,
1852
+ uncachedInputTokens: usage.inputTokens ?? 0,
833
1853
  cacheReadTokens: usage.cacheReadTokens ?? 0,
834
1854
  cacheWriteTokens: usage.cacheWriteTokens ?? 0,
835
- outputTokens: usage.outputTokens,
1855
+ outputTokens: usage.outputTokens ?? 0,
836
1856
  })
837
1857
  const bucketsEqual = (a, b) =>
838
1858
  a.uncachedInputTokens === b.uncachedInputTokens && a.cacheReadTokens === b.cacheReadTokens &&
@@ -851,12 +1871,80 @@ export function makeCostProjection(configOrGetter) {
851
1871
  })
852
1872
  const round6 = (n) => Math.round(n * 1e6) / 1e6
853
1873
 
1874
+ /**
1875
+ * v1.4.0: 从一条 assistant stream 里取**最后一次** usage chunk。
1876
+ * 与 dsh-llm 的 `lastAssistantStreamChunk(stream, 'usage')` 同语义, 本地实现以免给插件引入额外依赖
1877
+ * (`dependencies` 必须保持为空是硬约束)。
1878
+ */
1879
+ const lastUsageFromStream = (stream) => {
1880
+ if (!Array.isArray(stream)) return undefined
1881
+ for (let i = stream.length - 1; i >= 0; i -= 1) {
1882
+ const rec = stream[i]
1883
+ if (rec !== null && typeof rec === 'object' && rec.type === 'chunk' && rec.chunk?.type === 'usage') return rec.chunk.usage
1884
+ }
1885
+ return undefined
1886
+ }
1887
+
1888
+ /**
1889
+ * v1.4.0: 一个事件所携带的用量样本。对齐 dsh-token-meter 的 usage-projection:
1890
+ * - `assistant/message` 优先用自带 `usage`, 没有则回落到 stream 里的 usage chunk;
1891
+ * - `assistant/attempt`(失败/重试/取消/流错误、没有产出可见消息的尝试) 从 stream 里取。
1892
+ * ⚠️ 旧代码读的是 `assistant/chunk` —— 该事件名**不在**框架 `KNOWN_SESSION_EVENT_TYPES` 里, 是死分支,
1893
+ * 导致上面两种真实事件里 `assistant/attempt` 的 token 被整段漏计。
1894
+ */
1895
+ const usageOfEvent = (event) => {
1896
+ if (event.type === 'assistant/message' && event.data.usage !== undefined) return event.data.usage
1897
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined
1898
+ return lastUsageFromStream(event.data.stream)
1899
+ }
1900
+
1901
+ /**
1902
+ * v1.4.0: 把一份投影状态折成金额汇总 —— 主视图与子代理汇总**共用同一套口径**,
1903
+ * 保证「子代理那行」与「主板数字」算法完全一致 (含峰谷、币种、缓存读写分桶)。
1904
+ */
1905
+ const summarize = (state) => {
1906
+ const cfg = getConfig()
1907
+ const mainCurrency = (cfg.currency ?? 'CNY').toUpperCase()
1908
+ const tokens = { uncachedInput: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
1909
+ const costByModel = {}
1910
+ const costByCurrency = {}
1911
+ const currencyByModel = {}
1912
+ let cost = 0
1913
+ const order = Array.isArray(state?.modelOrder) ? state.modelOrder : []
1914
+ for (const model of order) {
1915
+ const b = state.byModel?.[model] ?? zero()
1916
+ tokens.uncachedInput += b.uncachedInputTokens
1917
+ tokens.cacheRead += b.cacheReadTokens
1918
+ tokens.cacheWrite += b.cacheWriteTokens
1919
+ tokens.output += b.outputTokens
1920
+ // 支持 DeepSeek 谷峰自动计费
1921
+ const price = resolveModelPrice(cfg, model)
1922
+ const c = ((b.uncachedInputTokens + b.cacheWriteTokens) * price.cacheMiss + b.cacheReadTokens * price.cacheHit + b.outputTokens * price.output) / 1e6
1923
+ // v1.3.2: 该模型实际币种 (海外模型可能与主货币不同)
1924
+ const cur = currencyForModel(cfg, model)
1925
+ if (c > 0) {
1926
+ costByModel[model] = round6(c)
1927
+ currencyByModel[model] = cur
1928
+ costByCurrency[cur] = round6((costByCurrency[cur] ?? 0) + c)
1929
+ }
1930
+ // cost 仍只汇总「主货币」那一份, 保持字段语义单一 (混合时另一半在 costByCurrency 里)。
1931
+ // overseasCurrency='follow' 时所有模型都是主货币, cost === 全部合计, 与 v1.2.6 一致。
1932
+ if (cur === mainCurrency) cost += c
1933
+ }
1934
+ return {
1935
+ cost: round6(cost), costByModel, costByCurrency, currencyByModel,
1936
+ mixedCurrency: Object.keys(costByCurrency).length > 1,
1937
+ tokens, tokensByModel: state?.byModel ?? {}, models: order, mainCurrency,
1938
+ }
1939
+ }
1940
+
854
1941
  return {
855
1942
  key: 'queryBalanceCost',
856
1943
  // 框架要求的投影定义 API: stateSchema(内部状态) + wire.{viewSchema,view}(客户端可见视图)。
857
1944
  // 旧版误用顶层 schema+view, 导致 wire 缺失, 服务端 drive 永不通知、客户端永远拿不到值。
858
1945
  stateSchema: z.object({
859
1946
  currentModel: z.string().nullable(),
1947
+ currentProvider: z.string().nullable(),
860
1948
  last: z.object({
861
1949
  turn: z.number(),
862
1950
  step: z.number(),
@@ -875,76 +1963,115 @@ export function makeCostProjection(configOrGetter) {
875
1963
  outputTokens: z.number(),
876
1964
  })),
877
1965
  modelOrder: z.array(z.string()),
1966
+ /** v1.4.0: 本投影所属会话 id —— 子代理汇总要拿它去查 `subagentCatalog`。空串表示未知。 */
1967
+ sessionId: z.string(),
1968
+ }),
1969
+ init: (header) => ({
1970
+ currentModel: null, currentProvider: null, last: null, byModel: {}, modelOrder: [],
1971
+ sessionId: typeof header?.id === 'string' ? header.id : '',
878
1972
  }),
879
- init: () => ({ currentModel: null, last: null, byModel: {}, modelOrder: [] }),
880
1973
  apply: (state, event) => {
1974
+ // v1.4.0: `llm/retry-started` 关闭「替换槽位」—— 被重试的那次 attempt 的用量要**留在总量里**,
1975
+ // 下一次 attempt 是**新增**而不是替换。与 dsh-token-meter 的 usage-projection 对齐。
1976
+ if (event.type === 'llm/retry-started') {
1977
+ const turn = event.data?.turn
1978
+ const step = event.data?.step
1979
+ return state.last !== null && state.last.turn === turn && state.last.step === step
1980
+ ? { ...state, last: null }
1981
+ : state
1982
+ }
881
1983
  let nextModel = state.currentModel
1984
+ let nextProvider = state.currentProvider
882
1985
  if (event.type === 'request/header') {
883
1986
  const model = event.data.header?.config?.model
884
1987
  if (typeof model === 'string' && model !== '') nextModel = model
1988
+ const prov = event.data.header?.config?.provider
1989
+ if (typeof prov === 'string' && prov !== '') nextProvider = prov
885
1990
  } else if (event.type === 'request/context') {
886
1991
  const model = event.data.model
887
1992
  if (typeof model === 'string' && model !== '') nextModel = model
1993
+ const prov = event.data.provider
1994
+ if (typeof prov === 'string' && prov !== '') nextProvider = prov
888
1995
  }
889
1996
  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)
1997
+ const sample = usageOfEvent(event)
1998
+ if (sample !== undefined && sample !== null) {
1999
+ turn = event.data.turn
2000
+ step = event.data.step
2001
+ usage = sample
894
2002
  }
895
- if (usage === null) return nextModel === state.currentModel ? state : { ...state, currentModel: nextModel }
2003
+ const unchanged = nextModel === state.currentModel && nextProvider === state.currentProvider
2004
+ if (usage === null) return unchanged ? state : { ...state, currentModel: nextModel, currentProvider: nextProvider }
896
2005
  const model = nextModel ?? 'unknown'
897
2006
  const buckets = bucketsOf(usage)
898
2007
  const prev = state.last !== null && state.last.turn === turn && state.last.step === step ? state.last : null
899
2008
  if (prev !== null && prev.model === model && bucketsEqual(prev.buckets, buckets)) {
900
- return nextModel === state.currentModel ? state : { ...state, currentModel: nextModel }
2009
+ return unchanged ? state : { ...state, currentModel: nextModel, currentProvider: nextProvider }
901
2010
  }
902
2011
  const isNewModel = !(model in state.byModel)
903
2012
  let byModel = state.byModel
904
2013
  if (prev !== null) byModel = { ...byModel, [prev.model]: subBuckets(byModel[prev.model] ?? zero(), prev.buckets) }
905
2014
  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 }
2015
+ return { ...state, currentModel: nextModel, currentProvider: nextProvider, last: { turn, step, model, buckets }, byModel, modelOrder: isNewModel ? [...state.modelOrder, model] : state.modelOrder }
907
2016
  },
908
2017
  wire: {
909
2018
  viewSchema: z.object({
910
2019
  models: z.array(z.string()),
911
2020
  // v0.5.3: 暴露当前会话正在使用的模型, 客户端据此自动切换选中平台
912
2021
  currentModel: z.string().nullable(),
2022
+ // 当前 provider (中转站/官方), 客户端据此判断是否走官方余额
2023
+ currentProvider: z.string().nullable().optional(),
913
2024
  cost: z.number(),
914
2025
  costByModel: z.record(z.string(), z.number().nonnegative()),
2026
+ // v1.3.2: 海外模型可独立走 USD, 于是一个会话可能同时产生两种货币的消耗。
2027
+ // 不做汇率折算合并 (折算=再引入 ×7 误差), 客户端两段拼接显示。
2028
+ costByCurrency: z.record(z.string(), z.number().nonnegative()).optional(),
2029
+ currencyByModel: z.record(z.string(), z.string()).optional(),
2030
+ mixedCurrency: z.boolean().optional(),
915
2031
  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
2032
  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
2033
  currency: z.string(),
918
2034
  isPeak: z.boolean().optional(),
919
2035
  waiting: z.boolean().optional(),
2036
+ // v1.4.0: 子代理消耗, 按父会话 catalog 事件顺序 (= 创建顺序) 从左到右展示。
2037
+ // 金额是「该子代理 + 其后代」的向上汇总; 取不到会话服务时为空数组。
2038
+ subagents: z.array(z.object({
2039
+ id: z.string(),
2040
+ label: z.string(),
2041
+ mode: z.enum(['one-shot', 'continuable']),
2042
+ createdAt: z.number(),
2043
+ cost: z.number(),
2044
+ costByCurrency: z.record(z.string(), z.number().nonnegative()),
2045
+ currencyByModel: z.record(z.string(), z.string()),
2046
+ mixedCurrency: z.boolean(),
2047
+ tokens: z.object({
2048
+ uncachedInput: z.number().int().nonnegative(),
2049
+ cacheRead: z.number().int().nonnegative(),
2050
+ cacheWrite: z.number().int().nonnegative(),
2051
+ output: z.number().int().nonnegative(),
2052
+ }).strict(),
2053
+ models: z.array(z.string()),
2054
+ }).strict()).optional(),
920
2055
  }).strict(),
921
2056
  view: (state) => {
922
2057
  const cfg = getConfig()
2058
+ const mainCurrency = (cfg.currency ?? 'CNY').toUpperCase()
2059
+ // v1.4.0: 子代理消耗 (换行单独展示)。取不到服务/没有子代理 → 空数组。
2060
+ const subagents = collectSubagentCosts(services, state.sessionId, summarize)
923
2061
  // 无事件时返回 waiting 标记, 客户端据此显示 "~—" 而非 "~¥0"
924
2062
  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 }
2063
+ 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
2064
  }
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
2065
+ const s = summarize(state)
2066
+ return {
2067
+ models: s.models, currentModel: state.currentModel ?? null, currentProvider: state.currentProvider ?? null,
2068
+ cost: s.cost, costByModel: s.costByModel, costByCurrency: s.costByCurrency, currencyByModel: s.currencyByModel,
2069
+ mixedCurrency: s.mixedCurrency, tokens: s.tokens, tokensByModel: s.tokensByModel,
2070
+ currency: mainCurrency, isPeak: isPeakTime(), waiting: false, subagents,
943
2071
  }
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
2072
  },
946
2073
  },
947
- stateVersion: 1,
2074
+ stateVersion: 2,
948
2075
  }
949
2076
  }
950
2077
 
@@ -954,19 +2081,37 @@ export function makeCostProjection(configOrGetter) {
954
2081
  export function apply(ctx, config) {
955
2082
  // 用户保存的配置优先于 cordis.patch.yml 的默认 config (持久化状态)
956
2083
  const persisted = loadPersistedState()
2084
+ // A: 载入上次记住的中转站端点
2085
+ try {
2086
+ relayEndpointHints.clear()
2087
+ const saved = persisted.relayEndpoints
2088
+ if (saved && typeof saved === 'object' && !Array.isArray(saved)) {
2089
+ for (const [k, v] of Object.entries(saved)) {
2090
+ if (typeof k === 'string' && k.length <= 128 && typeof v === 'string' && v.length <= 32) relayEndpointHints.set(k, v)
2091
+ }
2092
+ }
2093
+ } catch { /* 忽略 */ }
957
2094
  const runtimeConfig = {
958
2095
  refreshIntervalMs: persisted.refreshIntervalMs ?? config.refreshIntervalMs ?? 5000,
959
2096
  clientPollIntervalMs: persisted.clientPollIntervalMs ?? config.clientPollIntervalMs ?? 5000,
960
2097
  timeoutMs: persisted.timeoutMs ?? config.timeoutMs ?? 8000,
961
2098
  presets: config.presets ?? PLATFORM_PRESETS.map(p => p.id),
962
- customRelays: (persisted.customRelays ?? config.customRelays ?? []).map(r => ({ ...r })),
963
- customModels: (persisted.customModels ?? config.customModels ?? []).map(m => ({ ...m })),
2099
+ // H-1 (v1.4.1): 这里必须带 Array.isArray —— 状态文件形状跑偏时 apply() 抛出 =
2100
+ // 整个 dsh web 启动失败(migratePersistedState 已消毒, 这里是第二道防线)
2101
+ customRelays: (Array.isArray(persisted.customRelays) ? persisted.customRelays : (Array.isArray(config.customRelays) ? config.customRelays : [])).map(r => ({ ...r })),
2102
+ customModels: (Array.isArray(persisted.customModels) ? persisted.customModels : (Array.isArray(config.customModels) ? config.customModels : [])).map(m => ({ ...m })),
964
2103
  prices: config.prices ?? { 'deepseek-chat': { cacheHit: 0.1, cacheMiss: 1, output: 2 } },
965
2104
  defaultPrices: config.defaultPrices ?? { cacheHit: 0.1, cacheMiss: 1, output: 2 },
966
2105
  currency: persisted.currency ?? config.currency ?? 'CNY',
2106
+ // v1.3.2: 海外模型独立计价货币 ('follow' = 跟随主货币, 默认, 行为同 v1.2.6)
2107
+ overseasCurrency: persisted.overseasCurrency ?? config.overseasCurrency ?? 'follow',
967
2108
  safeThreshold: persisted.safeThreshold ?? config.safeThreshold ?? 50,
968
2109
  warnThreshold: persisted.warnThreshold ?? config.warnThreshold ?? 10,
969
2110
  whaleEnabled: persisted.whaleEnabled ?? config.whaleEnabled ?? false,
2111
+ showNoBalanceBrands: persisted.showNoBalanceBrands ?? config.showNoBalanceBrands ?? false,
2112
+ officialProviders: normalizeOfficialProviders(persisted.officialProviders ?? config.officialProviders ?? []),
2113
+ // v1.4.0「真自动」: 被用户关掉的 DSH provider (默认空 = 全部启用)。复用同一个名单规范化器。
2114
+ dshProviderOptOut: normalizeOfficialProviders(persisted.dshProviderOptOut ?? config.dshProviderOptOut ?? []),
970
2115
  whaleSettings: {
971
2116
  scale: 1, soundOn: true, soundSet: 'duck', volume: 0.5, bubbleOn: true,
972
2117
  peakMode: 'default', snapOn: true, peekRatio: 0.5, left: null, top: null, side: 'right',
@@ -977,16 +2122,56 @@ export function apply(ctx, config) {
977
2122
 
978
2123
  const getConfig = () => runtimeConfig
979
2124
 
2125
+ /** 直接读 ~/.dsh/.credentials.yaml 的 refs: 段 —— 拿不到 credentials 服务时的兜底。
2126
+ * @param {string[]} names 要找的 ref 名 (遇到第一个有值的就返回) */
2127
+ const readCredentialRefs = (names) => {
2128
+ const wanted = Array.isArray(names) ? names : []
2129
+ if (wanted.length === 0) return ''
2130
+ try {
2131
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
2132
+ const raw = readFileSync(join(home, '.credentials.yaml'), 'utf8')
2133
+ let inRefs = false
2134
+ for (const line of raw.split('\n')) {
2135
+ if (line === 'refs:') { inRefs = true; continue }
2136
+ if (!inRefs) continue
2137
+ if (!line.startsWith(' ')) { inRefs = false; continue }
2138
+ const idx = line.indexOf(':')
2139
+ if (idx === -1) continue
2140
+ const key = line.slice(0, idx).trim()
2141
+ const val = line.slice(idx + 1).trim()
2142
+ if (key && val && wanted.includes(key)) return val
2143
+ }
2144
+ } catch { /* 忽略 */ }
2145
+ return ''
2146
+ }
2147
+
2148
+ /** 解析一个 apiKeyEnv 名 → 真实 key (环境变量 → credentials 服务 → 凭据文件)。
2149
+ * v1.4.0「真自动」用它取 DSH provider 的 key, 与预设平台同一套三层兜底。 */
2150
+ const resolveApiKeyRef = async (ref) => {
2151
+ const name = typeof ref === 'string' ? ref.trim() : ''
2152
+ if (name === '') return ''
2153
+ if (process.env[name]) return process.env[name]
2154
+ const creds = ctx.get('credentials')
2155
+ if (creds !== undefined) {
2156
+ try {
2157
+ const hit = await creds.resolve(name)
2158
+ if (hit !== undefined) return hit.value
2159
+ } catch { /* 忽略 */ }
2160
+ }
2161
+ return readCredentialRefs([name])
2162
+ }
2163
+
980
2164
  /** 解析预设平台的 API key (从环境变量、credentials 系统或直接读凭据文件) */
981
2165
  const resolvePresetKey = async (platform) => {
2166
+ const refs = platform.envKeys || []
982
2167
  // 1) 环境变量
983
- for (const name of platform.envKeys || []) {
2168
+ for (const name of refs) {
984
2169
  if (process.env[name]) return process.env[name]
985
2170
  }
986
2171
  // 2) DSH credentials 服务
987
2172
  const creds = ctx.get('credentials')
988
2173
  if (creds !== undefined) {
989
- for (const ref of (platform.envKeys || [])) {
2174
+ for (const ref of refs) {
990
2175
  try {
991
2176
  const hit = await creds.resolve(ref)
992
2177
  if (hit !== undefined) return hit.value
@@ -994,27 +2179,48 @@ export function apply(ctx, config) {
994
2179
  }
995
2180
  }
996
2181
  // 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
- }
2182
+ return readCredentialRefs(refs)
2183
+ }
2184
+
2185
+ /**
2186
+ * v1.4.0「真自动」: DSH settings.yaml 里的 provider 合成为可查余额的中转站条目。
2187
+ * 挑选规则见模块级纯函数 `selectDshProviders` (可单测)
2188
+ * 这里只多一步: 解析 key —— 要访问 credentials 服务, 所以必须是异步的。
2189
+ * 与手填的 customRelays 合并时**手填优先** (同 id / 同 baseUrl 都算重复), 见 refreshAll。
2190
+ */
2191
+ const listDshProviderRelays = async () => {
2192
+ const { entries, kinds } = readSettingsDerived()
2193
+ const picked = selectDshProviders(entries, kinds, runtimeConfig.dshProviderOptOut)
2194
+ const out = []
2195
+ for (const p of picked) {
2196
+ out.push({
2197
+ id: 'dsh:' + p.name,
2198
+ name: p.name + ' (DSH)',
2199
+ baseUrl: p.baseURL,
2200
+ apiKey: await resolveApiKeyRef(p.apiKeyEnv),
2201
+ queryType: 'auto',
2202
+ fromDsh: true,
2203
+ })
2204
+ }
2205
+ return out
2206
+ }
2207
+
2208
+ /** 设置面板用: DSH provider 自动发现结果 (只读展示 + 开关状态)。**绝不下发 key**。 */
2209
+ const readDshProviderStatus = () => {
2210
+ const { entries, kinds } = readSettingsDerived()
2211
+ const on = new Set(selectDshProviders(entries, kinds, runtimeConfig.dshProviderOptOut).map((p) => p.name))
2212
+ return Object.keys(entries).sort().map((name) => {
2213
+ const e = entries[name]
2214
+ const kind = e.baseURL ? (kinds[name] || 'unknown') : 'no-base-url'
2215
+ return {
2216
+ name,
2217
+ baseURL: e.baseURL,
2218
+ apiKeyEnv: e.apiKeyEnv,
2219
+ // official | relay | unknown(主机名解析不出) | no-base-url(没写 baseURL, 不表态)
2220
+ kind,
2221
+ enabled: on.has(name),
1015
2222
  }
1016
- } catch { /* 忽略 */ }
1017
- return ''
2223
+ })
1018
2224
  }
1019
2225
 
1020
2226
  let cache = { balances: [], fetchedAt: 0, error: null }
@@ -1024,7 +2230,16 @@ export function apply(ctx, config) {
1024
2230
  if (inflight !== null) return inflight
1025
2231
  inflight = (async () => {
1026
2232
  const presetList = PLATFORM_PRESETS.filter(p => runtimeConfig.presets.includes(p.id))
1027
- const relayList = runtimeConfig.customRelays
2233
+ // v1.4.0「真自动」: 手填的 customRelays + 从 settings.yaml 自动发现的 DSH provider。
2234
+ // 手填优先 —— 同 id 或同 baseUrl 时不重复查一遍 (用户手填的那条口径由他自己定)。
2235
+ const manualRelays = runtimeConfig.customRelays
2236
+ const dshRelays = await listDshProviderRelays()
2237
+ const manualIds = new Set(manualRelays.map(r => String(r.id)))
2238
+ const manualUrls = new Set(manualRelays.map(r => String(r.baseUrl || '').replace(/\/+$/, '')))
2239
+ const relayList = [
2240
+ ...manualRelays,
2241
+ ...dshRelays.filter(r => !manualIds.has(r.id) && !manualUrls.has(r.baseUrl)),
2242
+ ]
1028
2243
  const modelList = runtimeConfig.customModels
1029
2244
  const tasks = [
1030
2245
  ...presetList.map(async (p) => queryPreset(p, await resolvePresetKey(p), runtimeConfig)),
@@ -1054,11 +2269,19 @@ export function apply(ctx, config) {
1054
2269
  safeThreshold: runtimeConfig.safeThreshold,
1055
2270
  warnThreshold: runtimeConfig.warnThreshold,
1056
2271
  currency: runtimeConfig.currency,
2272
+ overseasCurrency: runtimeConfig.overseasCurrency,
1057
2273
  isPeak: isPeakTime(),
1058
2274
  isWeekend: isWeekend(),
1059
2275
  whaleEnabled: !!runtimeConfig.whaleEnabled,
2276
+ showNoBalanceBrands: !!runtimeConfig.showNoBalanceBrands,
2277
+ // provider 官方/中转判定素材下发给客户端 (第 1 层: 用户名单; 第 2 层: baseURL 域名判定)
2278
+ officialProviders: runtimeConfig.officialProviders,
2279
+ providerKinds: readProviderKinds(),
2280
+ // v1.4.0「真自动」: DSH provider 发现结果 (只读, 不含 key)
2281
+ dshProviders: readDshProviderStatus(),
1060
2282
  },
1061
2283
  }
2284
+ cache.etag = '"' + fnv1a(JSON.stringify(cache.balances) + '|' + JSON.stringify(cache.config)) + '"'
1062
2285
  // A3: 告警检测
1063
2286
  checkAlerts(balances, runtimeConfig, ctx)
1064
2287
  })().finally(() => { inflight = null })
@@ -1068,7 +2291,7 @@ export function apply(ctx, config) {
1068
2291
  let loopTimer = null
1069
2292
  const resetLoop = () => {
1070
2293
  if (loopTimer !== null) { clearTimeout(loopTimer); loopTimer = null }
1071
- const run = () => { void refreshAll().then(() => { loopTimer = setTimeout(run, runtimeConfig.refreshIntervalMs) }) }
2294
+ const run = () => { refreshAll().catch(() => {}).finally(() => { loopTimer = setTimeout(run, runtimeConfig.refreshIntervalMs) }) }
1072
2295
  loopTimer = setTimeout(run, 0)
1073
2296
  }
1074
2297
 
@@ -1085,23 +2308,80 @@ export function apply(ctx, config) {
1085
2308
  res.end(body)
1086
2309
  }
1087
2310
 
2311
+ /**
2312
+ * H-3 (v1.4.1): 插件路由的鉴权闸门。
2313
+ *
2314
+ * 为什么必须有这道闸: dsh 的 webserver 是「先查 exact 路由表, 再走 fallback」,
2315
+ * 而浏览器的登录 cookie 校验只写在 fallback 里(dsh-host-frontend-static) ——
2316
+ * 于是 `/api-dashboard/*` 全部绕过鉴权: 无需 token、无需 cookie 就能读配置、
2317
+ * 改配置、改挂件、甚至触发 `/update/install`(会重写插件目录)。
2318
+ * 更糟的是 `Content-Type: text/plain` 属于**浏览器不预检的 simple request**,
2319
+ * 用户手机上随便打开一个网页, 那个网页就能 POST 过来改配置(实测 HTTP 200 且真的改了)。
2320
+ *
2321
+ * 老版本 dsh 没有 connection 服务时放行(那时本来也没有鉴权概念), 避免把插件打死。
2322
+ */
2323
+ /**
2324
+ * H-3 (v1.4.1): 插件路由的鉴权闸门。
2325
+ *
2326
+ * 为什么必须有这道闸: dsh 的 webserver 是「先查 exact 路由表, 再走 fallback」,
2327
+ * 而浏览器的登录 cookie 校验只写在 fallback 里(dsh-host-frontend-static) ——
2328
+ * 于是 `/api-dashboard/*` 全部绕过鉴权: 无需 token、无需 cookie 就能读配置、
2329
+ * 改配置、改挂件、甚至触发 `/update/install`(会重写插件目录)。
2330
+ * 更糟的是 `Content-Type: text/plain` 属于**浏览器不预检的 simple request**,
2331
+ * 用户手机上随便打开一个网页, 那个网页就能 POST 过来改配置(实测 HTTP 200 且真的改了)。
2332
+ *
2333
+ * 用 `connection.requestRejection(req)`: 与 dsh-web-mobile 同一个闸门,
2334
+ * 同时覆盖「浏览器 cookie 鉴权」与「Host/来源可信」两项检查。
2335
+ * ⚠️ 不能写 `ctx.get('connection')` —— 实测在插件 fiber 上取不到(返回 undefined),
2336
+ * 必须用嵌套 inject 拿服务实例。
2337
+ */
2338
+ let connectionSvc = null
2339
+ ctx.inject(['connection'], (c) => { connectionSvc = c.connection })
2340
+ const allowRequest = (req, res) => {
2341
+ const conn = connectionSvc
2342
+ // 老版本 dsh 没有 connection 服务时放行(那时本来也没有鉴权概念), 避免把插件打死
2343
+ if (!conn || typeof conn.requestRejection !== 'function') return true
2344
+ let rejection
2345
+ try { rejection = conn.requestRejection(req) } catch { rejection = undefined }
2346
+ if (rejection === undefined) return true
2347
+ res.writeHead(rejection, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
2348
+ res.end(rejection === 401 ? 'dsh web authentication required; reopen the URL printed by dsh web.\n' : 'forbidden\n')
2349
+ return false
2350
+ }
2351
+
1088
2352
  webCtx.effect(() => webCtx.webServer.register({
1089
2353
  kind: 'exact', path: '/api-dashboard/balances',
1090
2354
  async handler(req, res) {
2355
+ if (!allowRequest(req, res)) return
1091
2356
  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()
2357
+ const params = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams
2358
+ const force = req.method === 'POST' || params.get('force') === '1'
2359
+ /**
2360
+ * v1.4.0 `?stale=1` = stale-while-revalidate:
2361
+ * 「把手上有的先给我」。应用切回前台 / 页面重载时用它打首屏。
2362
+ * 为什么需要它: `force=1` 是**阻塞**的 —— 底下 `await refreshAll()` 要等最慢的那个
2363
+ * 端点(最长 timeoutMs=8s)。首屏卡这么久, 用户看到的就是「插件加载很慢」。
2364
+ * 有缓存时改成「立刻回旧数据 + 后台刷新」, 由下一次轮询把新数据带上来。
2365
+ * 没有缓存(服务端刚重启)时仍然只能等 —— 那时确实没有东西可显示。
2366
+ */
2367
+ const peek = params.get('stale') === '1'
2368
+ const plan = planBalancesFetch({
2369
+ force, peek,
2370
+ hasData: cache.balances.length > 0,
2371
+ age: Date.now() - cache.fetchedAt,
2372
+ intervalMs: runtimeConfig.refreshIntervalMs,
2373
+ })
2374
+ if (plan === 'wait') await refreshAll() // 冷启动/显式强刷: 等新数据
2375
+ else if (plan === 'background') refreshAll().catch(() => {}) // 有缓存: 立刻回旧的, 刷新丢后台
1096
2376
  // v0.5.0: ETag 协商缓存 — 轮询期间数据没变就 304 空响应, 省 JSON 序列化与流量
1097
- const etag = '"' + Number(cache.fetchedAt || 0).toString(36) + '"'
2377
+ const etag = cache.etag || '"' + Number(cache.fetchedAt || 0).toString(36) + '"'
1098
2378
  if (req.method === 'HEAD') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ETag: etag }); res.end(); return }
1099
2379
  if (!force && req.headers['if-none-match'] === etag && cache.balances.length > 0) {
1100
2380
  res.writeHead(304, { ETag: etag })
1101
2381
  res.end()
1102
2382
  return
1103
2383
  }
1104
- const body = JSON.stringify({ ok: true, balances: cache.balances, fetchedAt: cache.fetchedAt, config: cache.config })
2384
+ const body = JSON.stringify({ ok: true, balances: cache.balances, fetchedAt: cache.fetchedAt, config: cache.config, loading: cache.balances.length === 0 })
1105
2385
  res.writeHead(200, {
1106
2386
  'Content-Type': 'application/json; charset=utf-8',
1107
2387
  'Cache-Control': 'private, no-cache',
@@ -1116,16 +2396,18 @@ export function apply(ctx, config) {
1116
2396
  webCtx.effect(() => webCtx.webServer.register({
1117
2397
  kind: 'exact', path: '/api-dashboard/prices',
1118
2398
  async handler(req, res) {
2399
+ if (!allowRequest(req, res)) return
1119
2400
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
1120
2401
  const cfg = runtimeConfig
1121
2402
  const cur = (cfg.currency ?? 'CNY').toUpperCase() === 'USD' ? 'USD' : 'CNY'
1122
2403
  const table = V4_RATES[cur] ?? V4_RATES.CNY
1123
2404
  const mk = (p) => p ? { cacheHit: p.cacheHit, cacheMiss: p.cacheMiss, output: p.output } : null
1124
- // v4 峰谷系列
2405
+ // v1.3.4: 官方定价页与 GET https://api.deepseek.com/models (2026-09-10 实测) 一致 ——
2406
+ // 现役仅 deepseek-flash / deepseek-v4-pro 两个模型。旧名 deepseek-v4-flash / -vision-exp
2407
+ // 仍可调用但由 V4.1-Flash 服务、按 Flash 价计费, 解析层已映射到 flash 档, 故此处不再单列免误导。
1125
2408
  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
2409
+ for (const key of ['deepseek-flash', 'deepseek-v4-pro']) {
2410
+ const src = key.startsWith('deepseek-v4-pro') ? 'deepseek-v4-pro' : 'deepseek-flash'
1129
2411
  models.push({ model: key, peak: mk(table.peak[src]), offPeak: mk(table.offPeak[src]), peakValley: true })
1130
2412
  }
1131
2413
  sendJson(res, 200, { ok: true, currency: cfg.currency ?? 'CNY', peakNow: isPeakTime(), weekend: isWeekend(), models })
@@ -1135,6 +2417,7 @@ export function apply(ctx, config) {
1135
2417
  webCtx.effect(() => webCtx.webServer.register({
1136
2418
  kind: 'exact', path: '/api-dashboard/platforms',
1137
2419
  async handler(req, res) {
2420
+ if (!allowRequest(req, res)) return
1138
2421
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
1139
2422
  const presets = PLATFORM_PRESETS.filter(p => runtimeConfig.presets.includes(p.id)).map(p => ({
1140
2423
  id: p.id, name: p.label, icon: p.icon, color: p.color, category: p.category, queryType: p.queryType,
@@ -1147,6 +2430,7 @@ export function apply(ctx, config) {
1147
2430
  webCtx.effect(() => webCtx.webServer.register({
1148
2431
  kind: 'exact', path: '/api-dashboard/update',
1149
2432
  async handler(req, res) {
2433
+ if (!allowRequest(req, res)) return
1150
2434
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
1151
2435
  const force = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('force') === '1'
1152
2436
  try {
@@ -1161,6 +2445,7 @@ export function apply(ctx, config) {
1161
2445
  webCtx.effect(() => webCtx.webServer.register({
1162
2446
  kind: 'exact', path: '/api-dashboard/update/install',
1163
2447
  async handler(req, res) {
2448
+ if (!allowRequest(req, res)) return
1164
2449
  if (req.method !== 'POST') { res.writeHead(405, { Allow: 'POST' }); res.end(); return }
1165
2450
  try {
1166
2451
  const result = await applyUpdate({})
@@ -1170,7 +2455,8 @@ export function apply(ctx, config) {
1170
2455
  const msg = /already up to date/.test(String(err?.message)) ? `already up to date`
1171
2456
  : /GitHub API|download|remote version/.test(String(err?.message)) ? 'network failed'
1172
2457
  : 'update failed'
1173
- sendJson(res, 500, { ok: false, error: msg })
2458
+ const code = /already up to date/.test(String(err?.message)) ? 200 : 500
2459
+ sendJson(res, code, { ok: false, error: msg })
1174
2460
  }
1175
2461
  },
1176
2462
  }), 'dsh-api-dashboard: update install route')
@@ -1181,6 +2467,7 @@ export function apply(ctx, config) {
1181
2467
  webCtx.effect(() => webCtx.webServer.register({
1182
2468
  kind: 'exact', path: '/api-dashboard/icon',
1183
2469
  async handler(req, res) {
2470
+ if (!allowRequest(req, res)) return
1184
2471
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
1185
2472
  try {
1186
2473
  if (!iconCache) {
@@ -1206,6 +2493,7 @@ export function apply(ctx, config) {
1206
2493
  webCtx.effect(() => webCtx.webServer.register({
1207
2494
  kind: 'exact', path: '/api-dashboard/whale/image.png',
1208
2495
  async handler(req, res) {
2496
+ if (!allowRequest(req, res)) return
1209
2497
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
1210
2498
  try {
1211
2499
  if (!whaleImgCache) whaleImgCache = readFileSync(whaleAsset('DSniang1.png'))
@@ -1223,6 +2511,7 @@ export function apply(ctx, config) {
1223
2511
  webCtx.effect(() => webCtx.webServer.register({
1224
2512
  kind: 'exact', path: '/api-dashboard/whale/rua.gif',
1225
2513
  async handler(req, res) {
2514
+ if (!allowRequest(req, res)) return
1226
2515
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
1227
2516
  try {
1228
2517
  if (!whaleGifCache) whaleGifCache = readFileSync(whaleAsset('rua.gif'))
@@ -1240,6 +2529,7 @@ export function apply(ctx, config) {
1240
2529
  webCtx.effect(() => webCtx.webServer.register({
1241
2530
  kind: 'exact', path: `/api-dashboard/whale/sound/${kind}.mp3`,
1242
2531
  async handler(req, res) {
2532
+ if (!allowRequest(req, res)) return
1243
2533
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
1244
2534
  try {
1245
2535
  const set = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('set') === 'fx1' ? 'fx1' : 'duck'
@@ -1261,11 +2551,11 @@ export function apply(ctx, config) {
1261
2551
  webCtx.effect(() => webCtx.webServer.register({
1262
2552
  kind: 'exact', path: '/api-dashboard/whale/settings',
1263
2553
  async handler(req, res) {
2554
+ if (!allowRequest(req, res)) return
1264
2555
  if (req.method === 'GET') { sendJson(res, 200, { ok: true, settings: runtimeConfig.whaleSettings }); return }
1265
2556
  if (req.method === 'PUT' || req.method === 'POST') {
1266
2557
  try {
1267
- let raw = ''
1268
- for await (const chunk of req) { raw += chunk }
2558
+ let raw = await readBody(req)
1269
2559
  const body = raw ? JSON.parse(raw) : {}
1270
2560
  const cur = runtimeConfig.whaleSettings
1271
2561
  const num = (v, lo, hi, dflt) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : dflt)
@@ -1290,22 +2580,29 @@ export function apply(ctx, config) {
1290
2580
  customRelays: runtimeConfig.customRelays,
1291
2581
  customModels: runtimeConfig.customModels,
1292
2582
  currency: runtimeConfig.currency,
2583
+ overseasCurrency: runtimeConfig.overseasCurrency,
1293
2584
  safeThreshold: runtimeConfig.safeThreshold,
1294
2585
  warnThreshold: runtimeConfig.warnThreshold,
1295
2586
  whaleEnabled: runtimeConfig.whaleEnabled,
2587
+ showNoBalanceBrands: runtimeConfig.showNoBalanceBrands,
1296
2588
  whaleSettings: runtimeConfig.whaleSettings,
1297
2589
  })
1298
2590
  sendJson(res, 200, { ok: true, settings: runtimeConfig.whaleSettings })
1299
- } catch (err) { sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) }) }
2591
+ } catch (err) {
2592
+ const code = err && err.statusCode === 413 ? 413 : 400
2593
+ sendJson(res, code, { ok: false, error: err instanceof Error ? err.message : String(err) })
2594
+ }
1300
2595
  return
1301
2596
  }
1302
2597
  res.writeHead(405, { Allow: 'GET, PUT, POST' }); res.end()
1303
2598
  },
1304
2599
  }), 'dsh-api-dashboard: whale settings route')
1305
2600
 
2601
+
1306
2602
  webCtx.effect(() => webCtx.webServer.register({
1307
2603
  kind: 'exact', path: '/api-dashboard/config',
1308
2604
  async handler(req, res) {
2605
+ if (!allowRequest(req, res)) return
1309
2606
  if (req.method === 'GET') {
1310
2607
  sendJson(res, 200, {
1311
2608
  ok: true,
@@ -1313,38 +2610,57 @@ export function apply(ctx, config) {
1313
2610
  customModels: runtimeConfig.customModels.map(m => ({ ...m, apiKey: m.apiKey ? '***' : '' })),
1314
2611
  presets: runtimeConfig.presets,
1315
2612
  refreshIntervalSec: Math.round(runtimeConfig.refreshIntervalMs / 1000),
2613
+ currency: runtimeConfig.currency,
2614
+ // C-2 (v1.4.1): 以前这里不返回阈值, 设置面板只能等 /balances 带过来;
2615
+ // 冷启动那几秒(/balances 可能要等 8~10s)打开面板 → 显示默认 50/10 →
2616
+ // 用户一点"保存并生效"就把自己存的阈值覆盖掉了。补上。
2617
+ safeThreshold: runtimeConfig.safeThreshold,
2618
+ warnThreshold: runtimeConfig.warnThreshold,
2619
+ overseasCurrency: runtimeConfig.overseasCurrency,
1316
2620
  whaleEnabled: !!runtimeConfig.whaleEnabled,
2621
+ showNoBalanceBrands: !!runtimeConfig.showNoBalanceBrands,
2622
+ officialProviders: runtimeConfig.officialProviders,
2623
+ // 只读: 供设置面板显示「自动判定结果」, 让用户知道哪些还需要手填
2624
+ providerKinds: readProviderKinds(),
2625
+ // v1.4.0「真自动」: 从 settings.yaml 自动发现的 provider 及启用状态 (不含 key)
2626
+ dshProviders: readDshProviderStatus(),
2627
+ dshProviderOptOut: runtimeConfig.dshProviderOptOut,
1317
2628
  })
1318
2629
  return
1319
2630
  }
1320
2631
  if (req.method === 'POST') {
1321
2632
  try {
1322
- let body = ''
1323
- for await (const chunk of req) { body += chunk }
2633
+ let body = await readBody(req)
1324
2634
  body = body ? JSON.parse(body) : {}
2635
+ // 数组规模上限: 状态文件与每次轮询都要带它们, 防垃圾数据无限膨胀
2636
+ const MAX_ITEMS = 64
2637
+ const cleanId = (v) => cleanStr(v, 64).replace(/[^a-zA-Z0-9_-]/g, '')
2638
+ const cleanQueryType = (v) => { const s = cleanStr(v, 32); return /^[a-zA-Z0-9_-]+$/.test(s) ? s : 'auto' }
1325
2639
  if (Array.isArray(body.customRelays)) {
1326
- runtimeConfig.customRelays = body.customRelays.map(r => {
2640
+ runtimeConfig.customRelays = body.customRelays.slice(0, MAX_ITEMS).map(r => {
1327
2641
  const prev = runtimeConfig.customRelays.find(x => x.id === r.id)
2642
+ const rk = cleanStr(r.apiKey, 256) // '***' / 空 = 保留旧 key (掩码回填约定)
1328
2643
  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',
2644
+ id: cleanId(r.id) || Math.random().toString(36).slice(2), name: cleanStr(r.name, 128) || '中转站',
2645
+ baseUrl: cleanUrl(r.baseUrl).replace(/\/+$/, ''), apiKey: (rk && rk !== '***') ? rk : (prev?.apiKey || ''), queryType: cleanQueryType(r.queryType),
1331
2646
  }
1332
2647
  })
1333
2648
  }
1334
2649
  if (Array.isArray(body.customModels)) {
1335
- runtimeConfig.customModels = body.customModels.map(m => {
2650
+ runtimeConfig.customModels = body.customModels.slice(0, MAX_ITEMS).map(m => {
1336
2651
  const prev = runtimeConfig.customModels.find(x => x.id === m.id)
2652
+ const mk = cleanStr(m.apiKey, 256)
1337
2653
  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',
2654
+ id: cleanId(m.id) || Math.random().toString(36).slice(2), name: cleanStr(m.name, 128) || '自定义模型',
2655
+ apiUrl: cleanUrl(m.apiUrl), apiKey: mk !== '***' && mk !== '' ? mk : (prev?.apiKey || ''),
2656
+ queryType: cleanQueryType(m.queryType), totalPath: cleanStr(m.totalPath, 128), usedPath: cleanStr(m.usedPath, 128),
2657
+ currency: cleanStr(m.currency, 8) || 'CNY',
1342
2658
  }
1343
2659
  })
1344
2660
  }
1345
- // 自定义刷新时间 (5~60 秒, 最高一分钟)
2661
+ // 自定义刷新时间 (1~60 秒; v1.4.0 下限由 5 秒放宽到 1 秒)
1346
2662
  if (typeof body.refreshIntervalSec === 'number' && Number.isFinite(body.refreshIntervalSec)) {
1347
- const sec = Math.min(Math.max(Math.round(body.refreshIntervalSec), 5), 60)
2663
+ const sec = clampRefreshSec(body.refreshIntervalSec)
1348
2664
  runtimeConfig.refreshIntervalMs = sec * 1000
1349
2665
  runtimeConfig.clientPollIntervalMs = sec * 1000
1350
2666
  }
@@ -1352,8 +2668,23 @@ export function apply(ctx, config) {
1352
2668
  if (typeof body.safeThreshold === 'number' && body.safeThreshold >= 0) runtimeConfig.safeThreshold = body.safeThreshold
1353
2669
  if (typeof body.warnThreshold === 'number' && body.warnThreshold >= 0) runtimeConfig.warnThreshold = body.warnThreshold
1354
2670
  if (typeof body.currency === 'string' && body.currency.trim()) runtimeConfig.currency = body.currency.trim().toUpperCase()
2671
+ // v1.3.2: 海外模型独立计价货币, 只接受白名单三值 (脏值一律落回 follow, 不放行任意字符串)
2672
+ if (typeof body.overseasCurrency === 'string') {
2673
+ const v = body.overseasCurrency.trim().toLowerCase()
2674
+ runtimeConfig.overseasCurrency = v === 'usd' ? 'USD' : v === 'cny' ? 'CNY' : 'follow'
2675
+ }
1355
2676
  // v1.1.0: 收养大肥鱼开关
1356
2677
  if (typeof body.whaleEnabled === 'boolean') runtimeConfig.whaleEnabled = body.whaleEnabled
2678
+ // 显示无余额模型品牌
2679
+ if (typeof body.showNoBalanceBrands === 'boolean') runtimeConfig.showNoBalanceBrands = body.showNoBalanceBrands
2680
+ // 官方直连 provider 名单 (第 1 层判定); 接受数组或逗号/换行分隔的字符串
2681
+ if (Array.isArray(body.officialProviders) || typeof body.officialProviders === 'string') {
2682
+ runtimeConfig.officialProviders = normalizeOfficialProviders(body.officialProviders)
2683
+ }
2684
+ // v1.4.0「真自动」: 被关掉的 DSH provider 名单 (默认空 = 全部启用)
2685
+ if (Array.isArray(body.dshProviderOptOut) || typeof body.dshProviderOptOut === 'string') {
2686
+ runtimeConfig.dshProviderOptOut = normalizeOfficialProviders(body.dshProviderOptOut)
2687
+ }
1357
2688
  // 持久化: 写入状态文件, 重启后恢复 (用户配置优先)
1358
2689
  savePersistedState({
1359
2690
  refreshIntervalMs: runtimeConfig.refreshIntervalMs,
@@ -1362,9 +2693,13 @@ export function apply(ctx, config) {
1362
2693
  customRelays: runtimeConfig.customRelays,
1363
2694
  customModels: runtimeConfig.customModels,
1364
2695
  currency: runtimeConfig.currency,
2696
+ overseasCurrency: runtimeConfig.overseasCurrency,
1365
2697
  safeThreshold: runtimeConfig.safeThreshold,
1366
2698
  warnThreshold: runtimeConfig.warnThreshold,
1367
2699
  whaleEnabled: runtimeConfig.whaleEnabled,
2700
+ showNoBalanceBrands: runtimeConfig.showNoBalanceBrands,
2701
+ officialProviders: runtimeConfig.officialProviders,
2702
+ dshProviderOptOut: runtimeConfig.dshProviderOptOut,
1368
2703
  })
1369
2704
  resetLoop(); await refreshAll()
1370
2705
  sendJson(res, 200, {
@@ -1372,9 +2707,19 @@ export function apply(ctx, config) {
1372
2707
  customRelays: runtimeConfig.customRelays.map(r => ({ ...r, apiKey: r.apiKey ? '***' : '' })),
1373
2708
  customModels: runtimeConfig.customModels.map(m => ({ ...m, apiKey: m.apiKey ? '***' : '' })),
1374
2709
  refreshIntervalSec: Math.round(runtimeConfig.refreshIntervalMs / 1000),
2710
+ currency: runtimeConfig.currency,
2711
+ overseasCurrency: runtimeConfig.overseasCurrency,
1375
2712
  whaleEnabled: !!runtimeConfig.whaleEnabled,
2713
+ showNoBalanceBrands: !!runtimeConfig.showNoBalanceBrands,
2714
+ officialProviders: runtimeConfig.officialProviders,
2715
+ providerKinds: readProviderKinds(),
2716
+ dshProviders: readDshProviderStatus(),
2717
+ dshProviderOptOut: runtimeConfig.dshProviderOptOut,
1376
2718
  })
1377
- } catch (err) { sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) }) }
2719
+ } catch (err) {
2720
+ const code = err && err.statusCode === 413 ? 413 : 400
2721
+ sendJson(res, code, { ok: false, error: err instanceof Error ? err.message : String(err) })
2722
+ }
1378
2723
  return
1379
2724
  }
1380
2725
  res.writeHead(405, { Allow: 'GET, POST' })
@@ -1385,6 +2730,13 @@ export function apply(ctx, config) {
1385
2730
 
1386
2731
  // 会话消耗投影
1387
2732
  ctx.inject(['sessionProjections'], (projectionCtx) => {
1388
- projectionCtx.sessionProjections.register(makeCostProjection(getConfig))
2733
+ // v1.4.0: 把 session 存储与投影注册表惰性交给投影, 用于汇总子代理消耗。
2734
+ // 惰性 (每次读取时才 get) 是为了不把服务解析绑死在注册时刻 —— 服务可能后挂载。
2735
+ const services = () => {
2736
+ let sessions = null
2737
+ try { sessions = ctx.get('sessions') ?? projectionCtx.get('sessions') ?? null } catch { sessions = null }
2738
+ return { sessions, projections: projectionCtx.sessionProjections }
2739
+ }
2740
+ projectionCtx.sessionProjections.register(makeCostProjection(getConfig, services))
1389
2741
  })
1390
2742
  }