dsh-dock 0.14.0 → 0.16.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/README.md +49 -19
- package/client.js +2461 -1008
- package/features/animation/host.js +2 -1
- package/features/balance/host.js +2 -1
- package/features/mobile/host.js +93 -98
- package/features/mobile/view.js +136 -133
- package/features/mobile-relay/host.js +2 -1
- package/features/modelconfig/host.js +21 -462
- package/features/modelconfig/view.js +19 -704
- package/features/notify/host.js +2 -1
- package/features/notify/view.jsx +153 -30
- package/features/providerconfig/host.js +235 -0
- package/features/providerconfig/view.js +604 -0
- package/features/runstate/host.js +12 -1
- package/features/tokenlog/host.js +2 -1
- package/features/tokenlog/view.jsx +42 -12
- package/features/tts/host.js +164 -0
- package/features/tts/view.jsx +1189 -0
- package/features/visionproxy/host.js +1 -1
- package/index.js +9 -1
- package/package.json +3 -3
- package/screenshots/dock-modelconfig.png +0 -0
- package/src/client.jsx +54 -24
- package/src/host-core.js +84 -0
- package/src/model-directory.js +275 -0
- package/src/model-draft.js +119 -0
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
// dsh-dock · 功能模块【模型设置】·
|
|
1
|
+
// dsh-dock · 功能模块【模型设置】· 宿主半部
|
|
2
|
+
//
|
|
3
|
+
// GET /dsh-dock/models —— 读模型目录(各 Provider 的模型、输入模态、思考强度)
|
|
4
|
+
// POST /dsh-dock/models —— 把面板对**单个模型**的编辑写回官方 settings(热生效)
|
|
5
|
+
//
|
|
6
|
+
// 边界(Provider 配置拆出后):本模块只管模型条目自身的属性——输入模态
|
|
7
|
+
// (text/image)与思考强度/默认档。Provider 自身的增删改、API Key、拉取上游模型
|
|
8
|
+
// 列表属于【模型提供商】(features/providerconfig/),两个功能可各自启停。
|
|
2
9
|
//
|
|
3
|
-
// GET /dsh-dock/models —— 枚举全部已配置 Provider 的模型目录(含输入类型/思考强度)
|
|
4
|
-
// POST /dsh-dock/models —— 把面板编辑写回官方 settings(settings.mutate,热生效)
|
|
5
10
|
// 官方链路(集成不改内核):
|
|
6
11
|
// - 会话模型选择器按 model.reasoning.efforts 展示强度档;档位源自 Provider 配置:
|
|
7
12
|
// · pi-ai 路由:每模型 reasoningEfforts(档位 → wire 值;off:null 表示"支持关闭、不发参数";false = 不支持思考)
|
|
@@ -9,293 +14,14 @@
|
|
|
9
14
|
// - 输入类型官方 schema 仅接受 text/image;「视频」等以 dockTags 标注随配置持久化(不参与请求路由)
|
|
10
15
|
// - schemastery 保留未知字段(实测),dockTags 对官方校验透明
|
|
11
16
|
// - POST 也收图片理解代理配置(visionProxy 分支,写 dsh-dock 自有命名空间)
|
|
12
|
-
import { DOCK_NS, sendJson, readBody,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
/** pi-ai 路由支持的 API 协议。 */
|
|
21
|
-
const PI_AI_APIS = ['openai-completions', 'openai-responses', 'anthropic-messages']
|
|
22
|
-
/** Provider 路由 key(= settings 里 providers.<route> 的 key),须能安全用作对象键与凭据寻址。 */
|
|
23
|
-
const ROUTE_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
24
|
-
/** pi-ai 支持的思考档位(升序)。 */
|
|
25
|
-
const PI_AI_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
26
|
-
/** deepseek 官方支持的思考档位。 */
|
|
27
|
-
const DEEPSEEK_LEVELS = ['off', 'low', 'high', 'max']
|
|
28
|
-
|
|
29
|
-
/** deepseek-official 内置默认值(与 balance 模块同款兜底)。 */
|
|
30
|
-
const DEEPSEEK_DEFAULTS = {
|
|
31
|
-
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
|
32
|
-
baseURL: 'https://api.deepseek.com',
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function providerKindOf(entry) {
|
|
36
|
-
if (entry.settingsNs === 'llm-deepseek') return 'deepseek'
|
|
37
|
-
if (entry.settingsNs === 'llm-pi-ai') return 'pi-ai'
|
|
38
|
-
return 'other'
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** 正整数或 undefined(目录数字字段透传)。 */
|
|
42
|
-
function posInt(v) {
|
|
43
|
-
return Number.isInteger(v) && v > 0 ? v : undefined
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** 字符串数组透传(仅保留非空字符串)。 */
|
|
47
|
-
function strList(v) {
|
|
48
|
-
return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x) : []
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** 把 pi-ai 模型条目归一为面板视图(raw = 原始条目,保存时原样回传保留未知字段)。 */
|
|
52
|
-
function piAiModelView(entry, routeDefaultInput) {
|
|
53
|
-
const input = strList(entry.input).filter((m) => HOST_MODALITIES.includes(m))
|
|
54
|
-
const raw = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}
|
|
55
|
-
return {
|
|
56
|
-
id: typeof entry.id === 'string' ? entry.id : '',
|
|
57
|
-
name: typeof entry.name === 'string' ? entry.name : undefined,
|
|
58
|
-
contextWindow: posInt(entry.contextWindow),
|
|
59
|
-
maxTokens: posInt(entry.maxTokens),
|
|
60
|
-
input: input.length > 0 ? input : (strList(routeDefaultInput).length > 0 ? strList(routeDefaultInput) : ['text']),
|
|
61
|
-
inputInherited: input.length === 0,
|
|
62
|
-
efforts: raw.reasoningEfforts === undefined ? 'inherit' : raw.reasoningEfforts === false ? 'off' : 'custom',
|
|
63
|
-
effortMap: raw.reasoningEfforts && typeof raw.reasoningEfforts === 'object' ? raw.reasoningEfforts : null,
|
|
64
|
-
tags: strList(entry.dockTags),
|
|
65
|
-
raw,
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** 把 deepseek 目录模型归一为面板视图。 */
|
|
70
|
-
function deepSeekModelView(entry) {
|
|
71
|
-
const input = strList(entry.inputModalities).filter((m) => HOST_MODALITIES.includes(m))
|
|
72
|
-
const raw = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : {}
|
|
73
|
-
return {
|
|
74
|
-
id: typeof entry.id === 'string' ? entry.id : '',
|
|
75
|
-
name: typeof entry.name === 'string' ? entry.name : undefined,
|
|
76
|
-
contextWindow: posInt(entry.contextWindow),
|
|
77
|
-
maxTokens: posInt(entry.maxTokens),
|
|
78
|
-
input: input.length > 0 ? input : ['text'],
|
|
79
|
-
inputInherited: input.length === 0,
|
|
80
|
-
tags: strList(entry.dockTags),
|
|
81
|
-
raw,
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* 读模型目录:按"已配置"口径枚举 Provider,
|
|
87
|
-
* 输出各 Provider 的模型列表(含输入类型 / 思考强度)与各 settings 命名空间的 revision。
|
|
88
|
-
* (visionproxy 模块的目录兜底缓存也复用本函数)
|
|
89
|
-
*/
|
|
90
|
-
export function readModelDirectory(ctx) {
|
|
91
|
-
const llm = ctx.get('llm')
|
|
92
|
-
const settings = ctx.get('settings')
|
|
93
|
-
const entries = (llm && llm.listConfigurableProviders ? llm.listConfigurableProviders() : []) || []
|
|
94
|
-
let views = []
|
|
95
|
-
try {
|
|
96
|
-
views = (settings && settings.describe ? settings.describe({ redactSecrets: true }) : []) || []
|
|
97
|
-
} catch {
|
|
98
|
-
// settings 未挂载
|
|
99
|
-
}
|
|
100
|
-
const descOf = (ns) => views.find((v) => v && String(v.ns) === ns)
|
|
101
|
-
|
|
102
|
-
let sel = null
|
|
103
|
-
try {
|
|
104
|
-
sel = (ctx.get('agentDefaultModel') && ctx.get('agentDefaultModel').currentSelection()) || null
|
|
105
|
-
} catch {
|
|
106
|
-
// 服务缺失
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const providers = []
|
|
110
|
-
for (const e of entries) {
|
|
111
|
-
const kind = providerKindOf(e)
|
|
112
|
-
if (kind === 'other') continue // 官方之外的适配器暂不编辑(无稳定 schema)
|
|
113
|
-
const desc = descOf(e.settingsNs)
|
|
114
|
-
if (!desc) continue
|
|
115
|
-
const profileUser = desc.user ? walkPath(desc.user, e.settingsPath) : undefined
|
|
116
|
-
const profileBase = desc.base ? walkPath(desc.base, e.settingsPath) : undefined
|
|
117
|
-
const isDefaultProvider = !!(sel && sel.provider === e.provider)
|
|
118
|
-
// deepseek 官方始终可编辑(schema 默认目录兜底);其余按"已配置"口径
|
|
119
|
-
const configured = kind === 'deepseek' || isDefaultProvider
|
|
120
|
-
|| (profileUser && typeof profileUser === 'object')
|
|
121
|
-
|| (profileBase && typeof profileBase === 'object')
|
|
122
|
-
if (!configured) continue
|
|
123
|
-
|
|
124
|
-
const resolved = walkPath(desc.value, e.settingsPath) // schema 默认已套上的合成视图
|
|
125
|
-
if (kind === 'deepseek') {
|
|
126
|
-
const section = resolved && typeof resolved === 'object' ? resolved : {}
|
|
127
|
-
const models = Array.isArray(section.models) ? section.models : []
|
|
128
|
-
providers.push({
|
|
129
|
-
id: e.provider,
|
|
130
|
-
displayName: (profileUser && profileUser.displayName) || e.displayName || e.provider,
|
|
131
|
-
api: 'deepseek',
|
|
132
|
-
baseURL: (profileUser && profileUser.baseURL) || section.baseURL || DEEPSEEK_DEFAULTS.baseURL,
|
|
133
|
-
apiKeyEnv: (profileUser && profileUser.apiKeyEnv) || DEEPSEEK_DEFAULTS.apiKeyEnv,
|
|
134
|
-
kind,
|
|
135
|
-
settingsNs: e.settingsNs,
|
|
136
|
-
settingsPath: [...e.settingsPath],
|
|
137
|
-
thinking: section.thinking === 'disabled' ? 'disabled' : 'enabled',
|
|
138
|
-
defaultEffort: DEEPSEEK_LEVELS.includes(section.reasoningEffort) ? section.reasoningEffort : undefined,
|
|
139
|
-
effortLevels: DEEPSEEK_LEVELS.slice(),
|
|
140
|
-
models: models.map(deepSeekModelView),
|
|
141
|
-
})
|
|
142
|
-
} else {
|
|
143
|
-
const route = e.settingsPath[1] || e.provider
|
|
144
|
-
const profile = (profileUser && typeof profileUser === 'object' ? profileUser
|
|
145
|
-
: (profileBase && typeof profileBase === 'object' ? profileBase : resolved)) || {}
|
|
146
|
-
const resolvedProfile = (resolved && typeof resolved === 'object' ? resolved : {}) || {}
|
|
147
|
-
const models = Array.isArray(profile.models) ? profile.models : []
|
|
148
|
-
providers.push({
|
|
149
|
-
id: e.provider,
|
|
150
|
-
route,
|
|
151
|
-
displayName: profile.displayName || e.displayName || e.provider,
|
|
152
|
-
api: typeof profile.api === 'string' ? profile.api : undefined,
|
|
153
|
-
baseURL: typeof profile.baseURL === 'string' ? profile.baseURL : undefined,
|
|
154
|
-
apiKeyEnv: typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : undefined,
|
|
155
|
-
kind,
|
|
156
|
-
settingsNs: e.settingsNs,
|
|
157
|
-
settingsPath: [...e.settingsPath],
|
|
158
|
-
// 用户层原始配置:编辑表单按它回填,未知字段(自配 compat/description 等)原样保留。
|
|
159
|
-
rawProfile: profileUser && typeof profileUser === 'object' && !Array.isArray(profileUser) ? profileUser : {},
|
|
160
|
-
apis: PI_AI_APIS.slice(),
|
|
161
|
-
defaultInput: strList(resolvedProfile.defaultInput),
|
|
162
|
-
models: models.map((m) => piAiModelView(m && typeof m === 'object' ? m : { id: String(m) }, resolvedProfile.defaultInput)),
|
|
163
|
-
})
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const revisions = {}
|
|
168
|
-
for (const ns of ['llm-pi-ai', 'llm-deepseek']) {
|
|
169
|
-
const d = descOf(ns)
|
|
170
|
-
if (d && d.revision !== undefined) revisions[ns] = d.revision
|
|
171
|
-
}
|
|
172
|
-
return { generatedAt: Date.now(), default: sel, revisions, providers }
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* 给目录视图补运行时视角的输入模态(llm.resolveModelInfo,与官方投影同源:
|
|
177
|
-
* entry.input → 内置目录 → 路由默认)。面板据此展示「原图直发 vs 走视觉代理」,
|
|
178
|
-
* 避免"条目留空但目录继承多模态"的模型被误分组。
|
|
179
|
-
* 注意:功能启用后 llm.resolveModelInfo 被本插件包装(虚拟多模态),此处须取原函数保真。
|
|
180
|
-
*/
|
|
181
|
-
async function enrichRuntimeInput(ctx, dir) {
|
|
182
|
-
const llm = ctx.get('llm')
|
|
183
|
-
const resolve = llm && (llm.__dockOrigResolveModelInfo
|
|
184
|
-
|| (typeof llm.resolveModelInfo === 'function' ? llm.resolveModelInfo.bind(llm) : null))
|
|
185
|
-
if (!resolve) return
|
|
186
|
-
await Promise.all((dir.providers || []).flatMap((p) => (p.models || []).map(async (m) => {
|
|
187
|
-
try {
|
|
188
|
-
const info = await resolve(p.id, m.id)
|
|
189
|
-
if (info && Array.isArray(info.inputModalities)) {
|
|
190
|
-
m.runtimeInput = info.inputModalities.filter((x) => HOST_MODALITIES.includes(x))
|
|
191
|
-
}
|
|
192
|
-
} catch {
|
|
193
|
-
// 单模型解析失败不影响整表(面板回退用 input 展示)
|
|
194
|
-
}
|
|
195
|
-
})))
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* 并行补 Provider 的 apiKeyConfigured:apiKeyEnv 指向的条目是否已在本地凭据库配置。
|
|
200
|
-
* 凭据库不可用 / 环境变量名非法时一律降级为 false(不抛错,避免整表 500)。
|
|
201
|
-
*/
|
|
202
|
-
async function enrichApiKeyStatus(ctx, dir) {
|
|
203
|
-
const credentials = ctx.get('credentials')
|
|
204
|
-
if (!credentials || typeof credentials.describe !== 'function') return
|
|
205
|
-
await Promise.all((dir.providers || []).filter((p) => p.kind === 'pi-ai').map(async (p) => {
|
|
206
|
-
if (typeof p.apiKeyEnv !== 'string' || !p.apiKeyEnv) { p.apiKeyConfigured = false; return }
|
|
207
|
-
try {
|
|
208
|
-
const d = await credentials.describe(credentialRef(p.apiKeyEnv))
|
|
209
|
-
p.apiKeyConfigured = !!(d && d.configured)
|
|
210
|
-
} catch {
|
|
211
|
-
p.apiKeyConfigured = false
|
|
212
|
-
}
|
|
213
|
-
}))
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** 校验面板数字草稿并落到条目上(空 = 删除字段,继承默认)。 */
|
|
217
|
-
function setPosInt(entry, field, value) {
|
|
218
|
-
if (value === undefined || value === null || value === '') {
|
|
219
|
-
delete entry[field]
|
|
220
|
-
return
|
|
221
|
-
}
|
|
222
|
-
const n = Number(value)
|
|
223
|
-
if (!Number.isInteger(n) || n <= 0) throw new Error(`模型 ${entry.id} 的 ${field} 须为正整数`)
|
|
224
|
-
entry[field] = n
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* 校验面板提交的模型草稿,产出写回 pi-ai 的模型条目(不合法直接抛错)。
|
|
229
|
-
* 基于原条目(raw)合并编辑:未知字段(用户自配 compat/description 等)原样保留。
|
|
230
|
-
* 启用思考(custom)时的兜底(2026-08-22 事故修复):
|
|
231
|
-
* 不在 pi-ai 内置目录里的模型没有 catalog compat,检测默认 supportsDeveloperRole=true,
|
|
232
|
-
* 而 pi-ai 对 reasoning 模型会把 system 改写成 developer 角色 → 百炼等 OpenAI 兼容端点 400。
|
|
233
|
-
* 故凡启用思考一律显式 supportsDeveloperRole:false(system 全端点通用);
|
|
234
|
-
* 百炼/阿里云家族 baseURL 再补 thinkingFormat:'qwen'(与目录内模型一致,思考参数用 qwen 格式)。
|
|
235
|
-
*/
|
|
236
|
-
function piAiModelWrite(m, p) {
|
|
237
|
-
if (!m || typeof m.id !== 'string' || !m.id) throw new Error('模型 id 不能为空')
|
|
238
|
-
const raw = m.raw && typeof m.raw === 'object' && !Array.isArray(m.raw) ? m.raw : {}
|
|
239
|
-
const entry = Object.assign({}, raw)
|
|
240
|
-
entry.id = m.id
|
|
241
|
-
if (typeof m.name === 'string' && m.name) entry.name = m.name
|
|
242
|
-
else delete entry.name
|
|
243
|
-
setPosInt(entry, 'contextWindow', m.contextWindow)
|
|
244
|
-
setPosInt(entry, 'maxTokens', m.maxTokens)
|
|
245
|
-
const input = Array.isArray(m.input) ? m.input.filter((x) => HOST_MODALITIES.includes(x)) : []
|
|
246
|
-
if (input.length > 0) entry.input = input // 留空 = 继承路由/目录默认
|
|
247
|
-
else delete entry.input
|
|
248
|
-
const tags = strList(m.tags)
|
|
249
|
-
if (tags.length > 0) entry.dockTags = tags // 标注(如 video),官方校验忽略、随配置持久化
|
|
250
|
-
else delete entry.dockTags
|
|
251
|
-
if (m.effortsMode === 'off') {
|
|
252
|
-
entry.reasoningEfforts = false
|
|
253
|
-
} else if (m.effortsMode === 'custom') {
|
|
254
|
-
const levels = m.effortLevels || {}
|
|
255
|
-
const map = {}
|
|
256
|
-
let real = 0
|
|
257
|
-
for (const lv of PI_AI_LEVELS) {
|
|
258
|
-
if (!levels[lv]) continue
|
|
259
|
-
if (lv === 'off') map.off = null // 支持关闭:不发参数
|
|
260
|
-
else { map[lv] = lv; real += 1 }
|
|
261
|
-
}
|
|
262
|
-
if (real === 0) throw new Error(`模型 ${m.id} 至少要勾选一个思考档位(off 之外)`)
|
|
263
|
-
entry.reasoningEfforts = map
|
|
264
|
-
// 目录路由(用户层未写 api/baseURL,靠 pi-ai 内置目录提供)视作 openai 兼容
|
|
265
|
-
const apiOk = !p.api || (typeof p.api === 'string' && p.api.indexOf('openai') === 0)
|
|
266
|
-
if (apiOk) {
|
|
267
|
-
const compat = Object.assign({}, entry.compat)
|
|
268
|
-
compat.supportsDeveloperRole = false
|
|
269
|
-
// 百炼/阿里云家族(路由 id 或 baseURL 命中)思考参数用 qwen 格式,与目录内模型一致
|
|
270
|
-
if (/qwen|dashscope|aliyuncs/i.test(String(p.route || '') + ' ' + String(p.baseURL || ''))) {
|
|
271
|
-
compat.thinkingFormat = 'qwen'
|
|
272
|
-
}
|
|
273
|
-
entry.compat = compat
|
|
274
|
-
}
|
|
275
|
-
} else {
|
|
276
|
-
delete entry.reasoningEfforts // inherit = 不写该字段,跟随内置目录
|
|
277
|
-
}
|
|
278
|
-
return entry
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** 校验并产出写回 deepseek 官方的目录模型条目(raw 未知字段保留)。 */
|
|
282
|
-
function deepSeekModelWrite(m) {
|
|
283
|
-
if (!m || typeof m.id !== 'string' || !m.id) throw new Error('模型 id 不能为空')
|
|
284
|
-
const raw = m.raw && typeof m.raw === 'object' && !Array.isArray(m.raw) ? m.raw : {}
|
|
285
|
-
const entry = Object.assign({}, raw)
|
|
286
|
-
entry.id = m.id
|
|
287
|
-
if (typeof m.name === 'string' && m.name) entry.name = m.name
|
|
288
|
-
else delete entry.name
|
|
289
|
-
setPosInt(entry, 'contextWindow', m.contextWindow)
|
|
290
|
-
setPosInt(entry, 'maxTokens', m.maxTokens)
|
|
291
|
-
const input = Array.isArray(m.input) ? m.input.filter((x) => HOST_MODALITIES.includes(x)) : []
|
|
292
|
-
if (input.includes('image')) entry.inputModalities = input // 纯文本留空走 schema 默认 ['text']
|
|
293
|
-
else delete entry.inputModalities
|
|
294
|
-
const tags = strList(m.tags)
|
|
295
|
-
if (tags.length > 0) entry.dockTags = tags
|
|
296
|
-
else delete entry.dockTags
|
|
297
|
-
return entry
|
|
298
|
-
}
|
|
17
|
+
import { DOCK_NS, sendJson, readBody, rejectUntrustedRequest, readDockRoot, mutateDockSection } from '../../src/host-core.js'
|
|
18
|
+
import {
|
|
19
|
+
DEEPSEEK_LEVELS,
|
|
20
|
+
readModelDirectory,
|
|
21
|
+
enrichRuntimeInput,
|
|
22
|
+
piAiModelWrite,
|
|
23
|
+
deepSeekModelWrite,
|
|
24
|
+
} from '../../src/model-directory.js'
|
|
299
25
|
|
|
300
26
|
/**
|
|
301
27
|
* 写回官方配置:按 Provider kind 生成 settings.mutate ops。
|
|
@@ -307,7 +33,8 @@ async function writeModelConfig(ctx, body) {
|
|
|
307
33
|
const settings = ctx.get('settings')
|
|
308
34
|
if (!settings || typeof settings.mutate !== 'function') throw new Error('settings 服务不可用或不可写')
|
|
309
35
|
|
|
310
|
-
//
|
|
36
|
+
// 图片理解代理配置:独立于模型目录的小分支(该功能的界面挂在【模型设置】页内,
|
|
37
|
+
// 拆出 Provider 配置时未动它,仍是同一条 POST 的旁路)。
|
|
311
38
|
if (body.visionProxy !== undefined) {
|
|
312
39
|
const vp = body.visionProxy
|
|
313
40
|
if (!vp || typeof vp !== 'object' || Array.isArray(vp)) throw new Error('visionProxy 格式不符')
|
|
@@ -383,182 +110,21 @@ async function writeModelConfig(ctx, body) {
|
|
|
383
110
|
return { ok: true, savedAt: Date.now() }
|
|
384
111
|
}
|
|
385
112
|
|
|
386
|
-
/**
|
|
387
|
-
* Provider 级增 / 改 / 删(走 llm-pi-ai 的 providers 字典,不改内核)。
|
|
388
|
-
* body.action = 'create' | 'update' | 'delete'
|
|
389
|
-
* body.route = providers 字典的 key(= 路由 id)
|
|
390
|
-
* body.profile = { displayName, api, baseURL, apiKeyEnv, defaultInput, models: [草稿...] }
|
|
391
|
-
* body.apiKey = 可选明文 Key(写本地凭据库,不落到配置文件)
|
|
392
|
-
* body.revisions['llm-pi-ai'] = GET 拿到的 revision(乐观锁,撞车 409)
|
|
393
|
-
* 未在 profile 里出现的未知字段?——不会:base 以原 profile 为基础合并,用户自配的
|
|
394
|
-
* compat / description 等原样保留,只覆盖面板负责的那几个键。
|
|
395
|
-
*/
|
|
396
|
-
async function writeProviderConfig(ctx, body) {
|
|
397
|
-
const settings = ctx.get('settings')
|
|
398
|
-
if (!settings || typeof settings.mutate !== 'function') throw new Error('settings 服务不可用或不可写')
|
|
399
|
-
const action = body.action
|
|
400
|
-
const route = typeof body.route === 'string' ? body.route.trim() : ''
|
|
401
|
-
const revision = body.revisions && typeof body.revisions['llm-pi-ai'] === 'number'
|
|
402
|
-
? body.revisions['llm-pi-ai'] : undefined
|
|
403
|
-
const bad = (msg) => { const e = new Error(msg); e.statusCode = 400; return e }
|
|
404
|
-
|
|
405
|
-
if (action === 'delete') {
|
|
406
|
-
if (!route) throw bad('请求缺少 route')
|
|
407
|
-
const dir = readModelDirectory(ctx)
|
|
408
|
-
const p = (dir.providers || []).find((x) => (x.route || x.id) === route)
|
|
409
|
-
if (!p) throw bad(`未找到 Provider:${route}`)
|
|
410
|
-
if (p.kind !== 'pi-ai') throw bad('内置 Provider(如 DeepSeek 官方)不能删除,只能编辑其模型')
|
|
411
|
-
try {
|
|
412
|
-
await settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', p.route || route] }], revision)
|
|
413
|
-
} catch (e) {
|
|
414
|
-
const msg = e && e.message ? e.message : String(e)
|
|
415
|
-
const err = new Error(`删除被拒绝:${msg}`)
|
|
416
|
-
err.statusCode = /conflict|revision/i.test(msg) ? 409 : 400
|
|
417
|
-
throw err
|
|
418
|
-
}
|
|
419
|
-
return { ok: true, deleted: p.route || route, savedAt: Date.now() }
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
if (action !== 'create' && action !== 'update') throw bad(`不支持的 action:${action}`)
|
|
423
|
-
if (!route || !ROUTE_KEY_RE.test(route)) throw bad('路由 key 非法:须以字母数字开头,只能含字母、数字与 . _ -')
|
|
424
|
-
const src = body.profile && typeof body.profile === 'object' && !Array.isArray(body.profile) ? body.profile : {}
|
|
425
|
-
const api = typeof src.api === 'string' ? src.api : ''
|
|
426
|
-
if (PI_AI_APIS.indexOf(api) < 0) throw bad(`api 非法:只支持 ${PI_AI_APIS.join(' / ')}`)
|
|
427
|
-
const baseURL = typeof src.baseURL === 'string' ? src.baseURL.trim() : ''
|
|
428
|
-
if (!/^https?:\/\//i.test(baseURL)) throw bad('baseURL 非法:须以 http:// 或 https:// 开头')
|
|
429
|
-
let apiKeyEnv = typeof src.apiKeyEnv === 'string' ? src.apiKeyEnv.trim() : ''
|
|
430
|
-
if (apiKeyEnv) {
|
|
431
|
-
try { credentialRef(apiKeyEnv) } catch { throw bad('apiKeyEnv 非法:须是合法的环境变量名(如 MODELSCOPE_API_KEY)') }
|
|
432
|
-
}
|
|
433
|
-
const models = Array.isArray(src.models) ? src.models : []
|
|
434
|
-
if (models.length === 0) throw bad('至少需要一个模型')
|
|
435
|
-
const seen = new Set()
|
|
436
|
-
for (const m of models) {
|
|
437
|
-
const id = m && typeof m.id === 'string' ? m.id.trim() : ''
|
|
438
|
-
if (!id) throw bad('模型 id 不能为空')
|
|
439
|
-
if (seen.has(id)) throw bad(`模型 id 重复:${id}`)
|
|
440
|
-
seen.add(id)
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const base = Object.assign({}, src)
|
|
444
|
-
delete base.models
|
|
445
|
-
const displayName = typeof src.displayName === 'string' ? src.displayName.trim() : ''
|
|
446
|
-
if (displayName) base.displayName = displayName; else delete base.displayName
|
|
447
|
-
base.api = api
|
|
448
|
-
base.baseURL = baseURL
|
|
449
|
-
if (apiKeyEnv) base.apiKeyEnv = apiKeyEnv; else delete base.apiKeyEnv
|
|
450
|
-
base.models = models.map((m) => piAiModelWrite(
|
|
451
|
-
Object.assign({}, m, { id: String(m.id).trim() }),
|
|
452
|
-
{ api, route, baseURL }
|
|
453
|
-
))
|
|
454
|
-
|
|
455
|
-
const apiKey = typeof body.apiKey === 'string' ? body.apiKey.trim() : ''
|
|
456
|
-
if (apiKey) {
|
|
457
|
-
if (!apiKeyEnv) {
|
|
458
|
-
// Key 填了但 apiKeyEnv 留空:按路由 key 自动生成凭据库条目名(与客户端 dkmEnvFromRoute 一致),
|
|
459
|
-
// 不再要求用户现编一个环境变量名
|
|
460
|
-
const s = String(route || '').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
|
461
|
-
apiKeyEnv = (s ? (/^[0-9]/.test(s) ? 'KEY_' + s : s) : 'PROVIDER') + '_API_KEY'
|
|
462
|
-
base.apiKeyEnv = apiKeyEnv
|
|
463
|
-
}
|
|
464
|
-
const credentials = ctx.get('credentials')
|
|
465
|
-
if (!credentials || typeof credentials.set !== 'function') throw bad('凭据库不可用,无法保存 API Key')
|
|
466
|
-
try {
|
|
467
|
-
await credentials.set(credentialRef(apiKeyEnv), apiKey)
|
|
468
|
-
} catch (e) {
|
|
469
|
-
const msg = e && e.message ? e.message : String(e)
|
|
470
|
-
const err = new Error(`写入本地凭据库失败:${msg}`)
|
|
471
|
-
err.statusCode = 400
|
|
472
|
-
throw err
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
try {
|
|
477
|
-
await settings.mutate('llm-pi-ai', [{ op: 'set', path: ['providers', route], value: base }], revision)
|
|
478
|
-
} catch (e) {
|
|
479
|
-
const msg = e && e.message ? e.message : String(e)
|
|
480
|
-
const err = new Error(`写回官方配置被拒绝:${msg}`)
|
|
481
|
-
err.statusCode = /conflict|revision/i.test(msg) ? 409 : 400
|
|
482
|
-
throw err
|
|
483
|
-
}
|
|
484
|
-
return { ok: true, route, created: action === 'create', savedAt: Date.now() }
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* 拉取 Provider 的可用模型列表:OpenAI 兼容端点 GET {baseURL}/models。
|
|
489
|
-
* Key 优先用请求里刚填的明文(还没保存就能先拉列表),否则回退到已配置的凭据库条目。
|
|
490
|
-
* 只做「列目录」,不代表该模型真能调用(额度/权限以上游为准)。
|
|
491
|
-
*/
|
|
492
|
-
async function fetchProviderModels(ctx, body) {
|
|
493
|
-
const bad = (msg) => { const e = new Error(msg); e.statusCode = 400; return e }
|
|
494
|
-
const baseURL = typeof body.baseURL === 'string' ? body.baseURL.trim().replace(/\/+$/, '') : ''
|
|
495
|
-
if (!/^https?:\/\//i.test(baseURL)) throw bad('请先填写合法的 baseURL(http:// 或 https://)')
|
|
496
|
-
let key = typeof body.apiKey === 'string' ? body.apiKey.trim() : ''
|
|
497
|
-
if (!key && typeof body.apiKeyEnv === 'string' && body.apiKeyEnv.trim()) {
|
|
498
|
-
const credentials = ctx.get('credentials')
|
|
499
|
-
if (credentials && typeof credentials.resolve === 'function') {
|
|
500
|
-
try {
|
|
501
|
-
const got = await credentials.resolve(credentialRef(body.apiKeyEnv.trim()))
|
|
502
|
-
key = typeof got === 'string' ? got : (got && typeof got.value === 'string' ? got.value : '')
|
|
503
|
-
} catch { key = '' }
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
const headers = { accept: 'application/json' }
|
|
507
|
-
if (key) {
|
|
508
|
-
if (body.api === 'anthropic-messages') {
|
|
509
|
-
headers['x-api-key'] = key
|
|
510
|
-
headers['anthropic-version'] = '2023-06-01'
|
|
511
|
-
} else {
|
|
512
|
-
headers.authorization = 'Bearer ' + key
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
let res
|
|
516
|
-
try {
|
|
517
|
-
res = await fetch(baseURL + '/models', { headers, signal: AbortSignal.timeout(20000) })
|
|
518
|
-
} catch (e) {
|
|
519
|
-
const err = new Error(`请求 ${baseURL}/models 失败:${(e && e.message) || String(e)}`)
|
|
520
|
-
err.statusCode = 400
|
|
521
|
-
throw err
|
|
522
|
-
}
|
|
523
|
-
let text = ''
|
|
524
|
-
try { text = await res.text() } catch { text = '' }
|
|
525
|
-
if (!res.ok) {
|
|
526
|
-
const err = new Error(`上游返回 HTTP ${res.status}${text ? ':' + text.slice(0, 240) : ''}`)
|
|
527
|
-
err.statusCode = 400
|
|
528
|
-
throw err
|
|
529
|
-
}
|
|
530
|
-
let json = null
|
|
531
|
-
try { json = JSON.parse(text) } catch { /* 非 JSON:下面按空列表处理 */ }
|
|
532
|
-
let list = []
|
|
533
|
-
if (Array.isArray(json)) list = json
|
|
534
|
-
else if (json && Array.isArray(json.data)) list = json.data
|
|
535
|
-
else if (json && Array.isArray(json.models)) list = json.models
|
|
536
|
-
const models = list.map((x) => {
|
|
537
|
-
if (typeof x === 'string') return { id: x }
|
|
538
|
-
const o = x && typeof x === 'object' ? x : {}
|
|
539
|
-
return { id: String(o.id || o.model || o.name || ''), name: typeof o.name === 'string' ? o.name : undefined }
|
|
540
|
-
}).filter((m) => m.id)
|
|
541
|
-
return { ok: true, count: models.length, models, fetchedAt: Date.now() }
|
|
542
|
-
}
|
|
543
|
-
|
|
544
113
|
/** 功能注册表条目:index.js 组装 hostSetups 时使用。 */
|
|
545
114
|
export const feature = {
|
|
546
|
-
// id 必须与客户端视图描述符(view.js 的 feature.id)一致:面板开关 / 补推同步都按它
|
|
547
|
-
// POST /dsh-dock/features,历史上这里叫 'models' 而客户端叫 'modelconfig',
|
|
548
|
-
// 开关永远被宿主当「未知功能」拒掉,目录接口随之 404(迁移见 host-core 的 migrateModelsFeatureId)。
|
|
549
115
|
id: 'modelconfig',
|
|
550
116
|
name: '模型设置',
|
|
551
|
-
description: '
|
|
117
|
+
description: '模型目录:编辑各模型的输入模态与思考强度,写回官方配置热生效',
|
|
552
118
|
defaultEnabled: false,
|
|
553
119
|
setup(ctx) {
|
|
554
120
|
const webServer = ctx.get('webServer')
|
|
555
121
|
if (webServer === undefined) throw new Error('webServer 服务不可用,无法提供模型设置路由')
|
|
556
122
|
const handler = async (req, res) => {
|
|
123
|
+
if (rejectUntrustedRequest(ctx, req, res)) return
|
|
557
124
|
try {
|
|
558
125
|
if (req.method === 'GET') {
|
|
559
126
|
const payload = readModelDirectory(ctx)
|
|
560
127
|
await enrichRuntimeInput(ctx, payload)
|
|
561
|
-
await enrichApiKeyStatus(ctx, payload)
|
|
562
128
|
// 附带图片理解代理配置与自有命名空间 revision(面板保存用)
|
|
563
129
|
try {
|
|
564
130
|
const v = readDockRoot(ctx)
|
|
@@ -579,14 +145,7 @@ export const feature = {
|
|
|
579
145
|
}
|
|
580
146
|
if (req.method === 'POST') {
|
|
581
147
|
const body = await readBody(req)
|
|
582
|
-
|
|
583
|
-
// action 显式区分三条链路:拉模型列表 / Provider 增删改 / 旧版模型目录写回
|
|
584
|
-
const result = action === 'listModels'
|
|
585
|
-
? await fetchProviderModels(ctx, body)
|
|
586
|
-
: (action === 'create' || action === 'update' || action === 'delete')
|
|
587
|
-
? await writeProviderConfig(ctx, body)
|
|
588
|
-
: await writeModelConfig(ctx, body)
|
|
589
|
-
sendJson(res, 200, result)
|
|
148
|
+
sendJson(res, 200, await writeModelConfig(ctx, body))
|
|
590
149
|
return
|
|
591
150
|
}
|
|
592
151
|
sendJson(res, 405, { error: `不支持的请求方法:${req.method}` })
|