dsh-api-dashboard 1.0.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/LICENSE +21 -0
- package/README.md +192 -0
- package/assets/icon.png +0 -0
- package/assets/icons/alibaba.svg +1 -0
- package/assets/icons/claude.svg +1 -0
- package/assets/icons/deepseek.svg +1 -0
- package/assets/icons/gemini.svg +1 -0
- package/assets/icons/groq.svg +1 -0
- package/assets/icons/icons.js +17 -0
- package/assets/icons/mistral.svg +1 -0
- package/assets/icons/moonshot.svg +1 -0
- package/assets/icons/ollama.svg +1 -0
- package/assets/icons/openai.svg +1 -0
- package/assets/icons/openrouter.svg +1 -0
- package/assets/icons/qwen.svg +1 -0
- package/assets/icons/relay.svg +1 -0
- package/assets/icons/siliconflow.svg +1 -0
- package/assets/icons/together.svg +1 -0
- package/assets/icons/zhipu.svg +1 -0
- package/client/client.js +1317 -0
- package/cordis.patch.yml +8 -0
- package/package.json +66 -0
- package/src/index.js +1202 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-api-dashboard — server half (v3 完整版).
|
|
3
|
+
*
|
|
4
|
+
* 多平台 API 余额/用量看板。内置全部国内外平台预设,支持:
|
|
5
|
+
* - 海外官方: DeepSeek / OpenAI / Claude / Gemini / Groq / Mistral / Together / OpenRouter / Ollama
|
|
6
|
+
* - 国内平台: 智谱GLM / 通义Qwen / Kimi / 阶跃StepFun / 硅基流动 / 基元律动 / 小米MiMo / 百度千帆 / 阿里百炼 / 腾讯混元
|
|
7
|
+
* - 中转站: one-api / new-api 系 quota, 通用 OpenAI 兼容中转站
|
|
8
|
+
* - 自定义中转站: 用户填 base_url + api_key, 自动探测余额端点, 能查显示, 查不到标未开放。
|
|
9
|
+
*
|
|
10
|
+
* 学习 dsh-balance 架构:
|
|
11
|
+
* - 服务端按 refreshIntervalMs 定时拉取各平台余额并缓存 (stale-while-error)。
|
|
12
|
+
* - HTTP 路由 /api-dashboard/balances 提供只读缓存给前端。
|
|
13
|
+
* - sessionProjections 单元 queryBalanceCost 估算本会话消耗 (按模型单价)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
17
|
+
import { z } from 'zod'
|
|
18
|
+
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, rmSync, cpSync, statSync, readdirSync, realpathSync } from 'node:fs'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { execFileSync } from 'node:child_process'
|
|
21
|
+
import { tmpdir } from 'node:os'
|
|
22
|
+
import { join, dirname, basename } from 'node:path'
|
|
23
|
+
|
|
24
|
+
export const name = 'dsh-api-dashboard'
|
|
25
|
+
|
|
26
|
+
// ============================================================
|
|
27
|
+
// 自动更新模块 (v0.6.0): GitHub 远端版本检查 + 一键自更新
|
|
28
|
+
// 流程: check(api.github.com 读远端 manifest version)
|
|
29
|
+
// → install(codeload 下载 → 临时目录解压校验 → 备份 → 原子交换 → 回滚兜底)
|
|
30
|
+
// 仅允许更新为「严格更新」版本, 不接受降级; 不接收任何路径类入参.
|
|
31
|
+
// ============================================================
|
|
32
|
+
const REPO_OWNER = '133563825as-ai'
|
|
33
|
+
const REPO_NAME = 'dsh-api-dashboard'
|
|
34
|
+
const REPO_BRANCH = 'main'
|
|
35
|
+
const MANIFEST_API = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/contents/package.json?ref=${REPO_BRANCH}`
|
|
36
|
+
const TARBALL_URL = `https://codeload.github.com/${REPO_OWNER}/${REPO_NAME}/tar.gz/refs/heads/${REPO_BRANCH}`
|
|
37
|
+
|
|
38
|
+
/** 插件运行实体的安装根目录 (src/index.js 上两级; ESM 默认按 realpath 加载) */
|
|
39
|
+
const SELF_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
40
|
+
|
|
41
|
+
/** 读取指定目录中 package.json 的 version, 异常返回 null */
|
|
42
|
+
const readVersionAt = (dir) => {
|
|
43
|
+
try {
|
|
44
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
45
|
+
return typeof pkg.version === 'string' && /^\d+\.\d+\.\d+/.test(pkg.version) ? pkg.version : null
|
|
46
|
+
} catch { return null }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 轻量 semver 比较: a>b 返回 1, a<b 返回 -1, 相等返回 0 (忽略预发布后缀) */
|
|
50
|
+
export function semverCompare(a, b) {
|
|
51
|
+
const pa = String(a).split('-')[0].split('.').map(Number)
|
|
52
|
+
const pb = String(b).split('-')[0].split('.').map(Number)
|
|
53
|
+
for (let i = 0; i < 3; i++) {
|
|
54
|
+
const x = Number.isFinite(pa[i]) ? pa[i] : 0
|
|
55
|
+
const y = Number.isFinite(pb[i]) ? pb[i] : 0
|
|
56
|
+
if (x !== y) return x > y ? 1 : -1
|
|
57
|
+
}
|
|
58
|
+
return 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 经 api.github.com Contents API 读取远端 main 分支的 package.json version */
|
|
62
|
+
async function fetchRemoteVersion(timeoutMs = 8000) {
|
|
63
|
+
const res = await fetch(MANIFEST_API, {
|
|
64
|
+
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'dsh-api-dashboard-updater' },
|
|
65
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
66
|
+
})
|
|
67
|
+
if (!res.ok) throw new Error(`GitHub API ${res.status}`)
|
|
68
|
+
const meta = await res.json()
|
|
69
|
+
const content = Buffer.from(meta.content ?? '', 'base64').toString('utf8')
|
|
70
|
+
const parsed = JSON.parse(content)
|
|
71
|
+
return typeof parsed.version === 'string' ? parsed.version : null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 下载并校验最新 tarball 到临时目录, 通过后对每个目标目录执行替换.
|
|
76
|
+
* 任一目标替换后校验失败即从本次备份自动回滚.
|
|
77
|
+
* @param {object} opts
|
|
78
|
+
* @param {string[]} opts.targets 待替换的插件根目录列表 (缺省自动探测; 测试可注入)
|
|
79
|
+
* @param {number} opts.timeoutMs 下载超时
|
|
80
|
+
* @param {string} [opts.remoteVersion] 测试注入口: 跳过 GitHub 版本查询
|
|
81
|
+
* @param {string} [opts.localTarball] 测试注入口: 使用本地 tarball 代替 codeload 下载
|
|
82
|
+
* @returns {{installed:string, targets:string[], backup:string}}
|
|
83
|
+
*/
|
|
84
|
+
export async function applyUpdate({ targets = null, timeoutMs = 30000, remoteVersion = null, localTarball = null } = {}) {
|
|
85
|
+
const currentVersion = readVersionAt(SELF_ROOT)
|
|
86
|
+
const wantVersion = remoteVersion !== null ? remoteVersion : await fetchRemoteVersion(timeoutMs).catch(() => null)
|
|
87
|
+
if (!wantVersion) throw new Error('remote version unavailable')
|
|
88
|
+
if (currentVersion && semverCompare(wantVersion, currentVersion) <= 0) {
|
|
89
|
+
throw new Error(`already up to date (${currentVersion})`)
|
|
90
|
+
}
|
|
91
|
+
// 待写入目录: 运行实体优先; 若经典源码目录 (/root/dsha-api-dashboard) 存在
|
|
92
|
+
// 且是与运行实体不同的另一条真实路径, 一并同步, 避免链接形态下两边版本漂移.
|
|
93
|
+
// (仅在缺省自动模式下探测; 显式注入 targets 的测试/调试调用不受影响)
|
|
94
|
+
const dirs = Array.isArray(targets) && targets.length ? [...new Set(targets)] : [SELF_ROOT]
|
|
95
|
+
if (!Array.isArray(targets)) {
|
|
96
|
+
try {
|
|
97
|
+
const legacyReal = realPathSafe('/root/dsha-api-dashboard')
|
|
98
|
+
const selfReal = realPathSafe(SELF_ROOT)
|
|
99
|
+
if (legacyReal && selfReal && legacyReal !== selfReal && existsSync(join(legacyReal, 'package.json'))) {
|
|
100
|
+
dirs.push(legacyReal)
|
|
101
|
+
}
|
|
102
|
+
} catch { /* 探测失败不影响主流程 */ }
|
|
103
|
+
}
|
|
104
|
+
for (const dir of dirs) {
|
|
105
|
+
if (!existsSync(join(dir, 'package.json'))) throw new Error(`target missing: ${dir}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 1) 下载 tarball 到临时文件
|
|
109
|
+
const tmpBase = join(tmpdir(), `dshadb-update-${Date.now()}`)
|
|
110
|
+
mkdirSync(tmpBase, { recursive: true })
|
|
111
|
+
const tgzPath = join(tmpBase, 'pkg.tar.gz')
|
|
112
|
+
const extractDir = join(tmpBase, 'extract')
|
|
113
|
+
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)
|
|
114
|
+
let swapped = false
|
|
115
|
+
try {
|
|
116
|
+
if (localTarball) {
|
|
117
|
+
cpSync(localTarball, tgzPath)
|
|
118
|
+
} else {
|
|
119
|
+
const res = await fetch(TARBALL_URL, { signal: AbortSignal.timeout(timeoutMs) })
|
|
120
|
+
if (!res.ok) throw new Error(`download ${res.status}`)
|
|
121
|
+
writeFileSync(tgzPath, Buffer.from(await res.arrayBuffer()))
|
|
122
|
+
}
|
|
123
|
+
// 2) 解压 (--strip-components=1 剥离 codeload 顶层目录)
|
|
124
|
+
mkdirSync(extractDir, { recursive: true })
|
|
125
|
+
execFileSync('tar', ['xzf', tgzPath, '-C', extractDir, '--strip-components=1'], { timeout: 20000 })
|
|
126
|
+
// 3) 校验: 版本号匹配预期 + 关键文件齐全 (装坏了宁可拒绝, 保住回滚机会)
|
|
127
|
+
if (readVersionAt(extractDir) !== wantVersion) throw new Error('extracted version mismatch')
|
|
128
|
+
for (const rel of ['src/index.js', 'client/client.js', 'cordis.patch.yml']) {
|
|
129
|
+
if (!existsSync(join(extractDir, rel))) throw new Error(`missing file after extract: ${rel}`)
|
|
130
|
+
}
|
|
131
|
+
// 4) 备份每个目标目录 (tar 包存于其父目录旁, 不放包内避免自我包含)
|
|
132
|
+
for (const dir of dirs) {
|
|
133
|
+
execFileSync('tar', ['czf', `${dir}.preupdate-${stamp}.tar.gz`, '-C', dirname(dir), basename(dir)], { timeout: 20000 })
|
|
134
|
+
}
|
|
135
|
+
// 5) 交换: 删旧内容 → 拷新内容 (node_modules 保留, 避免重装依赖)
|
|
136
|
+
for (const dir of dirs) {
|
|
137
|
+
swapped = true
|
|
138
|
+
const keep = new Set(['node_modules'])
|
|
139
|
+
for (const entry of readdirSafe(dir)) {
|
|
140
|
+
if (!keep.has(entry)) rmSync(join(dir, entry), { recursive: true, force: true })
|
|
141
|
+
}
|
|
142
|
+
cpSync(extractDir, dir, { recursive: true })
|
|
143
|
+
if (readVersionAt(dir) !== wantVersion) throw new Error(`verify failed at ${dir}`)
|
|
144
|
+
}
|
|
145
|
+
return { installed: wantVersion, targets: dirs, backup: `${dirs[0]}.preupdate-${stamp}.tar.gz` }
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// 回滚: 仅在已开始交换后才需要; 从本次备份整目录还原
|
|
148
|
+
if (swapped) {
|
|
149
|
+
for (const dir of dirs) {
|
|
150
|
+
try {
|
|
151
|
+
const bakTar = `${dir}.preupdate-${stamp}.tar.gz`
|
|
152
|
+
if (!existsSync(bakTar)) continue
|
|
153
|
+
rmSync(dir, { recursive: true, force: true })
|
|
154
|
+
mkdirSync(dir, { recursive: true })
|
|
155
|
+
execFileSync('tar', ['xzf', bakTar, '-C', dirname(dir)], { timeout: 20000 })
|
|
156
|
+
} catch { /* 回滚自身失败时保留备份 tar 供手动恢复 */ }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
throw err
|
|
160
|
+
} finally {
|
|
161
|
+
// 临时区无论成败都清掉 (备份 tar 在 targets 旁边, 不受影响)
|
|
162
|
+
rmSync(tmpBase, { recursive: true, force: true })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 安全取真实路径: 不存在返回 null */
|
|
167
|
+
function realPathSafe(p) {
|
|
168
|
+
try { return realpathSync(p) } catch { return null }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 安全列目录: 不存在/不可读返回空数组 */
|
|
172
|
+
function readdirSafe(dir) {
|
|
173
|
+
try { return statSync(dir).isDirectory() ? readdirSync(dir) : [] } catch { return [] }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 更新检查结果内存缓存: 面板反复打开不重复请求 GitHub */
|
|
177
|
+
let updateCache = { checkedAt: 0, result: null }
|
|
178
|
+
const UPDATE_TTL_MS = 5 * 60 * 1000
|
|
179
|
+
|
|
180
|
+
async function getUpdateStatus(force = false) {
|
|
181
|
+
const fresh = Date.now() - updateCache.checkedAt < UPDATE_TTL_MS
|
|
182
|
+
if (!force && fresh && updateCache.result) return updateCache.result
|
|
183
|
+
const current = readVersionAt(SELF_ROOT)
|
|
184
|
+
let result
|
|
185
|
+
try {
|
|
186
|
+
const remote = await fetchRemoteVersion()
|
|
187
|
+
result = { ok: true, current, remote, hasUpdate: current !== null && semverCompare(remote, current) > 0, checkedAt: Date.now() }
|
|
188
|
+
} catch {
|
|
189
|
+
// 错误信息固定文案, 不外泄内部异常细节 (沿用 v0.5.16 安全审计口径)
|
|
190
|
+
result = { ok: false, current, remote: null, hasUpdate: false, checkedAt: Date.now() }
|
|
191
|
+
}
|
|
192
|
+
updateCache = { checkedAt: Date.now(), result }
|
|
193
|
+
return result
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ============================================================
|
|
197
|
+
// 配置持久化: 设置面板保存的配置写入独立状态文件, 重启后恢复
|
|
198
|
+
// (不写回 cordis.patch.yml, 避免 YAML 写坏导致 dsh 起不来)
|
|
199
|
+
// ============================================================
|
|
200
|
+
const STATE_FILE = '/root/.dsh/dsh-api-dashboard.json'
|
|
201
|
+
|
|
202
|
+
const loadPersistedState = () => {
|
|
203
|
+
try {
|
|
204
|
+
const raw = readFileSync(STATE_FILE, 'utf8')
|
|
205
|
+
const parsed = JSON.parse(raw)
|
|
206
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
207
|
+
} catch { return {} }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const savePersistedState = (state) => {
|
|
211
|
+
try {
|
|
212
|
+
writeFileSync(STATE_FILE + '.tmp', JSON.stringify(state, null, 2), 'utf8')
|
|
213
|
+
renameSync(STATE_FILE + '.tmp', STATE_FILE)
|
|
214
|
+
return true
|
|
215
|
+
} catch { return false }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ============================================================
|
|
219
|
+
// 工具函数
|
|
220
|
+
// ============================================================
|
|
221
|
+
const toAmount = (value) => {
|
|
222
|
+
const n = Number(value)
|
|
223
|
+
return Number.isFinite(n) ? n : 0
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ============================================================
|
|
227
|
+
// DeepSeek 峰谷计费引擎 (学习 dsh-balance)
|
|
228
|
+
// 北京时间 09:00~12:00 / 14:00~18:00 为峰时(100%), 其余时段谷时特惠(5折)
|
|
229
|
+
// ============================================================
|
|
230
|
+
export const V4_RATES = {
|
|
231
|
+
CNY: {
|
|
232
|
+
peak: { 'deepseek-v4-flash': { cacheHit: 0.1, cacheMiss: 3, output: 9 }, 'deepseek-v4-pro': { cacheHit: 0.3, cacheMiss: 9, output: 27 } },
|
|
233
|
+
offPeak: { 'deepseek-v4-flash': { cacheHit: 0.05, cacheMiss: 1.5, output: 4.5 }, 'deepseek-v4-pro': { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 } },
|
|
234
|
+
},
|
|
235
|
+
USD: {
|
|
236
|
+
peak: { 'deepseek-v4-flash': { cacheHit: 0.014, cacheMiss: 0.44, output: 1.32 }, 'deepseek-v4-pro': { cacheHit: 0.044, cacheMiss: 1.32, output: 3.96 } },
|
|
237
|
+
offPeak: { 'deepseek-v4-flash': { cacheHit: 0.007, cacheMiss: 0.22, output: 0.66 }, 'deepseek-v4-pro': { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 } },
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 当前是否处于 DeepSeek 峰时.
|
|
243
|
+
* 工作日(周一~周五): 北京时间 09-12 / 14-18 为峰时, 其余谷时。
|
|
244
|
+
* 周末(周六日): 整天都是谷时特惠。
|
|
245
|
+
*/
|
|
246
|
+
export const isPeakTime = (timestamp = Date.now()) => {
|
|
247
|
+
const d = new Date(timestamp)
|
|
248
|
+
const day = d.getUTCDay()
|
|
249
|
+
const hourBJT = (d.getUTCHours() + 8) % 24
|
|
250
|
+
// 周末(0=周日, 6=周六)整天谷时
|
|
251
|
+
if (day === 0 || day === 6) return false
|
|
252
|
+
return (hourBJT >= 9 && hourBJT < 12) || (hourBJT >= 14 && hourBJT < 18)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 当前是否周末 */
|
|
256
|
+
export const isWeekend = (timestamp = Date.now()) => {
|
|
257
|
+
const d = new Date(timestamp)
|
|
258
|
+
const day = d.getUTCDay()
|
|
259
|
+
return day === 0 || day === 6
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
// ============================================================
|
|
264
|
+
// 通用模型价格表 (CNY, 每百万token)
|
|
265
|
+
// 用于 balanceCost 投影中非 DeepSeek 模型的计费估算.
|
|
266
|
+
// 来源: 各平台官方定价 (2025-08), 仅做参考, 实际以平台为准.
|
|
267
|
+
// ============================================================
|
|
268
|
+
export const MODEL_PRICES = {
|
|
269
|
+
// OpenAI
|
|
270
|
+
'gpt-4o': { cacheHit: 1.25, cacheMiss: 2.5, output: 10 },
|
|
271
|
+
'gpt-4o-mini': { cacheHit: 0.075, cacheMiss: 0.15, output: 0.6 },
|
|
272
|
+
'gpt-4-turbo': { cacheHit: 5, cacheMiss: 10, output: 30 },
|
|
273
|
+
'gpt-4': { cacheHit: 15, cacheMiss: 30, output: 60 },
|
|
274
|
+
'o1': { cacheHit: 7.5, cacheMiss: 15, output: 60 },
|
|
275
|
+
'o1-mini': { cacheHit: 0.55, cacheMiss: 1.1, output: 4.4 },
|
|
276
|
+
'o3-mini': { cacheHit: 0.55, cacheMiss: 1.1, output: 4.4 },
|
|
277
|
+
// Claude
|
|
278
|
+
'claude-3-5-sonnet': { cacheHit: 1.5, cacheMiss: 3, output: 15 },
|
|
279
|
+
'claude-3-5-haiku': { cacheHit: 0.4, cacheMiss: 0.8, output: 4 },
|
|
280
|
+
'claude-3-opus': { cacheHit: 7.5, cacheMiss: 15, output: 75 },
|
|
281
|
+
// Gemini
|
|
282
|
+
'gemini-2.0-flash': { cacheHit: 0.05, cacheMiss: 0.1, output: 0.4 },
|
|
283
|
+
'gemini-2.0-pro': { cacheHit: 1.25, cacheMiss: 2.5, output: 10 },
|
|
284
|
+
'gemini-1.5-pro': { cacheHit: 1.75, cacheMiss: 3.5, output: 10.5 },
|
|
285
|
+
// DeepSeek (标准价兜底)
|
|
286
|
+
'deepseek-chat': { cacheHit: 0.1, cacheMiss: 1, output: 2 },
|
|
287
|
+
'deepseek-reasoner': { cacheHit: 0.2, cacheMiss: 2, output: 8 },
|
|
288
|
+
'deepseek-r1': { cacheHit: 0.2, cacheMiss: 2, output: 8 },
|
|
289
|
+
// 智谱
|
|
290
|
+
'glm-4-plus': { cacheHit: 2.5, cacheMiss: 5, output: 5 },
|
|
291
|
+
'glm-4-flash': { cacheHit: 0.05, cacheMiss: 0.1, output: 0.1 },
|
|
292
|
+
// 通义千问
|
|
293
|
+
'qwen-plus': { cacheHit: 0.4, cacheMiss: 0.8, output: 2 },
|
|
294
|
+
'qwen-max': { cacheHit: 10, cacheMiss: 20, output: 60 },
|
|
295
|
+
'qwen-turbo': { cacheHit: 0.15, cacheMiss: 0.3, output: 0.6 },
|
|
296
|
+
'qwen2.5-72b-instruct': { cacheHit: 2, cacheMiss: 4, output: 12 },
|
|
297
|
+
// Kimi
|
|
298
|
+
'moonshot-v1-8k': { cacheHit: 0.6, cacheMiss: 1.2, output: 2.4 },
|
|
299
|
+
'moonshot-v1-32k': { cacheHit: 1.2, cacheMiss: 2.4, output: 4.8 },
|
|
300
|
+
'moonshot-v1-128k': { cacheHit: 3, cacheMiss: 6, output: 12 },
|
|
301
|
+
// 阶跃星辰
|
|
302
|
+
'step-1-flash': { cacheHit: 0.5, cacheMiss: 1, output: 2 },
|
|
303
|
+
'step-1-8k': { cacheHit: 2, cacheMiss: 4, output: 8 },
|
|
304
|
+
'step-1-32k': { cacheHit: 4, cacheMiss: 8, output: 15 },
|
|
305
|
+
// 其他
|
|
306
|
+
'mistral-large': { cacheHit: 1.5, cacheMiss: 3, output: 9 },
|
|
307
|
+
'groq-llama-3.3-70b': { cacheHit: 0.29, cacheMiss: 0.59, output: 0.79 },
|
|
308
|
+
'openrouter-auto': { cacheHit: 0.5, cacheMiss: 1, output: 2 },
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 解析模型单价, 仅 deepseek-v4-* 支持峰谷自动切换; chat/reasoner 等走通用价格表 */
|
|
312
|
+
export const resolveModelPrice = (configOrGetter, model, timestamp = Date.now()) => {
|
|
313
|
+
const config = typeof configOrGetter === 'function' ? configOrGetter() : configOrGetter
|
|
314
|
+
const peak = isPeakTime(timestamp)
|
|
315
|
+
|
|
316
|
+
// 自定义价格优先
|
|
317
|
+
if (config?.prices && Object.prototype.hasOwnProperty.call(config.prices, model) && config.prices[model]) {
|
|
318
|
+
return config.prices[model]
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// v0.5.3 修复: 原 startsWith('deepseek') 会把 deepseek-chat/reasoner 劫持进 V4 峰谷表,
|
|
322
|
+
// 导致其按 v4-flash 价格计费 (output 虚高至 4.5 倍)。仅精确匹配 v4 系列。
|
|
323
|
+
if (model === 'deepseek-v4-pro' || model === 'deepseek-v4-flash' || model.startsWith('deepseek-v4')) {
|
|
324
|
+
const currency = (config?.currency ?? 'CNY').toUpperCase() === 'USD' ? 'USD' : 'CNY'
|
|
325
|
+
const table = V4_RATES[currency] ?? V4_RATES.CNY
|
|
326
|
+
const key = model === 'deepseek-v4-pro' || model.startsWith('deepseek-v4-pro') ? 'deepseek-v4-pro' : 'deepseek-v4-flash'
|
|
327
|
+
return (peak ? table.peak[key] : table.offPeak[key]) ?? config?.defaultPrices
|
|
328
|
+
}
|
|
329
|
+
// 查询 MODEL_PRICES 表兜底 (优先匹配完整模型名, 再试前缀匹配)
|
|
330
|
+
const exact = MODEL_PRICES[model]
|
|
331
|
+
if (exact) return exact
|
|
332
|
+
const prefix = Object.keys(MODEL_PRICES).find(k => model.startsWith(k) || k.startsWith(model))
|
|
333
|
+
if (prefix) return MODEL_PRICES[prefix]
|
|
334
|
+
return config?.defaultPrices ?? { cacheHit: 0.1, cacheMiss: 1, output: 2 }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 通用 fetch 请求, 带超时。 */
|
|
338
|
+
async function fetchWithTimeout(url, headers, timeoutMs, method = 'GET') {
|
|
339
|
+
const controller = new AbortController()
|
|
340
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
341
|
+
try {
|
|
342
|
+
return await fetch(url, { method, headers, signal: controller.signal })
|
|
343
|
+
} finally {
|
|
344
|
+
clearTimeout(timer)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ============================================================
|
|
349
|
+
// 平台预设 (完整清单)
|
|
350
|
+
// ============================================================
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 每个平台:
|
|
354
|
+
* id / label / icon(图标文件名) / color / category(官方|海外|国内|本地|中转站)
|
|
355
|
+
* baseUrl / queryType(余额解析类型) / envKeys(可读取的key名) / noBalance(是否默认无余额接口)
|
|
356
|
+
*/
|
|
357
|
+
const PLATFORM_PRESETS = [
|
|
358
|
+
// ===== 国内平台(有公开余额/配额查询接口)=====
|
|
359
|
+
{ id: 'deepseek', label: 'DeepSeek', icon: 'deepseek', color: '#4D6BFE', category: '国内',
|
|
360
|
+
baseUrl: 'https://api.deepseek.com', queryType: 'deepseek', envKeys: ['DEEPSEEK_API_KEY'] },
|
|
361
|
+
{ id: 'zhipu', label: '智谱 GLM', icon: 'zhipu', color: '#3859FF', category: '国内',
|
|
362
|
+
// v0.5.5: 实测余额监控接口在 open.bigmodel.cn (api.z.ai 同路径 401); 补 ZAI_CODING_CN 等 key 别名
|
|
363
|
+
baseUrl: 'https://open.bigmodel.cn', queryType: 'glm', envKeys: ['ZHIPU_API_KEY', 'GLM_API_KEY', 'BIGMODEL_API_KEY', 'ZAI_CODING_CN_API_KEY', 'ZAI_API_KEY'] },
|
|
364
|
+
{ id: 'moonshot', label: 'Kimi Moonshot', icon: 'moonshot', color: '#000000', category: '国内',
|
|
365
|
+
baseUrl: 'https://api.moonshot.cn', queryType: 'kimi', envKeys: ['MOONSHOT_API_KEY', 'KIMI_API_KEY'] },
|
|
366
|
+
{ id: 'stepfun', label: '阶跃星辰 StepFun', icon: 'mistral', color: '#FA520F', category: '国内',
|
|
367
|
+
baseUrl: 'https://api.stepfun.com', queryType: 'stepfun', envKeys: ['STEPFUN_API_KEY'] },
|
|
368
|
+
{ id: 'siliconflow', label: '硅基流动', icon: 'siliconflow', color: '#6E29F6', category: '国内',
|
|
369
|
+
baseUrl: 'https://api.siliconflow.cn', queryType: 'siliconflow', envKeys: ['SILICONFLOW_API_KEY', 'SILICON_API_KEY'] },
|
|
370
|
+
{ id: 'minimax', label: 'MiniMax', icon: 'together', color: '#1E40AF', category: '国内',
|
|
371
|
+
baseUrl: 'https://api.minimaxi.com', queryType: 'minimax', envKeys: ['MINIMAX_API_KEY'] },
|
|
372
|
+
|
|
373
|
+
// ===== 海外平台(有公开余额/配额查询接口)=====
|
|
374
|
+
{ id: 'openrouter', label: 'OpenRouter', icon: 'openrouter', color: '#6469FF', category: '海外',
|
|
375
|
+
baseUrl: 'https://openrouter.ai', queryType: 'openrouter', envKeys: ['OPENROUTER_API_KEY'] },
|
|
376
|
+
{ id: 'novita', label: 'Novita AI', icon: 'together', color: '#FA520F', category: '海外',
|
|
377
|
+
baseUrl: 'https://api.novita.ai', queryType: 'novita', envKeys: ['NOVITA_API_KEY'] },
|
|
378
|
+
{ id: 'xai', label: 'xAI Grok', icon: 'mistral', color: '#000000', category: '海外',
|
|
379
|
+
baseUrl: 'https://api.x.ai', queryType: 'openai', envKeys: ['XAI_API_KEY'] },
|
|
380
|
+
]
|
|
381
|
+
|
|
382
|
+
// ============================================================
|
|
383
|
+
// Config Schema
|
|
384
|
+
// ============================================================
|
|
385
|
+
const RelaySchema = Schema.object({
|
|
386
|
+
id: Schema.string(),
|
|
387
|
+
name: Schema.string().required(),
|
|
388
|
+
baseUrl: Schema.string().required(),
|
|
389
|
+
apiKey: Schema.string().default(''),
|
|
390
|
+
queryType: Schema.string().default('auto'),
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
export const Config = Schema.object({
|
|
394
|
+
refreshIntervalMs: Schema.number().min(1000).default(5000),
|
|
395
|
+
clientPollIntervalMs: Schema.number().min(5000).default(5000),
|
|
396
|
+
timeoutMs: Schema.number().min(1000).default(8000),
|
|
397
|
+
presets: Schema.array(Schema.string()).default(PLATFORM_PRESETS.map(p => p.id)),
|
|
398
|
+
customRelays: Schema.array(RelaySchema).default([]),
|
|
399
|
+
/** 安全阈值: 余额 > safe 显示绿色, > warn 黄色, 否则红色 */
|
|
400
|
+
safeThreshold: Schema.number().min(0).default(50),
|
|
401
|
+
warnThreshold: Schema.number().min(0).default(10),
|
|
402
|
+
/** 计价货币 */
|
|
403
|
+
currency: Schema.string().default('CNY'),
|
|
404
|
+
prices: Schema.dict(Schema.object({
|
|
405
|
+
cacheHit: Schema.number().min(0).default(0.2),
|
|
406
|
+
cacheMiss: Schema.number().min(0).default(2),
|
|
407
|
+
output: Schema.number().min(0).default(8),
|
|
408
|
+
})).default({}),
|
|
409
|
+
defaultPrices: Schema.object({
|
|
410
|
+
cacheHit: Schema.number().min(0).default(0.1),
|
|
411
|
+
cacheMiss: Schema.number().min(0).default(1),
|
|
412
|
+
output: Schema.number().min(0).default(2),
|
|
413
|
+
}).default({}),
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// ============================================================
|
|
417
|
+
|
|
418
|
+
// ============================================================
|
|
419
|
+
// 余额告警检测 (A3: 低于阈值时推送通知)
|
|
420
|
+
// ============================================================
|
|
421
|
+
let lastAlertState = {}
|
|
422
|
+
|
|
423
|
+
function checkAlerts(balances, config, ctx) {
|
|
424
|
+
const safe = config.safeThreshold ?? 50
|
|
425
|
+
const warn = config.warnThreshold ?? 10
|
|
426
|
+
const newState = {}
|
|
427
|
+
|
|
428
|
+
for (const b of balances) {
|
|
429
|
+
if (b.status !== 'ok') continue
|
|
430
|
+
const id = b.platform
|
|
431
|
+
const val = b.percent != null ? b.percent : b.total
|
|
432
|
+
const prev = lastAlertState[id]
|
|
433
|
+
let level = val > safe ? 'ok' : val > warn ? 'warn' : 'err'
|
|
434
|
+
|
|
435
|
+
if (level === 'warn' && prev !== 'warn') {
|
|
436
|
+
newState[id] = 'warn'
|
|
437
|
+
try {
|
|
438
|
+
const name = b.name || id
|
|
439
|
+
const msg = `🔔 ${name} 余额偏低: ${val}${b.percent != null ? '%' : (b.currency || '')}`
|
|
440
|
+
if (ctx && typeof ctx.notify === 'function') {
|
|
441
|
+
ctx.notify({ title: '哦鲸鲸', message: msg, level: 'warning' })
|
|
442
|
+
} else if (ctx && ctx.get && typeof ctx.get('webServer')?.notify === 'function') {
|
|
443
|
+
ctx.get('webServer').notify({ title: '哦鲸鲸', message: msg, level: 'warning' })
|
|
444
|
+
}
|
|
445
|
+
} catch { /* 静默 */ }
|
|
446
|
+
} else if (level === 'err' && prev !== 'err') {
|
|
447
|
+
newState[id] = 'err'
|
|
448
|
+
try {
|
|
449
|
+
const name = b.name || id
|
|
450
|
+
const msg = `🚨 ${name} 余额不足: ${val}${b.percent != null ? '%' : (b.currency || '')}`
|
|
451
|
+
if (ctx && typeof ctx.notify === 'function') {
|
|
452
|
+
ctx.notify({ title: '哦鲸鲸', message: msg, level: 'error' })
|
|
453
|
+
} else if (ctx && ctx.get && typeof ctx.get('webServer')?.notify === 'function') {
|
|
454
|
+
ctx.get('webServer').notify({ title: '哦鲸鲸', message: msg, level: 'error' })
|
|
455
|
+
}
|
|
456
|
+
} catch { /* 静默 */ }
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
lastAlertState = newState
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// 余额解析适配器
|
|
463
|
+
// ============================================================
|
|
464
|
+
|
|
465
|
+
function parseResponse(queryType, json) {
|
|
466
|
+
switch (queryType) {
|
|
467
|
+
case 'deepseek': {
|
|
468
|
+
const infos = Array.isArray(json?.balance_infos) ? json.balance_infos : []
|
|
469
|
+
const p = infos[0]
|
|
470
|
+
if (!p) return null
|
|
471
|
+
// total_balance 当前余额, granted_balance 赠送, topped_up_balance 充值
|
|
472
|
+
const total = toAmount(p.total_balance)
|
|
473
|
+
const grant = toAmount(p.granted_balance)
|
|
474
|
+
const topup = toAmount(p.topped_up_balance)
|
|
475
|
+
// 已用 = 充值 + 赠送 - 当前余额 (近似)
|
|
476
|
+
return { total, currency: p.currency || 'CNY', available: total, used: Math.max(0, topup + grant - total), topup, grant, note: '可用余额' }
|
|
477
|
+
}
|
|
478
|
+
case 'openai':
|
|
479
|
+
case 'openai-credit-grants': {
|
|
480
|
+
if (!json || typeof json !== 'object') return null
|
|
481
|
+
const hasAny = 'total_granted' in json || 'total_available' in json || 'total_used' in json
|
|
482
|
+
if (!hasAny) return null
|
|
483
|
+
const total = toAmount(json?.total_granted)
|
|
484
|
+
const used = toAmount(json?.total_used)
|
|
485
|
+
const available = toAmount(json?.total_available)
|
|
486
|
+
return { total: available || (total - used), currency: 'USD', available, used, note: 'OpenAI 兼容额度' }
|
|
487
|
+
}
|
|
488
|
+
case 'siliconflow': {
|
|
489
|
+
const d = json?.data
|
|
490
|
+
if (!d) return null
|
|
491
|
+
return { total: toAmount(d.totalBalance), currency: 'CNY', available: null, used: null, note: '硅基流动总余额' }
|
|
492
|
+
}
|
|
493
|
+
case 'openrouter': {
|
|
494
|
+
const d = json?.data
|
|
495
|
+
if (!d) return null
|
|
496
|
+
return { total: toAmount(d.total_credits) - toAmount(d.total_usage), currency: 'USD', available: toAmount(d.total_credits), used: toAmount(d.total_usage), note: 'OpenRouter 余额' }
|
|
497
|
+
}
|
|
498
|
+
case 'novita': {
|
|
499
|
+
if (!json || !('availableBalance' in json)) return null
|
|
500
|
+
const raw = toAmount(json?.availableBalance)
|
|
501
|
+
return { total: raw / 10000, currency: 'USD', available: null, used: null, note: 'Novita (0.0001单位)' }
|
|
502
|
+
}
|
|
503
|
+
case 'stepfun': {
|
|
504
|
+
if (!json || !('balance' in json)) return null
|
|
505
|
+
const b = toAmount(json?.balance)
|
|
506
|
+
return { total: b, currency: 'CNY', available: null, used: null, note: '阶梯星辰余额' }
|
|
507
|
+
}
|
|
508
|
+
case 'quota': {
|
|
509
|
+
const quota = toAmount(json?.data?.quota)
|
|
510
|
+
if (quota === 0 && !json?.data?.username) return null
|
|
511
|
+
return { total: quota / 500000, currency: 'USD', available: null, used: null, note: 'one-api quota (÷500000)' }
|
|
512
|
+
}
|
|
513
|
+
case 'openai-billing': {
|
|
514
|
+
const limit = toAmount(json?.hard_limit_usd)
|
|
515
|
+
if (limit === 0 && !json?.has_credit_card) return null
|
|
516
|
+
return { total: limit, currency: 'USD', available: limit, used: null, note: 'OpenAI 订阅硬上限' }
|
|
517
|
+
}
|
|
518
|
+
case 'glm': {
|
|
519
|
+
// 智谱 monitor 返回 data.limits[], 同时存在「套餐%」与「限流配额(五小时×2000 token)」。
|
|
520
|
+
// 判决口径(用户确认): 保留「百分比」显示——积分/套餐的 percentage 较准确。
|
|
521
|
+
// 仅当完全无 percentage 时才回退到 remaining(带"剩余 token"标注, 不冒充真实余额),
|
|
522
|
+
// 避免把「五小时限流窗口」误当账户余额误导用户。
|
|
523
|
+
const limits = Array.isArray(json?.data?.limits) ? json.data.limits : []
|
|
524
|
+
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
|
|
525
|
+
const pctOf = (l) => { const n = Number(l?.percentage ?? l?.remaining_percentage); return Number.isFinite(n) && n >= 0 && n <= 100 ? n : null }
|
|
526
|
+
const near = (a, b) => (Number(a?.nextResetTime) || 0) - (Number(b?.nextResetTime) || 0)
|
|
527
|
+
// 1) 优先: 百分比套餐
|
|
528
|
+
const pctCands = limits.map((l) => ({ l, p: pctOf(l) })).filter((x) => x.p !== null)
|
|
529
|
+
let pk = pctCands.find((x) => x.l.type === 'TOKENS_LIMIT') || (pctCands.sort((a, b) => near(b.l, a.l))[0] ?? null)
|
|
530
|
+
if (pk) {
|
|
531
|
+
const p = pk.p
|
|
532
|
+
return { total: p, currency: '%', available: p, used: 100 - p, note: '智谱剩余%', percent: true, resetAt: pk.l.nextResetTime ?? null }
|
|
533
|
+
}
|
|
534
|
+
// 2) 兜底: 无任何 percentage → 只剩配额/remaining 值; 明确标注是"剩余 token"而非金额余额
|
|
535
|
+
const rem = (l) => num(l?.remaining ?? l?.remaining_quota)
|
|
536
|
+
const balCands = limits.map((l) => ({ l, r: rem(l) })).filter((x) => x.r !== null && x.r > 0)
|
|
537
|
+
if (balCands.length > 0) {
|
|
538
|
+
const best = balCands.sort((a, b) => b.r - a.r)[0]
|
|
539
|
+
return { total: best.r, currency: 'tokens', available: best.r, used: null, note: '智谱剩余 token(限流配额)', percent: null, resetAt: best.l.nextResetTime ?? null }
|
|
540
|
+
}
|
|
541
|
+
return null
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
case 'kimi': {
|
|
545
|
+
const u = json?.usage
|
|
546
|
+
if (!u) return null
|
|
547
|
+
const limit = toAmount(u.limit), remaining = toAmount(u.remaining)
|
|
548
|
+
return { total: limit, currency: 'tokens', available: remaining, used: limit - remaining, note: 'Kimi 套餐剩余 tokens', percent: limit > 0 ? (remaining / limit) * 100 : null }
|
|
549
|
+
}
|
|
550
|
+
case 'minimax': {
|
|
551
|
+
const models = Array.isArray(json?.model_remains) ? json.model_remains : []
|
|
552
|
+
const m = models[0]
|
|
553
|
+
if (!m || !('remaining_credit' in m)) return null
|
|
554
|
+
return { total: toAmount(m.remaining_credit), currency: 'CNY', available: toAmount(m.remaining_credit), used: null, note: 'MiniMax 剩余额度' }
|
|
555
|
+
}
|
|
556
|
+
default:
|
|
557
|
+
return null
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** 某些类型无法用普通 API key 查询余额 (需 OAuth 等) */
|
|
562
|
+
// ============================================================
|
|
563
|
+
// 查询单个预设平台
|
|
564
|
+
// ============================================================
|
|
565
|
+
async function queryPreset(platform, apiKey, config) {
|
|
566
|
+
if (!apiKey) {
|
|
567
|
+
return {
|
|
568
|
+
platform: platform.id, name: platform.label, icon: platform.icon, color: platform.color,
|
|
569
|
+
category: platform.category, status: 'no-key', error: '未配置 API Key', noBalance: false,
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// 端点构造
|
|
574
|
+
let url = '', headers = { Accept: 'application/json' }, method = 'GET'
|
|
575
|
+
let queryType = platform.queryType
|
|
576
|
+
const base = platform.baseUrl.replace(/\/+$/, '')
|
|
577
|
+
|
|
578
|
+
switch (platform.queryType) {
|
|
579
|
+
case 'deepseek': url = `${base}/user/balance`; headers['Authorization'] = `Bearer ${apiKey}`; break
|
|
580
|
+
case 'openai': url = `${base}/v1/dashboard/billing/credit_grants`; headers['Authorization'] = `Bearer ${apiKey}`; break
|
|
581
|
+
case 'siliconflow': url = `${base}/v1/user/info`; headers['Authorization'] = `Bearer ${apiKey}`; break
|
|
582
|
+
case 'openrouter': url = `${base}/api/v1/credits`; headers['Authorization'] = `Bearer ${apiKey}`; break
|
|
583
|
+
case 'novita': url = `${base}/v3/user/balance`; headers['Authorization'] = `Bearer ${apiKey}`; break
|
|
584
|
+
case 'stepfun': url = `${base}/v1/accounts`; headers['Authorization'] = `Bearer ${apiKey}`; break
|
|
585
|
+
case 'quota': url = `${base}/api/user/self`; headers['Authorization'] = `Bearer ${apiKey}`; method = 'POST'; break
|
|
586
|
+
case 'openai-billing': url = `${base}/v1/dashboard/billing/subscription`; headers['Authorization'] = `Bearer ${apiKey}`; break
|
|
587
|
+
case 'glm': url = `${base}/api/monitor/usage/quota/limit`; headers['Authorization'] = apiKey; break
|
|
588
|
+
case 'kimi': url = `${base}/coding/v1/usages`; headers['Authorization'] = `Bearer ${apiKey}`; break
|
|
589
|
+
case 'minimax': url = `${base}/v1/api/openplatform/coding_plan/remains`; headers['Authorization'] = `Bearer ${apiKey}`; break
|
|
590
|
+
default: url = `${base}/v1/dashboard/billing/credit_grants`; headers['Authorization'] = `Bearer ${apiKey}`; queryType = 'openai'; break
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
try {
|
|
594
|
+
const res = await fetchWithTimeout(url, headers, config.timeoutMs || 8000, method)
|
|
595
|
+
if (!res.ok) {
|
|
596
|
+
const isAuth = res.status === 401 || res.status === 403
|
|
597
|
+
return {
|
|
598
|
+
platform: platform.id, name: platform.label, icon: platform.icon, color: platform.color,
|
|
599
|
+
category: platform.category,
|
|
600
|
+
status: isAuth ? 'auth-error' : (res.status === 404 || res.status === 405 ? 'no-balance-api' : 'error'),
|
|
601
|
+
error: `HTTP ${res.status}${isAuth ? ' (认证失败)' : res.status === 404 ? ' (未开放余额接口)' : ''}`,
|
|
602
|
+
noBalance: res.status === 404 || res.status === 405,
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const text = await res.text()
|
|
607
|
+
let json
|
|
608
|
+
try { json = JSON.parse(text) } catch { json = null }
|
|
609
|
+
const parsed = parseResponse(queryType, json)
|
|
610
|
+
|
|
611
|
+
if (!parsed) {
|
|
612
|
+
return {
|
|
613
|
+
platform: platform.id, name: platform.label, icon: platform.icon, color: platform.color,
|
|
614
|
+
category: platform.category, status: 'parse-error', error: '无法解析余额数据', noBalance: true,
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
platform: platform.id, name: platform.label, icon: platform.icon, color: platform.color,
|
|
620
|
+
category: platform.category, status: 'ok', total: parsed.total, currency: parsed.currency,
|
|
621
|
+
available: parsed.available, used: parsed.used, topup: parsed.topup, grant: parsed.grant,
|
|
622
|
+
note: parsed.note, percent: parsed.percent,
|
|
623
|
+
noBalance: false, fetchedAt: Date.now(),
|
|
624
|
+
}
|
|
625
|
+
} catch (error) {
|
|
626
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
627
|
+
return {
|
|
628
|
+
platform: platform.id, name: platform.label, icon: platform.icon, color: platform.color,
|
|
629
|
+
category: platform.category,
|
|
630
|
+
status: 'error', error: message.includes('abort') ? '请求超时' : '网络错误', noBalance,
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ============================================================
|
|
636
|
+
// 自定义中转站查询 (自动探测)
|
|
637
|
+
// ============================================================
|
|
638
|
+
function relayTypePath(queryType) {
|
|
639
|
+
switch (queryType) {
|
|
640
|
+
case 'openai-billing': return '/v1/dashboard/billing/subscription'
|
|
641
|
+
case 'openai': return '/v1/dashboard/billing/credit_grants'
|
|
642
|
+
case 'quota': return '/api/user/self'
|
|
643
|
+
case 'auto': return null
|
|
644
|
+
default: return '/v1/dashboard/billing/subscription'
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function queryCustomRelay(relay, config) {
|
|
649
|
+
const { id, name, baseUrl, apiKey, queryType } = relay
|
|
650
|
+
if (!apiKey) {
|
|
651
|
+
return { platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站', status: 'no-key', error: '未配置 API Key', noBalance: true }
|
|
652
|
+
}
|
|
653
|
+
const base = (baseUrl || '').replace(/\/+$/, '')
|
|
654
|
+
|
|
655
|
+
// 候选端点探测
|
|
656
|
+
const candidates = []
|
|
657
|
+
if (queryType && queryType !== 'auto') {
|
|
658
|
+
candidates.push({ type: queryType, path: relayTypePath(queryType) })
|
|
659
|
+
} else {
|
|
660
|
+
candidates.push(
|
|
661
|
+
{ type: 'openai-billing', path: '/v1/dashboard/billing/subscription' },
|
|
662
|
+
{ type: 'quota', path: '/api/user/self' },
|
|
663
|
+
{ type: 'openai', path: '/v1/dashboard/billing/credit_grants' },
|
|
664
|
+
)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
for (const cand of candidates) {
|
|
668
|
+
const headers = { Accept: 'application/json', Authorization: `Bearer ${apiKey}` }
|
|
669
|
+
const method = cand.type === 'quota' ? 'POST' : 'GET'
|
|
670
|
+
try {
|
|
671
|
+
const res = await fetchWithTimeout(base + cand.path, headers, config.timeoutMs || 8000, method)
|
|
672
|
+
if (!res.ok) continue
|
|
673
|
+
const text = await res.text()
|
|
674
|
+
let json
|
|
675
|
+
try { json = JSON.parse(text) } catch { continue }
|
|
676
|
+
const parsed = parseResponse(cand.type, json)
|
|
677
|
+
if (parsed) {
|
|
678
|
+
return {
|
|
679
|
+
platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站',
|
|
680
|
+
status: 'ok', total: parsed.total, currency: parsed.currency, available: parsed.available,
|
|
681
|
+
used: parsed.used, note: parsed.note || cand.type, percent: parsed.percent,
|
|
682
|
+
noBalance: false, queryType: cand.type, fetchedAt: Date.now(),
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
} catch { /* 尝试下一个 */ }
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return {
|
|
689
|
+
platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站',
|
|
690
|
+
status: 'no-balance-api', error: '该平台未开放余额查询', noBalance: true,
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ============================================================
|
|
695
|
+
// 自定义模型余额查询 (用户自己提供余额接口)
|
|
696
|
+
// 支持: 手动映射(totalPath/usedPath 点分路径) / 指定解析类型(queryType) / 自动探测(auto)
|
|
697
|
+
// ============================================================
|
|
698
|
+
export const dotGet = (obj, path) => {
|
|
699
|
+
if (!path || obj == null) return undefined
|
|
700
|
+
return String(path).split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export async function queryCustomModel(model, config) {
|
|
704
|
+
const { id, name, apiUrl, apiKey, queryType, totalPath, usedPath, currency } = model
|
|
705
|
+
const base = { platform: id, name: name || '自定义模型', icon: 'relay', color: '#8B5CF6', category: '自定义' }
|
|
706
|
+
if (!apiUrl) {
|
|
707
|
+
return { ...base, status: 'no-key', error: '未配置接口 URL', noBalance: true }
|
|
708
|
+
}
|
|
709
|
+
const headers = { Accept: 'application/json' }
|
|
710
|
+
if (apiKey) headers['Authorization'] = apiKey.trim().startsWith('Bearer') ? apiKey.trim() : `Bearer ${apiKey.trim()}`
|
|
711
|
+
try {
|
|
712
|
+
const res = await fetchWithTimeout(apiUrl, headers, config.timeoutMs || 8000, 'GET')
|
|
713
|
+
if (!res.ok) {
|
|
714
|
+
const isAuth = res.status === 401 || res.status === 403
|
|
715
|
+
return { ...base, status: isAuth ? 'auth-error' : 'error', error: `HTTP ${res.status}${isAuth ? ' (认证失败)' : ''}`, noBalance: true }
|
|
716
|
+
}
|
|
717
|
+
const text = await res.text()
|
|
718
|
+
let json
|
|
719
|
+
try { json = JSON.parse(text) } catch { json = null }
|
|
720
|
+
|
|
721
|
+
// 1) 手动映射优先 (totalPath 点分路径, 如 data.balance)
|
|
722
|
+
if (totalPath) {
|
|
723
|
+
const total = toAmount(dotGet(json, totalPath))
|
|
724
|
+
if (Number.isFinite(total) && (total !== 0 || json != null)) {
|
|
725
|
+
const used = usedPath ? toAmount(dotGet(json, usedPath)) : null
|
|
726
|
+
return {
|
|
727
|
+
...base, status: 'ok', total, currency: currency || 'CNY',
|
|
728
|
+
available: used != null && used >= 0 ? total - used : total, used,
|
|
729
|
+
note: '自定义映射', percent: null, noBalance: false, queryType: 'custom', fetchedAt: Date.now(),
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// 2) 指定解析类型
|
|
735
|
+
if (queryType && queryType !== 'auto') {
|
|
736
|
+
const parsed = parseResponse(queryType, json)
|
|
737
|
+
if (parsed) {
|
|
738
|
+
return { ...base, status: 'ok', total: parsed.total, currency: parsed.currency, available: parsed.available, used: parsed.used, note: parsed.note, percent: parsed.percent, noBalance: false, queryType, fetchedAt: Date.now() }
|
|
739
|
+
}
|
|
740
|
+
} else {
|
|
741
|
+
// 3) auto: 尝试常见格式
|
|
742
|
+
for (const qt of ['openai', 'quota', 'deepseek', 'openai-billing']) {
|
|
743
|
+
const parsed = parseResponse(qt, json)
|
|
744
|
+
if (parsed) {
|
|
745
|
+
return { ...base, status: 'ok', total: parsed.total, currency: parsed.currency, available: parsed.available, used: parsed.used, note: parsed.note, percent: parsed.percent, noBalance: false, queryType: qt, fetchedAt: Date.now() }
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return { ...base, status: 'parse-error', error: '无法解析余额数据', noBalance: true }
|
|
750
|
+
} catch (error) {
|
|
751
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
752
|
+
return { ...base, status: 'error', error: message.includes('abort') ? '请求超时' : '网络错误', noBalance: true }
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ============================================================
|
|
757
|
+
// 会话消耗投影 (学习 dsh-balance queryBalanceCost)
|
|
758
|
+
// ============================================================
|
|
759
|
+
export function makeCostProjection(configOrGetter) {
|
|
760
|
+
const getConfig = () => typeof configOrGetter === 'function' ? configOrGetter() : configOrGetter
|
|
761
|
+
const zero = () => ({ uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 })
|
|
762
|
+
const bucketsOf = (usage) => ({
|
|
763
|
+
uncachedInputTokens: usage.inputTokens,
|
|
764
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
765
|
+
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
|
766
|
+
outputTokens: usage.outputTokens,
|
|
767
|
+
})
|
|
768
|
+
const bucketsEqual = (a, b) =>
|
|
769
|
+
a.uncachedInputTokens === b.uncachedInputTokens && a.cacheReadTokens === b.cacheReadTokens &&
|
|
770
|
+
a.cacheWriteTokens === b.cacheWriteTokens && a.outputTokens === b.outputTokens
|
|
771
|
+
const addBuckets = (a, b) => ({
|
|
772
|
+
uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens,
|
|
773
|
+
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
|
|
774
|
+
cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
|
|
775
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
776
|
+
})
|
|
777
|
+
const subBuckets = (a, b) => ({
|
|
778
|
+
uncachedInputTokens: a.uncachedInputTokens - b.uncachedInputTokens,
|
|
779
|
+
cacheReadTokens: a.cacheReadTokens - b.cacheReadTokens,
|
|
780
|
+
cacheWriteTokens: a.cacheWriteTokens - b.cacheWriteTokens,
|
|
781
|
+
outputTokens: a.outputTokens - b.outputTokens,
|
|
782
|
+
})
|
|
783
|
+
const round6 = (n) => Math.round(n * 1e6) / 1e6
|
|
784
|
+
|
|
785
|
+
return {
|
|
786
|
+
key: 'queryBalanceCost',
|
|
787
|
+
// 框架要求的投影定义 API: stateSchema(内部状态) + wire.{viewSchema,view}(客户端可见视图)。
|
|
788
|
+
// 旧版误用顶层 schema+view, 导致 wire 缺失, 服务端 drive 永不通知、客户端永远拿不到值。
|
|
789
|
+
stateSchema: z.object({
|
|
790
|
+
currentModel: z.string().nullable(),
|
|
791
|
+
last: z.object({
|
|
792
|
+
turn: z.number(),
|
|
793
|
+
step: z.number(),
|
|
794
|
+
model: z.string(),
|
|
795
|
+
buckets: z.object({
|
|
796
|
+
uncachedInputTokens: z.number(),
|
|
797
|
+
cacheReadTokens: z.number(),
|
|
798
|
+
cacheWriteTokens: z.number(),
|
|
799
|
+
outputTokens: z.number(),
|
|
800
|
+
}),
|
|
801
|
+
}).nullable(),
|
|
802
|
+
byModel: z.record(z.string(), z.object({
|
|
803
|
+
uncachedInputTokens: z.number(),
|
|
804
|
+
cacheReadTokens: z.number(),
|
|
805
|
+
cacheWriteTokens: z.number(),
|
|
806
|
+
outputTokens: z.number(),
|
|
807
|
+
})),
|
|
808
|
+
modelOrder: z.array(z.string()),
|
|
809
|
+
}),
|
|
810
|
+
init: () => ({ currentModel: null, last: null, byModel: {}, modelOrder: [] }),
|
|
811
|
+
apply: (state, event) => {
|
|
812
|
+
let nextModel = state.currentModel
|
|
813
|
+
if (event.type === 'request/header') {
|
|
814
|
+
const model = event.data.header?.config?.model
|
|
815
|
+
if (typeof model === 'string' && model !== '') nextModel = model
|
|
816
|
+
} else if (event.type === 'request/context') {
|
|
817
|
+
const model = event.data.model
|
|
818
|
+
if (typeof model === 'string' && model !== '') nextModel = model
|
|
819
|
+
}
|
|
820
|
+
let usage = null, turn = 0, step = 0
|
|
821
|
+
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
|
|
822
|
+
({ turn, step } = event.data); usage = event.data.chunk.usage
|
|
823
|
+
} else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
|
|
824
|
+
({ turn, step, usage } = event.data)
|
|
825
|
+
}
|
|
826
|
+
if (usage === null) return nextModel === state.currentModel ? state : { ...state, currentModel: nextModel }
|
|
827
|
+
const model = nextModel ?? 'unknown'
|
|
828
|
+
const buckets = bucketsOf(usage)
|
|
829
|
+
const prev = state.last !== null && state.last.turn === turn && state.last.step === step ? state.last : null
|
|
830
|
+
if (prev !== null && prev.model === model && bucketsEqual(prev.buckets, buckets)) {
|
|
831
|
+
return nextModel === state.currentModel ? state : { ...state, currentModel: nextModel }
|
|
832
|
+
}
|
|
833
|
+
const isNewModel = !(model in state.byModel)
|
|
834
|
+
let byModel = state.byModel
|
|
835
|
+
if (prev !== null) byModel = { ...byModel, [prev.model]: subBuckets(byModel[prev.model] ?? zero(), prev.buckets) }
|
|
836
|
+
byModel = { ...byModel, [model]: addBuckets(byModel[model] ?? zero(), buckets) }
|
|
837
|
+
return { ...state, currentModel: nextModel, last: { turn, step, model, buckets }, byModel, modelOrder: isNewModel ? [...state.modelOrder, model] : state.modelOrder }
|
|
838
|
+
},
|
|
839
|
+
wire: {
|
|
840
|
+
viewSchema: z.object({
|
|
841
|
+
models: z.array(z.string()),
|
|
842
|
+
// v0.5.3: 暴露当前会话正在使用的模型, 客户端据此自动切换选中平台
|
|
843
|
+
currentModel: z.string().nullable(),
|
|
844
|
+
cost: z.number(),
|
|
845
|
+
costByModel: z.record(z.string(), z.number().nonnegative()),
|
|
846
|
+
tokens: z.object({ uncachedInput: z.number().int().nonnegative(), cacheRead: z.number().int().nonnegative(), cacheWrite: z.number().int().nonnegative(), output: z.number().int().nonnegative() }).strict(),
|
|
847
|
+
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(),
|
|
848
|
+
currency: z.string(),
|
|
849
|
+
isPeak: z.boolean().optional(),
|
|
850
|
+
waiting: z.boolean().optional(),
|
|
851
|
+
}).strict(),
|
|
852
|
+
view: (state) => {
|
|
853
|
+
const cfg = getConfig()
|
|
854
|
+
// 无事件时返回 waiting 标记, 客户端据此显示 "~—" 而非 "~¥0"
|
|
855
|
+
if (state.modelOrder.length === 0) {
|
|
856
|
+
return { models: [], currentModel: state.currentModel ?? null, cost: -1, costByModel: {}, tokens: { uncachedInput: 0, cacheRead: 0, cacheWrite: 0, output: 0 }, tokensByModel: {}, currency: cfg.currency ?? 'CNY', isPeak: isPeakTime(), waiting: true }
|
|
857
|
+
}
|
|
858
|
+
const tokens = { uncachedInput: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
|
|
859
|
+
const costByModel = {}
|
|
860
|
+
let cost = 0
|
|
861
|
+
const defaultPrice = cfg.defaultPrices ?? { cacheHit: 0.1, cacheMiss: 1, output: 2 }
|
|
862
|
+
const peak = isPeakTime()
|
|
863
|
+
for (const model of state.modelOrder) {
|
|
864
|
+
const b = state.byModel[model] ?? zero()
|
|
865
|
+
tokens.uncachedInput += b.uncachedInputTokens
|
|
866
|
+
tokens.cacheRead += b.cacheReadTokens
|
|
867
|
+
tokens.cacheWrite += b.cacheWriteTokens
|
|
868
|
+
tokens.output += b.outputTokens
|
|
869
|
+
// 支持 DeepSeek 谷峰自动计费
|
|
870
|
+
const price = resolveModelPrice(cfg, model)
|
|
871
|
+
const c = ((b.uncachedInputTokens + b.cacheWriteTokens) * price.cacheMiss + b.cacheReadTokens * price.cacheHit + b.outputTokens * price.output) / 1e6
|
|
872
|
+
if (c > 0) costByModel[model] = round6(c)
|
|
873
|
+
cost += c
|
|
874
|
+
}
|
|
875
|
+
return { models: state.modelOrder, currentModel: state.currentModel ?? null, cost: round6(cost), costByModel, tokens, tokensByModel: state.byModel, currency: cfg.currency ?? 'CNY', isPeak: peak, waiting: false }
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
stateVersion: 1,
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// ============================================================
|
|
883
|
+
// 插件主体
|
|
884
|
+
// ============================================================
|
|
885
|
+
export function apply(ctx, config) {
|
|
886
|
+
// 用户保存的配置优先于 cordis.patch.yml 的默认 config (持久化状态)
|
|
887
|
+
const persisted = loadPersistedState()
|
|
888
|
+
const runtimeConfig = {
|
|
889
|
+
refreshIntervalMs: persisted.refreshIntervalMs ?? config.refreshIntervalMs ?? 5000,
|
|
890
|
+
clientPollIntervalMs: persisted.clientPollIntervalMs ?? config.clientPollIntervalMs ?? 5000,
|
|
891
|
+
timeoutMs: persisted.timeoutMs ?? config.timeoutMs ?? 8000,
|
|
892
|
+
presets: config.presets ?? PLATFORM_PRESETS.map(p => p.id),
|
|
893
|
+
customRelays: (persisted.customRelays ?? config.customRelays ?? []).map(r => ({ ...r })),
|
|
894
|
+
customModels: (persisted.customModels ?? config.customModels ?? []).map(m => ({ ...m })),
|
|
895
|
+
prices: config.prices ?? { 'deepseek-chat': { cacheHit: 0.1, cacheMiss: 1, output: 2 } },
|
|
896
|
+
defaultPrices: config.defaultPrices ?? { cacheHit: 0.1, cacheMiss: 1, output: 2 },
|
|
897
|
+
currency: persisted.currency ?? config.currency ?? 'CNY',
|
|
898
|
+
safeThreshold: persisted.safeThreshold ?? config.safeThreshold ?? 50,
|
|
899
|
+
warnThreshold: persisted.warnThreshold ?? config.warnThreshold ?? 10,
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const getConfig = () => runtimeConfig
|
|
903
|
+
|
|
904
|
+
/** 解析预设平台的 API key (从环境变量、credentials 系统或直接读凭据文件) */
|
|
905
|
+
const resolvePresetKey = async (platform) => {
|
|
906
|
+
// 1) 环境变量
|
|
907
|
+
for (const name of platform.envKeys || []) {
|
|
908
|
+
if (process.env[name]) return process.env[name]
|
|
909
|
+
}
|
|
910
|
+
// 2) DSH credentials 服务
|
|
911
|
+
const creds = ctx.get('credentials')
|
|
912
|
+
if (creds !== undefined) {
|
|
913
|
+
for (const ref of (platform.envKeys || [])) {
|
|
914
|
+
try {
|
|
915
|
+
const hit = await creds.resolve(ref)
|
|
916
|
+
if (hit !== undefined) return hit.value
|
|
917
|
+
} catch { /* 忽略 */ }
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
// 3) 直接读 ~/.dsh/.credentials.yaml 文件兜底
|
|
921
|
+
try {
|
|
922
|
+
const { readFileSync } = await import('node:fs')
|
|
923
|
+
const { homedir } = await import('node:os')
|
|
924
|
+
const { join } = await import('node:path')
|
|
925
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
926
|
+
const raw = readFileSync(join(home, '.credentials.yaml'), 'utf8')
|
|
927
|
+
const lines = raw.split('\n')
|
|
928
|
+
let inRefs = false
|
|
929
|
+
for (const line of lines) {
|
|
930
|
+
if (line === 'refs:') { inRefs = true; continue }
|
|
931
|
+
if (inRefs) {
|
|
932
|
+
if (!line.startsWith(' ')) { inRefs = false; continue }
|
|
933
|
+
const idx = line.indexOf(':')
|
|
934
|
+
if (idx === -1) continue
|
|
935
|
+
const key = line.slice(0, idx).trim()
|
|
936
|
+
const val = line.slice(idx + 1).trim()
|
|
937
|
+
if (key && val && (platform.envKeys || []).includes(key)) return val
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
} catch { /* 忽略 */ }
|
|
941
|
+
return ''
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
let cache = { balances: [], fetchedAt: 0, error: null }
|
|
945
|
+
let inflight = null
|
|
946
|
+
|
|
947
|
+
const refreshAll = async () => {
|
|
948
|
+
if (inflight !== null) return inflight
|
|
949
|
+
inflight = (async () => {
|
|
950
|
+
const presetList = PLATFORM_PRESETS.filter(p => runtimeConfig.presets.includes(p.id))
|
|
951
|
+
const relayList = runtimeConfig.customRelays
|
|
952
|
+
const modelList = runtimeConfig.customModels
|
|
953
|
+
const tasks = [
|
|
954
|
+
...presetList.map(async (p) => queryPreset(p, await resolvePresetKey(p), runtimeConfig)),
|
|
955
|
+
...relayList.map(async (r) => queryCustomRelay(r, runtimeConfig)),
|
|
956
|
+
...modelList.map(async (m) => queryCustomModel(m, runtimeConfig)),
|
|
957
|
+
]
|
|
958
|
+
const results = await Promise.allSettled(tasks)
|
|
959
|
+
// v0.5.7: last-known-good 兜底 — 平台瞬时网络故障(超时/DNS抖动)不冲掉上次成功数据,
|
|
960
|
+
// 避免看板红闪「异常」; 标注 stale 提示用户这是暂存值
|
|
961
|
+
const prevBalances = Array.isArray(cache.balances) ? cache.balances : []
|
|
962
|
+
const prevOf = new Map(prevBalances.map((b) => [b.platform, b]))
|
|
963
|
+
const balances = results.map((r) => {
|
|
964
|
+
const cur = r.status === 'fulfilled' ? r.value : { platform: 'unknown', name: '未知', icon: 'relay', color: '#64748B', category: '中转站', status: 'error', error: '查询失败', noBalance: true }
|
|
965
|
+
if (cur && cur.status === 'error' && !String(cur.error || '').includes('认证')) {
|
|
966
|
+
const old = prevOf.get(cur.platform)
|
|
967
|
+
if (old && old.status === 'ok') {
|
|
968
|
+
return { ...old, fetchedAt: old.fetchedAt, staleNote: '暂用上次数据(本次查询失败)', error: undefined }
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return cur
|
|
972
|
+
})
|
|
973
|
+
cache = {
|
|
974
|
+
balances, fetchedAt: Date.now(), error: null,
|
|
975
|
+
config: {
|
|
976
|
+
refreshIntervalMs: runtimeConfig.refreshIntervalMs,
|
|
977
|
+
clientPollIntervalMs: runtimeConfig.clientPollIntervalMs,
|
|
978
|
+
safeThreshold: runtimeConfig.safeThreshold,
|
|
979
|
+
warnThreshold: runtimeConfig.warnThreshold,
|
|
980
|
+
currency: runtimeConfig.currency,
|
|
981
|
+
isPeak: isPeakTime(),
|
|
982
|
+
isWeekend: isWeekend(),
|
|
983
|
+
},
|
|
984
|
+
}
|
|
985
|
+
// A3: 告警检测
|
|
986
|
+
checkAlerts(balances, runtimeConfig, ctx)
|
|
987
|
+
})().finally(() => { inflight = null })
|
|
988
|
+
return inflight
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
let loopTimer = null
|
|
992
|
+
const resetLoop = () => {
|
|
993
|
+
if (loopTimer !== null) { clearTimeout(loopTimer); loopTimer = null }
|
|
994
|
+
const run = () => { void refreshAll().then(() => { loopTimer = setTimeout(run, runtimeConfig.refreshIntervalMs) }) }
|
|
995
|
+
loopTimer = setTimeout(run, 0)
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
ctx.effect(() => {
|
|
999
|
+
resetLoop()
|
|
1000
|
+
return () => { if (loopTimer !== null) clearTimeout(loopTimer) }
|
|
1001
|
+
}, 'dsh-api-dashboard: refresh loop')
|
|
1002
|
+
|
|
1003
|
+
// HTTP 路由
|
|
1004
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
1005
|
+
const sendJson = (res, code, data) => {
|
|
1006
|
+
const body = JSON.stringify(data)
|
|
1007
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Length': Buffer.byteLength(body) })
|
|
1008
|
+
res.end(body)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1012
|
+
kind: 'exact', path: '/api-dashboard/balances',
|
|
1013
|
+
async handler(req, res) {
|
|
1014
|
+
if (!['GET', 'HEAD', 'POST'].includes(req.method)) { res.writeHead(405, { Allow: 'GET, HEAD, POST' }); res.end(); return }
|
|
1015
|
+
const force = req.method === 'POST' || new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('force') === '1'
|
|
1016
|
+
// 自动刷新: 缓存为空 或 缓存超过 refreshIntervalMs 时自动拉取最新 (解决进入页面要手动刷新)
|
|
1017
|
+
const stale = Date.now() - cache.fetchedAt > (runtimeConfig.refreshIntervalMs || 300000)
|
|
1018
|
+
if ((force || stale || cache.balances.length === 0) && (Date.now() - cache.fetchedAt > 2000 || cache.balances.length === 0)) await refreshAll()
|
|
1019
|
+
// v0.5.0: ETag 协商缓存 — 轮询期间数据没变就 304 空响应, 省 JSON 序列化与流量
|
|
1020
|
+
const etag = '"' + Number(cache.fetchedAt || 0).toString(36) + '"'
|
|
1021
|
+
if (req.method === 'HEAD') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ETag: etag }); res.end(); return }
|
|
1022
|
+
if (!force && req.headers['if-none-match'] === etag && cache.balances.length > 0) {
|
|
1023
|
+
res.writeHead(304, { ETag: etag })
|
|
1024
|
+
res.end()
|
|
1025
|
+
return
|
|
1026
|
+
}
|
|
1027
|
+
const body = JSON.stringify({ ok: true, balances: cache.balances, fetchedAt: cache.fetchedAt, config: cache.config })
|
|
1028
|
+
res.writeHead(200, {
|
|
1029
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1030
|
+
'Cache-Control': 'private, no-cache',
|
|
1031
|
+
ETag: etag,
|
|
1032
|
+
'Content-Length': Buffer.byteLength(body),
|
|
1033
|
+
})
|
|
1034
|
+
res.end(body)
|
|
1035
|
+
},
|
|
1036
|
+
}), 'dsh-api-dashboard: balances route')
|
|
1037
|
+
|
|
1038
|
+
// v0.5.5: 价格表 (DeepSeek 峰谷全量 + 平价模型), 供详情页标注
|
|
1039
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1040
|
+
kind: 'exact', path: '/api-dashboard/prices',
|
|
1041
|
+
async handler(req, res) {
|
|
1042
|
+
if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
|
|
1043
|
+
const cfg = runtimeConfig
|
|
1044
|
+
const cur = (cfg.currency ?? 'CNY').toUpperCase() === 'USD' ? 'USD' : 'CNY'
|
|
1045
|
+
const table = V4_RATES[cur] ?? V4_RATES.CNY
|
|
1046
|
+
const mk = (p) => p ? { cacheHit: p.cacheHit, cacheMiss: p.cacheMiss, output: p.output } : null
|
|
1047
|
+
// v4 峰谷系列
|
|
1048
|
+
const models = []
|
|
1049
|
+
for (const key of ['deepseek-v4-flash', 'deepseek-v4-pro']) {
|
|
1050
|
+
models.push({ model: key, peak: mk(table.peak[key]), offPeak: mk(table.offPeak[key]), peakValley: true })
|
|
1051
|
+
}
|
|
1052
|
+
sendJson(res, 200, { ok: true, currency: cfg.currency ?? 'CNY', peakNow: isPeakTime(), weekend: isWeekend(), models })
|
|
1053
|
+
},
|
|
1054
|
+
}), 'dsh-api-dashboard: prices route')
|
|
1055
|
+
|
|
1056
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1057
|
+
kind: 'exact', path: '/api-dashboard/platforms',
|
|
1058
|
+
async handler(req, res) {
|
|
1059
|
+
if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
|
|
1060
|
+
const presets = PLATFORM_PRESETS.filter(p => runtimeConfig.presets.includes(p.id)).map(p => ({
|
|
1061
|
+
id: p.id, name: p.label, icon: p.icon, color: p.color, category: p.category, queryType: p.queryType,
|
|
1062
|
+
}))
|
|
1063
|
+
sendJson(res, 200, { ok: true, presets })
|
|
1064
|
+
},
|
|
1065
|
+
}), 'dsh-api-dashboard: platforms route')
|
|
1066
|
+
|
|
1067
|
+
// v0.6.0: 更新检查 (GET) — 对比远端 main 版本, 5 分钟内存缓存, ?force=1 绕过
|
|
1068
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1069
|
+
kind: 'exact', path: '/api-dashboard/update',
|
|
1070
|
+
async handler(req, res) {
|
|
1071
|
+
if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
|
|
1072
|
+
const force = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('force') === '1'
|
|
1073
|
+
try {
|
|
1074
|
+
sendJson(res, 200, await getUpdateStatus(force))
|
|
1075
|
+
} catch {
|
|
1076
|
+
sendJson(res, 200, { ok: false, error: 'check failed', hasUpdate: false })
|
|
1077
|
+
}
|
|
1078
|
+
},
|
|
1079
|
+
}), 'dsh-api-dashboard: update check route')
|
|
1080
|
+
|
|
1081
|
+
// v0.6.0: 执行自更新 (POST /install) — 下载+校验+备份+原子交换, 失败自动回滚
|
|
1082
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1083
|
+
kind: 'exact', path: '/api-dashboard/update/install',
|
|
1084
|
+
async handler(req, res) {
|
|
1085
|
+
if (req.method !== 'POST') { res.writeHead(405, { Allow: 'POST' }); res.end(); return }
|
|
1086
|
+
try {
|
|
1087
|
+
const result = await applyUpdate({})
|
|
1088
|
+
updateCache = { checkedAt: Date.now(), result: { ok: true, current: result.installed, remote: result.installed, hasUpdate: false, checkedAt: Date.now() } }
|
|
1089
|
+
sendJson(res, 200, { ok: true, installed: result.installed, targets: result.targets, backup: result.backup, needRestart: true })
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
const msg = /already up to date/.test(String(err?.message)) ? `already up to date`
|
|
1092
|
+
: /GitHub API|download|remote version/.test(String(err?.message)) ? 'network failed'
|
|
1093
|
+
: 'update failed'
|
|
1094
|
+
sendJson(res, 500, { ok: false, error: msg })
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1097
|
+
}), 'dsh-api-dashboard: update install route')
|
|
1098
|
+
|
|
1099
|
+
// v0.7.1: 插件图标 (哦鲸鲸)
|
|
1100
|
+
const ICON_PATH = join(SELF_ROOT, 'assets', 'icon.png')
|
|
1101
|
+
let iconCache = null
|
|
1102
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1103
|
+
kind: 'exact', path: '/api-dashboard/icon',
|
|
1104
|
+
async handler(req, res) {
|
|
1105
|
+
if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
|
|
1106
|
+
try {
|
|
1107
|
+
if (!iconCache) {
|
|
1108
|
+
const data = readFileSync(ICON_PATH)
|
|
1109
|
+
iconCache = { data, mtime: statSync(ICON_PATH).mtimeMs }
|
|
1110
|
+
}
|
|
1111
|
+
res.writeHead(200, {
|
|
1112
|
+
'Content-Type': 'image/png',
|
|
1113
|
+
'Cache-Control': 'public, max-age=86400',
|
|
1114
|
+
'Content-Length': iconCache.data.length,
|
|
1115
|
+
})
|
|
1116
|
+
res.end(iconCache.data)
|
|
1117
|
+
} catch {
|
|
1118
|
+
res.writeHead(404); res.end()
|
|
1119
|
+
}
|
|
1120
|
+
},
|
|
1121
|
+
}), 'dsh-api-dashboard: icon route')
|
|
1122
|
+
|
|
1123
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1124
|
+
kind: 'exact', path: '/api-dashboard/config',
|
|
1125
|
+
async handler(req, res) {
|
|
1126
|
+
if (req.method === 'GET') {
|
|
1127
|
+
sendJson(res, 200, {
|
|
1128
|
+
ok: true,
|
|
1129
|
+
customRelays: runtimeConfig.customRelays.map(r => ({ ...r, apiKey: r.apiKey ? '***' : '' })),
|
|
1130
|
+
customModels: runtimeConfig.customModels.map(m => ({ ...m, apiKey: m.apiKey ? '***' : '' })),
|
|
1131
|
+
presets: runtimeConfig.presets,
|
|
1132
|
+
refreshIntervalSec: Math.round(runtimeConfig.refreshIntervalMs / 1000),
|
|
1133
|
+
})
|
|
1134
|
+
return
|
|
1135
|
+
}
|
|
1136
|
+
if (req.method === 'POST') {
|
|
1137
|
+
try {
|
|
1138
|
+
let body = ''
|
|
1139
|
+
for await (const chunk of req) { body += chunk }
|
|
1140
|
+
body = body ? JSON.parse(body) : {}
|
|
1141
|
+
if (Array.isArray(body.customRelays)) {
|
|
1142
|
+
runtimeConfig.customRelays = body.customRelays.map(r => {
|
|
1143
|
+
const prev = runtimeConfig.customRelays.find(x => x.id === r.id)
|
|
1144
|
+
return {
|
|
1145
|
+
id: r.id || Math.random().toString(36).slice(2), name: r.name || '中转站',
|
|
1146
|
+
baseUrl: (r.baseUrl || '').replace(/\/+$/, ''), apiKey: r.apiKey && r.apiKey !== '***' ? r.apiKey : (prev?.apiKey || ''), queryType: r.queryType || 'auto',
|
|
1147
|
+
}
|
|
1148
|
+
})
|
|
1149
|
+
}
|
|
1150
|
+
if (Array.isArray(body.customModels)) {
|
|
1151
|
+
runtimeConfig.customModels = body.customModels.map(m => {
|
|
1152
|
+
const prev = runtimeConfig.customModels.find(x => x.id === m.id)
|
|
1153
|
+
return {
|
|
1154
|
+
id: m.id || Math.random().toString(36).slice(2), name: m.name || '自定义模型',
|
|
1155
|
+
apiUrl: (m.apiUrl || '').trim(), apiKey: m.apiKey && m.apiKey !== '***' ? m.apiKey : (prev?.apiKey || ''),
|
|
1156
|
+
queryType: m.queryType || 'auto', totalPath: (m.totalPath || '').trim(), usedPath: (m.usedPath || '').trim(),
|
|
1157
|
+
currency: (m.currency || '').trim() || 'CNY',
|
|
1158
|
+
}
|
|
1159
|
+
})
|
|
1160
|
+
}
|
|
1161
|
+
// 自定义刷新时间 (5~60 秒, 最高一分钟)
|
|
1162
|
+
if (typeof body.refreshIntervalSec === 'number' && Number.isFinite(body.refreshIntervalSec)) {
|
|
1163
|
+
const sec = Math.min(Math.max(Math.round(body.refreshIntervalSec), 5), 60)
|
|
1164
|
+
runtimeConfig.refreshIntervalMs = sec * 1000
|
|
1165
|
+
runtimeConfig.clientPollIntervalMs = sec * 1000
|
|
1166
|
+
}
|
|
1167
|
+
// 更新安全阈值
|
|
1168
|
+
if (typeof body.safeThreshold === 'number' && body.safeThreshold >= 0) runtimeConfig.safeThreshold = body.safeThreshold
|
|
1169
|
+
if (typeof body.warnThreshold === 'number' && body.warnThreshold >= 0) runtimeConfig.warnThreshold = body.warnThreshold
|
|
1170
|
+
if (typeof body.currency === 'string' && body.currency.trim()) runtimeConfig.currency = body.currency.trim().toUpperCase()
|
|
1171
|
+
// 持久化: 写入状态文件, 重启后恢复 (用户配置优先)
|
|
1172
|
+
savePersistedState({
|
|
1173
|
+
refreshIntervalMs: runtimeConfig.refreshIntervalMs,
|
|
1174
|
+
clientPollIntervalMs: runtimeConfig.clientPollIntervalMs,
|
|
1175
|
+
timeoutMs: runtimeConfig.timeoutMs,
|
|
1176
|
+
customRelays: runtimeConfig.customRelays,
|
|
1177
|
+
customModels: runtimeConfig.customModels,
|
|
1178
|
+
currency: runtimeConfig.currency,
|
|
1179
|
+
safeThreshold: runtimeConfig.safeThreshold,
|
|
1180
|
+
warnThreshold: runtimeConfig.warnThreshold,
|
|
1181
|
+
})
|
|
1182
|
+
resetLoop(); await refreshAll()
|
|
1183
|
+
sendJson(res, 200, {
|
|
1184
|
+
ok: true,
|
|
1185
|
+
customRelays: runtimeConfig.customRelays.map(r => ({ ...r, apiKey: r.apiKey ? '***' : '' })),
|
|
1186
|
+
customModels: runtimeConfig.customModels.map(m => ({ ...m, apiKey: m.apiKey ? '***' : '' })),
|
|
1187
|
+
refreshIntervalSec: Math.round(runtimeConfig.refreshIntervalMs / 1000),
|
|
1188
|
+
})
|
|
1189
|
+
} catch (err) { sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) }) }
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1192
|
+
res.writeHead(405, { Allow: 'GET, POST' })
|
|
1193
|
+
res.end()
|
|
1194
|
+
},
|
|
1195
|
+
}), 'dsh-api-dashboard: config route')
|
|
1196
|
+
})
|
|
1197
|
+
|
|
1198
|
+
// 会话消耗投影
|
|
1199
|
+
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
|
1200
|
+
projectionCtx.sessionProjections.register(makeCostProjection(getConfig))
|
|
1201
|
+
})
|
|
1202
|
+
}
|