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