dsh-api-dashboard 1.4.0 → 1.4.2
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 +83 -296
- package/client/client.js +142 -32
- package/package.json +3 -3
- package/src/index.js +280 -30
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
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) => {
|
|
@@ -96,7 +161,12 @@ export async function applyUpdate({ targets = null, timeoutMs = 30000, remoteVer
|
|
|
96
161
|
try {
|
|
97
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
|
}
|
|
@@ -202,6 +284,15 @@ async function getUpdateStatus(force = false) {
|
|
|
202
284
|
// ============================================================
|
|
203
285
|
const STATE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-api-dashboard.json')
|
|
204
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
|
+
|
|
205
296
|
/**
|
|
206
297
|
* 状态文件结构版本。**改动已持久化字段的默认值时必须 +1 并补一段迁移**,
|
|
207
298
|
* 否则老用户的状态文件会把字段钉死在旧值上 —— 新默认值对老用户永远不生效。
|
|
@@ -211,8 +302,44 @@ const STATE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-ap
|
|
|
211
302
|
*/
|
|
212
303
|
const CONFIG_VERSION = 2
|
|
213
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
|
+
|
|
214
341
|
const migratePersistedState = (parsed) => {
|
|
215
|
-
const s = (parsed && typeof parsed === 'object') ? parsed : {}
|
|
342
|
+
const s = sanitizePersistedShape((parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {})
|
|
216
343
|
const ver = Number.isFinite(s.configVersion) ? s.configVersion : 1
|
|
217
344
|
let out = s
|
|
218
345
|
if (ver < 2) {
|
|
@@ -234,6 +361,9 @@ const loadPersistedState = () => {
|
|
|
234
361
|
const savePersistedState = (state) => {
|
|
235
362
|
try {
|
|
236
363
|
const merged = { ...loadPersistedState(), ...state }
|
|
364
|
+
// H-4c (v1.4.1): 以前这里从不建父目录, 且吞掉所有异常 → ~/.dsh 不存在时
|
|
365
|
+
// 接口照样回 ok:true, 用户配置"保存成功"却没落盘, 重启即丢。现在先建目录。
|
|
366
|
+
mkdirSync(dirname(STATE_FILE), { recursive: true })
|
|
237
367
|
// mode 0o600: 状态文件含自定义中转站/模型的 API Key 明文, 必须限定本用户可读
|
|
238
368
|
// (不能依赖 umask —— 默认 umask 0022 的桌面机会落成 0644); chmod 兜底修正旧文件
|
|
239
369
|
writeFileSync(STATE_FILE + '.tmp', JSON.stringify(merged, null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
@@ -471,7 +601,15 @@ export const selectDshProviders = (entries, kinds, optOut) => {
|
|
|
471
601
|
*/
|
|
472
602
|
export const planBalancesFetch = ({ force = false, peek = false, hasData = false, age = 0, intervalMs = 5000 } = {}) => {
|
|
473
603
|
const stale = age > (intervalMs || 300000) // 兼容旧行为: intervalMs 缺失时用 5 分钟
|
|
474
|
-
|
|
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'
|
|
475
613
|
if (!force && !stale) return 'none'
|
|
476
614
|
if (peek) return age > 1000 ? 'background' : 'none' // 1 秒内刚拉过就不重复打
|
|
477
615
|
return age > 2000 ? 'wait' : 'none' // 显式强刷留 2 秒节流, 防连点打爆平台接口
|
|
@@ -945,12 +1083,18 @@ async function fetchWithTimeout(url, headers, timeoutMs, method = 'GET') {
|
|
|
945
1083
|
|
|
946
1084
|
/** 读取请求体并限制大小 (默认 256KB) —— 防持有 token 者灌大包打爆内存。超限抛错。 */
|
|
947
1085
|
async function readBody(req, limit = 256 * 1024) {
|
|
948
|
-
|
|
1086
|
+
// H-4d (v1.4.1): 不能对每个 chunk 单独 toString —— 一个汉字的 3 个字节被 TCP 分到两个 chunk 时,
|
|
1087
|
+
// 两边都会解出替换字符 U+FFFD, 中文中转站名/自定义模型名会被写坏并持久化(实测)。
|
|
1088
|
+
// 改成先收集 Buffer 再整体解码。
|
|
1089
|
+
const chunks = []
|
|
1090
|
+
let size = 0
|
|
949
1091
|
for await (const chunk of req) {
|
|
950
|
-
|
|
951
|
-
|
|
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)
|
|
952
1096
|
}
|
|
953
|
-
return
|
|
1097
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
954
1098
|
}
|
|
955
1099
|
|
|
956
1100
|
/** 字符串清洗: 截断到 max 长度 (设置面板传入的任意字段统一过这里) */
|
|
@@ -1118,16 +1262,30 @@ function checkAlerts(balances, config, ctx) {
|
|
|
1118
1262
|
// ============================================================
|
|
1119
1263
|
|
|
1120
1264
|
// 纯函数, 导出便于单测 (不影响对外行为)
|
|
1121
|
-
export function parseResponse(queryType, json) {
|
|
1265
|
+
export function parseResponse(queryType, json, pref) {
|
|
1122
1266
|
if (!json || typeof json !== 'object' || Array.isArray(json)) return null
|
|
1123
1267
|
switch (queryType) {
|
|
1124
1268
|
case 'deepseek': {
|
|
1125
1269
|
const infos = Array.isArray(json?.balance_infos) ? json.balance_infos : []
|
|
1126
|
-
|
|
1270
|
+
// H-4d (v1.4.2): balance_infos 是「一个币种钱包一条」—— 官方文档 currency 取值 CNY / USD。
|
|
1271
|
+
// 旧代码盲取 infos[0],而**数组顺序不保证**:真机实测同一 key 连打 5 次,第 4 次顺序翻成
|
|
1272
|
+
// [USD=0.00, CNY=123.45] → 读到 USD 那条 → 有 123.45 元的账户显示成「$0.00 · 异常」。
|
|
1273
|
+
// 更糟的是下面那道 `total_balance == null` 红线**拦不住**它 —— "0.00" 是合法字符串,
|
|
1274
|
+
// 于是红线守卫被绕过、0 被当成真实余额渲染(与 v1.4.0 openai-credit-grants、
|
|
1275
|
+
// v1.4.1 openrouter 是同一族漏洞:选错一条就当真实数字)。
|
|
1276
|
+
// 现在按「主货币优先 → 余额 > 0 → 首条」确定性挑选,结果与接口返回顺序无关。
|
|
1277
|
+
const amountOf = (v) => {
|
|
1278
|
+
if (v === null || v === undefined || v === '') return null
|
|
1279
|
+
const n = Number(v)
|
|
1280
|
+
return Number.isFinite(n) ? n : null
|
|
1281
|
+
}
|
|
1282
|
+
const want = String(pref ?? '').trim().toUpperCase()
|
|
1283
|
+
const usable = infos.filter((x) => x && amountOf(x.total_balance) !== null)
|
|
1284
|
+
const p =
|
|
1285
|
+
(want ? usable.find((x) => String(x.currency || '').toUpperCase() === want) : undefined) ??
|
|
1286
|
+
usable.find((x) => amountOf(x.total_balance) > 0) ??
|
|
1287
|
+
usable[0]
|
|
1127
1288
|
if (!p) return null
|
|
1128
|
-
// v1.4.0 修复: total_balance 缺失时 toAmount(null)=0 会伪造「余额 0」。
|
|
1129
|
-
// 与 AGENTS.md「字段存在性校验」一致 —— 缺关键字段即返回 null, 交给上层显示「未开放」。
|
|
1130
|
-
if (p.total_balance == null) return null
|
|
1131
1289
|
// total_balance 当前余额, granted_balance 赠送, topped_up_balance 充值
|
|
1132
1290
|
const total = toAmount(p.total_balance)
|
|
1133
1291
|
const grant = toAmount(p.granted_balance)
|
|
@@ -1140,11 +1298,22 @@ export function parseResponse(queryType, json) {
|
|
|
1140
1298
|
if (!json || typeof json !== 'object') return null
|
|
1141
1299
|
const hasAny = 'total_granted' in json || 'total_available' in json || 'total_used' in json
|
|
1142
1300
|
if (!hasAny) return null
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1301
|
+
// H-4a (v1.4.1): 字段**存在但值无效**(null / 非数字)时, toAmount 会归 0,
|
|
1302
|
+
// 于是 total = 0 - used 得到一个**负数余额** —— 与 openrouter 那次是同一类漏洞
|
|
1303
|
+
// (红线: 不许把解析失败冒充成真实数字)。这里改成「值无效就不表态」。
|
|
1304
|
+
// ⚠️ Number(null) === 0、Number('') === 0 —— 必须先把「空值」挡掉, 否则等于没挡
|
|
1305
|
+
const numOrNull = (v) => {
|
|
1306
|
+
if (v === null || v === undefined || v === '') return null
|
|
1307
|
+
const n = Number(v)
|
|
1308
|
+
return Number.isFinite(n) ? n : null
|
|
1309
|
+
}
|
|
1310
|
+
const grantedN = numOrNull(json?.total_granted)
|
|
1311
|
+
const usedN = numOrNull(json?.total_used)
|
|
1312
|
+
const availN = numOrNull(json?.total_available)
|
|
1313
|
+
if (availN === null && (grantedN === null || usedN === null)) return null
|
|
1314
|
+
const used = usedN ?? 0
|
|
1315
|
+
const hasAvail = availN !== null
|
|
1316
|
+
return { total: hasAvail ? availN : (grantedN - used), currency: 'USD', available: hasAvail ? availN : null, used, note: 'OpenAI 兼容额度' }
|
|
1148
1317
|
}
|
|
1149
1318
|
case 'siliconflow': {
|
|
1150
1319
|
const d = json?.data
|
|
@@ -1335,7 +1504,7 @@ async function queryPreset(platform, apiKey, config) {
|
|
|
1335
1504
|
const text = await res.text()
|
|
1336
1505
|
let json
|
|
1337
1506
|
try { json = JSON.parse(text) } catch { json = null }
|
|
1338
|
-
const parsed = parseResponse(queryType, json)
|
|
1507
|
+
const parsed = parseResponse(queryType, json, config?.currency)
|
|
1339
1508
|
|
|
1340
1509
|
if (!parsed) {
|
|
1341
1510
|
// v1.2.6: 业务错误分类抽到 classifyBizError (可单测)。
|
|
@@ -1398,6 +1567,13 @@ async function queryCustomRelay(relay, config) {
|
|
|
1398
1567
|
)
|
|
1399
1568
|
}
|
|
1400
1569
|
|
|
1570
|
+
// A: 命中过的端点排到最前(只影响顺序, 不影响"全都会试一遍"的语义)
|
|
1571
|
+
if (candidates.length > 1 && relayEndpointHints.has(id)) {
|
|
1572
|
+
const hint = relayEndpointHints.get(id)
|
|
1573
|
+
const idx = candidates.findIndex((c) => c.type === hint)
|
|
1574
|
+
if (idx > 0) candidates.unshift(candidates.splice(idx, 1)[0])
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1401
1577
|
for (const cand of candidates) {
|
|
1402
1578
|
const headers = { Accept: 'application/json', Authorization: `Bearer ${apiKey}` }
|
|
1403
1579
|
const method = cand.type === 'quota' ? 'POST' : 'GET'
|
|
@@ -1407,8 +1583,13 @@ async function queryCustomRelay(relay, config) {
|
|
|
1407
1583
|
const text = await res.text()
|
|
1408
1584
|
let json
|
|
1409
1585
|
try { json = JSON.parse(text) } catch { continue }
|
|
1410
|
-
const parsed = parseResponse(cand.type, json)
|
|
1586
|
+
const parsed = parseResponse(cand.type, json, config?.currency)
|
|
1411
1587
|
if (parsed) {
|
|
1588
|
+
// A: 记住这次命中的端点(变了才落盘, 避免每次刷新都写状态文件)
|
|
1589
|
+
if (relayEndpointHints.get(id) !== cand.type) {
|
|
1590
|
+
relayEndpointHints.set(id, cand.type)
|
|
1591
|
+
try { savePersistedState({ relayEndpoints: Object.fromEntries(relayEndpointHints) }) } catch { /* 落盘失败不影响本次结果 */ }
|
|
1592
|
+
}
|
|
1412
1593
|
return {
|
|
1413
1594
|
platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站',
|
|
1414
1595
|
status: 'ok', total: parsed.total, currency: parsed.currency, available: parsed.available,
|
|
@@ -1468,14 +1649,14 @@ export async function queryCustomModel(model, config) {
|
|
|
1468
1649
|
|
|
1469
1650
|
// 2) 指定解析类型
|
|
1470
1651
|
if (queryType && queryType !== 'auto') {
|
|
1471
|
-
const parsed = parseResponse(queryType, json)
|
|
1652
|
+
const parsed = parseResponse(queryType, json, config?.currency)
|
|
1472
1653
|
if (parsed) {
|
|
1473
1654
|
return { ...base, status: 'ok', total: parsed.total, currency: parsed.currency, available: parsed.available, used: parsed.used, note: parsed.note, percent: parsed.percent, noBalance: false, queryType, fetchedAt: Date.now() }
|
|
1474
1655
|
}
|
|
1475
1656
|
} else {
|
|
1476
1657
|
// 3) auto: 尝试常见格式
|
|
1477
1658
|
for (const qt of ['openai', 'quota', 'deepseek', 'openai-billing']) {
|
|
1478
|
-
const parsed = parseResponse(qt, json)
|
|
1659
|
+
const parsed = parseResponse(qt, json, config?.currency)
|
|
1479
1660
|
if (parsed) {
|
|
1480
1661
|
return { ...base, status: 'ok', total: parsed.total, currency: parsed.currency, available: parsed.available, used: parsed.used, note: parsed.note, percent: parsed.percent, noBalance: false, queryType: qt, fetchedAt: Date.now() }
|
|
1481
1662
|
}
|
|
@@ -1914,13 +2095,25 @@ export function makeCostProjection(configOrGetter, services) {
|
|
|
1914
2095
|
export function apply(ctx, config) {
|
|
1915
2096
|
// 用户保存的配置优先于 cordis.patch.yml 的默认 config (持久化状态)
|
|
1916
2097
|
const persisted = loadPersistedState()
|
|
2098
|
+
// A: 载入上次记住的中转站端点
|
|
2099
|
+
try {
|
|
2100
|
+
relayEndpointHints.clear()
|
|
2101
|
+
const saved = persisted.relayEndpoints
|
|
2102
|
+
if (saved && typeof saved === 'object' && !Array.isArray(saved)) {
|
|
2103
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
2104
|
+
if (typeof k === 'string' && k.length <= 128 && typeof v === 'string' && v.length <= 32) relayEndpointHints.set(k, v)
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
} catch { /* 忽略 */ }
|
|
1917
2108
|
const runtimeConfig = {
|
|
1918
2109
|
refreshIntervalMs: persisted.refreshIntervalMs ?? config.refreshIntervalMs ?? 5000,
|
|
1919
2110
|
clientPollIntervalMs: persisted.clientPollIntervalMs ?? config.clientPollIntervalMs ?? 5000,
|
|
1920
2111
|
timeoutMs: persisted.timeoutMs ?? config.timeoutMs ?? 8000,
|
|
1921
2112
|
presets: config.presets ?? PLATFORM_PRESETS.map(p => p.id),
|
|
1922
|
-
|
|
1923
|
-
|
|
2113
|
+
// H-1 (v1.4.1): 这里必须带 Array.isArray —— 状态文件形状跑偏时 apply() 抛出 =
|
|
2114
|
+
// 整个 dsh web 启动失败(migratePersistedState 已消毒, 这里是第二道防线)
|
|
2115
|
+
customRelays: (Array.isArray(persisted.customRelays) ? persisted.customRelays : (Array.isArray(config.customRelays) ? config.customRelays : [])).map(r => ({ ...r })),
|
|
2116
|
+
customModels: (Array.isArray(persisted.customModels) ? persisted.customModels : (Array.isArray(config.customModels) ? config.customModels : [])).map(m => ({ ...m })),
|
|
1924
2117
|
prices: config.prices ?? { 'deepseek-chat': { cacheHit: 0.1, cacheMiss: 1, output: 2 } },
|
|
1925
2118
|
defaultPrices: config.defaultPrices ?? { cacheHit: 0.1, cacheMiss: 1, output: 2 },
|
|
1926
2119
|
currency: persisted.currency ?? config.currency ?? 'CNY',
|
|
@@ -2129,9 +2322,51 @@ export function apply(ctx, config) {
|
|
|
2129
2322
|
res.end(body)
|
|
2130
2323
|
}
|
|
2131
2324
|
|
|
2325
|
+
/**
|
|
2326
|
+
* H-3 (v1.4.1): 插件路由的鉴权闸门。
|
|
2327
|
+
*
|
|
2328
|
+
* 为什么必须有这道闸: dsh 的 webserver 是「先查 exact 路由表, 再走 fallback」,
|
|
2329
|
+
* 而浏览器的登录 cookie 校验只写在 fallback 里(dsh-host-frontend-static) ——
|
|
2330
|
+
* 于是 `/api-dashboard/*` 全部绕过鉴权: 无需 token、无需 cookie 就能读配置、
|
|
2331
|
+
* 改配置、改挂件、甚至触发 `/update/install`(会重写插件目录)。
|
|
2332
|
+
* 更糟的是 `Content-Type: text/plain` 属于**浏览器不预检的 simple request**,
|
|
2333
|
+
* 用户手机上随便打开一个网页, 那个网页就能 POST 过来改配置(实测 HTTP 200 且真的改了)。
|
|
2334
|
+
*
|
|
2335
|
+
* 老版本 dsh 没有 connection 服务时放行(那时本来也没有鉴权概念), 避免把插件打死。
|
|
2336
|
+
*/
|
|
2337
|
+
/**
|
|
2338
|
+
* H-3 (v1.4.1): 插件路由的鉴权闸门。
|
|
2339
|
+
*
|
|
2340
|
+
* 为什么必须有这道闸: dsh 的 webserver 是「先查 exact 路由表, 再走 fallback」,
|
|
2341
|
+
* 而浏览器的登录 cookie 校验只写在 fallback 里(dsh-host-frontend-static) ——
|
|
2342
|
+
* 于是 `/api-dashboard/*` 全部绕过鉴权: 无需 token、无需 cookie 就能读配置、
|
|
2343
|
+
* 改配置、改挂件、甚至触发 `/update/install`(会重写插件目录)。
|
|
2344
|
+
* 更糟的是 `Content-Type: text/plain` 属于**浏览器不预检的 simple request**,
|
|
2345
|
+
* 用户手机上随便打开一个网页, 那个网页就能 POST 过来改配置(实测 HTTP 200 且真的改了)。
|
|
2346
|
+
*
|
|
2347
|
+
* 用 `connection.requestRejection(req)`: 与 dsh-web-mobile 同一个闸门,
|
|
2348
|
+
* 同时覆盖「浏览器 cookie 鉴权」与「Host/来源可信」两项检查。
|
|
2349
|
+
* ⚠️ 不能写 `ctx.get('connection')` —— 实测在插件 fiber 上取不到(返回 undefined),
|
|
2350
|
+
* 必须用嵌套 inject 拿服务实例。
|
|
2351
|
+
*/
|
|
2352
|
+
let connectionSvc = null
|
|
2353
|
+
ctx.inject(['connection'], (c) => { connectionSvc = c.connection })
|
|
2354
|
+
const allowRequest = (req, res) => {
|
|
2355
|
+
const conn = connectionSvc
|
|
2356
|
+
// 老版本 dsh 没有 connection 服务时放行(那时本来也没有鉴权概念), 避免把插件打死
|
|
2357
|
+
if (!conn || typeof conn.requestRejection !== 'function') return true
|
|
2358
|
+
let rejection
|
|
2359
|
+
try { rejection = conn.requestRejection(req) } catch { rejection = undefined }
|
|
2360
|
+
if (rejection === undefined) return true
|
|
2361
|
+
res.writeHead(rejection, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
2362
|
+
res.end(rejection === 401 ? 'dsh web authentication required; reopen the URL printed by dsh web.\n' : 'forbidden\n')
|
|
2363
|
+
return false
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2132
2366
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2133
2367
|
kind: 'exact', path: '/api-dashboard/balances',
|
|
2134
2368
|
async handler(req, res) {
|
|
2369
|
+
if (!allowRequest(req, res)) return
|
|
2135
2370
|
if (!['GET', 'HEAD', 'POST'].includes(req.method)) { res.writeHead(405, { Allow: 'GET, HEAD, POST' }); res.end(); return }
|
|
2136
2371
|
const params = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams
|
|
2137
2372
|
const force = req.method === 'POST' || params.get('force') === '1'
|
|
@@ -2160,7 +2395,7 @@ export function apply(ctx, config) {
|
|
|
2160
2395
|
res.end()
|
|
2161
2396
|
return
|
|
2162
2397
|
}
|
|
2163
|
-
const body = JSON.stringify({ ok: true, balances: cache.balances, fetchedAt: cache.fetchedAt, config: cache.config })
|
|
2398
|
+
const body = JSON.stringify({ ok: true, balances: cache.balances, fetchedAt: cache.fetchedAt, config: cache.config, loading: cache.balances.length === 0 })
|
|
2164
2399
|
res.writeHead(200, {
|
|
2165
2400
|
'Content-Type': 'application/json; charset=utf-8',
|
|
2166
2401
|
'Cache-Control': 'private, no-cache',
|
|
@@ -2175,6 +2410,7 @@ export function apply(ctx, config) {
|
|
|
2175
2410
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2176
2411
|
kind: 'exact', path: '/api-dashboard/prices',
|
|
2177
2412
|
async handler(req, res) {
|
|
2413
|
+
if (!allowRequest(req, res)) return
|
|
2178
2414
|
if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
|
|
2179
2415
|
const cfg = runtimeConfig
|
|
2180
2416
|
const cur = (cfg.currency ?? 'CNY').toUpperCase() === 'USD' ? 'USD' : 'CNY'
|
|
@@ -2195,6 +2431,7 @@ export function apply(ctx, config) {
|
|
|
2195
2431
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2196
2432
|
kind: 'exact', path: '/api-dashboard/platforms',
|
|
2197
2433
|
async handler(req, res) {
|
|
2434
|
+
if (!allowRequest(req, res)) return
|
|
2198
2435
|
if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
|
|
2199
2436
|
const presets = PLATFORM_PRESETS.filter(p => runtimeConfig.presets.includes(p.id)).map(p => ({
|
|
2200
2437
|
id: p.id, name: p.label, icon: p.icon, color: p.color, category: p.category, queryType: p.queryType,
|
|
@@ -2207,6 +2444,7 @@ export function apply(ctx, config) {
|
|
|
2207
2444
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2208
2445
|
kind: 'exact', path: '/api-dashboard/update',
|
|
2209
2446
|
async handler(req, res) {
|
|
2447
|
+
if (!allowRequest(req, res)) return
|
|
2210
2448
|
if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
|
|
2211
2449
|
const force = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('force') === '1'
|
|
2212
2450
|
try {
|
|
@@ -2221,6 +2459,7 @@ export function apply(ctx, config) {
|
|
|
2221
2459
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2222
2460
|
kind: 'exact', path: '/api-dashboard/update/install',
|
|
2223
2461
|
async handler(req, res) {
|
|
2462
|
+
if (!allowRequest(req, res)) return
|
|
2224
2463
|
if (req.method !== 'POST') { res.writeHead(405, { Allow: 'POST' }); res.end(); return }
|
|
2225
2464
|
try {
|
|
2226
2465
|
const result = await applyUpdate({})
|
|
@@ -2242,6 +2481,7 @@ export function apply(ctx, config) {
|
|
|
2242
2481
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2243
2482
|
kind: 'exact', path: '/api-dashboard/icon',
|
|
2244
2483
|
async handler(req, res) {
|
|
2484
|
+
if (!allowRequest(req, res)) return
|
|
2245
2485
|
if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
|
|
2246
2486
|
try {
|
|
2247
2487
|
if (!iconCache) {
|
|
@@ -2267,6 +2507,7 @@ export function apply(ctx, config) {
|
|
|
2267
2507
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2268
2508
|
kind: 'exact', path: '/api-dashboard/whale/image.png',
|
|
2269
2509
|
async handler(req, res) {
|
|
2510
|
+
if (!allowRequest(req, res)) return
|
|
2270
2511
|
if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
|
|
2271
2512
|
try {
|
|
2272
2513
|
if (!whaleImgCache) whaleImgCache = readFileSync(whaleAsset('DSniang1.png'))
|
|
@@ -2284,6 +2525,7 @@ export function apply(ctx, config) {
|
|
|
2284
2525
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2285
2526
|
kind: 'exact', path: '/api-dashboard/whale/rua.gif',
|
|
2286
2527
|
async handler(req, res) {
|
|
2528
|
+
if (!allowRequest(req, res)) return
|
|
2287
2529
|
if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
|
|
2288
2530
|
try {
|
|
2289
2531
|
if (!whaleGifCache) whaleGifCache = readFileSync(whaleAsset('rua.gif'))
|
|
@@ -2301,6 +2543,7 @@ export function apply(ctx, config) {
|
|
|
2301
2543
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2302
2544
|
kind: 'exact', path: `/api-dashboard/whale/sound/${kind}.mp3`,
|
|
2303
2545
|
async handler(req, res) {
|
|
2546
|
+
if (!allowRequest(req, res)) return
|
|
2304
2547
|
if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
|
|
2305
2548
|
try {
|
|
2306
2549
|
const set = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('set') === 'fx1' ? 'fx1' : 'duck'
|
|
@@ -2322,6 +2565,7 @@ export function apply(ctx, config) {
|
|
|
2322
2565
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2323
2566
|
kind: 'exact', path: '/api-dashboard/whale/settings',
|
|
2324
2567
|
async handler(req, res) {
|
|
2568
|
+
if (!allowRequest(req, res)) return
|
|
2325
2569
|
if (req.method === 'GET') { sendJson(res, 200, { ok: true, settings: runtimeConfig.whaleSettings }); return }
|
|
2326
2570
|
if (req.method === 'PUT' || req.method === 'POST') {
|
|
2327
2571
|
try {
|
|
@@ -2372,6 +2616,7 @@ export function apply(ctx, config) {
|
|
|
2372
2616
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2373
2617
|
kind: 'exact', path: '/api-dashboard/config',
|
|
2374
2618
|
async handler(req, res) {
|
|
2619
|
+
if (!allowRequest(req, res)) return
|
|
2375
2620
|
if (req.method === 'GET') {
|
|
2376
2621
|
sendJson(res, 200, {
|
|
2377
2622
|
ok: true,
|
|
@@ -2380,6 +2625,11 @@ export function apply(ctx, config) {
|
|
|
2380
2625
|
presets: runtimeConfig.presets,
|
|
2381
2626
|
refreshIntervalSec: Math.round(runtimeConfig.refreshIntervalMs / 1000),
|
|
2382
2627
|
currency: runtimeConfig.currency,
|
|
2628
|
+
// C-2 (v1.4.1): 以前这里不返回阈值, 设置面板只能等 /balances 带过来;
|
|
2629
|
+
// 冷启动那几秒(/balances 可能要等 8~10s)打开面板 → 显示默认 50/10 →
|
|
2630
|
+
// 用户一点"保存并生效"就把自己存的阈值覆盖掉了。补上。
|
|
2631
|
+
safeThreshold: runtimeConfig.safeThreshold,
|
|
2632
|
+
warnThreshold: runtimeConfig.warnThreshold,
|
|
2383
2633
|
overseasCurrency: runtimeConfig.overseasCurrency,
|
|
2384
2634
|
whaleEnabled: !!runtimeConfig.whaleEnabled,
|
|
2385
2635
|
showNoBalanceBrands: !!runtimeConfig.showNoBalanceBrands,
|