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/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 }