dsh-dock 0.1.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +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: true,
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
+ }