@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/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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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
+ }
@@ -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
+ }