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.
- package/README.md +191 -25
- package/client/client.js +847 -163
- package/package.json +2 -2
- 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
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
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
|
-
|
|
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
|
-
// 待写入目录: 运行实体优先; 若经典源码目录 (
|
|
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('
|
|
162
|
+
const legacyReal = realPathSafe(join(homedir(), 'dsha-api-dashboard'))
|
|
98
163
|
const selfReal = realPathSafe(SELF_ROOT)
|
|
99
|
-
|
|
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
|
-
|
|
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 = '
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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-
|
|
236
|
-
offPeak: { 'deepseek-
|
|
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-
|
|
240
|
-
offPeak: { 'deepseek-
|
|
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
|
|
251
|
-
const day = d.getUTCDay()
|
|
252
|
-
const hourBJT = (d.getUTCHours() + 8) % 24
|
|
793
|
+
const { weekday, hour } = bjtParts(timestamp)
|
|
253
794
|
// 周末(0=周日, 6=周六)整天谷时
|
|
254
|
-
if (
|
|
255
|
-
return (
|
|
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
|
|
261
|
-
|
|
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,
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
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
|
-
// ——
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
'
|
|
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.
|
|
286
|
-
'gemini-3.
|
|
287
|
-
'gemini-3.
|
|
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
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
//
|
|
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
|
-
|
|
307
|
-
'claude-3-5-
|
|
308
|
-
'claude-3-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
'gemini-2.0-
|
|
312
|
-
'gemini-
|
|
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)
|
|
315
|
-
//
|
|
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:
|
|
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
|
-
|
|
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 倍)
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
//
|
|
544
|
-
|
|
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 ||
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
793
|
-
|
|
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
|
-
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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:
|
|
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
|
-
|
|
963
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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 = () => {
|
|
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
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
//
|
|
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-
|
|
1127
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
|
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
|
|
1340
|
-
queryType: m.queryType
|
|
1341
|
-
currency: (m.currency
|
|
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
|
-
// 自定义刷新时间 (
|
|
2661
|
+
// 自定义刷新时间 (1~60 秒; v1.4.0 下限由 5 秒放宽到 1 秒)
|
|
1346
2662
|
if (typeof body.refreshIntervalSec === 'number' && Number.isFinite(body.refreshIntervalSec)) {
|
|
1347
|
-
const sec =
|
|
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) {
|
|
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
|
-
|
|
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
|
}
|