dsh-workbuddy2api-hub 0.0.0-stage → 1.0.7

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/lib/hub.js ADDED
@@ -0,0 +1,827 @@
1
+ /**
2
+ * dsh-workbuddy2api-hub 的纯函数层。
3
+ *
4
+ * 这个文件刻意不含 cordis、不含网络、不读文件:所有「把 hub 的字段翻译成 DSH 的字段」
5
+ * 的规则都集中在这里,于是测试可以不启动 DSH、不起 HTTP 服务器,直接把各种畸形响应
6
+ * 喂进来验证。参考项目 dsh-workbuddy2api-panel 的 `lib/panel.js` 是同一套分层,
7
+ * 它也是那个仓库里测试覆盖最厚的文件。
8
+ *
9
+ * hub 的字段名与参考项目(workbuddy2api-panel)并不完全一致,这里两套都认:
10
+ * - 上下文:`context_length` / `max_input_tokens` / `context_windows`
11
+ * - 输出上限:`max_output_tokens` / `max_completion_tokens`
12
+ * - 思考档位:`reasoning_efforts` + `reasoning_can_disable`(hub)
13
+ * `supported_efforts` + `can_disable_thinking`(参考面板)
14
+ * - 单价:`credits`(形如 `"x0.29"`)/ `credit_rate` / `rate`
15
+ */
16
+
17
+ /** hub 默认监听地址(本地部署,端口 8788)。 */
18
+ export const DEFAULT_BASE_URL = 'http://127.0.0.1:8788'
19
+
20
+ /** 面板默认密码。hub 首次启动时就是 `admin`,并会在看板上标注「用的是默认密码」。 */
21
+ export const DEFAULT_PWD = 'admin'
22
+
23
+ /** 看板默认页签。 */
24
+ export const DEFAULT_TAB = 'gateway'
25
+
26
+ /**
27
+ * hub 看板支持的页签名单。
28
+ *
29
+ * 来源是 `dashboard.html` 里的 `var TABS = [...]`;`?tab=` 只是前端深链参数,
30
+ * 服务端不解析它,所以这里必须自己挡住非法值——否则「打开看板」会落到一个空页签。
31
+ */
32
+ export const TABS = ['gateway', 'accounts', 'tasks', 'analytics', 'agents', 'settings', 'logs']
33
+
34
+ /** llm-pi-ai 认可的思考档位(顺序即选择器里的显示顺序)。 */
35
+ export const DSH_THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
36
+
37
+ /** 至少一个「非 off」档才算合法:pi-ai 会拒绝只有 off 的档案。 */
38
+ const POSITIVE_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
39
+
40
+ /**
41
+ * 归一 hub 根地址。
42
+ *
43
+ * 用户的输入形态很杂:`127.0.0.1:8788`、`http://127.0.0.1:8788/`、
44
+ * `http://127.0.0.1:8788/?pwd=admin&tab=gateway`(直接从浏览器地址栏复制)。
45
+ * 这里把「缺少 scheme」「多余的斜杠 / 查询串 / 片段」都收干净,只留下 origin + 可选子路径,
46
+ * 让后面拼 `/v1`、`/health` 时不会出现 `//v1`。
47
+ * @param value - 用户输入。
48
+ * @returns 归一后的地址;无法解析时返回空串。
49
+ */
50
+ export function normalizeBaseUrl(value) {
51
+ if (typeof value !== 'string') return ''
52
+ let text = value.trim()
53
+ if (text === '') return ''
54
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) text = `http://${text}`
55
+ let parsed
56
+ try {
57
+ parsed = new URL(text)
58
+ } catch {
59
+ return ''
60
+ }
61
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''
62
+ if (parsed.hostname === '') return ''
63
+ // 只保留 pathname,丢掉 query/hash:`?pwd=` 是前端便利参数,不属于服务地址。
64
+ const path = parsed.pathname.replace(/\/+$/, '')
65
+ return `${parsed.protocol}//${parsed.host}${path}`
66
+ }
67
+
68
+ /**
69
+ * 归一面板密码。
70
+ *
71
+ * 只裁掉首尾空白:密码中间的空格是合法字符,全裁会把用户的密码改坏。
72
+ * @param value - 配置里读到的密码。
73
+ * @returns 密码字符串(非字符串输入退回默认密码)。
74
+ */
75
+ export function normalizePwd(value) {
76
+ if (typeof value !== 'string') return DEFAULT_PWD
77
+ return value.trim()
78
+ }
79
+
80
+ /**
81
+ * 归一页签,非法值退回兜底页签。
82
+ * @param value - 候选页签。
83
+ * @param fallback - 兜底页签。
84
+ * @returns 白名单内的页签。
85
+ */
86
+ export function normalizeTab(value, fallback = DEFAULT_TAB) {
87
+ const safeFallback = TABS.includes(fallback) ? fallback : DEFAULT_TAB
88
+ if (typeof value !== 'string') return safeFallback
89
+ const tab = value.trim().toLowerCase()
90
+ return TABS.includes(tab) ? tab : safeFallback
91
+ }
92
+
93
+ /** `/usage` 支持的统计窗口。`all` 表示不带参数(hub 侧不过滤,即全量历史)。 */
94
+ export const USAGE_RANGES = ['today', 'week', 'month', 'all']
95
+
96
+ /**
97
+ * 归一用量统计窗口。
98
+ *
99
+ * hub 的 `/usage` 只认 `range` / `since` / `until` 三个参数
100
+ * (`wb_proxy.py:784 range_query()`),传 `hours=` 会被**静默忽略**、退化成
101
+ * 全量窗口——这一点害我们之前一直标着「近 24 小时」却在看历史总额。
102
+ * `wb_proxy.py:747 range_window()` 的取值:`today|day|1d` → 本地零点;
103
+ * `week|w` → 本周一;`month|m` → 本月一号;未知/空 → 不过滤。
104
+ * @param value - 用户输入或查询参数。
105
+ * @returns 归一后的窗口名,无法识别时回退 `today`。
106
+ */
107
+ export function normalizeUsageRange(value) {
108
+ const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''
109
+ if (raw === '') return 'today'
110
+ if (raw === 'day' || raw === '1d') return 'today'
111
+ if (raw === 'w') return 'week'
112
+ if (raw === 'm') return 'month'
113
+ if (raw === 'total' || raw === 'none') return 'all'
114
+ return USAGE_RANGES.includes(raw) ? raw : 'today'
115
+ }
116
+
117
+ /**
118
+ * 拼 `/usage` 的请求路径。
119
+ * @param range - 任意窗口名(会先归一)。
120
+ */
121
+ export function usageQuery(range) {
122
+ const wanted = normalizeUsageRange(range)
123
+ return wanted === 'all' ? '/usage' : `/usage?range=${wanted}`
124
+ }
125
+
126
+ /**
127
+ * 拼「打开看板」的深链。
128
+ *
129
+ * 形态与用户给的地址一致:`http://127.0.0.1:8788/?pwd=admin&tab=gateway`。
130
+ * 注意 `pwd` 与 `tab` 都只被 dashboard.html 的前端脚本读取(`URLSearchParams.get`),
131
+ * 服务端不处理它们——所以这个链接等价于「打开看板并预填密码 + 定位页签」。
132
+ * @param base - 已归一的根地址。
133
+ * @param pwd - 面板密码(空则不附)。
134
+ * @param tab - 页签。
135
+ * @returns 完整 URL;地址无效时返回空串。
136
+ */
137
+ export function buildDashboardUrl(base, pwd, tab) {
138
+ const root = normalizeBaseUrl(base)
139
+ if (root === '') return ''
140
+ const url = new URL(root.endsWith('/') ? root : `${root}/`)
141
+ const password = normalizePwd(pwd)
142
+ if (password !== '') url.searchParams.set('pwd', password)
143
+ url.searchParams.set('tab', normalizeTab(tab))
144
+ return url.toString()
145
+ }
146
+
147
+ /**
148
+ * 把一次失败的 HTTP 结果翻译成人话。
149
+ *
150
+ * 三类必须能分清,因为用户的处置完全不同:
151
+ * - 连不上 / 超时 → 去看服务起没起(不是密码问题);
152
+ * - 401 / 403 → `pwd` 不对或 Token 失效;
153
+ * - 429 → 登录试太多次,要等,别继续试。
154
+ * @param result - `{ status, text, body }`(`status` 为 0 表示网络层失败)。
155
+ * @returns `{ kind, message, status }`。
156
+ */
157
+ export function classifyFailure(result) {
158
+ const status = Number(result?.status) || 0
159
+ const raw = typeof result?.text === 'string' ? result.text : ''
160
+ const detail = typeof result?.body?.error === 'string' ? result.body.error : ''
161
+ const hint = detail !== '' ? detail : raw
162
+ if (status === 0) {
163
+ const timeout = /超时|timeout|abort/i.test(raw)
164
+ return {
165
+ kind: timeout ? 'timeout' : 'unreachable',
166
+ status,
167
+ message: timeout
168
+ ? `hub 请求超时:${hint || '服务没有在预期时间内响应'}`
169
+ : `连不上 hub(${hint || 'connection refused'}):确认 workbuddy2api-hub 已启动、端口正确`,
170
+ }
171
+ }
172
+ if (status === 401 || status === 403) {
173
+ return {
174
+ kind: 'auth',
175
+ status,
176
+ message: `hub 鉴权失败:面板密码(pwd)不对,或会话已过期${hint === '' ? '' : `(${hint})`}`,
177
+ }
178
+ }
179
+ if (status === 429) {
180
+ const wait = retryAfterSeconds(hint)
181
+ return {
182
+ kind: 'rate-limited',
183
+ status,
184
+ message: `hub 登录尝试过于频繁${wait > 0 ? `,请等 ${wait} 秒` : ''}:密码错太多次会触发 60 秒限流`,
185
+ }
186
+ }
187
+ if (status === 404) {
188
+ return {
189
+ kind: 'http',
190
+ status,
191
+ message: `hub 上找不到这个接口(404):服务版本可能与本插件不匹配${hint === '' ? '' : `(${hint})`}`,
192
+ }
193
+ }
194
+ if (status >= 500) {
195
+ return {
196
+ kind: 'http',
197
+ status,
198
+ message: `hub 内部错误(${status})${hint === '' ? '' : `:${hint}`}`,
199
+ }
200
+ }
201
+ return { kind: 'http', status, message: `hub 返回 ${status}${hint === '' ? '' : `:${hint}`}` }
202
+ }
203
+
204
+ /**
205
+ * 从限流提示里抠出「还有多少秒」。
206
+ * @param text - 形如 `too many login attempts, please wait 42s`。
207
+ * @returns 秒数(抠不到返回 0)。
208
+ */
209
+ export function retryAfterSeconds(text) {
210
+ if (typeof text !== 'string') return 0
211
+ const match = /wait\s+(\d+)\s*s|(\d+)\s*s(?:ec(?:onds?)?)?\b/i.exec(text)
212
+ if (match === null) return 0
213
+ const value = Number(match[1] ?? match[2])
214
+ return Number.isFinite(value) && value > 0 ? Math.min(Math.round(value), 3600) : 0
215
+ }
216
+
217
+ /**
218
+ * 单价的展示文本。
219
+ * @param rate - 数字倍率。
220
+ * @returns 形如 `×0.29`;无值返回空串。
221
+ */
222
+ export function rateText(rate) {
223
+ const value = Number(rate)
224
+ if (!Number.isFinite(value) || value <= 0) return ''
225
+ return `×${Math.round(value * 1000) / 1000}`
226
+ }
227
+
228
+ /**
229
+ * 解析模型单价(hub 用「倍率」计费,`credits: "x0.29"` 表示 ×0.29)。
230
+ * @param model - hub 的模型条目。
231
+ * @returns 正数倍率,解析不到返回 null。
232
+ */
233
+ export function modelRate(model) {
234
+ if (model === null || typeof model !== 'object') return null
235
+ const candidates = [model.credits, model.credit_rate, model.rate, model.price, model.price_rate]
236
+ for (const raw of candidates) {
237
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return raw
238
+ if (typeof raw !== 'string') continue
239
+ const match = /(\d+(?:\.\d+)?)/.exec(raw)
240
+ if (match === null) continue
241
+ const value = Number(match[1])
242
+ if (Number.isFinite(value) && value > 0) return value
243
+ }
244
+ return null
245
+ }
246
+
247
+ /**
248
+ * 模型的展示名。
249
+ * @param model - hub 的模型条目。
250
+ * @returns 名称(缺失时退回 id)。
251
+ */
252
+ export function modelDisplayName(model) {
253
+ const name = typeof model?.name === 'string' ? model.name.trim() : ''
254
+ if (name !== '') return name
255
+ const id = typeof model?.id === 'string' ? model.id.trim() : ''
256
+ return id !== '' ? id : '未命名模型'
257
+ }
258
+
259
+ /**
260
+ * 模型支持的思考档位。
261
+ *
262
+ * 返回值有三种语义(与 pi-ai 的 `reasoningEfforts` 字段一一对应):
263
+ * - `false`:明确不支持思考,选择器里不出现档位;
264
+ * - `undefined`:没声明,交给作者默认(不写这个字段);
265
+ * - 对象:`{ off, low, high, ... }`,键是档位名、值是线上的写法。
266
+ * @param model - hub 的模型条目。
267
+ * @returns `false` / `undefined` / 档位对象。
268
+ */
269
+ export function modelReasoningEfforts(model) {
270
+ const declared = Array.isArray(model?.reasoning_efforts)
271
+ ? model.reasoning_efforts
272
+ : Array.isArray(model?.supported_efforts)
273
+ ? model.supported_efforts
274
+ : []
275
+ const levels = []
276
+ for (const raw of declared) {
277
+ if (typeof raw !== 'string') continue
278
+ const level = raw.trim().toLowerCase()
279
+ if (level === '' || level === 'off' || level === 'minimal') continue
280
+ if (!POSITIVE_LEVELS.includes(level)) continue
281
+ if (!levels.includes(level)) levels.push(level)
282
+ }
283
+ // 档位按 DSH 的顺序排,避免 hub 给的顺序把「高」排到「低」前面。
284
+ levels.sort((left, right) => POSITIVE_LEVELS.indexOf(left) - POSITIVE_LEVELS.indexOf(right))
285
+ const canDisable = model?.reasoning_can_disable === true || model?.can_disable_thinking === true
286
+ const alwaysThinking = model?.always_reasoning === true
287
+ const efforts = {}
288
+ if (canDisable && !alwaysThinking) efforts.off = 'off'
289
+ for (const level of levels) efforts[level] = level
290
+ if (levels.length === 0) {
291
+ // 没给任何正档:如果声明了支持思考就留空(用作者默认),否则明确关掉。
292
+ const supported = model?.supports_reasoning === true || model?.capabilities?.reasoning === true
293
+ return supported ? undefined : false
294
+ }
295
+ return efforts
296
+ }
297
+
298
+ /**
299
+ * 按用户在设置页的选择裁剪思考档位。
300
+ *
301
+ * 裁剪后如果只剩 `off`,必须退回「全支持」——因为 pi-ai 会拒绝
302
+ * 「非空但全是 off」的档案,整条路由会因此注册失败。
303
+ * @param efforts - {@link modelReasoningEfforts} 的结果。
304
+ * @param selected - 用户勾选的档位列表。
305
+ * @returns 裁剪后的结果。
306
+ */
307
+ export function clampEfforts(efforts, selected) {
308
+ if (efforts === false || efforts === undefined) return efforts
309
+ if (!Array.isArray(selected) || selected.length === 0) return efforts
310
+ const kept = {}
311
+ for (const [level, wire] of Object.entries(efforts)) {
312
+ if (level === 'off' || selected.includes(level)) kept[level] = wire
313
+ }
314
+ if (!Object.keys(kept).some((level) => level !== 'off')) return efforts
315
+ return kept
316
+ }
317
+
318
+ /**
319
+ * 把一个 hub 模型翻译成 llm-pi-ai 的模型档案。
320
+ *
321
+ * 只写「有值」的字段:pi-ai 对 `contextWindow` / `maxTokens` 有正整数校验,
322
+ * 塞一个 0 或 NaN 进去会让整条 provider 路由校验失败,连带所有模型一起消失。
323
+ * @param model - hub 的模型条目。
324
+ * @param options.efforts - 用户勾选的档位。
325
+ * @returns 模型档案;id 缺失时返回 undefined。
326
+ */
327
+ export function toModelProfile(model, options = {}) {
328
+ if (model === null || typeof model !== 'object') return undefined
329
+ const id = typeof model.id === 'string' ? model.id.trim() : ''
330
+ if (id === '') return undefined
331
+ const profile = { id, name: modelDisplayName(model) }
332
+ const context = Number(model.context_length ?? model.max_input_tokens)
333
+ if (Number.isFinite(context) && context > 0) profile.contextWindow = Math.round(context)
334
+ const maxTokens = Number(model.max_output_tokens ?? model.max_completion_tokens)
335
+ if (Number.isFinite(maxTokens) && maxTokens > 0) profile.maxTokens = Math.round(maxTokens)
336
+ if (supportsImages(model)) profile.input = ['text', 'image']
337
+ const efforts = clampEfforts(modelReasoningEfforts(model), options.efforts)
338
+ if (efforts !== undefined) profile.reasoningEfforts = efforts
339
+ return profile
340
+ }
341
+
342
+ /**
343
+ * 模型是否收图。
344
+ * @param model - hub 的模型条目。
345
+ * @returns 是否支持图片输入。
346
+ */
347
+ function supportsImages(model) {
348
+ if (model?.supports_images === true || model?.supports_vision === true) return true
349
+ if (model?.capabilities?.vision === true) return true
350
+ return Array.isArray(model?.input_modalities) && model.input_modalities.includes('image')
351
+ }
352
+
353
+ /**
354
+ * 模型名排序的比较器:先按显示名(忽略大小写、数字按数值),同名再按 id。
355
+ *
356
+ * 设置页的表格与写进 llm-pi-ai 的 `models` 共用这一个比较器:用户在设置页里看到的
357
+ * 第一行,就是模型选择器里的第一项。倍率只用来展示,不参与排序。
358
+ * @param left - 模型条目。
359
+ * @param right - 模型条目。
360
+ * @returns 比较结果。
361
+ */
362
+ export function compareModelsByName(left, right) {
363
+ const leftName = modelDisplayName(left).toLocaleLowerCase()
364
+ const rightName = modelDisplayName(right).toLocaleLowerCase()
365
+ if (leftName !== rightName) {
366
+ return leftName.localeCompare(rightName, undefined, { numeric: true, sensitivity: 'base' })
367
+ }
368
+ return String(left?.id ?? '').localeCompare(String(right?.id ?? ''), undefined, { numeric: true })
369
+ }
370
+
371
+ /**
372
+ * 按用户选择组装写进 llm-pi-ai 的 `models` 数组。
373
+ *
374
+ * 排序:按模型名称(不区分大小写),与设置页表格同一顺序。
375
+ * hub 只有一个域(没有参考项目的 `cn:` / `global:` 前缀),所以不需要按域分组。
376
+ * @param models - hub 的模型数组。
377
+ * @param selection - `{ [id]: { enabled, efforts } }`。
378
+ * @returns 模型档案数组。
379
+ */
380
+ export function buildRouteModels(models, selection) {
381
+ const rows = Array.isArray(models) ? models : []
382
+ const picked = []
383
+ for (const model of rows) {
384
+ const id = typeof model?.id === 'string' ? model.id.trim() : ''
385
+ if (id === '') continue
386
+ const choice = selection?.[id]
387
+ if (choice !== undefined && choice.enabled === false) continue
388
+ const profile = toModelProfile(model, { efforts: choice?.efforts })
389
+ if (profile !== undefined) picked.push({ profile, model })
390
+ }
391
+ picked.sort((left, right) => compareModelsByName(left.model, right.model))
392
+ return picked.map((item) => item.profile)
393
+ }
394
+
395
+ /**
396
+ * 按名称给模型排序(不区分大小写,数字按数值比较)。
397
+ *
398
+ * 设置页的表格与写进 llm-pi-ai 的 `models` 用同一个顺序:用户在设置页里看到的
399
+ * 第一行,就是模型选择器里的第一项。
400
+ * @param models - 模型条目数组。
401
+ * @returns 排好序的新数组(不改入参)。
402
+ */
403
+ export function sortModels(models) {
404
+ return Array.isArray(models) ? models.slice().sort(compareModelsByName) : []
405
+ }
406
+
407
+ /**
408
+ * 把模型整理成设置页要渲染的一行。
409
+ * @param model - hub 的模型条目。
410
+ * @param selection - `{ [id]: { enabled, efforts } }`。
411
+ * @returns 展示行。
412
+ */
413
+ export function toModelRow(model, selection) {
414
+ const id = typeof model?.id === 'string' ? model.id : ''
415
+ const choice = selection?.[id]
416
+ const efforts = modelReasoningEfforts(model)
417
+ const levels = efforts === false || efforts === undefined ? [] : Object.keys(efforts)
418
+ const rate = modelRate(model)
419
+ return {
420
+ id,
421
+ name: modelDisplayName(model),
422
+ description: typeof model?.description === 'string' ? model.description : '',
423
+ vendor: typeof model?.vendor === 'string' ? model.vendor : '',
424
+ rate,
425
+ rateText: rateText(rate),
426
+ contextWindow: Number(model?.context_length ?? model?.max_input_tokens) || 0,
427
+ maxTokens: Number(model?.max_output_tokens ?? model?.max_completion_tokens) || 0,
428
+ vision: supportsImages(model),
429
+ tools: model?.supports_tool_calls === true || model?.capabilities?.tool_calls === true,
430
+ reasoning: levels.length > 0,
431
+ efforts: levels,
432
+ defaultEffort:
433
+ typeof model?.reasoning_default_effort === 'string' ? model.reasoning_default_effort : '',
434
+ enabled: choice?.enabled !== false,
435
+ selectedEfforts: Array.isArray(choice?.efforts) ? choice.efforts : null,
436
+ retired: model?.retired === true,
437
+ retiredAt: Number(model?.retiredAt) || 0,
438
+ }
439
+ }
440
+
441
+ /**
442
+ * 整理 `/health`。
443
+ * @param body - hub 响应。
444
+ * @returns 摘要或 null。
445
+ */
446
+ export function summarizeHealth(body) {
447
+ if (body === null || typeof body !== 'object') return null
448
+ const ok = body.ok !== false
449
+ return {
450
+ ok,
451
+ realm: typeof body.realm === 'string' ? body.realm : '',
452
+ accounts: Number(body.accounts) || 0,
453
+ accountsReady: Number(body.accounts_ready) || 0,
454
+ apiKeyRequired: body.api_key_required === true,
455
+ version: typeof body.version === 'string' ? body.version : '',
456
+ }
457
+ }
458
+
459
+ /**
460
+ * 整理 `/panel/status`。
461
+ * @param body - hub 响应。
462
+ * @returns 摘要或 null。
463
+ */
464
+ export function summarizePanelStatus(body) {
465
+ if (body === null || typeof body !== 'object') return null
466
+ return {
467
+ passwordRequired: body.panel_password_required === true,
468
+ passwordIsDefault: body.panel_password_is_default === true,
469
+ authenticated: body.authenticated === true,
470
+ apiKeySet: body.api_key_set === true,
471
+ }
472
+ }
473
+
474
+ /**
475
+ * 整理 `/v1/balance-summary`。
476
+ * @param body - hub 响应。
477
+ * @returns 摘要或 null。
478
+ */
479
+ export function summarizeBalance(body) {
480
+ if (body === null || typeof body !== 'object') return null
481
+ const balance = Number(body.balance)
482
+ return {
483
+ balance: Number.isFinite(balance) ? Math.round(balance * 100) / 100 : 0,
484
+ unit: typeof body.unit === 'string' ? body.unit : '',
485
+ realm: typeof body.realm === 'string' ? body.realm : '',
486
+ accountsEnabled: Number(body.accounts_enabled) || 0,
487
+ accountsWithCreditData: Number(body.accounts_with_credit_data) || 0,
488
+ updatedAt: toEpochMs(body.updated_at),
489
+ }
490
+ }
491
+
492
+ /**
493
+ * 整理 `/accounts`(账号池)。
494
+ *
495
+ * 只留「会让人动手」的信息:哪个账号要重登、哪个在冷却、哪个快过期、谁在低额度。
496
+ * 完整账号对象里有 uid 和域名字段,没有展示价值,不进快照。
497
+ * @param body - hub 响应。
498
+ * @param now - 当前时间戳(毫秒),用于算过期天数。
499
+ * @returns 摘要或 null。
500
+ */
501
+ export function summarizeAccounts(body, now = Date.now()) {
502
+ const list = Array.isArray(body?.accounts) ? body.accounts : Array.isArray(body) ? body : null
503
+ if (list === null) return null
504
+ const summary = {
505
+ total: list.length,
506
+ enabled: 0,
507
+ ready: 0,
508
+ needRelogin: 0,
509
+ inCooldown: 0,
510
+ expired: 0,
511
+ totalCredits: 0,
512
+ accounts: [],
513
+ }
514
+ for (const account of list) {
515
+ if (account === null || typeof account !== 'object') continue
516
+ const enabled = account.enabled !== false
517
+ const tokenExpired = account.tokenExpired === true
518
+ const needRelogin = account.needRelogin === true
519
+ const inCooldown = account.inCooldown === true
520
+ const credits = Number(account?.credits?.remain)
521
+ if (enabled) summary.enabled += 1
522
+ if (enabled && !tokenExpired && !needRelogin && !inCooldown) summary.ready += 1
523
+ if (needRelogin) summary.needRelogin += 1
524
+ if (inCooldown) summary.inCooldown += 1
525
+ if (tokenExpired) summary.expired += 1
526
+ const daysLeft = countdownDays(account.expiresAt, now)
527
+ if (Number.isFinite(credits)) summary.totalCredits += credits
528
+ summary.accounts.push({
529
+ nickname: typeof account.nickname === 'string' ? account.nickname : '',
530
+ realm: typeof account.realm === 'string' ? account.realm : '',
531
+ platform: typeof account.platform === 'string' ? account.platform : '',
532
+ enabled,
533
+ ready: enabled && !tokenExpired && !needRelogin && !inCooldown,
534
+ tokenExpired,
535
+ needRelogin,
536
+ inCooldown,
537
+ credits: Number.isFinite(credits) ? Math.round(credits * 100) / 100 : null,
538
+ expiresAt: toEpochMs(account.expiresAt),
539
+ expiresIn: typeof account.expiresIn === 'string' ? account.expiresIn : '',
540
+ daysLeft,
541
+ weight: Number(account.weight) || 1,
542
+ })
543
+ }
544
+ summary.totalCredits = Math.round(summary.totalCredits * 100) / 100
545
+ return summary
546
+ }
547
+
548
+ /**
549
+ * 距离某个时间点还有几天(向上取整,已过期返回负数)。
550
+ * @param expiresAt - 秒/毫秒时间戳。
551
+ * @param now - 当前时间戳。
552
+ * @returns 天数;无值返回 null。
553
+ */
554
+ function countdownDays(expiresAt, now) {
555
+ const stamp = toEpochMs(expiresAt)
556
+ if (stamp <= 0) return null
557
+ return Math.ceil((stamp - now) / 86400000)
558
+ }
559
+
560
+ /**
561
+ * 整理 `/usage`。
562
+ *
563
+ * `by_model` 是对象,按花费排序后只留前若干个:一个开了 20 个模型的 hub
564
+ * 会把整个快照撑到几十 KB,而设置页只画一张前 N 的条形图。
565
+ * @param body - hub 响应。
566
+ * @param limit - `by_model` 最多保留几条。
567
+ * @returns 摘要或 null。
568
+ */
569
+ export function summarizeUsage(body, limit = 12) {
570
+ if (body === null || typeof body !== 'object') return null
571
+ const requests = Number(body.requests) || 0
572
+ const errors = Number(body.errors) || 0
573
+ const totalTokens = Number(body.total_tokens) || 0
574
+ const cachedTokens = Number(body.cached_tokens) || 0
575
+ const byModel = []
576
+ const source = body.by_model !== null && typeof body.by_model === 'object' ? body.by_model : {}
577
+ for (const [id, entry] of Object.entries(source)) {
578
+ if (entry === null || typeof entry !== 'object') continue
579
+ byModel.push({
580
+ id,
581
+ requests: Number(entry.requests) || 0,
582
+ credit: round2(Number(entry.credit) || 0),
583
+ costCny: round2(Number(entry.cost_cny) || 0),
584
+ totalTokens: Number(entry.total_tokens) || 0,
585
+ })
586
+ }
587
+ byModel.sort((left, right) => right.costCny - left.costCny || right.requests - left.requests)
588
+ return {
589
+ requests,
590
+ errors,
591
+ errorRate: requests > 0 ? Math.round((errors / requests) * 1000) / 10 : 0,
592
+ promptTokens: Number(body.prompt_tokens) || 0,
593
+ completionTokens: Number(body.completion_tokens) || 0,
594
+ reasoningTokens: Number(body.reasoning_tokens) || 0,
595
+ cachedTokens,
596
+ totalTokens,
597
+ cacheHitRate: totalTokens > 0 ? Math.round((cachedTokens / totalTokens) * 1000) / 10 : 0,
598
+ credit: round2(Number(body.credit) || 0),
599
+ costCny: round2(Number(body.cost_cny) || 0),
600
+ started: toEpochMs(body.started),
601
+ byModel: byModel.slice(0, Math.max(1, Number(limit) || 12)),
602
+ }
603
+ }
604
+
605
+ /** 保留两位小数。 */
606
+ function round2(value) {
607
+ return Math.round(value * 100) / 100
608
+ }
609
+
610
+ /**
611
+ * 稳定的 JSON 序列化(对象键递归排序)。
612
+ *
613
+ * 用来比较「现有路由配置」和「目标路由配置」是否等价:直接 `JSON.stringify`
614
+ * 会被键序影响,于是同样的内容会被判成不同,每轮都白写一次配置。
615
+ * @param value - 任意可 JSON 化的值。
616
+ * @returns 键序稳定的字符串。
617
+ */
618
+ export function canonicalJson(value) {
619
+ if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)
620
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(',')}]`
621
+ const keys = Object.keys(value).sort()
622
+ const parts = []
623
+ for (const key of keys) {
624
+ if (value[key] === undefined) continue
625
+ parts.push(`${JSON.stringify(key)}:${canonicalJson(value[key])}`)
626
+ }
627
+ return `{${parts.join(',')}}`
628
+ }
629
+
630
+ /**
631
+ * 现有路由配置是否已经等于目标配置。
632
+ *
633
+ * 只遍历 target 的键:现有配置里可能还有用户手动加的 `headers`、`timeoutMs`
634
+ * 之类字段,插件不该因为它们存在就判定「不一致」并反复重写。
635
+ * @param current - 当前配置。
636
+ * @param target - 目标配置。
637
+ * @returns 是否等价。
638
+ */
639
+ export function routeConfigMatches(current, target) {
640
+ if (current === null || typeof current !== 'object') return false
641
+ for (const key of Object.keys(target)) {
642
+ if (canonicalJson(current[key]) !== canonicalJson(target[key])) return false
643
+ }
644
+ return true
645
+ }
646
+
647
+ /**
648
+ * 秒 / 毫秒 / 字符串三种时间戳统一成毫秒。
649
+ * @param value - 时间戳。
650
+ * @returns 毫秒时间戳;无效返回 0。
651
+ */
652
+ export function toEpochMs(value) {
653
+ if (typeof value === 'number') {
654
+ if (!Number.isFinite(value) || value <= 0) return 0
655
+ // hub 用秒(`updated_at: 1792760426.5`),DSH 侧用毫秒:小于 1e12 的当秒。
656
+ return value < 1e12 ? Math.round(value * 1000) : Math.round(value)
657
+ }
658
+ if (typeof value === 'string' && value.trim() !== '') {
659
+ const numeric = Number(value)
660
+ if (Number.isFinite(numeric) && numeric > 0) return toEpochMs(numeric)
661
+ const parsed = Date.parse(value)
662
+ return Number.isFinite(parsed) ? parsed : 0
663
+ }
664
+ return 0
665
+ }
666
+
667
+ /**
668
+ * 算下一轮轮询的间隔。
669
+ *
670
+ * 连续失败时指数退避(间隔翻倍,最多 5 次翻倍、上限 30 分钟),
671
+ * 既不会在 hub 掉线时每 2 分钟硬撞一次,也不会退避到「用户改好了要等半小时」。
672
+ * @param baseSeconds - 基础间隔(秒)。
673
+ * @param failures - 连续失败次数。
674
+ * @param options.floorSeconds - 间隔下限(秒),被限流时抬高。
675
+ * @returns 毫秒间隔。
676
+ */
677
+ export function pollIntervalMs(baseSeconds, failures, options = {}) {
678
+ const base = Math.max(30, Number(baseSeconds) || 120)
679
+ const count = Math.max(0, Math.floor(Number(failures) || 0))
680
+ const backoff = Math.min(base * 2 ** Math.min(count, 5), 1800)
681
+ const floor = Math.max(0, Number(options.floorSeconds) || 0)
682
+ return Math.max(backoff, floor, 15) * 1000
683
+ }
684
+
685
+ /**
686
+ * 对齐「本轮模型清单」与「上一轮的记录」。
687
+ *
688
+ * 解决两个真实场景:
689
+ * 1. hub 抖一下、`/v1/models` 少返回了几个模型 —— 直接删会让 DSH 里的模型
690
+ * 瞬间消失、正在跑的会话选不到模型。所以下架的模型先进宽限期(默认 24h),
691
+ * 期间仍然保留在清单里(标记 `retired`),到期才真正移除;
692
+ * 2. 模型回来了(网络恢复或账号重新可用)—— 从宽限期里复活,不该重复提醒。
693
+ * @param list - 本轮 hub 返回的模型数组。
694
+ * @param previous.lastIds - 上一轮的模型 id 数组。
695
+ * @param previous.models - 上一轮「打过标记」的模型数组。
696
+ * @param previous.retired - 宽限期表 `{ [id]: { at, model } }`。
697
+ * @param now - 当前时间戳。
698
+ * @param graceMs - 宽限期毫秒数。
699
+ * @returns `{ models, retired, added, removed }`。
700
+ */
701
+ export function reconcileModels(list, previous, now = Date.now(), graceMs = 24 * 3600000) {
702
+ const current = (Array.isArray(list) ? list : []).filter(
703
+ (model) => typeof model?.id === 'string' && model.id !== '',
704
+ )
705
+ const currentIds = new Set(current.map((model) => model.id))
706
+ const lastIds = Array.isArray(previous?.lastIds) ? previous.lastIds : []
707
+ const keptRetired = {}
708
+ const models = []
709
+
710
+ // 1) 本轮还在的模型:清掉 retired 标记,并从宽限期表里移出(复活)。
711
+ for (const model of current) {
712
+ models.push({ ...model })
713
+ }
714
+ const added = current
715
+ .map((model) => model.id)
716
+ .filter((id) => lastIds.length > 0 && !lastIds.includes(id))
717
+
718
+ // 2) 上一轮有、本轮没有的模型:进宽限期。
719
+ const previousModels = new Map()
720
+ for (const model of Array.isArray(previous?.models) ? previous.models : []) {
721
+ if (typeof model?.id === 'string' && model.id !== '') previousModels.set(model.id, model)
722
+ }
723
+ const removed = []
724
+ for (const id of lastIds) {
725
+ if (currentIds.has(id)) continue
726
+ removed.push(id)
727
+ const existing = previous?.retired?.[id]
728
+ const entry =
729
+ existing !== undefined && typeof existing === 'object'
730
+ ? existing
731
+ : { at: now, model: previousModels.get(id) ?? { id } }
732
+ keptRetired[id] = entry
733
+ }
734
+ // 3) 已经在宽限期里的模型:还没到期就继续留在清单里(标记 retired)。
735
+ for (const [id, entry] of Object.entries(keptRetired)) {
736
+ const at = Number(entry?.at) || 0
737
+ if (graceMs > 0 && now - at < graceMs) {
738
+ models.push({ ...(entry?.model ?? { id }), id, retired: true, retiredAt: at })
739
+ } else if (graceMs <= 0) {
740
+ delete keptRetired[id]
741
+ } else {
742
+ // 到期:从宽限期表里删掉,模型真正消失。
743
+ delete keptRetired[id]
744
+ }
745
+ }
746
+ return { models, retired: keptRetired, added, removed }
747
+ }
748
+
749
+ /**
750
+ * 生成要提示给用户的告警。
751
+ *
752
+ * 每条告警都有稳定 key,前端可以「关掉这条」——`dismissed[key]` 记 24 小时内不再提示,
753
+ * 否则一条「hub 连不上」会每分钟在设置页闪一次,用户只能把插件卸载掉。
754
+ * @param input - 上下文。
755
+ * @returns 告警数组 `[{ key, level, title, detail }]`。
756
+ */
757
+ export function computeAlerts(input) {
758
+ const alerts = []
759
+ const now = Number(input?.now) || Date.now()
760
+ const dismissed = input?.dismissed ?? {}
761
+ const thresholds = input?.thresholds ?? {}
762
+ const seen = (key) => {
763
+ const at = Number(dismissed?.[key]) || 0
764
+ return at > 0 && now - at < 86400000
765
+ }
766
+ const push = (key, level, title, detail) => {
767
+ if (seen(key)) return
768
+ alerts.push({ key, level, title, detail })
769
+ }
770
+ const failures = Number(input?.consecutiveFailures) || 0
771
+ const limit = Number(thresholds.unreachablePolls) > 0 ? Number(thresholds.unreachablePolls) : 5
772
+ if (input?.failureKind === 'auth') {
773
+ push('auth', 'error', '面板密码不对', input?.lastError || '请检查 pwd 配置或设置页里的密码。')
774
+ } else if (input?.failureKind === 'rate-limited') {
775
+ push('rate-limited', 'warning', '登录被限流', input?.lastError || '密码错太多次,等一分钟再试。')
776
+ } else if (failures >= limit) {
777
+ push(
778
+ 'unreachable',
779
+ 'error',
780
+ '连不上 workbuddy2api-hub',
781
+ input?.lastError || '确认本地服务已启动(默认 http://127.0.0.1:8788)。',
782
+ )
783
+ }
784
+ if (input?.apiKeyPresent === false) {
785
+ push(
786
+ 'api-key',
787
+ 'warning',
788
+ '缺少 hub API Key',
789
+ '面板密码只能管理看板;真实对话需要 API Key。可在设置页填入,或让插件从面板自动获取。',
790
+ )
791
+ }
792
+ const balance = Number(input?.balance?.balance)
793
+ const low = Number(thresholds.lowCredits)
794
+ if (Number.isFinite(balance) && Number.isFinite(low) && low > 0 && balance < low) {
795
+ push('low-credits', 'warning', '额度偏低', `当前额度 ${balance},低于阈值 ${low}。`)
796
+ }
797
+ const accounts = input?.accounts
798
+ if (accounts !== null && typeof accounts === 'object') {
799
+ if (Number(accounts.needRelogin) > 0) {
800
+ push('relogin', 'warning', '有账号需要重新登录', `${accounts.needRelogin} 个账号的登录态失效了。`)
801
+ }
802
+ if (Number(accounts.expired) > 0) {
803
+ push('expired', 'error', '有账号已过期', `${accounts.expired} 个账号的订阅过期了。`)
804
+ }
805
+ const soon = (Array.isArray(accounts.accounts) ? accounts.accounts : []).filter(
806
+ (item) => Number.isFinite(item?.daysLeft) && item.daysLeft >= 0 && item.daysLeft <= 3,
807
+ )
808
+ if (soon.length > 0) {
809
+ push('expiring', 'warning', '账号即将过期', `${soon.length} 个账号 3 天内到期。`)
810
+ }
811
+ }
812
+ return alerts
813
+ }
814
+
815
+ /**
816
+ * 从候选里挑出一个可用地址(保留给「多地址」扩展与测试用)。
817
+ * @param candidates - 候选地址列表。
818
+ * @param fallback - 兜底地址。
819
+ * @returns 第一个能归一的地址。
820
+ */
821
+ export function pickBaseUrl(candidates, fallback = DEFAULT_BASE_URL) {
822
+ for (const candidate of Array.isArray(candidates) ? candidates : [candidates]) {
823
+ const normalized = normalizeBaseUrl(candidate)
824
+ if (normalized !== '') return normalized
825
+ }
826
+ return normalizeBaseUrl(fallback) || DEFAULT_BASE_URL
827
+ }