@jedylicong/dsh-xinlab 0.0.0-stage → 0.2.2
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 +47 -0
- package/LICENSE +21 -0
- package/README.md +102 -2
- package/bin/dsh-providers.mjs +28 -0
- package/client.js +220 -0
- package/cordis.patch.yml +310 -0
- package/data/sources.json +7 -0
- package/data/vllm.json +63 -0
- package/data/xin-lab.json +284 -0
- package/data/zai.json +47 -0
- package/package.json +73 -4
- package/src/catalog.mjs +50 -0
- package/src/client-page.mjs +191 -0
- package/src/inspect.mjs +60 -0
- package/src/key-preview-protocol.mjs +28 -0
- package/src/key-preview.mjs +37 -0
- package/src/model-settings.mjs +37 -0
- package/src/official-adapter.mjs +52 -0
- package/src/runtime.mjs +99 -0
- package/src/state.mjs +40 -0
- package/src/vllm-mm-uuid.mjs +14 -0
- package/vendor/vllm-mm-uuid.mjs +239 -0
- package/vendor/vllm-reasoning-only-retry.mjs +41 -0
package/data/zai.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"displayName": "Z.ai",
|
|
3
|
+
"apiKeyEnv": "ZAI_API_KEY",
|
|
4
|
+
"api": "openai-completions",
|
|
5
|
+
"baseURL": "https://api.z.ai/api/coding/paas/v4",
|
|
6
|
+
"compat": {
|
|
7
|
+
"supportsStore": false,
|
|
8
|
+
"supportsDeveloperRole": false,
|
|
9
|
+
"supportsReasoningEffort": true,
|
|
10
|
+
"maxTokensField": "max_tokens",
|
|
11
|
+
"thinkingFormat": "zai"
|
|
12
|
+
},
|
|
13
|
+
"defaultContextWindow": 1000000,
|
|
14
|
+
"defaultMaxTokens": 131072,
|
|
15
|
+
"reasoning": "max",
|
|
16
|
+
"models": [
|
|
17
|
+
{
|
|
18
|
+
"id": "glm-5.3",
|
|
19
|
+
"name": "GLM-5.3",
|
|
20
|
+
"input": [
|
|
21
|
+
"text"
|
|
22
|
+
],
|
|
23
|
+
"reasoningEfforts": {
|
|
24
|
+
"low": "low",
|
|
25
|
+
"high": "high",
|
|
26
|
+
"max": "max"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "glm-5.3-flash",
|
|
31
|
+
"name": "GLM-5.3-Flash",
|
|
32
|
+
"input": [
|
|
33
|
+
"text",
|
|
34
|
+
"image"
|
|
35
|
+
],
|
|
36
|
+
"reasoningEfforts": {
|
|
37
|
+
"low": "low",
|
|
38
|
+
"high": "high",
|
|
39
|
+
"max": "max"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "glm-5.2",
|
|
44
|
+
"name": "GLM-5.2"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jedylicong/dsh-xinlab",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "XinLab 托管供应商配置:Z.ai、XinLab、vLLM 与 vLLM 请求扩展",
|
|
5
|
+
"private": false,
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "jedylicong",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./src/runtime.mjs",
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22"
|
|
12
|
+
},
|
|
13
|
+
"dsh": {
|
|
14
|
+
"manifestVersion": 1,
|
|
15
|
+
"bundle": {
|
|
16
|
+
"patch": "./cordis.patch.yml"
|
|
17
|
+
},
|
|
18
|
+
"client": {
|
|
19
|
+
"inject": [
|
|
20
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
21
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
22
|
+
"@deepseek-ai/dsh-api-remotes"
|
|
23
|
+
],
|
|
24
|
+
"platform": "web"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./src/runtime.mjs",
|
|
29
|
+
"./client": "./client.js",
|
|
30
|
+
"./catalog": "./src/catalog.mjs",
|
|
31
|
+
"./vllm-mm-uuid": "./src/vllm-mm-uuid.mjs",
|
|
32
|
+
"./vllm-reasoning-only-retry": "./vendor/vllm-reasoning-only-retry.mjs",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"bin": {
|
|
36
|
+
"dsh-providers": "./bin/dsh-providers.mjs"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"bin/",
|
|
40
|
+
"src/",
|
|
41
|
+
"data/",
|
|
42
|
+
"vendor/",
|
|
43
|
+
"client.js",
|
|
44
|
+
"cordis.patch.yml",
|
|
45
|
+
"README.md",
|
|
46
|
+
"CHANGELOG.md"
|
|
47
|
+
],
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public",
|
|
50
|
+
"registry": "https://registry.npmjs.org/"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"yaml": "2.9.1"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@deepseek-ai/cordis": "^4.0.5",
|
|
57
|
+
"@deepseek-ai/schemastery": "^3.18.5",
|
|
58
|
+
"@deepseek-ai/dsh-llm": "^0.2.1-rc.3 || ^0.2.2-0",
|
|
59
|
+
"@deepseek-ai/dsh-llm-pi-ai": "^0.2.1-rc.3 || ^0.2.2-0",
|
|
60
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.2.1-rc.3 || ^0.2.2-0"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "node scripts/sync-managed-assets.mjs --write && node scripts/build-bundle.mjs --write && node scripts/build-client.mjs --write",
|
|
64
|
+
"check": "node scripts/sync-managed-assets.mjs --check && node scripts/build-bundle.mjs --check && node scripts/build-client.mjs --check",
|
|
65
|
+
"test": "node --test --test-concurrency=1 tests/*.test.mjs",
|
|
66
|
+
"prepack": "node scripts/release.mjs check",
|
|
67
|
+
"prepublishOnly": "node scripts/release.mjs block-direct-publish",
|
|
68
|
+
"release:check": "node scripts/release.mjs check",
|
|
69
|
+
"release:version": "node scripts/release.mjs version",
|
|
70
|
+
"release:prepare": "node scripts/release.mjs prepare",
|
|
71
|
+
"release:tag": "node scripts/release.mjs tag",
|
|
72
|
+
"release:publish": "node scripts/release.mjs publish",
|
|
73
|
+
"release:verify": "node scripts/release.mjs verify"
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/catalog.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
export const MANAGED_PROVIDERS = Object.freeze(['zai', 'vllm', 'xin-lab'])
|
|
4
|
+
export const ENDPOINT_ENV = Object.freeze({
|
|
5
|
+
zai: 'DSH_ZAI_BASE_URL',
|
|
6
|
+
vllm: 'DSH_VLLM_BASE_URL',
|
|
7
|
+
'xin-lab': 'DSH_XINLAB_BASE_URL',
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export function readCatalog() {
|
|
11
|
+
const catalog = Object.fromEntries(MANAGED_PROVIDERS.map((id) => [
|
|
12
|
+
id, JSON.parse(readFileSync(new URL(`../data/${id}.json`, import.meta.url), 'utf8')),
|
|
13
|
+
]))
|
|
14
|
+
rejectEmbeddedCredentials(catalog)
|
|
15
|
+
return catalog
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function rejectEmbeddedCredentials(value) {
|
|
19
|
+
if (!value || typeof value !== 'object') return
|
|
20
|
+
for (const [key, child] of Object.entries(value)) {
|
|
21
|
+
if (/^(?:apiKey|authorization|access_token|refresh_token|password|secret|token|privateKey)$/i.test(key)) {
|
|
22
|
+
throw new Error('供应商目录不能包含实际凭据,只能使用 apiKeyEnv 引用')
|
|
23
|
+
}
|
|
24
|
+
if (key === 'apiKeyEnv' && (typeof child !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(child))) {
|
|
25
|
+
throw new Error('apiKeyEnv 必须为凭据引用名称')
|
|
26
|
+
}
|
|
27
|
+
rejectEmbeddedCredentials(child)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeBaseURL(value) {
|
|
32
|
+
const url = new URL(value)
|
|
33
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
34
|
+
throw new Error('供应商地址须为不含凭据、查询参数和片段的 HTTP(S) 地址')
|
|
35
|
+
}
|
|
36
|
+
return url.href.replace(/\/+$/, '')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildProviders(env = {}) {
|
|
40
|
+
const providers = readCatalog()
|
|
41
|
+
for (const id of MANAGED_PROVIDERS) {
|
|
42
|
+
const override = env[ENDPOINT_ENV[id]]
|
|
43
|
+
providers[id].baseURL = normalizeBaseURL(override ?? providers[id].baseURL)
|
|
44
|
+
}
|
|
45
|
+
return providers
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function chatCompletionsURL(baseURL) {
|
|
49
|
+
return `${normalizeBaseURL(baseURL)}/chat/completions`
|
|
50
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// This factory is embedded in the browser artifact without a Node dependency.
|
|
2
|
+
// Only transient password inputs and the credentials RPC ever receive key values.
|
|
3
|
+
export function createClientPlugin(React, catalog, settingsId, keyRef, previewDescriptor, packageName) {
|
|
4
|
+
const { createElement: h, useEffect, useState, useSyncExternalStore } = React
|
|
5
|
+
const protocols = {
|
|
6
|
+
'openai-completions': 'OpenAI Chat Completions',
|
|
7
|
+
'openai-responses': 'OpenAI Responses',
|
|
8
|
+
'anthropic-messages': 'Anthropic Messages',
|
|
9
|
+
}
|
|
10
|
+
const models = catalog.flatMap((provider) => provider.models.map((model) => ({
|
|
11
|
+
...model, provider: provider.id, defaultApi: provider.api, ref: keyRef(provider.id, model.id),
|
|
12
|
+
})))
|
|
13
|
+
|
|
14
|
+
class Controller {
|
|
15
|
+
constructor(ctx) {
|
|
16
|
+
this.ctx = ctx
|
|
17
|
+
this.scope = ctx.configForms.get(settingsId)
|
|
18
|
+
this.listeners = new Set()
|
|
19
|
+
this.credentials = {}
|
|
20
|
+
this.notice = {}
|
|
21
|
+
this.busy = new Set()
|
|
22
|
+
this.sequence = 0
|
|
23
|
+
this.disposed = false
|
|
24
|
+
this.refreshState()
|
|
25
|
+
this.unsubscribe = this.scope.subscribe(() => this.refreshState())
|
|
26
|
+
}
|
|
27
|
+
subscribe = (listener) => { this.listeners.add(listener); return () => this.listeners.delete(listener) }
|
|
28
|
+
getSnapshot = () => this.state
|
|
29
|
+
refreshState() {
|
|
30
|
+
if (this.disposed) return
|
|
31
|
+
this.state = { form: this.scope.getSnapshot(), credentials: { ...this.credentials }, notice: { ...this.notice }, busy: new Set(this.busy) }
|
|
32
|
+
for (const listener of this.listeners) listener()
|
|
33
|
+
}
|
|
34
|
+
async refreshCredentials() {
|
|
35
|
+
const sequence = ++this.sequence
|
|
36
|
+
const next = {}
|
|
37
|
+
try {
|
|
38
|
+
for (let start = 0; start < models.length; start += 64) {
|
|
39
|
+
const refs = models.slice(start, start + 64).map((model) => model.ref)
|
|
40
|
+
const result = await this.ctx.remote.xinlabModelKeys.describe(refs)
|
|
41
|
+
if (!result.ok) throw new Error()
|
|
42
|
+
for (const ref of refs) next[ref] = { configured: result.value[ref]?.configured === true, writable: result.value[ref]?.writable === true, hint: result.value[ref]?.hint }
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Failed reads never pretend the key is absent or writable.
|
|
46
|
+
if (!this.disposed && sequence === this.sequence) {
|
|
47
|
+
this.credentials = {}
|
|
48
|
+
this.refreshState()
|
|
49
|
+
}
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
if (this.disposed) return false
|
|
53
|
+
if (sequence === this.sequence) { this.credentials = next; this.refreshState() }
|
|
54
|
+
// A write's readback remains valid even if a pushed credential event
|
|
55
|
+
// started a newer refresh. Only publication is latest-read-wins.
|
|
56
|
+
return next
|
|
57
|
+
}
|
|
58
|
+
async action(id, work, success) {
|
|
59
|
+
if (this.disposed || this.busy.has(id)) return false
|
|
60
|
+
this.busy.add(id)
|
|
61
|
+
delete this.notice[id]
|
|
62
|
+
this.refreshState()
|
|
63
|
+
let ok = false
|
|
64
|
+
try {
|
|
65
|
+
ok = await work()
|
|
66
|
+
this.notice[id] = ok ? success : '保存未完成,请刷新状态后重试。'
|
|
67
|
+
} catch {
|
|
68
|
+
// RPC/provider errors may contain submitted data. Never render or log them.
|
|
69
|
+
this.notice[id] = '保存失败,请检查连接和写入权限后重试。'
|
|
70
|
+
} finally {
|
|
71
|
+
this.busy.delete(id)
|
|
72
|
+
this.refreshState()
|
|
73
|
+
}
|
|
74
|
+
return ok
|
|
75
|
+
}
|
|
76
|
+
saveKey(model, value) {
|
|
77
|
+
const id = model.ref + ':key'
|
|
78
|
+
if (!value.trim() || /[^\x21-\x7E]/.test(value.trim())) {
|
|
79
|
+
this.notice[id] = '请输入有效的 API Key,不能包含空白、换行或非 ASCII 字符。'
|
|
80
|
+
this.refreshState()
|
|
81
|
+
return Promise.resolve(false)
|
|
82
|
+
}
|
|
83
|
+
if (!this.credentials[model.ref]?.writable) return Promise.resolve(false)
|
|
84
|
+
return this.action(id, async () => {
|
|
85
|
+
const result = await this.ctx.remote.credentials.set(model.ref, value.trim())
|
|
86
|
+
if (!result.ok) return false
|
|
87
|
+
const readback = await this.refreshCredentials()
|
|
88
|
+
return readback && readback[model.ref]?.configured === true
|
|
89
|
+
}, '独立密钥已安全保存。')
|
|
90
|
+
}
|
|
91
|
+
clearKey(model) {
|
|
92
|
+
if (!this.credentials[model.ref]?.writable) return Promise.resolve(false)
|
|
93
|
+
return this.action(model.ref + ':key', async () => {
|
|
94
|
+
const result = await this.ctx.remote.credentials.unset(model.ref)
|
|
95
|
+
if (!result.ok) return false
|
|
96
|
+
const readback = await this.refreshCredentials()
|
|
97
|
+
return readback && readback[model.ref]?.configured === false
|
|
98
|
+
}, '独立密钥已移除,恢复使用供应商凭据。')
|
|
99
|
+
}
|
|
100
|
+
saveProtocol(model, api, revision) {
|
|
101
|
+
if (api !== '' && !Object.hasOwn(protocols, api)) return Promise.resolve(false)
|
|
102
|
+
if (!this.scope.getSnapshot().writable || revision === undefined) return Promise.resolve(false)
|
|
103
|
+
const path = ['protocols', model.provider, model.id]
|
|
104
|
+
return this.action(model.ref + ':protocol', () => this.scope.mutate([
|
|
105
|
+
api === '' ? { op: 'unset', path } : { op: 'set', path, value: api },
|
|
106
|
+
], revision), '协议已保存,将用于下一次模型调用。')
|
|
107
|
+
}
|
|
108
|
+
dispose() {
|
|
109
|
+
this.disposed = true
|
|
110
|
+
this.sequence++
|
|
111
|
+
this.unsubscribe()
|
|
112
|
+
this.listeners.clear()
|
|
113
|
+
this.credentials = {}
|
|
114
|
+
this.notice = {}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const inputStyle = { border: '1px solid var(--border, #8886)', borderRadius: 6, padding: '7px 9px', background: 'transparent', color: 'inherit', minWidth: 0, width: '100%', boxSizing: 'border-box' }
|
|
119
|
+
const buttonStyle = { ...inputStyle, width: 'auto', cursor: 'pointer', marginTop: 8, marginRight: 8 }
|
|
120
|
+
function ModelRow({ model, controller, state }) {
|
|
121
|
+
const accepted = state.form.value?.protocols?.[model.provider]?.[model.id] ?? ''
|
|
122
|
+
const [api, setApi] = useState(accepted)
|
|
123
|
+
const [revision, setRevision] = useState(state.form.revision)
|
|
124
|
+
const [dirty, setDirty] = useState(false)
|
|
125
|
+
const [key, setKey] = useState('')
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!dirty) { setApi(accepted); setRevision(state.form.revision) }
|
|
128
|
+
}, [accepted, state.form.revision, dirty])
|
|
129
|
+
const status = state.credentials[model.ref]
|
|
130
|
+
const keyBusy = state.busy.has(model.ref + ':key')
|
|
131
|
+
const protocolBusy = state.busy.has(model.ref + ':protocol')
|
|
132
|
+
const keyId = model.ref + '-input'
|
|
133
|
+
const apiId = model.ref + '-protocol'
|
|
134
|
+
return h('section', { style: { borderTop: '1px solid var(--border, #8884)', padding: '16px 0' } },
|
|
135
|
+
h('div', { style: { fontWeight: 600, marginBottom: 12 } }, model.name || model.id),
|
|
136
|
+
model.name && model.name !== model.id ? h('div', { style: { opacity: 0.65, fontSize: 12, marginBottom: 10 } }, model.id) : null,
|
|
137
|
+
h('label', { htmlFor: keyId }, 'API Key'),
|
|
138
|
+
h('input', { id: keyId, type: 'password', autoComplete: 'new-password', spellCheck: false, value: key,
|
|
139
|
+
disabled: !status?.writable || keyBusy, style: { ...inputStyle, marginTop: 5 },
|
|
140
|
+
placeholder: status?.configured ? '已保存独立密钥;输入新值可替换' : '留空沿用供应商凭据',
|
|
141
|
+
onChange: (event) => setKey(event.target.value),
|
|
142
|
+
}),
|
|
143
|
+
h('div', { style: { fontSize: 12, opacity: 0.75, marginTop: 5 } }, status ? (status.configured ? '已配置独立密钥:' + (status.hint || '••••••••') : '未配置独立密钥,将沿用供应商凭据。') : '密钥状态尚未确认。'),
|
|
144
|
+
h('button', { type: 'button', style: buttonStyle, disabled: !status?.writable || !key.trim() || keyBusy,
|
|
145
|
+
onClick: () => { const submitted = key; setKey(''); void controller.saveKey(model, submitted) },
|
|
146
|
+
}, keyBusy ? '正在保存…' : '保存密钥'),
|
|
147
|
+
h('button', { type: 'button', style: buttonStyle, disabled: !status?.writable || !status.configured || keyBusy,
|
|
148
|
+
onClick: () => { setKey(''); void controller.clearKey(model) },
|
|
149
|
+
}, '清除独立密钥'),
|
|
150
|
+
h('div', { role: 'status', style: { fontSize: 12, marginTop: 6 } }, state.notice[model.ref + ':key'] ?? ''),
|
|
151
|
+
h('label', { htmlFor: apiId, style: { display: 'block', marginTop: 16 } }, '调用协议'),
|
|
152
|
+
h('select', { id: apiId, value: api, disabled: !state.form.writable || protocolBusy, style: { ...inputStyle, marginTop: 5 },
|
|
153
|
+
onChange: (event) => { if (!dirty) setRevision(state.form.revision); setDirty(true); setApi(event.target.value) },
|
|
154
|
+
}, h('option', { value: '' }, '沿用预设(' + (protocols[model.defaultApi] || model.defaultApi) + ')'),
|
|
155
|
+
...Object.entries(protocols).map(([id, label]) => h('option', { key: id, value: id }, label))),
|
|
156
|
+
h('button', { type: 'button', style: buttonStyle, disabled: !state.form.writable || !dirty || protocolBusy,
|
|
157
|
+
onClick: async () => { if (await controller.saveProtocol(model, api, revision)) setDirty(false); else setRevision(controller.scope.getSnapshot().revision) },
|
|
158
|
+
}, protocolBusy ? '正在保存…' : '保存协议'),
|
|
159
|
+
h('div', { role: 'status', style: { fontSize: 12, marginTop: 6 } }, state.notice[model.ref + ':protocol'] ?? ''),
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
function Page({ controller }) {
|
|
163
|
+
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot)
|
|
164
|
+
if (state.form.status !== 'ready') return h('p', null, '正在读取供应商配置…')
|
|
165
|
+
return h('div', { style: { maxWidth: 720, padding: 16, overflowWrap: 'anywhere' } },
|
|
166
|
+
h('h2', { style: { marginTop: 0 } }, 'XinLab 模型配置'),
|
|
167
|
+
h('p', { style: { opacity: 0.75 } }, '为每个模型配置独立密钥和调用协议。密钥保存在当前节点的 DSH 凭据存储中。所选协议需由上游接口支持。'),
|
|
168
|
+
!state.form.writable ? h('p', { role: 'status' }, '当前连接不允许修改协议配置。') : null,
|
|
169
|
+
h('button', { type: 'button', style: buttonStyle, onClick: () => void controller.refreshCredentials() }, '刷新密钥状态'),
|
|
170
|
+
...catalog.map((provider) => h('details', { key: provider.id, open: true, style: { marginTop: 16 } },
|
|
171
|
+
h('summary', { style: { cursor: 'pointer', fontWeight: 600 } }, provider.name),
|
|
172
|
+
...models.filter((model) => model.provider === provider.id).map((model) => h(ModelRow, { key: model.ref, model, controller, state })),
|
|
173
|
+
)),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
async function apply(ctx) {
|
|
177
|
+
const unmount = await ctx.remote.$mount({ package: packageName, descriptors: [previewDescriptor(packageName)] })
|
|
178
|
+
ctx.effect(() => unmount)
|
|
179
|
+
const controller = new Controller(ctx)
|
|
180
|
+
ctx.effect(() => () => controller.dispose())
|
|
181
|
+
ctx.effect(() => ctx.configForms.whileServed([settingsId], () => ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
182
|
+
name: 'settings.section', id: 'xinlab-models', order: 11, label: () => 'XinLab 模型配置', inject: () => ({ controller }),
|
|
183
|
+
}, Page))))
|
|
184
|
+
ctx.effect(() => ctx.remote.$on('credentials/reference-updated', (ref) => {
|
|
185
|
+
if (models.some((model) => model.ref === ref)) void controller.refreshCredentials()
|
|
186
|
+
}))
|
|
187
|
+
ctx.on('connection/reset', () => void controller.refreshCredentials())
|
|
188
|
+
void controller.refreshCredentials()
|
|
189
|
+
}
|
|
190
|
+
return { inject: ['slots', 'remote', 'remote.credentials', 'configForms'], apply, Controller, Page, ModelRow, models }
|
|
191
|
+
}
|
package/src/inspect.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { parseDocument } from 'yaml'
|
|
4
|
+
import { MANAGED_PROVIDERS } from './catalog.mjs'
|
|
5
|
+
|
|
6
|
+
// Treat !!js as inert data. Inspection must never execute profile expressions.
|
|
7
|
+
export function parsePatch(text, file = 'patch') {
|
|
8
|
+
const doc = parseDocument(text, {
|
|
9
|
+
customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: (value) => ({ expression: value }) }],
|
|
10
|
+
})
|
|
11
|
+
if (doc.errors.length) throw new Error(`无法解析 YAML:${file}`)
|
|
12
|
+
let value
|
|
13
|
+
try { value = doc.toJS({ maxAliasCount: 100 }) } catch { throw new Error(`无法安全读取 YAML:${file}`) }
|
|
14
|
+
if (!Array.isArray(value)) throw new Error(`DSH patch 必须为列表:${file}`)
|
|
15
|
+
return value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function* rows(list) {
|
|
19
|
+
if (!Array.isArray(list)) return
|
|
20
|
+
for (const row of list) {
|
|
21
|
+
if (!row || typeof row !== 'object') continue
|
|
22
|
+
yield row
|
|
23
|
+
yield* rows(row.insert)
|
|
24
|
+
if (row.group === true || row.name === 'cordis:group') yield* rows(row.config)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function inspectHome(home, profile = 'web') {
|
|
29
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(profile) || profile === '.' || profile === '..') {
|
|
30
|
+
throw new Error('profile 名称不正确')
|
|
31
|
+
}
|
|
32
|
+
const root = resolve(home)
|
|
33
|
+
const directory = join(root, 'profiles', profile)
|
|
34
|
+
const findings = []
|
|
35
|
+
if (!existsSync(join(directory, 'package.json'))) findings.push({
|
|
36
|
+
code: 'PROFILE_MISSING', message: '指定 profile 尚未初始化', file: join(directory, 'package.json'),
|
|
37
|
+
})
|
|
38
|
+
for (const file of [join(directory, 'cordis.patch.yml'), join(root, 'cordis.patch.yml')]) {
|
|
39
|
+
if (!existsSync(file)) continue
|
|
40
|
+
const patches = parsePatch(readFileSync(file, 'utf8'), file)
|
|
41
|
+
for (const row of rows(patches)) {
|
|
42
|
+
if (row.id === 'llm-pi-ai' && (Object.hasOwn(row, 'config') || row.disabled === true)) {
|
|
43
|
+
const providers = row.config?.providers
|
|
44
|
+
const ids = providers && typeof providers === 'object' ? Object.keys(providers) : []
|
|
45
|
+
findings.push({
|
|
46
|
+
code: 'LOCAL_PROVIDER_OVERRIDE', file, entry: row.id,
|
|
47
|
+
managedProviders: ids.filter((id) => MANAGED_PROVIDERS.includes(id)),
|
|
48
|
+
otherProviders: ids.filter((id) => !MANAGED_PROVIDERS.includes(id)),
|
|
49
|
+
message: '本地覆盖会遮住组合包配置;迁移时须保留非托管供应商并移除旧覆盖',
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
const legacyName = typeof row.name === 'string' && /(?:^|\/)plugins\/vllm-(?:mm-uuid|reasoning-only-retry)\//.test(row.name)
|
|
53
|
+
if (row.disabled !== true && (['vllm-mm-uuid', 'vllm-reasoning-only-retry'].includes(row.id) || legacyName)) {
|
|
54
|
+
findings.push({ code: 'LEGACY_VLLM_PLUGIN', file, entry: row.id,
|
|
55
|
+
message: '旧 vLLM 扩展仍启用,迁移时须移除旧加载项,保留其 key 和 warm-uuids 文件' })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { home: root, profile, readyForActivation: findings.length === 0, findings }
|
|
60
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// One self-contained descriptor shared by Host and browser. Strict parsers do
|
|
2
|
+
// not require a compiler or a second schema runtime in the installed plugin.
|
|
3
|
+
export function keyPreviewDescriptor(packageName) {
|
|
4
|
+
const refsCodec = {
|
|
5
|
+
mode: 'strict', typeSymbol: 'XinlabModelKeyRefs',
|
|
6
|
+
create: () => ({ parse(value) {
|
|
7
|
+
if (!Array.isArray(value) || value.length > 64 || value.some((ref) => typeof ref !== 'string' || !/^XINLAB_MODEL_[0-9A-F]+$/.test(ref))) throw new Error('无效的模型密钥提示请求。')
|
|
8
|
+
return value
|
|
9
|
+
} }),
|
|
10
|
+
}
|
|
11
|
+
const resultCodec = {
|
|
12
|
+
mode: 'strict', typeSymbol: 'XinlabModelKeyPreviews',
|
|
13
|
+
create: () => ({ parse(value) {
|
|
14
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('无效的密钥提示响应。')
|
|
15
|
+
const safe = {}
|
|
16
|
+
for (const [ref, info] of Object.entries(value)) {
|
|
17
|
+
if (!info || typeof info.configured !== 'boolean' || typeof info.writable !== 'boolean') throw new Error('无效的密钥状态。')
|
|
18
|
+
if (info.hint !== undefined && (typeof info.hint !== 'string' || !/^(?:[\x21-\x7E]{7})?•{8}$/.test(info.hint))) throw new Error('无效的脱敏提示。')
|
|
19
|
+
safe[ref] = { configured: info.configured, writable: info.writable, ...(info.hint === undefined ? {} : { hint: info.hint }) }
|
|
20
|
+
}
|
|
21
|
+
return safe
|
|
22
|
+
} }),
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
id: packageName + '#xinlabModelKeys/describe', service: 'xinlabModelKeys', namespace: 'xinlabModelKeys', method: 'describe',
|
|
26
|
+
invocation: { kind: 'direct' }, parameters: [{ name: 'refs', wire: 'refs', source: 'json', codec: refsCodec }], result: resultCodec,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { TypertRemoteService, RemoteError, principalOf } from '@deepseek-ai/dsh-typert-protocol'
|
|
2
|
+
import { modelKeyRef } from './model-settings.mjs'
|
|
3
|
+
import { keyPreviewDescriptor } from './key-preview-protocol.mjs'
|
|
4
|
+
|
|
5
|
+
export function maskedKey(value) {
|
|
6
|
+
const key = value.trim()
|
|
7
|
+
// Never expose the whole value, even for short test/local-server keys.
|
|
8
|
+
return key.length > 14 && /^[\x21-\x7E]+$/.test(key) ? key.slice(0, 7) + '••••••••' : '••••••••'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ModelKeyPreviews extends TypertRemoteService {
|
|
12
|
+
constructor(ctx, providers) {
|
|
13
|
+
super(ctx, 'xinlabModelKeys')
|
|
14
|
+
this.allowed = new Set(Object.entries(providers).flatMap(([provider, profile]) => (profile.models ?? []).map((model) => modelKeyRef(provider, model.id))))
|
|
15
|
+
}
|
|
16
|
+
async describe(refs) {
|
|
17
|
+
// Gateway authentication and its global-resource ACL remain authoritative;
|
|
18
|
+
// even an explicitly admitted non-owner must not receive key fragments.
|
|
19
|
+
if (principalOf(this.ctx.invocation) !== undefined) throw new RemoteError('gateway/bad-request', '只有节点所有者可以查看密钥提示。', {})
|
|
20
|
+
const parse = keyPreviewDescriptor('').parameters[0].codec.create()
|
|
21
|
+
try { parse.parse(refs) } catch { throw new RemoteError('gateway/bad-request', '无效的模型密钥提示请求。', {}) }
|
|
22
|
+
if (refs.some((ref) => !this.allowed.has(ref))) throw new RemoteError('gateway/bad-request', '不能查看其他凭据的提示。', {})
|
|
23
|
+
const credentials = this.ctx.get('credentials')
|
|
24
|
+
const result = {}
|
|
25
|
+
try {
|
|
26
|
+
for (const ref of refs) {
|
|
27
|
+
const info = await credentials.describe(ref)
|
|
28
|
+
const hit = info.configured ? await credentials.resolve(ref) : undefined
|
|
29
|
+
result[ref] = { configured: Boolean(hit?.value), writable: info.writable === true, ...(hit?.value ? { hint: maskedKey(hit.value) } : {}) }
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Never let a provider's raw exception echo a credential through RPC.
|
|
33
|
+
throw new RemoteError('gateway/internal', '暂时无法读取密钥状态。', {})
|
|
34
|
+
}
|
|
35
|
+
return result
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Shared wire vocabulary. Key references depend on identity, never catalog order.
|
|
2
|
+
export const SETTINGS_ID = 'xinlab-providers'
|
|
3
|
+
export const PROTOCOLS = Object.freeze(['openai-completions', 'openai-responses', 'anthropic-messages'])
|
|
4
|
+
|
|
5
|
+
export function modelKeyRef(provider, model) {
|
|
6
|
+
const bytes = new TextEncoder().encode(JSON.stringify([provider, model]))
|
|
7
|
+
return 'XINLAB_MODEL_' + Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('').toUpperCase()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function modelProfile(provider, model, protocol) {
|
|
11
|
+
const profile = structuredClone(provider)
|
|
12
|
+
const entry = profile.models?.find((item) => item.id === model)
|
|
13
|
+
if (!entry) throw new Error('未知模型,不能更改其配置。')
|
|
14
|
+
profile.models = [entry]
|
|
15
|
+
delete profile.modelOverrides
|
|
16
|
+
if (protocol && protocol !== profile.api) {
|
|
17
|
+
if (!PROTOCOLS.includes(protocol)) throw new Error('不支持的模型协议。')
|
|
18
|
+
profile.api = protocol
|
|
19
|
+
// Compatibility flags belong to a wire protocol; carrying Chat flags into
|
|
20
|
+
// Messages/Responses is rejected by the official adapter, or changes semantics.
|
|
21
|
+
delete profile.compat
|
|
22
|
+
delete entry.compat
|
|
23
|
+
// Anthropic's SDK appends /v1/messages; OpenAI-compatible bases include /v1.
|
|
24
|
+
if (protocol === 'anthropic-messages') profile.baseURL = profile.baseURL.replace(/\/v1\/?$/, '')
|
|
25
|
+
}
|
|
26
|
+
return profile
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertModelSettings(providers, protocols) {
|
|
30
|
+
for (const [provider, models] of Object.entries(protocols ?? {})) {
|
|
31
|
+
if (!Object.hasOwn(providers, provider)) throw new Error('不能配置未知供应商。')
|
|
32
|
+
for (const [model, protocol] of Object.entries(models ?? {})) {
|
|
33
|
+
if (!providers[provider].models?.some((item) => item.id === model)) throw new Error('不能配置未知模型。')
|
|
34
|
+
if (!PROTOCOLS.includes(protocol)) throw new Error('不支持的模型协议。')
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import { Config, apply } from '@deepseek-ai/dsh-llm-pi-ai'
|
|
3
|
+
|
|
4
|
+
// Compose the published official plugin against its public adapter-registration
|
|
5
|
+
// seam. A private Context keeps its directory, authorization and Loader listeners
|
|
6
|
+
// out of the public settings UI; no SDK implementation or private adapter state
|
|
7
|
+
// is copied. Calls use the captured adapter directly, so llm/stream runs once.
|
|
8
|
+
export function officialAdapter(host, providers, fallback) {
|
|
9
|
+
const scope = new Context()
|
|
10
|
+
let adapter
|
|
11
|
+
scope.provide('llm', {
|
|
12
|
+
registerAdapter(_routes, value) {
|
|
13
|
+
adapter = value
|
|
14
|
+
return Object.assign(() => {}, { replace() {} })
|
|
15
|
+
},
|
|
16
|
+
registerConfigurableProviders() { return Object.assign(() => {}, { replace() {} }) },
|
|
17
|
+
registerModelDiscovery() { return () => {} },
|
|
18
|
+
})
|
|
19
|
+
const credentials = {
|
|
20
|
+
async resolve(ref) {
|
|
21
|
+
const service = host.get('credentials')
|
|
22
|
+
const hit = await service?.resolve(ref)
|
|
23
|
+
if (hit?.value) return hit
|
|
24
|
+
if (fallback && ref === fallback.ref && fallback.baseRef) return service?.resolve(fallback.baseRef)
|
|
25
|
+
return undefined
|
|
26
|
+
},
|
|
27
|
+
readRecord: (...args) => host.get('credentials')?.readRecord(...args),
|
|
28
|
+
listRecords: (...args) => host.get('credentials')?.listRecords(...args) ?? [],
|
|
29
|
+
}
|
|
30
|
+
const view = scope.extend({
|
|
31
|
+
// A prepared call may outlive this composition after a protocol change or
|
|
32
|
+
// HMR. Keep its auth/attachment seam alive independently of the disposable
|
|
33
|
+
// private registry; never freeze secret values in the profile snapshot.
|
|
34
|
+
get(name) {
|
|
35
|
+
if (name === 'credentials') return credentials
|
|
36
|
+
if (['attachments', 'fs', 'launchEnvironment'].includes(name)) return host.get(name)
|
|
37
|
+
return scope.get(name)
|
|
38
|
+
},
|
|
39
|
+
waterfall(name, ...args) {
|
|
40
|
+
if (name === 'llm-pi-ai/request-headers') return host.waterfall(name, ...args)
|
|
41
|
+
return scope.waterfall(name, ...args)
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
try {
|
|
45
|
+
apply(view, Config({ providers }))
|
|
46
|
+
if (!adapter) throw new Error('供应商配置未注册模型适配器。')
|
|
47
|
+
return { adapter, dispose: () => scope.fiber.dispose() }
|
|
48
|
+
} catch (error) {
|
|
49
|
+
scope.fiber.dispose()
|
|
50
|
+
throw error
|
|
51
|
+
}
|
|
52
|
+
}
|