@gehennawu/dsh-service 2.0.6 → 2.0.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "DSH Web 运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -25,6 +25,13 @@
25
25
  "quota-routes.js",
26
26
  "subagent-routes.js",
27
27
  "backup-routes.js",
28
+ "model-workbench.js",
29
+ "model-workbench-routes.js",
30
+ "model-workbench-settings.js",
31
+ "model-config-routes.js",
32
+ "provider-availability.js",
33
+ "model-config-schema.js",
34
+ "model-smart-fill.js",
28
35
  "plugin-health.js",
29
36
  "client.js",
30
37
  "cordis.patch.yml",
@@ -52,7 +59,7 @@
52
59
  "homepage": "https://github.com/gehennawu/dsh-service#readme",
53
60
  "engines": {
54
61
  "node": ">=22",
55
- "dsh": ">=0.1.1-rc.2 <0.2.1-alpha.2"
62
+ "dsh": ">=0.1.1-rc.2 <0.2.1-alpha.3"
56
63
  },
57
64
  "dependencies": {
58
65
  "@deepseek-ai/schemastery": "^3.18.3"
@@ -64,7 +71,8 @@
64
71
  "client": {
65
72
  "inject": [
66
73
  "@deepseek-ai/dsh-client-connection",
67
- "@deepseek-ai/dsh-api-remotes"
74
+ "@deepseek-ai/dsh-api-remotes",
75
+ "@deepseek-ai/dsh-client-ui-primitives"
68
76
  ],
69
77
  "platform": "web"
70
78
  }
@@ -0,0 +1,84 @@
1
+ // Profile-owned policy: configuration and credentials never move or disappear.
2
+ export function createProviderAvailability(store) {
3
+ let disabled = new Set()
4
+ let disabledModels = new Set()
5
+ const modelKey = (provider, model) => JSON.stringify([provider, model])
6
+ let ready = false
7
+ const loading = store.load().then((state) => {
8
+ disabled = new Set(Object.entries(state.disabledProviders ?? {}).filter(([, value]) => value === true).map(([id]) => id))
9
+ disabledModels = new Set(Object.entries(state.disabledModels ?? {}).filter(([, value]) => value === true).map(([key]) => key))
10
+ ready = true
11
+ })
12
+ const enabled = (provider) => !disabled.has(provider)
13
+ const modelEnabled = (provider, model) => !disabledModels.has(modelKey(provider, model))
14
+ const assertEnabled = (provider, model) => {
15
+ if (!ready || !enabled(provider) || model !== undefined && !modelEnabled(provider, model)) {
16
+ const code = !ready ? 'provider-availability-loading' : !enabled(provider) ? 'provider-disabled' : 'model-disabled'
17
+ const error = new Error(code)
18
+ error.code = code.toUpperCase().replaceAll('-', '_')
19
+ throw error
20
+ }
21
+ }
22
+ let changes = Promise.resolve()
23
+ const set = (provider, value, expected) => {
24
+ const result = changes.then(async () => {
25
+ await loading
26
+ if (enabled(provider) !== expected) throw new Error('conflict')
27
+ await store.mutate((state) => {
28
+ state.disabledProviders ??= {}
29
+ if (value) delete state.disabledProviders[provider]
30
+ else Object.defineProperty(state.disabledProviders, provider, { value: true, enumerable: true, configurable: true, writable: true })
31
+ })
32
+ if (value) disabled.delete(provider); else disabled.add(provider)
33
+ return { route: provider, enabled: value }
34
+ })
35
+ changes = result.catch(() => {})
36
+ return result
37
+ }
38
+ const setModel = (provider, model, value, expected) => {
39
+ const result = changes.then(async () => {
40
+ await loading
41
+ if (modelEnabled(provider, model) !== expected) throw new Error('conflict')
42
+ const key = modelKey(provider, model)
43
+ await store.mutate((state) => {
44
+ state.disabledModels ??= {}
45
+ if (value) delete state.disabledModels[key]
46
+ else Object.defineProperty(state.disabledModels, key, { value: true, enumerable: true, configurable: true, writable: true })
47
+ })
48
+ if (value) disabledModels.delete(key); else disabledModels.add(key)
49
+ return { route: provider, modelId: model, enabled: value }
50
+ })
51
+ changes = result.catch(() => {})
52
+ return result
53
+ }
54
+ return { loading, enabled, modelEnabled, assertEnabled, set, setModel }
55
+ }
56
+
57
+ // Filter the shared catalog and guard exact routing, including inherited routes.
58
+ // Keep every original method and restore it when the owning Fiber is disposed.
59
+ export function installProviderAvailability(llm, policy, notify = () => {}) {
60
+ const originals = new Map()
61
+ const wrappers = new Map()
62
+ const wrap = (key, factory) => {
63
+ if (typeof llm[key] !== 'function') return
64
+ const original = llm[key]
65
+ const wrapper = factory(original.bind(llm))
66
+ originals.set(key, original); wrappers.set(key, wrapper); llm[key] = wrapper
67
+ }
68
+ wrap('listProviders', (original) => () => original().filter((row) => policy.enabled(row.id)))
69
+ wrap('listModels', (original) => async (provider, ...args) => { await policy.loading; return policy.enabled(provider) ? (await original(provider, ...args)).filter((row) => policy.modelEnabled(provider, row.id)) : [] })
70
+ wrap('resolveModelInfo', (original) => async (provider, ...args) => { await policy.loading; policy.assertEnabled(provider, args[0]); return original(provider, ...args) })
71
+ wrap('resolveCallConfig', (original) => async (config, ...args) => { await policy.loading; policy.assertEnabled(config.provider, config.model); return original(config, ...args) })
72
+ wrap('prepareCall', (original) => async (config, ...args) => {
73
+ await policy.loading; policy.assertEnabled(config.provider, config.model)
74
+ const prepared = await original(config, ...args)
75
+ return { ...prepared, stream: async function* (options) {
76
+ policy.assertEnabled(config.provider, config.model)
77
+ yield* prepared.stream(options)
78
+ } }
79
+ })
80
+ wrap('stream', (original) => async function* (options) { await policy.loading; policy.assertEnabled(options.provider, options.model); yield* original(options) })
81
+ let disposed = false
82
+ policy.loading.then(() => { if (!disposed) notify() }, () => {})
83
+ return () => { disposed = true; for (const [key, original] of originals) if (llm[key] === wrappers.get(key)) llm[key] = original; notify() }
84
+ }
package/quota-adapters.js CHANGED
@@ -1110,6 +1110,37 @@ function parseCliproxyUpstream(provider, payload) {
1110
1110
  return normalizeGeminiBuckets(payload?.buckets)
1111
1111
  }
1112
1112
 
1113
+ // 管理面 403 的三种语义(上游 `internal/api/handlers/management/handler.go` 原文):
1114
+ // · `IP banned due to too many failed attempts. Try again in 29m10s` —— fail2ban 封禁(实测 2026-10-10)
1115
+ // · `remote management disabled` / `remote management key not set` —— 管理面没开或没配密钥
1116
+ // 三者与「密钥填错」(401 `invalid management key`)完全不同,必须分开归类,否则用户对着一句
1117
+ // 状态码猜:填错要给重填入口,封禁/未开启给重填入口是误导。
1118
+ const CLIPROXY_BANNED_DETAIL_RE = /IP banned due to too many failed attempts/i
1119
+ const CLIPROXY_MGMT_OFF_DETAIL_RE = /remote management (?:disabled|key not set)/i
1120
+
1121
+ /** CPA 管理面失败归类:401/403 = 管理密钥被拒(与其余 11 个 kind 同款,行回 unconfigured 就地重填);
1122
+ * 封禁 → mgmt-banned(专属码,给等待/换出口 IP 的指引);远程管理未开启 → mgmt-disabled。 */
1123
+ function cliproxyManagementFailure(error) {
1124
+ const detail = sanitizeQuotaErrorDetail(error?.detail)
1125
+ if (detail !== undefined && CLIPROXY_BANNED_DETAIL_RE.test(detail)) {
1126
+ const banned = new Error('mgmt-banned')
1127
+ banned.detail = detail
1128
+ return banned
1129
+ }
1130
+ if (detail !== undefined && CLIPROXY_MGMT_OFF_DETAIL_RE.test(detail)) {
1131
+ const disabled = new Error('mgmt-disabled')
1132
+ disabled.detail = detail
1133
+ return disabled
1134
+ }
1135
+ const message = typeof error?.message === 'string' ? error.message : ''
1136
+ if (message === 'http-status:401' || message === 'http-status:403') {
1137
+ const rejected = new Error('credential-rejected')
1138
+ if (detail !== undefined) rejected.detail = detail
1139
+ return rejected
1140
+ }
1141
+ return error
1142
+ }
1143
+
1113
1144
  async function fetchCliproxyUsage({ profile, config, credential, signal, requestJson }) {
1114
1145
  if (typeof requestJson !== 'function') throw new Error('transport-unavailable')
1115
1146
  const pinned = Array.isArray(config?.allowedHosts?.[profile.name]) ? config.allowedHosts[profile.name] : []
@@ -1119,8 +1150,9 @@ async function fetchCliproxyUsage({ profile, config, credential, signal, request
1119
1150
  try {
1120
1151
  filesPayload = await requestJson(`${origin}/v0/management/auth-files`, { authorization: credential, signal })
1121
1152
  } catch (error) {
1153
+ // secret-key 为空时管理路由整体不注册 → 404(既有口径,先于其余归类)。
1122
1154
  if (error?.message === 'http-status:404') throw new Error('mgmt-disabled')
1123
- throw error
1155
+ throw cliproxyManagementFailure(error)
1124
1156
  }
1125
1157
  const files = Array.isArray(filesPayload?.files) ? filesPayload.files : []
1126
1158
  const accounts = []
@@ -1162,8 +1194,12 @@ async function fetchCliproxyUsage({ profile, config, credential, signal, request
1162
1194
  body: JSON.stringify({ auth_index: account.authIndex, method: call.method, url: call.url, header: call.header, data: call.data }),
1163
1195
  })
1164
1196
  } catch (error) {
1165
- const detail = sanitizeQuotaErrorDetail(error?.detail)
1166
- pushFailure(accountIndex, quotaErrorCode(error), detail !== undefined ? { detail } : {})
1197
+ // 管理面自身失败(密钥被拒 / 封禁 / 管理面未开):与账号级上游失败(HTTP 200 信封里的
1198
+ // status_code)严格分开。密钥在 api-call 途中失效或撞上封禁时,整行必须给出可行动指引,
1199
+ // 而不是一个 upstream-status 家族码。
1200
+ const failure = cliproxyManagementFailure(error)
1201
+ const detail = sanitizeQuotaErrorDetail(failure?.detail)
1202
+ pushFailure(accountIndex, quotaErrorCode(failure), detail !== undefined ? { detail } : {})
1167
1203
  const fallback = cliproxFallbackWindows(account)
1168
1204
  if (fallback.length > 0) {
1169
1205
  accountResults.set(accountIndex, fallback)
package/quota-routes.js CHANGED
@@ -66,10 +66,12 @@ export function createQuotaRoutes({
66
66
  // 漏掉它会把卡片锁死在错误态,用户找不到任何入口(GUI 反馈「失效后无法再次填入」)。
67
67
  const credentialClass = view.lastError === 'credential-missing' || view.lastError === 'no-base-url' || view.lastError === 'credentials-unavailable' || view.lastError === 'credential-rejected'
68
68
  const providerResetCards = resetCardsByProvider.get(profile.name) ?? []
69
- // 凭据填写窗口的数据源:仅对「缺凭据」的未配置行附带候选线索名的配置状态(describe 只回
70
- // 配置与否/来源/可写,绝不带值);凭据服务缺席时省略字段——客户端隐藏窗口退回文案指引。
69
+ // 凭据窗口的数据源:**已适配行一律附带**候选线索名的配置状态,不再以「未配置」为门槛——
70
+ // 已保存的凭据(密钥轮换、换账号、填错想改)此前没有任何 GUI 修改入口(用户点名)。
71
+ // describe 只回配置与否/来源/可写,绝不带值,且是内存查询;凭据服务缺席时省略字段,
72
+ // 客户端随之隐藏入口、退回文案指引。
71
73
  let credentialHints
72
- if (credentialClass && !view.refreshing && view.lastError !== 'no-base-url') {
74
+ {
73
75
  const credentials = ctx.get('credentials')
74
76
  if (credentials !== undefined && typeof credentials.describe === 'function') {
75
77
  const described = []
@@ -103,6 +105,8 @@ export function createQuotaRoutes({
103
105
  ...(view.lastErrorEndpoint !== undefined ? { errorEndpoint: view.lastErrorEndpoint } : {}),
104
106
  ...(view.lastErrorAccount !== undefined ? { errorAccount: view.lastErrorAccount } : {}),
105
107
  nextAllowedAt: view.nextAllowedAt,
108
+ // 确定性失败后的手动模式:客户端据此把「{time} 后可重试」换成「自动重试已暂停」。
109
+ ...(view.manualOnly === true ? { manualOnly: true } : {}),
106
110
  ...(providerResetCards.length > 0 ? { resetCards: providerResetCards } : {}),
107
111
  ...(credentialHints !== undefined ? { credentialHints } : {}),
108
112
  // 凭据入口语义由 Adapter policy 下发稳定键,客户端只负责本地化,不再对 kind 重复分支。
@@ -119,8 +123,10 @@ export function createQuotaRoutes({
119
123
  } },
120
124
  'quota-refresh': { feature: 'quotaLookup', audit: true, handle: async (payload, rpcEndpoint) => {
121
125
  try {
122
- // 手动刷新入口:provider 过白名单且 kind 已适配;清掉节流闸后立即 kick。
123
- // 单飞仍生效(在途时本次点击为 no-op);上游结果经后续 quota 快照带出,不在此等待。
126
+ // 手动重试/连接入口:provider 过白名单且 kind 已适配;这是「确定性失败转手动模式」之后
127
+ // 唯一还能打上游的路径(kick 带 manual 标记,跳过手动模式与退避/TTL/间隔三道闸)。
128
+ // 单飞仍生效(在途时本次点击为 no-op);只受不可绕过的手动冷却约束;上游结果经后续
129
+ // quota 快照带出,不在此等待。
124
130
  const providerName = typeof payload?.provider === 'string' ? payload.provider : ''
125
131
  const profile = readQuotaProfiles(ctx.get('settings'), ctx.get('llm')).find((candidate) => candidate.name === providerName)
126
132
  if (profile === undefined) return { ok: false, error: 'unknown-provider' }
@@ -130,9 +136,9 @@ export function createQuotaRoutes({
130
136
  const forced = quotaThrottle.force(providerName)
131
137
  if (!forced.ok) {
132
138
  if (forced.reason === 'inflight') return { ok: true }
133
- return { ok: false, error: forced.reason === 'cooldown' ? 'refresh-cooldown' : 'refresh-backoff', nextAllowedAt: forced.nextAllowedAt }
139
+ return { ok: false, error: 'refresh-cooldown', nextAllowedAt: forced.nextAllowedAt }
134
140
  }
135
- kickQuotaRefresh(profile, adapter, config)
141
+ kickQuotaRefresh(profile, adapter, config, { manual: true })
136
142
  return { ok: true }
137
143
  } catch (error) {
138
144
  return rpcTechnicalFailure(error)
@@ -17,11 +17,13 @@ export function createSubagentRoutes({
17
17
  listSubagentDispatches,
18
18
  listSubagentModels,
19
19
  rpcTechnicalFailure,
20
+ providerAvailability,
20
21
  }) {
21
22
  return {
22
23
  'subagent-route': { feature: 'subagentRoute', handle: async (payload, rpcEndpoint) => {
23
24
  try {
24
25
  await subagentRouteLoadPromise
26
+ if (providerAvailability) await providerAvailability.loading
25
27
  const llm = ctx.get('llm')
26
28
  // 模型清单沿用 skills-models 的白名单口径(llm.listProviders × listModels,单渠道失败跳过),
27
29
  // 并对每个精确模型附加 adapter 的 reasoning metadata(resolveModelInfo 缺席时保留原目录项);
@@ -128,6 +130,11 @@ export function createSubagentRoutes({
128
130
  }
129
131
  }
130
132
  return await serializeSubagentRouteWrite(async (config) => {
133
+ if (providerAvailability) {
134
+ await providerAvailability.loading
135
+ if (primary.provider) providerAvailability.assertEnabled(primary.provider, primary.model)
136
+ for (const fallback of fallbacks) providerAvailability.assertEnabled(fallback.provider, fallback.model)
137
+ }
131
138
  config.mode = mode
132
139
  if (mode === 'custom') {
133
140
  config.provider = primary.provider