dsh-provider-hub 0.1.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/LICENSE +21 -0
- package/README.md +81 -0
- package/cordis.patch.yml +13 -0
- package/core/coverage.js +31 -0
- package/core/profile.js +144 -0
- package/core/providers.js +342 -0
- package/package.json +57 -0
- package/src/client.js +386 -0
- package/src/index.js +320 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// dsh-provider-hub - host half.
|
|
2
|
+
//
|
|
3
|
+
// Three loopback-only JSON routes the browser half calls:
|
|
4
|
+
//
|
|
5
|
+
// GET /dsh-provider-hub/state -> presets + configured routes + key status
|
|
6
|
+
// POST /dsh-provider-hub/enable -> { presetId } or { route, displayName?, baseURL, api, modelIds }, plus { key }
|
|
7
|
+
// POST /dsh-provider-hub/remove -> { route, removeKey? }
|
|
8
|
+
//
|
|
9
|
+
// Every write goes through the official services, so nothing here touches
|
|
10
|
+
// settings.yaml or .credentials.yaml directly:
|
|
11
|
+
//
|
|
12
|
+
// * `credentials.set(profile.apiKeyEnv, key)` stores the key in the
|
|
13
|
+
// credentials service (the same ref a hand-written profile would use);
|
|
14
|
+
// * `settings.mutate('llm-pi-ai', [set providers.<route>], revision)` writes
|
|
15
|
+
// the user layer of the official pi-ai namespace, which validates the
|
|
16
|
+
// profile and re-registers the route live (no restart, no YAML editing).
|
|
17
|
+
//
|
|
18
|
+
// The adapter this plugin feeds is `@deepseek-ai/dsh-llm-pi-ai`; the profile
|
|
19
|
+
// shape is built and validated by ../core/profile.js, shared with any other
|
|
20
|
+
// shell that consumes the same core.
|
|
21
|
+
|
|
22
|
+
import { PRESETS, customEnvName, findPreset } from '../core/providers.js'
|
|
23
|
+
import { buildProfile, routeOf } from '../core/profile.js'
|
|
24
|
+
import { coverageOf } from '../core/coverage.js'
|
|
25
|
+
|
|
26
|
+
export const name = 'dsh-provider-hub'
|
|
27
|
+
|
|
28
|
+
const ROUTE_PREFIX = '/dsh-provider-hub'
|
|
29
|
+
const PI_AI_NS = 'llm-pi-ai'
|
|
30
|
+
|
|
31
|
+
class HttpError extends Error {
|
|
32
|
+
constructor(status, code, message) {
|
|
33
|
+
super(message)
|
|
34
|
+
this.name = 'HttpError'
|
|
35
|
+
this.status = status
|
|
36
|
+
this.code = code
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- http helpers (same fence as the other loopback plugins) -----------------
|
|
41
|
+
|
|
42
|
+
function isLoopbackAddress(address) {
|
|
43
|
+
if (typeof address !== 'string' || address.length === 0) return false
|
|
44
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isLocalHostHeader(host) {
|
|
48
|
+
if (typeof host !== 'string' || host.length === 0) return false
|
|
49
|
+
const name = host.split(':')[0].replace(/^\[|\]$/g, '').toLowerCase()
|
|
50
|
+
return name === 'localhost' || name === '127.0.0.1' || name === '::1'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sendJson(res, status, body) {
|
|
54
|
+
const payload = JSON.stringify(body)
|
|
55
|
+
res.writeHead(status, {
|
|
56
|
+
'content-type': 'application/json; charset=utf-8',
|
|
57
|
+
'content-length': Buffer.byteLength(payload),
|
|
58
|
+
})
|
|
59
|
+
res.end(payload)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readBody(req) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
let data = ''
|
|
65
|
+
req.on('data', (chunk) => {
|
|
66
|
+
data += chunk
|
|
67
|
+
if (data.length > 1e6) req.destroy()
|
|
68
|
+
})
|
|
69
|
+
req.on('end', () => resolve(data))
|
|
70
|
+
req.on('error', reject)
|
|
71
|
+
req.on('aborted', () => reject(new Error('aborted')))
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Credential-adjacent surface: loopback socket, loopback Host header, and a
|
|
76
|
+
// same-origin check when the browser sends Origin.
|
|
77
|
+
function guard(req, res) {
|
|
78
|
+
if (!isLoopbackAddress(req.socket && req.socket.remoteAddress)) {
|
|
79
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'loopback only' })
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
const host = req.headers.host
|
|
83
|
+
if (!isLocalHostHeader(host)) {
|
|
84
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'unexpected host' })
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
const origin = req.headers.origin
|
|
88
|
+
if (typeof origin === 'string' && origin.length > 0) {
|
|
89
|
+
let originHost = null
|
|
90
|
+
try {
|
|
91
|
+
originHost = new URL(origin).host
|
|
92
|
+
} catch {
|
|
93
|
+
originHost = null
|
|
94
|
+
}
|
|
95
|
+
if (originHost !== host) {
|
|
96
|
+
sendJson(res, 403, { ok: false, code: 'forbidden', error: 'cross-origin request' })
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readJsonBody(body) {
|
|
104
|
+
return body && typeof body === 'object' ? body : {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- host facts --------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
function services(ctx) {
|
|
110
|
+
return {
|
|
111
|
+
settings: ctx.get('settings'),
|
|
112
|
+
credentials: ctx.get('credentials'),
|
|
113
|
+
llm: ctx.get('llm'),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function piAiDescriptor(settings) {
|
|
118
|
+
const descriptors = settings?.describe?.() ?? []
|
|
119
|
+
return descriptors.find((descriptor) => descriptor.ns === PI_AI_NS)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function userProviders(descriptor) {
|
|
123
|
+
const providers = descriptor?.user && typeof descriptor.user === 'object' ? descriptor.user.providers : undefined
|
|
124
|
+
return providers && typeof providers === 'object' ? providers : {}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isConflict(error) {
|
|
128
|
+
if (error && error.name === 'SettingsConflictError') return true
|
|
129
|
+
return /conflict|revision/i.test(String((error && error.message) || error))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One state snapshot: the curated catalog joined with what the user layer
|
|
134
|
+
* currently declares (configured route, stored key, live registration).
|
|
135
|
+
*/
|
|
136
|
+
async function stateOf(ctx) {
|
|
137
|
+
const { settings, credentials, llm } = services(ctx)
|
|
138
|
+
if (!settings) throw new HttpError(503, 'service-missing', 'DSH 设置服务不可用')
|
|
139
|
+
const descriptor = piAiDescriptor(settings)
|
|
140
|
+
if (!descriptor) throw new HttpError(503, 'namespace-missing', 'llm-pi-ai 设置命名空间未注册(pi-ai 适配器未加载)')
|
|
141
|
+
const providers = userProviders(descriptor)
|
|
142
|
+
const live = new Set((llm?.listProviders?.() ?? []).map((provider) => provider.id))
|
|
143
|
+
|
|
144
|
+
// Per-app de-duplication: presets the target app already covers natively
|
|
145
|
+
// (its built-in pi-ai catalog) stay in the catalog but are marked so the UI
|
|
146
|
+
// can hide them behind a reveal toggle. Nothing is deleted.
|
|
147
|
+
const nativeIds = (llm?.listConfigurableProviders?.() ?? []).map((entry) => entry.provider)
|
|
148
|
+
const coverage = coverageOf(PRESETS, nativeIds)
|
|
149
|
+
const coveredBy = new Map(coverage.covered.map((entry) => [entry.id, entry.as]))
|
|
150
|
+
|
|
151
|
+
const routes = {}
|
|
152
|
+
for (const [route, profile] of Object.entries(providers)) {
|
|
153
|
+
const env = profile && typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : undefined
|
|
154
|
+
let keyConfigured = false
|
|
155
|
+
if (env && credentials) {
|
|
156
|
+
try {
|
|
157
|
+
keyConfigured = (await credentials.describe(env))?.configured === true
|
|
158
|
+
} catch {
|
|
159
|
+
keyConfigured = false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
routes[route] = {
|
|
163
|
+
env,
|
|
164
|
+
keyConfigured,
|
|
165
|
+
live: live.has(route),
|
|
166
|
+
modelCount: Array.isArray(profile?.models) ? profile.models.length : 0,
|
|
167
|
+
displayName: typeof profile?.displayName === 'string' ? profile.displayName : route,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
revision: descriptor.revision,
|
|
173
|
+
routes,
|
|
174
|
+
coverage: {
|
|
175
|
+
native: nativeIds.length,
|
|
176
|
+
visible: coverage.visible.length,
|
|
177
|
+
covered: coverage.covered.length,
|
|
178
|
+
},
|
|
179
|
+
presets: PRESETS.map((preset) => ({
|
|
180
|
+
id: preset.id,
|
|
181
|
+
name: preset.name,
|
|
182
|
+
docs: preset.docs,
|
|
183
|
+
keyUrl: preset.keyUrl,
|
|
184
|
+
env: preset.env,
|
|
185
|
+
baseURL: preset.baseURL,
|
|
186
|
+
api: preset.api,
|
|
187
|
+
models: preset.models.map((model) => model.id),
|
|
188
|
+
configured: Object.hasOwn(providers, preset.id),
|
|
189
|
+
keyConfigured: routes[preset.id]?.keyConfigured === true,
|
|
190
|
+
live: live.has(preset.id),
|
|
191
|
+
covered: coveredBy.has(preset.id),
|
|
192
|
+
coveredBy: coveredBy.get(preset.id),
|
|
193
|
+
})),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- writes ------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
async function enable(ctx, body) {
|
|
200
|
+
const { settings, credentials } = services(ctx)
|
|
201
|
+
if (!settings || !credentials) throw new HttpError(503, 'service-missing', 'DSH 设置或凭据服务不可用')
|
|
202
|
+
const key = typeof body.key === 'string' ? body.key.trim() : ''
|
|
203
|
+
if (key === '') throw new HttpError(400, 'key-required', '请填写 API Key')
|
|
204
|
+
let route
|
|
205
|
+
let profile
|
|
206
|
+
try {
|
|
207
|
+
route = routeOf(body)
|
|
208
|
+
profile = buildProfile(body)
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new HttpError(400, 'invalid-draft', String((error && error.message) || error))
|
|
211
|
+
}
|
|
212
|
+
const descriptor = piAiDescriptor(settings)
|
|
213
|
+
if (!descriptor) throw new HttpError(503, 'namespace-missing', 'llm-pi-ai 设置命名空间未注册')
|
|
214
|
+
|
|
215
|
+
await credentials.set(profile.apiKeyEnv, key)
|
|
216
|
+
try {
|
|
217
|
+
await settings.mutate(PI_AI_NS, [{ op: 'set', path: ['providers', route], value: profile }], descriptor.revision)
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (isConflict(error)) throw new HttpError(409, 'conflict', '设置已被其他界面修改,请刷新后重试')
|
|
220
|
+
throw new HttpError(500, 'write-failed', `写入 llm-pi-ai 失败:${String((error && error.message) || error)}`)
|
|
221
|
+
}
|
|
222
|
+
return { route, env: profile.apiKeyEnv, models: profile.models.length }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function remove(ctx, body) {
|
|
226
|
+
const { settings, credentials } = services(ctx)
|
|
227
|
+
if (!settings) throw new HttpError(503, 'service-missing', 'DSH 设置服务不可用')
|
|
228
|
+
const route = typeof body.route === 'string' ? body.route.trim() : ''
|
|
229
|
+
if (route === '') throw new HttpError(400, 'invalid', 'route required')
|
|
230
|
+
const descriptor = piAiDescriptor(settings)
|
|
231
|
+
if (!descriptor) throw new HttpError(503, 'namespace-missing', 'llm-pi-ai 设置命名空间未注册')
|
|
232
|
+
const providers = userProviders(descriptor)
|
|
233
|
+
if (!Object.hasOwn(providers, route)) throw new HttpError(404, 'not-configured', `路由 ${route} 未配置`)
|
|
234
|
+
const env = typeof providers[route]?.apiKeyEnv === 'string' ? providers[route].apiKeyEnv : undefined
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
await settings.mutate(PI_AI_NS, [{ op: 'unset', path: ['providers', route] }], descriptor.revision)
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (isConflict(error)) throw new HttpError(409, 'conflict', '设置已被其他界面修改,请刷新后重试')
|
|
240
|
+
throw new HttpError(500, 'write-failed', `移除失败:${String((error && error.message) || error)}`)
|
|
241
|
+
}
|
|
242
|
+
if (body.removeKey === true && env && credentials) {
|
|
243
|
+
try {
|
|
244
|
+
await credentials.unset(env)
|
|
245
|
+
} catch {
|
|
246
|
+
// The route is gone; a leftover key is harmless and reported by /state.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { route }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- plugin ------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
export function apply(ctx) {
|
|
255
|
+
const registerRoutes = (webServer, fiber) => {
|
|
256
|
+
fiber.effect(() =>
|
|
257
|
+
webServer.register({
|
|
258
|
+
kind: 'exact',
|
|
259
|
+
path: `${ROUTE_PREFIX}/state`,
|
|
260
|
+
handler: async (req, res) => {
|
|
261
|
+
if (!guard(req, res)) return
|
|
262
|
+
if (req.method !== 'GET') {
|
|
263
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
sendJson(res, 200, { ok: true, ...(await stateOf(ctx)) })
|
|
268
|
+
} catch (error) {
|
|
269
|
+
const status = error instanceof HttpError ? error.status : 500
|
|
270
|
+
const code = error instanceof HttpError ? error.code : 'internal'
|
|
271
|
+
sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
}),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
const postRoute = (suffix, run) => {
|
|
278
|
+
fiber.effect(() =>
|
|
279
|
+
webServer.register({
|
|
280
|
+
kind: 'exact',
|
|
281
|
+
path: `${ROUTE_PREFIX}/${suffix}`,
|
|
282
|
+
handler: async (req, res) => {
|
|
283
|
+
if (!guard(req, res)) return
|
|
284
|
+
if (req.method !== 'POST') {
|
|
285
|
+
sendJson(res, 405, { ok: false, code: 'method', error: 'POST only' })
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
let body = {}
|
|
289
|
+
try {
|
|
290
|
+
const raw = await readBody(req)
|
|
291
|
+
if (raw) body = JSON.parse(raw)
|
|
292
|
+
} catch {
|
|
293
|
+
sendJson(res, 400, { ok: false, code: 'invalid', error: 'malformed JSON body' })
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
sendJson(res, 200, { ok: true, ...(await run(readJsonBody(body))) })
|
|
298
|
+
} catch (error) {
|
|
299
|
+
const status = error instanceof HttpError ? error.status : 500
|
|
300
|
+
const code = error instanceof HttpError ? error.code : 'internal'
|
|
301
|
+
sendJson(res, status, { ok: false, code, error: String((error && error.message) || error) })
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
}),
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
postRoute('enable', (body) => enable(ctx, body))
|
|
309
|
+
postRoute('remove', (body) => remove(ctx, body))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const webServer = ctx.get('webServer')
|
|
313
|
+
if (webServer) {
|
|
314
|
+
registerRoutes(webServer, ctx)
|
|
315
|
+
} else {
|
|
316
|
+
ctx.inject(['webServer'], (sub) => registerRoutes(sub.webServer, sub))
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export { customEnvName }
|