@dsh-plugins/dsh-llm-hub 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/lib/client.js +460 -209
- package/lib/index.js +91 -11
- package/package.json +1 -1
- package/scripts/prepublish-gate.mjs +10 -1
package/lib/index.js
CHANGED
|
@@ -291,6 +291,35 @@ export function parseRouteEntryPure(entry) {
|
|
|
291
291
|
* @param {object|undefined|null} routing - settings.routing 段。
|
|
292
292
|
* @returns {{ groups: Array, mode: 'manual'|'smart', activeGroup: string }}
|
|
293
293
|
*/
|
|
294
|
+
/**
|
|
295
|
+
* 备注清洗:只收字符串,去首尾空白,空串视为没有,最长 120 字。
|
|
296
|
+
* @param {unknown} value - 原始备注。
|
|
297
|
+
* @returns {string | null}
|
|
298
|
+
*/
|
|
299
|
+
export function cleanRouteNote(value) {
|
|
300
|
+
if (typeof value !== 'string') return null
|
|
301
|
+
const note = value.trim().slice(0, 120)
|
|
302
|
+
return note === '' ? null : note
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 候选备注表:只保留键是合法 `provider/model`、且确实在本组候选里的条目 ——
|
|
307
|
+
* 删掉候选时它的备注一并消失,不在 settings 里留孤儿。
|
|
308
|
+
* @param {unknown} raw - settings 里的 notes 字典。
|
|
309
|
+
* @param {Array<{provider: string, model: string}>} entries - 本组全部候选。
|
|
310
|
+
* @returns {Record<string, string> | null}
|
|
311
|
+
*/
|
|
312
|
+
function cleanRouteNotes(raw, entries) {
|
|
313
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
314
|
+
const keys = new Set(entries.map((entry) => `${entry.provider}/${entry.model}`))
|
|
315
|
+
const out = {}
|
|
316
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
317
|
+
const note = cleanRouteNote(value)
|
|
318
|
+
if (note !== null && keys.has(key)) out[key] = note
|
|
319
|
+
}
|
|
320
|
+
return Object.keys(out).length > 0 ? out : null
|
|
321
|
+
}
|
|
322
|
+
|
|
294
323
|
export function parseRoutingGroups(routing) {
|
|
295
324
|
const r = routing !== null && typeof routing === 'object' ? routing : {}
|
|
296
325
|
const mode = r.mode === 'smart' ? 'smart' : 'manual'
|
|
@@ -306,11 +335,15 @@ export function parseRoutingGroups(routing) {
|
|
|
306
335
|
.filter((entry) => entry !== undefined)
|
|
307
336
|
if (primary === null && fallbacks.length === 0) continue
|
|
308
337
|
seen.add(id)
|
|
338
|
+
const note = cleanRouteNote(raw.note)
|
|
339
|
+
const notes = cleanRouteNotes(raw.notes, [...(primary !== null ? [primary] : []), ...fallbacks])
|
|
309
340
|
groups.push({
|
|
310
341
|
id,
|
|
311
342
|
label: typeof raw.label === 'string' && raw.label.trim() !== '' ? raw.label.trim() : null,
|
|
312
343
|
primary,
|
|
313
|
-
fallbacks
|
|
344
|
+
fallbacks,
|
|
345
|
+
...(note !== null ? { note } : {}),
|
|
346
|
+
...(notes !== null ? { notes } : {})
|
|
314
347
|
})
|
|
315
348
|
}
|
|
316
349
|
if (groups.length === 0) {
|
|
@@ -422,11 +455,22 @@ export function normalizeRoutingGroupsInput(raw) {
|
|
|
422
455
|
}
|
|
423
456
|
if (primary === null && fallbacks.length === 0) { dropped.push(`组 ${id} 没有任何合法候选`); continue }
|
|
424
457
|
seen.add(id)
|
|
458
|
+
// 候选备注两种来源:编辑器形态里每个候选对象自带 note;落盘形态是组上的 notes 字典。
|
|
459
|
+
const noteSource = { ...(item.notes !== null && typeof item.notes === 'object' ? item.notes : {}) }
|
|
460
|
+
for (const candidate of ordered ?? []) {
|
|
461
|
+
const entry = asEntry(candidate)
|
|
462
|
+
const note = candidate !== null && typeof candidate === 'object' ? cleanRouteNote(candidate.note) : null
|
|
463
|
+
if (entry !== undefined && note !== null) noteSource[`${entry.provider}/${entry.model}`] = note
|
|
464
|
+
}
|
|
465
|
+
const note = cleanRouteNote(item.note)
|
|
466
|
+
const notes = cleanRouteNotes(noteSource, [...(primary !== null ? [primary] : []), ...fallbacks])
|
|
425
467
|
groups.push({
|
|
426
468
|
id,
|
|
427
469
|
label: typeof item.label === 'string' && item.label.trim() !== '' ? item.label.trim().slice(0, 32) : null,
|
|
428
470
|
primary,
|
|
429
|
-
fallbacks
|
|
471
|
+
fallbacks,
|
|
472
|
+
...(note !== null ? { note } : {}),
|
|
473
|
+
...(notes !== null ? { notes } : {})
|
|
430
474
|
})
|
|
431
475
|
}
|
|
432
476
|
return { groups, dropped }
|
|
@@ -638,7 +682,9 @@ export function apply(ctx) {
|
|
|
638
682
|
try {
|
|
639
683
|
const mod = await import('@deepseek-ai/schemastery')
|
|
640
684
|
const z = (mod && typeof mod === 'object' ? mod.default ?? mod : mod)
|
|
641
|
-
|
|
685
|
+
// note / notes(1.7.0):页面上写的备注。YAML 注释在页面保存时会被整段替换冲掉,
|
|
686
|
+
// 所以说明要落成字段才留得住。notes 以 'provider/model' 为键。
|
|
687
|
+
const groupItem = z.object({ id: z.string().required(), label: z.string(), note: z.string(), primary: z.string(), fallbacks: z.array(z.string()), notes: z.dict(z.string()) })
|
|
642
688
|
schema = z.object({
|
|
643
689
|
routing: z.object({
|
|
644
690
|
mode: z.union(['manual', 'smart']).default('manual'),
|
|
@@ -1294,7 +1340,12 @@ export function apply(ctx) {
|
|
|
1294
1340
|
auth: 'raw',
|
|
1295
1341
|
shape: (body) => {
|
|
1296
1342
|
if (body?.success === false || (typeof body?.code === 'number' && body.code !== 200)) {
|
|
1297
|
-
|
|
1343
|
+
// 服务端用一份完整的 JSON 明确回答「你这个号没有套餐」
|
|
1344
|
+
// (2026-09-29 实测:`当前用户不存在coding plan`)。
|
|
1345
|
+
// 这跟 `fail()` 那几条「连不上 / 看不懂 / 没这个接口」不是一回事:
|
|
1346
|
+
// 前者是确知不可用,值得判死并隐藏;后者是拿不准,必须 fail-open 保留。
|
|
1347
|
+
// 不标 definitive 的话,欠费会永远停在「显示着但一用就报错」。
|
|
1348
|
+
return { unavailable: label(body?.msg) ?? 'quota unavailable', definitive: true }
|
|
1298
1349
|
}
|
|
1299
1350
|
const limits = Array.isArray(body?.data?.limits) ? body.data.limits : []
|
|
1300
1351
|
const items = []
|
|
@@ -1361,6 +1412,13 @@ export function apply(ctx) {
|
|
|
1361
1412
|
return undefined
|
|
1362
1413
|
}
|
|
1363
1414
|
|
|
1415
|
+
/**
|
|
1416
|
+
* provider -> 最近一次余额信封。排序要用它判计费模式,而排序是同步的
|
|
1417
|
+
* (`llm.listProviders` 不能 await),所以只能读探测顺手存下来的快照。
|
|
1418
|
+
* @type {Map<string, object>}
|
|
1419
|
+
*/
|
|
1420
|
+
const balanceSnapshot = new Map()
|
|
1421
|
+
|
|
1364
1422
|
/**
|
|
1365
1423
|
* 查询某 provider 的余额/配额。统一信封:`{ ok, kind, available, items?, reason? }`;
|
|
1366
1424
|
* 匹配不到适配器给 `{ ok, supported:false }`,无 key / 上游失败都折叠成 `available:false`
|
|
@@ -1419,9 +1477,12 @@ export function apply(ctx) {
|
|
|
1419
1477
|
}
|
|
1420
1478
|
const shaped = adapter.shape(body)
|
|
1421
1479
|
if (shaped.unavailable !== undefined) {
|
|
1422
|
-
|
|
1480
|
+
// definitive 只在「上游结构化回答了不可用」时为真;网络/解析失败走 fail(),没有这个字段。
|
|
1481
|
+
return { ok: true, provider, kind: adapter.kind, available: false, reason: shaped.unavailable, definitive: shaped.definitive === true }
|
|
1423
1482
|
}
|
|
1424
|
-
|
|
1483
|
+
const envelope = { ok: true, provider, kind: adapter.kind, adapter: adapterName, meaning: adapter.meaning, level: shaped.level, items: shaped.items ?? [] }
|
|
1484
|
+
balanceSnapshot.set(provider, envelope)
|
|
1485
|
+
return envelope
|
|
1425
1486
|
}
|
|
1426
1487
|
|
|
1427
1488
|
/**
|
|
@@ -1665,9 +1726,18 @@ export function apply(ctx) {
|
|
|
1665
1726
|
return undefined
|
|
1666
1727
|
}
|
|
1667
1728
|
if (envelope === null || typeof envelope !== 'object' || envelope.ok !== true || envelope.supported === false) return undefined
|
|
1668
|
-
// available=false
|
|
1669
|
-
//
|
|
1670
|
-
|
|
1729
|
+
// available=false 有两种,混在一起处理会让欠费永远漏网:
|
|
1730
|
+
// · definitive —— 上游用完整 JSON 确知「这个号没有套餐」,判死并隐藏;
|
|
1731
|
+
// · 其余(没 key / 连不上 / 看不懂 / 没这个接口)—— 只是这次没查成,
|
|
1732
|
+
// 按 fail-open 保留,否则一次网络抖动就把能用的模型藏了。
|
|
1733
|
+
// 2026-09-29 实测智谱 coding 端点就栽在这:余额接口白纸黑字回了
|
|
1734
|
+
// 「当前用户不存在coding plan」,却因为和「查不通」共用 available=false 而被放过。
|
|
1735
|
+
if (envelope.available === false) {
|
|
1736
|
+
if (envelope.definitive === true) {
|
|
1737
|
+
return { state: 'unavailable', code: 'ACCOUNT_UNAVAILABLE', source: 'balance', reason: label(envelope.reason) ?? '账户没有可用套餐' }
|
|
1738
|
+
}
|
|
1739
|
+
return undefined
|
|
1740
|
+
}
|
|
1671
1741
|
const items = Array.isArray(envelope.items) ? envelope.items : []
|
|
1672
1742
|
if (envelope.kind === 'cash') {
|
|
1673
1743
|
const amount = items[0]?.amount
|
|
@@ -1993,7 +2063,13 @@ export function apply(ctx) {
|
|
|
1993
2063
|
if (profile?.billingMode === 'usage' || profile?.billingMode === 'subscription') {
|
|
1994
2064
|
return profile.billingMode
|
|
1995
2065
|
}
|
|
1996
|
-
|
|
2066
|
+
// ⚠️ balance 必须传:少了它,`detectBillingMode` 里认 `kind: 'plan' | 'quota'`、
|
|
2067
|
+
// `resetsAt`、`weeklyPercent` 的那几条分支全是死代码,最后一律落到兜底的
|
|
2068
|
+
// `return 'usage'`。2026-09-29 实测:MiniMax 余额信封明明是
|
|
2069
|
+
// `{kind:'plan', items:[{resetsAt:'15:00', weeklyPercent:97}]}`,下拉里却被当成
|
|
2070
|
+
// 按量付费排在套餐后面 —— 配置卡片显示对了(client 传了 balance),
|
|
2071
|
+
// 下拉排序却是错的(host 没传),两边对不上就是这个漏传造成的。
|
|
2072
|
+
return detectBillingMode(profile || { provider: cleanId }, profile, balanceSnapshot.get(cleanId) ?? balanceSnapshot.get(id))
|
|
1997
2073
|
}
|
|
1998
2074
|
|
|
1999
2075
|
const filtered = function (...args) {
|
|
@@ -2707,9 +2783,11 @@ export function apply(ctx) {
|
|
|
2707
2783
|
return {
|
|
2708
2784
|
id: group.id,
|
|
2709
2785
|
label: group.label,
|
|
2786
|
+
note: group.note ?? null,
|
|
2710
2787
|
candidates: candidates.map((candidate) => ({
|
|
2711
2788
|
provider: candidate.provider,
|
|
2712
2789
|
model: candidate.model,
|
|
2790
|
+
note: group.notes?.[`${candidate.provider}/${candidate.model}`] ?? null,
|
|
2713
2791
|
state: stateFor(candidate.provider, candidate.model)
|
|
2714
2792
|
})),
|
|
2715
2793
|
wouldRoute: winner.to,
|
|
@@ -2877,8 +2955,10 @@ export function apply(ctx) {
|
|
|
2877
2955
|
groups: normalized.groups.map((group) => ({
|
|
2878
2956
|
id: group.id,
|
|
2879
2957
|
...(group.label !== null ? { label: group.label } : {}),
|
|
2958
|
+
...(group.note ? { note: group.note } : {}),
|
|
2880
2959
|
...(group.primary !== null ? { primary: `${group.primary.provider}/${group.primary.model}` } : {}),
|
|
2881
|
-
fallbacks: group.fallbacks.map((entry) => `${entry.provider}/${entry.model}`)
|
|
2960
|
+
fallbacks: group.fallbacks.map((entry) => `${entry.provider}/${entry.model}`),
|
|
2961
|
+
...(group.notes ? { notes: group.notes } : {})
|
|
2882
2962
|
}))
|
|
2883
2963
|
}
|
|
2884
2964
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plugins/dsh-llm-hub",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "DSH 模型管理层:网关可达性探测、模型目录拉取、DeepSeek + MiniMax/智谱/Moonshot/StepFun 余额与配额、本月用量统计、余额预警、Provider 健康看板,再把本机装好的 codex / claude / agy 注册成子代理(装了才出现)。零运行时依赖,不改动 DSH 安装。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -85,8 +85,17 @@ const walkStrings = (v) => {
|
|
|
85
85
|
for (const field of ['main', 'types', 'module', 'bin', 'exports']) walkStrings(pkg[field])
|
|
86
86
|
if (pkg.dsh && typeof pkg.dsh === 'object') walkStrings(pkg.dsh.bundle)
|
|
87
87
|
|
|
88
|
+
// exports 允许「子路径模式」:`"./styles/*": "./styles/*"`。
|
|
89
|
+
// 这是 npm 官方推荐的整目录可 import 写法,不是错字 —— 2026-09-22 的 retro 记过:
|
|
90
|
+
// 白名单式把每个子路径列一遍,漏一个下游构建就直接 `Missing "./styles/x.css"` 失败。
|
|
91
|
+
// 所以带 * 的条目必须按 glob 去匹配 tarball,而不是要求存在一个名叫 `styles/*` 的文件。
|
|
92
|
+
// 但「模式一个文件都匹配不到」仍要拦 —— 那说明目录被删了或没进 files[],模式是死的。
|
|
93
|
+
const entryGlobRe = (g) => new RegExp(
|
|
94
|
+
'^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'
|
|
95
|
+
)
|
|
88
96
|
for (const p of entryPaths) {
|
|
89
|
-
|
|
97
|
+
const ok = /[*?]/.test(p) ? packFiles.some(f => entryGlobRe(p).test(f)) : inPack(p)
|
|
98
|
+
if (!ok) {
|
|
90
99
|
errors.push(`package.json 的入口字段引用了 '${p}',但 tarball 里没有\n → 装包的人会在 require/import 时直接失败`)
|
|
91
100
|
}
|
|
92
101
|
}
|