dsh-dock 0.11.2 → 0.11.5
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 +18 -18
- package/README.md +255 -396
- package/client.js +596 -98
- package/cordis.patch.yml +2 -2
- package/features/animation/host.js +119 -119
- package/features/animation/view.jsx +1662 -1662
- package/features/balance/host.js +494 -494
- package/features/balance/view.js +351 -351
- package/features/games/games/breakout.jsx +227 -227
- package/features/games/games/gomoku.jsx +244 -244
- package/features/games/games/racer.jsx +169 -169
- package/features/games/games/shared.jsx +118 -118
- package/features/games/games/snake.jsx +144 -144
- package/features/games/games/sokoban.jsx +180 -180
- package/features/games/games/tank.jsx +128 -128
- package/features/games/games/tetris.jsx +308 -308
- package/features/games/games/xiangqi.jsx +277 -277
- package/features/games/tank-core.js +152 -152
- package/features/games/view.jsx +639 -639
- package/features/games/xiangqi-core.js +299 -299
- package/features/heartbeat/view.js +35 -35
- package/features/mobile-relay/gateway.js +472 -472
- package/features/mobile-relay/host.js +622 -570
- package/features/mobile-relay/view.jsx +380 -300
- package/features/modelconfig/host.js +406 -406
- package/features/modelconfig/view.js +490 -490
- package/features/notify/host.js +374 -374
- package/features/notify/view.jsx +751 -751
- package/features/theme/view.js +61 -61
- package/features/tokenlog/host.js +882 -883
- package/features/tokenlog/view.jsx +1369 -932
- package/features/visionproxy/host.js +315 -315
- package/index.js +195 -195
- package/package.json +66 -64
- package/screenshots/dock-tokenlog-charts-2.png +0 -0
- package/screenshots/dock-tokenlog-charts-3.png +0 -0
- package/screenshots/dock-tokenlog-charts.png +0 -0
- package/src/client.jsx +677 -677
- package/src/host-core.js +207 -207
- package/src/shared.js +192 -192
- package/src/task-track.js +354 -359
package/features/balance/host.js
CHANGED
|
@@ -1,494 +1,494 @@
|
|
|
1
|
-
// dsh-dock · 功能模块【模型余额】· 宿主半部(v0.2.0 迁自 index.js,行为不变)
|
|
2
|
-
//
|
|
3
|
-
// 在回环 webServer 上注册 /dsh-dock/balance 路由:枚举所有已配置的模型 Provider,
|
|
4
|
-
// 按余额策略查询各账号余额/配额,返回归一化 JSON 给浏览器半身(同源 fetch,无需 typert)。
|
|
5
|
-
// 余额策略表沿用 @wycto/dsh-balance-panel@0.1.1 的实现(MIT,同作者)。
|
|
6
|
-
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
7
|
-
import { sendJson, walkPath } from '../../src/host-core.js'
|
|
8
|
-
|
|
9
|
-
/** deepseek-official 内置默认值(适配器默认配置的镜像,配置缺失时兜底)。 */
|
|
10
|
-
const DEEPSEEK_DEFAULTS = {
|
|
11
|
-
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
|
12
|
-
baseURL: 'https://api.deepseek.com',
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function parseDeepSeek(body) {
|
|
16
|
-
const infos = body && Array.isArray(body.balance_infos) ? body.balance_infos : []
|
|
17
|
-
if (infos.length === 0) return { status: 'error', message: '无法识别的余额响应' }
|
|
18
|
-
const entry = infos.find((i) => i && i.currency === 'CNY') || infos[0]
|
|
19
|
-
const total = Number(entry && entry.total_balance)
|
|
20
|
-
if (!Number.isFinite(total)) return { status: 'error', message: '余额不是数字' }
|
|
21
|
-
return {
|
|
22
|
-
status: 'ok',
|
|
23
|
-
kind: 'currency',
|
|
24
|
-
available: body.is_available !== false,
|
|
25
|
-
infos: [{
|
|
26
|
-
currency: typeof entry.currency === 'string' ? entry.currency : 'CNY',
|
|
27
|
-
totalBalance: String(total),
|
|
28
|
-
grantedBalance: String(Number(entry.granted_balance) || 0),
|
|
29
|
-
toppedUpBalance: String(Number(entry.topped_up_balance) || 0),
|
|
30
|
-
}],
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function parseStepFun(body) {
|
|
35
|
-
const balance = Number(body && body.balance)
|
|
36
|
-
if (!Number.isFinite(balance)) return { status: 'error', message: '无法识别的 StepFun 账户响应' }
|
|
37
|
-
return {
|
|
38
|
-
status: 'ok',
|
|
39
|
-
kind: 'currency',
|
|
40
|
-
infos: [{
|
|
41
|
-
currency: 'CNY',
|
|
42
|
-
totalBalance: String(balance),
|
|
43
|
-
grantedBalance: String(Number(body.total_voucher_balance) || 0),
|
|
44
|
-
toppedUpBalance: String(Number(body.total_cash_balance) || 0),
|
|
45
|
-
}],
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function parseKimiCoding(body) {
|
|
50
|
-
const usage = body && body.usage
|
|
51
|
-
const limit = Number(usage && usage.limit)
|
|
52
|
-
const used = Number(usage && usage.used)
|
|
53
|
-
const remaining = Number(usage && usage.remaining)
|
|
54
|
-
if (!Number.isFinite(limit) || !Number.isFinite(used) || !Number.isFinite(remaining)) {
|
|
55
|
-
return { status: 'error', message: '无法识别的 Kimi 配额响应' }
|
|
56
|
-
}
|
|
57
|
-
const dims = [{
|
|
58
|
-
window: 'weekly',
|
|
59
|
-
limit,
|
|
60
|
-
used,
|
|
61
|
-
remaining,
|
|
62
|
-
resetTime: typeof usage.resetTime === 'string' ? usage.resetTime : null,
|
|
63
|
-
}]
|
|
64
|
-
const limits = Array.isArray(body.limits) ? body.limits : []
|
|
65
|
-
if (limits.length > 0 && limits[0] && limits[0].detail) {
|
|
66
|
-
const d = limits[0].detail
|
|
67
|
-
const hLimit = Number(d.limit)
|
|
68
|
-
const hUsed = Number(d.used)
|
|
69
|
-
const hRemaining = Number(d.remaining)
|
|
70
|
-
if (Number.isFinite(hLimit) && Number.isFinite(hUsed) && Number.isFinite(hRemaining)) {
|
|
71
|
-
dims.push({
|
|
72
|
-
window: 'hourly',
|
|
73
|
-
limit: hLimit,
|
|
74
|
-
used: hUsed,
|
|
75
|
-
remaining: hRemaining,
|
|
76
|
-
resetTime: typeof d.resetTime === 'string' ? d.resetTime : null,
|
|
77
|
-
})
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return {
|
|
81
|
-
status: 'ok',
|
|
82
|
-
kind: 'quota',
|
|
83
|
-
unit: 'requests',
|
|
84
|
-
limit,
|
|
85
|
-
used,
|
|
86
|
-
remaining,
|
|
87
|
-
resetTime: typeof usage.resetTime === 'string' ? usage.resetTime : null,
|
|
88
|
-
dims,
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function parseOpenRouter(body) {
|
|
93
|
-
const data = body && body.data
|
|
94
|
-
if (!data) return { status: 'error', message: '无法识别的 OpenRouter 响应' }
|
|
95
|
-
// OpenRouter 返回的是美分
|
|
96
|
-
const limitCents = Number(data.limit)
|
|
97
|
-
const usageCents = Number(data.usage)
|
|
98
|
-
if (!Number.isFinite(limitCents)) return { status: 'error', message: 'OpenRouter limit 不是数字' }
|
|
99
|
-
const balance = limitCents > 0 ? (limitCents - usageCents) / 100 : 0
|
|
100
|
-
return {
|
|
101
|
-
status: 'ok',
|
|
102
|
-
kind: 'currency',
|
|
103
|
-
infos: [{ currency: 'USD', totalBalance: String(Math.max(0, balance)) }],
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function parseMiniMax(body) {
|
|
108
|
-
const remaining = Number(body && (body.data ?? body.remaining))
|
|
109
|
-
const total = Number(body && (body.total ?? body.limit))
|
|
110
|
-
if (!Number.isFinite(remaining)) return { status: 'error', message: '无法识别的 MiniMax 响应' }
|
|
111
|
-
return {
|
|
112
|
-
status: 'ok',
|
|
113
|
-
kind: 'quota',
|
|
114
|
-
unit: 'requests',
|
|
115
|
-
limit: Number.isFinite(total) ? total : 0,
|
|
116
|
-
used: Number.isFinite(total) && Number.isFinite(remaining) ? Math.max(0, total - remaining) : 0,
|
|
117
|
-
remaining,
|
|
118
|
-
dims: [],
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function parseXai(body) {
|
|
123
|
-
const balance = Number(body && (body.balance ?? body.total_granted))
|
|
124
|
-
if (!Number.isFinite(balance)) return { status: 'error', message: '无法识别的 xAI 响应' }
|
|
125
|
-
return {
|
|
126
|
-
status: 'ok',
|
|
127
|
-
kind: 'currency',
|
|
128
|
-
infos: [{ currency: 'USD', totalBalance: String(balance) }],
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** 策略表:key 为规范 provider id,aliases 为别名。 */
|
|
133
|
-
const STRATEGIES = {
|
|
134
|
-
deepseek: {
|
|
135
|
-
suffix: '/user/balance',
|
|
136
|
-
defaultBaseURL: 'https://api.deepseek.com',
|
|
137
|
-
defaultKeyEnv: 'DEEPSEEK_API_KEY',
|
|
138
|
-
parse: parseDeepSeek,
|
|
139
|
-
aliases: ['deepseek-official'],
|
|
140
|
-
},
|
|
141
|
-
stepfun: {
|
|
142
|
-
suffix: '/accounts',
|
|
143
|
-
defaultBaseURL: 'https://api.stepfun.com/v1',
|
|
144
|
-
defaultKeyEnv: 'STEPFUN_API_KEY',
|
|
145
|
-
parse: parseStepFun,
|
|
146
|
-
},
|
|
147
|
-
'kimi-coding': {
|
|
148
|
-
suffix: '/v1/usages',
|
|
149
|
-
defaultBaseURL: 'https://api.kimi.com/coding',
|
|
150
|
-
defaultKeyEnv: 'KIMI_API_KEY',
|
|
151
|
-
parse: parseKimiCoding,
|
|
152
|
-
aliases: ['kimi'],
|
|
153
|
-
},
|
|
154
|
-
openrouter: {
|
|
155
|
-
suffix: '/api/v1/auth/key',
|
|
156
|
-
defaultBaseURL: 'https://openrouter.ai',
|
|
157
|
-
defaultKeyEnv: 'OPENROUTER_API_KEY',
|
|
158
|
-
parse: parseOpenRouter,
|
|
159
|
-
},
|
|
160
|
-
minimax: {
|
|
161
|
-
suffix: '/v1/token_plan/remains',
|
|
162
|
-
defaultBaseURL: 'https://api.minimax.chat',
|
|
163
|
-
defaultKeyEnv: 'MINIMAX_API_KEY',
|
|
164
|
-
parse: parseMiniMax,
|
|
165
|
-
},
|
|
166
|
-
xai: {
|
|
167
|
-
suffix: '/v1/dashboard/billing/credit_grants',
|
|
168
|
-
defaultBaseURL: 'https://api.x.ai',
|
|
169
|
-
defaultKeyEnv: 'XAI_API_KEY',
|
|
170
|
-
parse: parseXai,
|
|
171
|
-
aliases: ['grok'],
|
|
172
|
-
},
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** provider id 别名 → 规范 key(大小写不敏感)。 */
|
|
176
|
-
const KNOWN_IDS = new Map()
|
|
177
|
-
for (const [canonical, s] of Object.entries(STRATEGIES)) {
|
|
178
|
-
KNOWN_IDS.set(canonical.toLowerCase(), canonical)
|
|
179
|
-
for (const alias of s.aliases || []) KNOWN_IDS.set(alias.toLowerCase(), canonical)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** baseURL 家族 → 规范 key(自定义别名指向已知端点时自动命中)。 */
|
|
183
|
-
const URL_MATCHERS = [
|
|
184
|
-
[/api\.deepseek\.com/i, 'deepseek'],
|
|
185
|
-
[/api\.stepfun\.com/i, 'stepfun'],
|
|
186
|
-
[/api\.kimi\.com/i, 'kimi-coding'],
|
|
187
|
-
[/openrouter\.ai/i, 'openrouter'],
|
|
188
|
-
[/api\.minimax\.chat/i, 'minimax'],
|
|
189
|
-
[/api\.x\.ai/i, 'xai'],
|
|
190
|
-
[/platform\.stepfun\.com/i, 'stepfun'],
|
|
191
|
-
]
|
|
192
|
-
|
|
193
|
-
/** 无 API 余额接口、需登录控制台查看的 Provider(id → 控制台 URL)。 */
|
|
194
|
-
const LOGIN_REQUIRED_BY_ID = {
|
|
195
|
-
'qwen-token-plan-cn': 'https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal',
|
|
196
|
-
'qwen-token-plan': 'https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal',
|
|
197
|
-
'qwen-coding-plan': 'https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal',
|
|
198
|
-
xiaomi: 'https://platform.xiaomimimo.com/console/balance',
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const LOGIN_REQUIRED_URLS = [
|
|
202
|
-
[/bailian\.console\.aliyun\.com|dashscope|aliyuns\.com/i, 'https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal'],
|
|
203
|
-
[/xiaomimimo\.com/i, 'https://platform.xiaomimimo.com/console/balance'],
|
|
204
|
-
]
|
|
205
|
-
|
|
206
|
-
function stripSlash(url) {
|
|
207
|
-
return String(url).replace(/\/+$/, '')
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* 解析 Provider 的余额查询策略:先按 id(含别名)精确匹配,再按 baseURL 家族匹配。
|
|
212
|
-
* @returns {object|undefined} { url, keyEnv, parse }
|
|
213
|
-
*/
|
|
214
|
-
function matchStrategy(providerId, profile) {
|
|
215
|
-
const canonical = KNOWN_IDS.get(String(providerId).toLowerCase())
|
|
216
|
-
if (canonical !== undefined) {
|
|
217
|
-
const s = STRATEGIES[canonical]
|
|
218
|
-
const baseURL = profile && typeof profile.baseURL === 'string' ? profile.baseURL : s.defaultBaseURL
|
|
219
|
-
const keyEnv = profile && typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : s.defaultKeyEnv
|
|
220
|
-
return { url: `${stripSlash(baseURL)}${s.suffix}`, keyEnv, parse: s.parse }
|
|
221
|
-
}
|
|
222
|
-
const baseURL = profile && typeof profile.baseURL === 'string' ? profile.baseURL : undefined
|
|
223
|
-
if (baseURL !== undefined) {
|
|
224
|
-
for (const [pattern, key] of URL_MATCHERS) {
|
|
225
|
-
if (pattern.test(baseURL)) {
|
|
226
|
-
const s = STRATEGIES[key]
|
|
227
|
-
if (s) {
|
|
228
|
-
return {
|
|
229
|
-
url: `${stripSlash(baseURL)}${s.suffix}`,
|
|
230
|
-
keyEnv: profile && typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : s.defaultKeyEnv,
|
|
231
|
-
parse: s.parse,
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
return undefined
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** 解析"需登录查看"的 Provider 控制台地址。 */
|
|
241
|
-
function matchLoginRequired(providerId, profile) {
|
|
242
|
-
const byId = LOGIN_REQUIRED_BY_ID[String(providerId).toLowerCase()]
|
|
243
|
-
if (byId !== undefined) return byId
|
|
244
|
-
const baseURL = profile && typeof profile.baseURL === 'string' ? profile.baseURL : undefined
|
|
245
|
-
if (baseURL !== undefined) {
|
|
246
|
-
for (const [pattern, url] of LOGIN_REQUIRED_URLS) {
|
|
247
|
-
if (pattern.test(baseURL)) return url
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
return undefined
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** 从 profile 里提取模型 id 列表(字符串或 {id|name} 均可)。 */
|
|
254
|
-
function modelsOf(profile) {
|
|
255
|
-
if (!profile || !Array.isArray(profile.models)) return []
|
|
256
|
-
return profile.models
|
|
257
|
-
.map((m) => (typeof m === 'string' ? m : m && (m.id || m.name)))
|
|
258
|
-
.filter((x) => typeof x === 'string' && x)
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* 枚举**已配置**的 Provider:settings 的 user 层(用户自己写的)或 base 层
|
|
263
|
-
* (部署层声明)里有该 Provider 的 profile,或者是默认模型选中的 Provider。
|
|
264
|
-
* 模型只取用户实际配置的;schema 默认模型列表不会带出。
|
|
265
|
-
*/
|
|
266
|
-
function collectProviders(ctx) {
|
|
267
|
-
const llm = ctx.get('llm')
|
|
268
|
-
const settings = ctx.get('settings')
|
|
269
|
-
const entries = (llm && llm.listConfigurableProviders ? llm.listConfigurableProviders() : []) || []
|
|
270
|
-
let views = []
|
|
271
|
-
try {
|
|
272
|
-
views = (settings && settings.describe ? settings.describe({ redactSecrets: true }) : []) || []
|
|
273
|
-
} catch {
|
|
274
|
-
// settings 未挂载
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
let sel = null
|
|
278
|
-
try {
|
|
279
|
-
sel = (ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection()) || null
|
|
280
|
-
} catch {
|
|
281
|
-
// 服务缺失
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const out = new Map()
|
|
285
|
-
for (const e of entries) {
|
|
286
|
-
const desc = views.find((v) => v && String(v.ns) === e.settingsNs)
|
|
287
|
-
const profileUser = desc ? walkPath(desc.user, e.settingsPath) : undefined
|
|
288
|
-
const profileBase = desc ? walkPath(desc.base, e.settingsPath) : undefined
|
|
289
|
-
const isDefaultProvider = !!(sel && sel.provider === e.provider)
|
|
290
|
-
|
|
291
|
-
const configured = isDefaultProvider
|
|
292
|
-
|| (profileUser && typeof profileUser === 'object')
|
|
293
|
-
|| (profileBase && typeof profileBase === 'object')
|
|
294
|
-
if (!configured) continue
|
|
295
|
-
|
|
296
|
-
const userModels = modelsOf(profileUser)
|
|
297
|
-
const baseModels = modelsOf(profileBase)
|
|
298
|
-
let models = userModels.length > 0 ? userModels : baseModels
|
|
299
|
-
if (models.length === 0 && isDefaultProvider && sel && sel.model) models = [sel.model]
|
|
300
|
-
|
|
301
|
-
const profile = profileUser && typeof profileUser === 'object'
|
|
302
|
-
? profileUser
|
|
303
|
-
: (profileBase && typeof profileBase === 'object' ? profileBase : undefined)
|
|
304
|
-
|
|
305
|
-
const isDeepseek = e.provider === 'deepseek-official' || e.provider === 'deepseek'
|
|
306
|
-
let apiKeyEnv = profile && typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : undefined
|
|
307
|
-
let baseURL = profile && typeof profile.baseURL === 'string' ? profile.baseURL : undefined
|
|
308
|
-
let api = profile && typeof profile.api === 'string' ? profile.api : undefined
|
|
309
|
-
if (isDeepseek) {
|
|
310
|
-
apiKeyEnv = apiKeyEnv || DEEPSEEK_DEFAULTS.apiKeyEnv
|
|
311
|
-
baseURL = baseURL || DEEPSEEK_DEFAULTS.baseURL
|
|
312
|
-
api = api || 'deepseek'
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
out.set(e.provider, {
|
|
316
|
-
id: e.provider,
|
|
317
|
-
displayName: (profile && typeof profile.displayName === 'string' && profile.displayName) || e.displayName || e.provider,
|
|
318
|
-
apiKeyEnv,
|
|
319
|
-
baseURL,
|
|
320
|
-
api,
|
|
321
|
-
models,
|
|
322
|
-
profile: profile || undefined,
|
|
323
|
-
})
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// 默认 Provider 若不在目录里,用内置默认兜底
|
|
327
|
-
if (sel && sel.provider && !out.has(sel.provider)) {
|
|
328
|
-
const isDeepseek = sel.provider === 'deepseek-official' || sel.provider === 'deepseek'
|
|
329
|
-
out.set(sel.provider, {
|
|
330
|
-
id: sel.provider,
|
|
331
|
-
displayName: sel.provider,
|
|
332
|
-
apiKeyEnv: isDeepseek ? DEEPSEEK_DEFAULTS.apiKeyEnv : undefined,
|
|
333
|
-
baseURL: isDeepseek ? DEEPSEEK_DEFAULTS.baseURL : undefined,
|
|
334
|
-
api: isDeepseek ? 'deepseek' : undefined,
|
|
335
|
-
models: sel.model ? [sel.model] : [],
|
|
336
|
-
profile: undefined,
|
|
337
|
-
})
|
|
338
|
-
}
|
|
339
|
-
return { providers: Array.from(out.values()), default: sel }
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/** 用宿主全局 fetch 发起带认证头的 GET 并解析 JSON。 */
|
|
343
|
-
async function httpGetJson(url, auth, key) {
|
|
344
|
-
if (typeof fetch !== 'function') {
|
|
345
|
-
return { ok: false, error: '宿主进程没有全局 fetch' }
|
|
346
|
-
}
|
|
347
|
-
const headers = {}
|
|
348
|
-
if (auth === 'bearer' && key) headers.authorization = `Bearer ${key}`
|
|
349
|
-
let res
|
|
350
|
-
try {
|
|
351
|
-
res = await fetch(url, { headers, signal: AbortSignal.timeout(15000) })
|
|
352
|
-
} catch (e) {
|
|
353
|
-
return { ok: false, error: e && e.message ? e.message : String(e) }
|
|
354
|
-
}
|
|
355
|
-
if (!res.ok) return { ok: false, error: `余额接口 HTTP ${res.status}` }
|
|
356
|
-
try {
|
|
357
|
-
return { ok: true, body: await res.json() }
|
|
358
|
-
} catch {
|
|
359
|
-
return { ok: false, error: '余额接口返回了非 JSON 内容' }
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
async function resolveKey(ctx, keyEnv) {
|
|
364
|
-
if (!keyEnv) return null
|
|
365
|
-
try {
|
|
366
|
-
const credentials = ctx.get('credentials')
|
|
367
|
-
const hit = credentials ? await credentials.resolve(credentialRef(keyEnv)) : null
|
|
368
|
-
return hit && hit.value ? hit.value : null
|
|
369
|
-
} catch {
|
|
370
|
-
return null
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
async function queryEndpoint(ctx, spec, entry) {
|
|
375
|
-
if (spec.keyEnv && !spec.key) {
|
|
376
|
-
return { status: 'no-credential', message: `凭证 ${spec.keyEnv} 未配置` }
|
|
377
|
-
}
|
|
378
|
-
const res = await httpGetJson(spec.url, spec.auth, spec.key)
|
|
379
|
-
if (!res.ok) return { status: 'error', message: res.error }
|
|
380
|
-
const parsed = spec.parse(res.body)
|
|
381
|
-
if (parsed.status === 'ok') entry.credentialConfigured = true
|
|
382
|
-
return parsed
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/** 查询单个 Provider 的余额/配额/登录地址,产出给前端的归一化条目。 */
|
|
386
|
-
async function queryProviderBalance(ctx, p) {
|
|
387
|
-
const entry = {
|
|
388
|
-
id: p.id,
|
|
389
|
-
displayName: p.displayName,
|
|
390
|
-
apiKeyEnv: p.apiKeyEnv || null,
|
|
391
|
-
baseURL: p.baseURL || null,
|
|
392
|
-
api: p.api || null,
|
|
393
|
-
models: p.models || [],
|
|
394
|
-
credentialConfigured: false,
|
|
395
|
-
balance: null,
|
|
396
|
-
}
|
|
397
|
-
// 先报告密钥配置状态(describe 不读取值)
|
|
398
|
-
if (p.apiKeyEnv) {
|
|
399
|
-
try {
|
|
400
|
-
const credentials = ctx.get('credentials')
|
|
401
|
-
const info = credentials ? await credentials.describe(credentialRef(p.apiKeyEnv)) : null
|
|
402
|
-
entry.credentialConfigured = !!(info && info.configured)
|
|
403
|
-
} catch {
|
|
404
|
-
// 保持 false
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// 1) 自定义 balance 配置优先
|
|
409
|
-
const custom = p.profile && typeof p.profile.balance === 'object' && p.profile.balance !== null
|
|
410
|
-
? p.profile.balance
|
|
411
|
-
: null
|
|
412
|
-
if (custom && typeof custom.endpoint === 'string' && custom.endpoint) {
|
|
413
|
-
entry.balance = await queryEndpoint(ctx, {
|
|
414
|
-
url: /^https?:\/\//.test(custom.endpoint)
|
|
415
|
-
? custom.endpoint
|
|
416
|
-
: `${(p.baseURL || '').replace(/\/+$/, '')}${custom.endpoint}`,
|
|
417
|
-
auth: custom.auth === 'none' ? 'none' : 'bearer',
|
|
418
|
-
keyEnv: p.apiKeyEnv,
|
|
419
|
-
parse: parseDeepSeek,
|
|
420
|
-
key: p.apiKeyEnv ? await resolveKey(ctx, p.apiKeyEnv) : null,
|
|
421
|
-
}, entry)
|
|
422
|
-
return entry
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// 2) 内置策略表(id 别名 + baseURL 家族)
|
|
426
|
-
const strat = matchStrategy(p.id, p.profile)
|
|
427
|
-
if (strat) {
|
|
428
|
-
entry.balance = await queryEndpoint(ctx, {
|
|
429
|
-
url: strat.url,
|
|
430
|
-
auth: 'bearer',
|
|
431
|
-
keyEnv: strat.keyEnv,
|
|
432
|
-
parse: strat.parse,
|
|
433
|
-
key: await resolveKey(ctx, strat.keyEnv),
|
|
434
|
-
}, entry)
|
|
435
|
-
return entry
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// 3) 登录跳转(无 API 接口但有控制台)
|
|
439
|
-
const consoleUrl = matchLoginRequired(p.id, p.profile)
|
|
440
|
-
if (consoleUrl) {
|
|
441
|
-
entry.balance = { status: 'login-required', consoleUrl }
|
|
442
|
-
return entry
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// 4) 不支持
|
|
446
|
-
entry.balance = { status: 'unsupported', message: '该 Provider 没有已知的余额查询接口' }
|
|
447
|
-
return entry
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/** 汇总全部 Provider 的余额视图。 */
|
|
451
|
-
async function queryAll(ctx) {
|
|
452
|
-
const collected = collectProviders(ctx)
|
|
453
|
-
const providers = []
|
|
454
|
-
for (const p of collected.providers) {
|
|
455
|
-
try {
|
|
456
|
-
providers.push(await queryProviderBalance(ctx, p))
|
|
457
|
-
} catch (e) {
|
|
458
|
-
providers.push({
|
|
459
|
-
id: p.id,
|
|
460
|
-
displayName: p.displayName,
|
|
461
|
-
apiKeyEnv: p.apiKeyEnv || null,
|
|
462
|
-
baseURL: p.baseURL || null,
|
|
463
|
-
api: p.api || null,
|
|
464
|
-
models: p.models || [],
|
|
465
|
-
credentialConfigured: false,
|
|
466
|
-
balance: { status: 'error', message: e && e.message ? e.message : String(e) },
|
|
467
|
-
})
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
return { generatedAt: Date.now(), default: collected.default, providers }
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/** 功能注册表条目:index.js 组装 hostSetups 时使用。 */
|
|
474
|
-
export const feature = {
|
|
475
|
-
id: 'balance',
|
|
476
|
-
name: '模型余额',
|
|
477
|
-
description: '拉取所有模型 Provider 账户余额并展示',
|
|
478
|
-
defaultEnabled: false,
|
|
479
|
-
setup(ctx) {
|
|
480
|
-
const webServer = ctx.get('webServer')
|
|
481
|
-
if (webServer === undefined) throw new Error('webServer 服务不可用,无法提供余额查询路由')
|
|
482
|
-
const handler = async (req, res) => {
|
|
483
|
-
try {
|
|
484
|
-
sendJson(res, 200, await queryAll(ctx))
|
|
485
|
-
} catch (e) {
|
|
486
|
-
sendJson(res, 500, {
|
|
487
|
-
error: e instanceof Error ? e.message : String(e),
|
|
488
|
-
providers: [],
|
|
489
|
-
})
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
return webServer.register({ kind: 'exact', path: '/dsh-dock/balance', handler })
|
|
493
|
-
},
|
|
494
|
-
}
|
|
1
|
+
// dsh-dock · 功能模块【模型余额】· 宿主半部(v0.2.0 迁自 index.js,行为不变)
|
|
2
|
+
//
|
|
3
|
+
// 在回环 webServer 上注册 /dsh-dock/balance 路由:枚举所有已配置的模型 Provider,
|
|
4
|
+
// 按余额策略查询各账号余额/配额,返回归一化 JSON 给浏览器半身(同源 fetch,无需 typert)。
|
|
5
|
+
// 余额策略表沿用 @wycto/dsh-balance-panel@0.1.1 的实现(MIT,同作者)。
|
|
6
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
7
|
+
import { sendJson, walkPath } from '../../src/host-core.js'
|
|
8
|
+
|
|
9
|
+
/** deepseek-official 内置默认值(适配器默认配置的镜像,配置缺失时兜底)。 */
|
|
10
|
+
const DEEPSEEK_DEFAULTS = {
|
|
11
|
+
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
|
12
|
+
baseURL: 'https://api.deepseek.com',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseDeepSeek(body) {
|
|
16
|
+
const infos = body && Array.isArray(body.balance_infos) ? body.balance_infos : []
|
|
17
|
+
if (infos.length === 0) return { status: 'error', message: '无法识别的余额响应' }
|
|
18
|
+
const entry = infos.find((i) => i && i.currency === 'CNY') || infos[0]
|
|
19
|
+
const total = Number(entry && entry.total_balance)
|
|
20
|
+
if (!Number.isFinite(total)) return { status: 'error', message: '余额不是数字' }
|
|
21
|
+
return {
|
|
22
|
+
status: 'ok',
|
|
23
|
+
kind: 'currency',
|
|
24
|
+
available: body.is_available !== false,
|
|
25
|
+
infos: [{
|
|
26
|
+
currency: typeof entry.currency === 'string' ? entry.currency : 'CNY',
|
|
27
|
+
totalBalance: String(total),
|
|
28
|
+
grantedBalance: String(Number(entry.granted_balance) || 0),
|
|
29
|
+
toppedUpBalance: String(Number(entry.topped_up_balance) || 0),
|
|
30
|
+
}],
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseStepFun(body) {
|
|
35
|
+
const balance = Number(body && body.balance)
|
|
36
|
+
if (!Number.isFinite(balance)) return { status: 'error', message: '无法识别的 StepFun 账户响应' }
|
|
37
|
+
return {
|
|
38
|
+
status: 'ok',
|
|
39
|
+
kind: 'currency',
|
|
40
|
+
infos: [{
|
|
41
|
+
currency: 'CNY',
|
|
42
|
+
totalBalance: String(balance),
|
|
43
|
+
grantedBalance: String(Number(body.total_voucher_balance) || 0),
|
|
44
|
+
toppedUpBalance: String(Number(body.total_cash_balance) || 0),
|
|
45
|
+
}],
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseKimiCoding(body) {
|
|
50
|
+
const usage = body && body.usage
|
|
51
|
+
const limit = Number(usage && usage.limit)
|
|
52
|
+
const used = Number(usage && usage.used)
|
|
53
|
+
const remaining = Number(usage && usage.remaining)
|
|
54
|
+
if (!Number.isFinite(limit) || !Number.isFinite(used) || !Number.isFinite(remaining)) {
|
|
55
|
+
return { status: 'error', message: '无法识别的 Kimi 配额响应' }
|
|
56
|
+
}
|
|
57
|
+
const dims = [{
|
|
58
|
+
window: 'weekly',
|
|
59
|
+
limit,
|
|
60
|
+
used,
|
|
61
|
+
remaining,
|
|
62
|
+
resetTime: typeof usage.resetTime === 'string' ? usage.resetTime : null,
|
|
63
|
+
}]
|
|
64
|
+
const limits = Array.isArray(body.limits) ? body.limits : []
|
|
65
|
+
if (limits.length > 0 && limits[0] && limits[0].detail) {
|
|
66
|
+
const d = limits[0].detail
|
|
67
|
+
const hLimit = Number(d.limit)
|
|
68
|
+
const hUsed = Number(d.used)
|
|
69
|
+
const hRemaining = Number(d.remaining)
|
|
70
|
+
if (Number.isFinite(hLimit) && Number.isFinite(hUsed) && Number.isFinite(hRemaining)) {
|
|
71
|
+
dims.push({
|
|
72
|
+
window: 'hourly',
|
|
73
|
+
limit: hLimit,
|
|
74
|
+
used: hUsed,
|
|
75
|
+
remaining: hRemaining,
|
|
76
|
+
resetTime: typeof d.resetTime === 'string' ? d.resetTime : null,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
status: 'ok',
|
|
82
|
+
kind: 'quota',
|
|
83
|
+
unit: 'requests',
|
|
84
|
+
limit,
|
|
85
|
+
used,
|
|
86
|
+
remaining,
|
|
87
|
+
resetTime: typeof usage.resetTime === 'string' ? usage.resetTime : null,
|
|
88
|
+
dims,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseOpenRouter(body) {
|
|
93
|
+
const data = body && body.data
|
|
94
|
+
if (!data) return { status: 'error', message: '无法识别的 OpenRouter 响应' }
|
|
95
|
+
// OpenRouter 返回的是美分
|
|
96
|
+
const limitCents = Number(data.limit)
|
|
97
|
+
const usageCents = Number(data.usage)
|
|
98
|
+
if (!Number.isFinite(limitCents)) return { status: 'error', message: 'OpenRouter limit 不是数字' }
|
|
99
|
+
const balance = limitCents > 0 ? (limitCents - usageCents) / 100 : 0
|
|
100
|
+
return {
|
|
101
|
+
status: 'ok',
|
|
102
|
+
kind: 'currency',
|
|
103
|
+
infos: [{ currency: 'USD', totalBalance: String(Math.max(0, balance)) }],
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseMiniMax(body) {
|
|
108
|
+
const remaining = Number(body && (body.data ?? body.remaining))
|
|
109
|
+
const total = Number(body && (body.total ?? body.limit))
|
|
110
|
+
if (!Number.isFinite(remaining)) return { status: 'error', message: '无法识别的 MiniMax 响应' }
|
|
111
|
+
return {
|
|
112
|
+
status: 'ok',
|
|
113
|
+
kind: 'quota',
|
|
114
|
+
unit: 'requests',
|
|
115
|
+
limit: Number.isFinite(total) ? total : 0,
|
|
116
|
+
used: Number.isFinite(total) && Number.isFinite(remaining) ? Math.max(0, total - remaining) : 0,
|
|
117
|
+
remaining,
|
|
118
|
+
dims: [],
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseXai(body) {
|
|
123
|
+
const balance = Number(body && (body.balance ?? body.total_granted))
|
|
124
|
+
if (!Number.isFinite(balance)) return { status: 'error', message: '无法识别的 xAI 响应' }
|
|
125
|
+
return {
|
|
126
|
+
status: 'ok',
|
|
127
|
+
kind: 'currency',
|
|
128
|
+
infos: [{ currency: 'USD', totalBalance: String(balance) }],
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 策略表:key 为规范 provider id,aliases 为别名。 */
|
|
133
|
+
const STRATEGIES = {
|
|
134
|
+
deepseek: {
|
|
135
|
+
suffix: '/user/balance',
|
|
136
|
+
defaultBaseURL: 'https://api.deepseek.com',
|
|
137
|
+
defaultKeyEnv: 'DEEPSEEK_API_KEY',
|
|
138
|
+
parse: parseDeepSeek,
|
|
139
|
+
aliases: ['deepseek-official'],
|
|
140
|
+
},
|
|
141
|
+
stepfun: {
|
|
142
|
+
suffix: '/accounts',
|
|
143
|
+
defaultBaseURL: 'https://api.stepfun.com/v1',
|
|
144
|
+
defaultKeyEnv: 'STEPFUN_API_KEY',
|
|
145
|
+
parse: parseStepFun,
|
|
146
|
+
},
|
|
147
|
+
'kimi-coding': {
|
|
148
|
+
suffix: '/v1/usages',
|
|
149
|
+
defaultBaseURL: 'https://api.kimi.com/coding',
|
|
150
|
+
defaultKeyEnv: 'KIMI_API_KEY',
|
|
151
|
+
parse: parseKimiCoding,
|
|
152
|
+
aliases: ['kimi'],
|
|
153
|
+
},
|
|
154
|
+
openrouter: {
|
|
155
|
+
suffix: '/api/v1/auth/key',
|
|
156
|
+
defaultBaseURL: 'https://openrouter.ai',
|
|
157
|
+
defaultKeyEnv: 'OPENROUTER_API_KEY',
|
|
158
|
+
parse: parseOpenRouter,
|
|
159
|
+
},
|
|
160
|
+
minimax: {
|
|
161
|
+
suffix: '/v1/token_plan/remains',
|
|
162
|
+
defaultBaseURL: 'https://api.minimax.chat',
|
|
163
|
+
defaultKeyEnv: 'MINIMAX_API_KEY',
|
|
164
|
+
parse: parseMiniMax,
|
|
165
|
+
},
|
|
166
|
+
xai: {
|
|
167
|
+
suffix: '/v1/dashboard/billing/credit_grants',
|
|
168
|
+
defaultBaseURL: 'https://api.x.ai',
|
|
169
|
+
defaultKeyEnv: 'XAI_API_KEY',
|
|
170
|
+
parse: parseXai,
|
|
171
|
+
aliases: ['grok'],
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** provider id 别名 → 规范 key(大小写不敏感)。 */
|
|
176
|
+
const KNOWN_IDS = new Map()
|
|
177
|
+
for (const [canonical, s] of Object.entries(STRATEGIES)) {
|
|
178
|
+
KNOWN_IDS.set(canonical.toLowerCase(), canonical)
|
|
179
|
+
for (const alias of s.aliases || []) KNOWN_IDS.set(alias.toLowerCase(), canonical)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** baseURL 家族 → 规范 key(自定义别名指向已知端点时自动命中)。 */
|
|
183
|
+
const URL_MATCHERS = [
|
|
184
|
+
[/api\.deepseek\.com/i, 'deepseek'],
|
|
185
|
+
[/api\.stepfun\.com/i, 'stepfun'],
|
|
186
|
+
[/api\.kimi\.com/i, 'kimi-coding'],
|
|
187
|
+
[/openrouter\.ai/i, 'openrouter'],
|
|
188
|
+
[/api\.minimax\.chat/i, 'minimax'],
|
|
189
|
+
[/api\.x\.ai/i, 'xai'],
|
|
190
|
+
[/platform\.stepfun\.com/i, 'stepfun'],
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
/** 无 API 余额接口、需登录控制台查看的 Provider(id → 控制台 URL)。 */
|
|
194
|
+
const LOGIN_REQUIRED_BY_ID = {
|
|
195
|
+
'qwen-token-plan-cn': 'https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal',
|
|
196
|
+
'qwen-token-plan': 'https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal',
|
|
197
|
+
'qwen-coding-plan': 'https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal',
|
|
198
|
+
xiaomi: 'https://platform.xiaomimimo.com/console/balance',
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const LOGIN_REQUIRED_URLS = [
|
|
202
|
+
[/bailian\.console\.aliyun\.com|dashscope|aliyuns\.com/i, 'https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal'],
|
|
203
|
+
[/xiaomimimo\.com/i, 'https://platform.xiaomimimo.com/console/balance'],
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
function stripSlash(url) {
|
|
207
|
+
return String(url).replace(/\/+$/, '')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 解析 Provider 的余额查询策略:先按 id(含别名)精确匹配,再按 baseURL 家族匹配。
|
|
212
|
+
* @returns {object|undefined} { url, keyEnv, parse }
|
|
213
|
+
*/
|
|
214
|
+
function matchStrategy(providerId, profile) {
|
|
215
|
+
const canonical = KNOWN_IDS.get(String(providerId).toLowerCase())
|
|
216
|
+
if (canonical !== undefined) {
|
|
217
|
+
const s = STRATEGIES[canonical]
|
|
218
|
+
const baseURL = profile && typeof profile.baseURL === 'string' ? profile.baseURL : s.defaultBaseURL
|
|
219
|
+
const keyEnv = profile && typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : s.defaultKeyEnv
|
|
220
|
+
return { url: `${stripSlash(baseURL)}${s.suffix}`, keyEnv, parse: s.parse }
|
|
221
|
+
}
|
|
222
|
+
const baseURL = profile && typeof profile.baseURL === 'string' ? profile.baseURL : undefined
|
|
223
|
+
if (baseURL !== undefined) {
|
|
224
|
+
for (const [pattern, key] of URL_MATCHERS) {
|
|
225
|
+
if (pattern.test(baseURL)) {
|
|
226
|
+
const s = STRATEGIES[key]
|
|
227
|
+
if (s) {
|
|
228
|
+
return {
|
|
229
|
+
url: `${stripSlash(baseURL)}${s.suffix}`,
|
|
230
|
+
keyEnv: profile && typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : s.defaultKeyEnv,
|
|
231
|
+
parse: s.parse,
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return undefined
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 解析"需登录查看"的 Provider 控制台地址。 */
|
|
241
|
+
function matchLoginRequired(providerId, profile) {
|
|
242
|
+
const byId = LOGIN_REQUIRED_BY_ID[String(providerId).toLowerCase()]
|
|
243
|
+
if (byId !== undefined) return byId
|
|
244
|
+
const baseURL = profile && typeof profile.baseURL === 'string' ? profile.baseURL : undefined
|
|
245
|
+
if (baseURL !== undefined) {
|
|
246
|
+
for (const [pattern, url] of LOGIN_REQUIRED_URLS) {
|
|
247
|
+
if (pattern.test(baseURL)) return url
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return undefined
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 从 profile 里提取模型 id 列表(字符串或 {id|name} 均可)。 */
|
|
254
|
+
function modelsOf(profile) {
|
|
255
|
+
if (!profile || !Array.isArray(profile.models)) return []
|
|
256
|
+
return profile.models
|
|
257
|
+
.map((m) => (typeof m === 'string' ? m : m && (m.id || m.name)))
|
|
258
|
+
.filter((x) => typeof x === 'string' && x)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 枚举**已配置**的 Provider:settings 的 user 层(用户自己写的)或 base 层
|
|
263
|
+
* (部署层声明)里有该 Provider 的 profile,或者是默认模型选中的 Provider。
|
|
264
|
+
* 模型只取用户实际配置的;schema 默认模型列表不会带出。
|
|
265
|
+
*/
|
|
266
|
+
function collectProviders(ctx) {
|
|
267
|
+
const llm = ctx.get('llm')
|
|
268
|
+
const settings = ctx.get('settings')
|
|
269
|
+
const entries = (llm && llm.listConfigurableProviders ? llm.listConfigurableProviders() : []) || []
|
|
270
|
+
let views = []
|
|
271
|
+
try {
|
|
272
|
+
views = (settings && settings.describe ? settings.describe({ redactSecrets: true }) : []) || []
|
|
273
|
+
} catch {
|
|
274
|
+
// settings 未挂载
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let sel = null
|
|
278
|
+
try {
|
|
279
|
+
sel = (ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection()) || null
|
|
280
|
+
} catch {
|
|
281
|
+
// 服务缺失
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const out = new Map()
|
|
285
|
+
for (const e of entries) {
|
|
286
|
+
const desc = views.find((v) => v && String(v.ns) === e.settingsNs)
|
|
287
|
+
const profileUser = desc ? walkPath(desc.user, e.settingsPath) : undefined
|
|
288
|
+
const profileBase = desc ? walkPath(desc.base, e.settingsPath) : undefined
|
|
289
|
+
const isDefaultProvider = !!(sel && sel.provider === e.provider)
|
|
290
|
+
|
|
291
|
+
const configured = isDefaultProvider
|
|
292
|
+
|| (profileUser && typeof profileUser === 'object')
|
|
293
|
+
|| (profileBase && typeof profileBase === 'object')
|
|
294
|
+
if (!configured) continue
|
|
295
|
+
|
|
296
|
+
const userModels = modelsOf(profileUser)
|
|
297
|
+
const baseModels = modelsOf(profileBase)
|
|
298
|
+
let models = userModels.length > 0 ? userModels : baseModels
|
|
299
|
+
if (models.length === 0 && isDefaultProvider && sel && sel.model) models = [sel.model]
|
|
300
|
+
|
|
301
|
+
const profile = profileUser && typeof profileUser === 'object'
|
|
302
|
+
? profileUser
|
|
303
|
+
: (profileBase && typeof profileBase === 'object' ? profileBase : undefined)
|
|
304
|
+
|
|
305
|
+
const isDeepseek = e.provider === 'deepseek-official' || e.provider === 'deepseek'
|
|
306
|
+
let apiKeyEnv = profile && typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : undefined
|
|
307
|
+
let baseURL = profile && typeof profile.baseURL === 'string' ? profile.baseURL : undefined
|
|
308
|
+
let api = profile && typeof profile.api === 'string' ? profile.api : undefined
|
|
309
|
+
if (isDeepseek) {
|
|
310
|
+
apiKeyEnv = apiKeyEnv || DEEPSEEK_DEFAULTS.apiKeyEnv
|
|
311
|
+
baseURL = baseURL || DEEPSEEK_DEFAULTS.baseURL
|
|
312
|
+
api = api || 'deepseek'
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
out.set(e.provider, {
|
|
316
|
+
id: e.provider,
|
|
317
|
+
displayName: (profile && typeof profile.displayName === 'string' && profile.displayName) || e.displayName || e.provider,
|
|
318
|
+
apiKeyEnv,
|
|
319
|
+
baseURL,
|
|
320
|
+
api,
|
|
321
|
+
models,
|
|
322
|
+
profile: profile || undefined,
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 默认 Provider 若不在目录里,用内置默认兜底
|
|
327
|
+
if (sel && sel.provider && !out.has(sel.provider)) {
|
|
328
|
+
const isDeepseek = sel.provider === 'deepseek-official' || sel.provider === 'deepseek'
|
|
329
|
+
out.set(sel.provider, {
|
|
330
|
+
id: sel.provider,
|
|
331
|
+
displayName: sel.provider,
|
|
332
|
+
apiKeyEnv: isDeepseek ? DEEPSEEK_DEFAULTS.apiKeyEnv : undefined,
|
|
333
|
+
baseURL: isDeepseek ? DEEPSEEK_DEFAULTS.baseURL : undefined,
|
|
334
|
+
api: isDeepseek ? 'deepseek' : undefined,
|
|
335
|
+
models: sel.model ? [sel.model] : [],
|
|
336
|
+
profile: undefined,
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
return { providers: Array.from(out.values()), default: sel }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** 用宿主全局 fetch 发起带认证头的 GET 并解析 JSON。 */
|
|
343
|
+
async function httpGetJson(url, auth, key) {
|
|
344
|
+
if (typeof fetch !== 'function') {
|
|
345
|
+
return { ok: false, error: '宿主进程没有全局 fetch' }
|
|
346
|
+
}
|
|
347
|
+
const headers = {}
|
|
348
|
+
if (auth === 'bearer' && key) headers.authorization = `Bearer ${key}`
|
|
349
|
+
let res
|
|
350
|
+
try {
|
|
351
|
+
res = await fetch(url, { headers, signal: AbortSignal.timeout(15000) })
|
|
352
|
+
} catch (e) {
|
|
353
|
+
return { ok: false, error: e && e.message ? e.message : String(e) }
|
|
354
|
+
}
|
|
355
|
+
if (!res.ok) return { ok: false, error: `余额接口 HTTP ${res.status}` }
|
|
356
|
+
try {
|
|
357
|
+
return { ok: true, body: await res.json() }
|
|
358
|
+
} catch {
|
|
359
|
+
return { ok: false, error: '余额接口返回了非 JSON 内容' }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function resolveKey(ctx, keyEnv) {
|
|
364
|
+
if (!keyEnv) return null
|
|
365
|
+
try {
|
|
366
|
+
const credentials = ctx.get('credentials')
|
|
367
|
+
const hit = credentials ? await credentials.resolve(credentialRef(keyEnv)) : null
|
|
368
|
+
return hit && hit.value ? hit.value : null
|
|
369
|
+
} catch {
|
|
370
|
+
return null
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function queryEndpoint(ctx, spec, entry) {
|
|
375
|
+
if (spec.keyEnv && !spec.key) {
|
|
376
|
+
return { status: 'no-credential', message: `凭证 ${spec.keyEnv} 未配置` }
|
|
377
|
+
}
|
|
378
|
+
const res = await httpGetJson(spec.url, spec.auth, spec.key)
|
|
379
|
+
if (!res.ok) return { status: 'error', message: res.error }
|
|
380
|
+
const parsed = spec.parse(res.body)
|
|
381
|
+
if (parsed.status === 'ok') entry.credentialConfigured = true
|
|
382
|
+
return parsed
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** 查询单个 Provider 的余额/配额/登录地址,产出给前端的归一化条目。 */
|
|
386
|
+
async function queryProviderBalance(ctx, p) {
|
|
387
|
+
const entry = {
|
|
388
|
+
id: p.id,
|
|
389
|
+
displayName: p.displayName,
|
|
390
|
+
apiKeyEnv: p.apiKeyEnv || null,
|
|
391
|
+
baseURL: p.baseURL || null,
|
|
392
|
+
api: p.api || null,
|
|
393
|
+
models: p.models || [],
|
|
394
|
+
credentialConfigured: false,
|
|
395
|
+
balance: null,
|
|
396
|
+
}
|
|
397
|
+
// 先报告密钥配置状态(describe 不读取值)
|
|
398
|
+
if (p.apiKeyEnv) {
|
|
399
|
+
try {
|
|
400
|
+
const credentials = ctx.get('credentials')
|
|
401
|
+
const info = credentials ? await credentials.describe(credentialRef(p.apiKeyEnv)) : null
|
|
402
|
+
entry.credentialConfigured = !!(info && info.configured)
|
|
403
|
+
} catch {
|
|
404
|
+
// 保持 false
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 1) 自定义 balance 配置优先
|
|
409
|
+
const custom = p.profile && typeof p.profile.balance === 'object' && p.profile.balance !== null
|
|
410
|
+
? p.profile.balance
|
|
411
|
+
: null
|
|
412
|
+
if (custom && typeof custom.endpoint === 'string' && custom.endpoint) {
|
|
413
|
+
entry.balance = await queryEndpoint(ctx, {
|
|
414
|
+
url: /^https?:\/\//.test(custom.endpoint)
|
|
415
|
+
? custom.endpoint
|
|
416
|
+
: `${(p.baseURL || '').replace(/\/+$/, '')}${custom.endpoint}`,
|
|
417
|
+
auth: custom.auth === 'none' ? 'none' : 'bearer',
|
|
418
|
+
keyEnv: p.apiKeyEnv,
|
|
419
|
+
parse: parseDeepSeek,
|
|
420
|
+
key: p.apiKeyEnv ? await resolveKey(ctx, p.apiKeyEnv) : null,
|
|
421
|
+
}, entry)
|
|
422
|
+
return entry
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// 2) 内置策略表(id 别名 + baseURL 家族)
|
|
426
|
+
const strat = matchStrategy(p.id, p.profile)
|
|
427
|
+
if (strat) {
|
|
428
|
+
entry.balance = await queryEndpoint(ctx, {
|
|
429
|
+
url: strat.url,
|
|
430
|
+
auth: 'bearer',
|
|
431
|
+
keyEnv: strat.keyEnv,
|
|
432
|
+
parse: strat.parse,
|
|
433
|
+
key: await resolveKey(ctx, strat.keyEnv),
|
|
434
|
+
}, entry)
|
|
435
|
+
return entry
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 3) 登录跳转(无 API 接口但有控制台)
|
|
439
|
+
const consoleUrl = matchLoginRequired(p.id, p.profile)
|
|
440
|
+
if (consoleUrl) {
|
|
441
|
+
entry.balance = { status: 'login-required', consoleUrl }
|
|
442
|
+
return entry
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// 4) 不支持
|
|
446
|
+
entry.balance = { status: 'unsupported', message: '该 Provider 没有已知的余额查询接口' }
|
|
447
|
+
return entry
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** 汇总全部 Provider 的余额视图。 */
|
|
451
|
+
async function queryAll(ctx) {
|
|
452
|
+
const collected = collectProviders(ctx)
|
|
453
|
+
const providers = []
|
|
454
|
+
for (const p of collected.providers) {
|
|
455
|
+
try {
|
|
456
|
+
providers.push(await queryProviderBalance(ctx, p))
|
|
457
|
+
} catch (e) {
|
|
458
|
+
providers.push({
|
|
459
|
+
id: p.id,
|
|
460
|
+
displayName: p.displayName,
|
|
461
|
+
apiKeyEnv: p.apiKeyEnv || null,
|
|
462
|
+
baseURL: p.baseURL || null,
|
|
463
|
+
api: p.api || null,
|
|
464
|
+
models: p.models || [],
|
|
465
|
+
credentialConfigured: false,
|
|
466
|
+
balance: { status: 'error', message: e && e.message ? e.message : String(e) },
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return { generatedAt: Date.now(), default: collected.default, providers }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** 功能注册表条目:index.js 组装 hostSetups 时使用。 */
|
|
474
|
+
export const feature = {
|
|
475
|
+
id: 'balance',
|
|
476
|
+
name: '模型余额',
|
|
477
|
+
description: '拉取所有模型 Provider 账户余额并展示',
|
|
478
|
+
defaultEnabled: false,
|
|
479
|
+
setup(ctx) {
|
|
480
|
+
const webServer = ctx.get('webServer')
|
|
481
|
+
if (webServer === undefined) throw new Error('webServer 服务不可用,无法提供余额查询路由')
|
|
482
|
+
const handler = async (req, res) => {
|
|
483
|
+
try {
|
|
484
|
+
sendJson(res, 200, await queryAll(ctx))
|
|
485
|
+
} catch (e) {
|
|
486
|
+
sendJson(res, 500, {
|
|
487
|
+
error: e instanceof Error ? e.message : String(e),
|
|
488
|
+
providers: [],
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return webServer.register({ kind: 'exact', path: '/dsh-dock/balance', handler })
|
|
493
|
+
},
|
|
494
|
+
}
|