@wxip/dsh-sub2api 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/src/routes.ts ADDED
@@ -0,0 +1,442 @@
1
+ /**
2
+ * HTTP routes for the dsh-sub2api settings page.
3
+ *
4
+ * The browser half of a static plugin talks to the host through the web
5
+ * server (there is no package-private `host.call` seam outside dynamic
6
+ * packages), so this module exposes read/write endpoints for the plugin's
7
+ * settings section. Requests are restricted to trusted local origins — the
8
+ * same `trustedRequest` posture the oauth plugin uses — because these routes
9
+ * mutate configuration and echo credential state.
10
+ *
11
+ * @module dsh-sub2api/routes
12
+ */
13
+
14
+ import type { IncomingMessage, ServerResponse } from 'node:http'
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import type {} from '@deepseek-ai/dsh-host-webserver'
17
+ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
18
+ import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
19
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
20
+ import { API_PROTOCOLS, PROVIDERS, gatewayApiRoot, type ApiProtocol, type CatalogModel, type Config, type ImageToolModelRef, type ImageToolsConfig, type ProviderKey, type ProviderProfile } from './index.ts'
21
+
22
+ export const ROUTES = {
23
+ get: '/plugins/dsh-sub2api/config',
24
+ set: '/plugins/dsh-sub2api/config',
25
+ discover: '/plugins/dsh-sub2api/discover',
26
+ usage: '/plugins/dsh-sub2api/usage',
27
+ status: '/plugins/dsh-sub2api/status',
28
+ attachment: '/plugins/dsh-sub2api/attachment',
29
+ } as const
30
+
31
+ export interface ConfigPayload {
32
+ baseURL: string
33
+ catalogFormat: 'structured-v1'
34
+ providers: Record<string, { keyConfigured: boolean; models: CatalogModel[] }>
35
+ tools: ImageToolsConfig
36
+ }
37
+
38
+ function trustedRequest(req: IncomingMessage): boolean {
39
+ const remote = req.socket.remoteAddress
40
+ if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') return false
41
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
42
+ const host = req.headers.host
43
+ if (host === undefined) return false
44
+ const origin = req.headers.origin
45
+ if (origin === undefined) return true
46
+ try {
47
+ return new URL(origin).host === new URL(`http://${host}`).host
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
54
+ const chunks: Buffer[] = []
55
+ for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
56
+ const text = Buffer.concat(chunks).toString('utf8').trim()
57
+ if (text.length === 0) return {}
58
+ const value: unknown = JSON.parse(text)
59
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
60
+ ? value as Record<string, unknown>
61
+ : {}
62
+ }
63
+
64
+ function json(res: ServerResponse, status: number, value: unknown): void {
65
+ res.writeHead(status, {
66
+ 'content-type': 'application/json; charset=utf-8',
67
+ 'cache-control': 'no-store',
68
+ 'x-content-type-options': 'nosniff',
69
+ })
70
+ res.end(JSON.stringify(value))
71
+ }
72
+
73
+ function safeMessage(error: unknown): string {
74
+ if (error instanceof Error) return error.message
75
+ try {
76
+ const text = String(error)
77
+ return text.length > 0 ? text : 'unknown error'
78
+ } catch {
79
+ return 'unknown error'
80
+ }
81
+ }
82
+
83
+ function readProviderConfig(config: Config): ConfigPayload {
84
+ const providers: ConfigPayload['providers'] = {}
85
+ for (const def of PROVIDERS) {
86
+ const profile = config.providers[def.key]
87
+ providers[def.key] = {
88
+ keyConfigured: profile.apiKeyEnv !== undefined,
89
+ models: profile.models?.map((model) => ({ ...model })) ?? [],
90
+ }
91
+ }
92
+ return {
93
+ baseURL: config.baseURL,
94
+ catalogFormat: 'structured-v1',
95
+ providers,
96
+ tools: {
97
+ ...(config.tools?.generate !== undefined ? { generate: { ...config.tools.generate } } : {}),
98
+ },
99
+ }
100
+ }
101
+
102
+ function legacyCatalogModel(value: string): CatalogModel | undefined {
103
+ const [rawId = '', rawName = '', rawContextWindow = ''] = value.split('|')
104
+ const id = rawId.trim()
105
+ if (id.length === 0) return undefined
106
+ const name = rawName.trim()
107
+ const parsedContextWindow = Number(rawContextWindow.trim())
108
+ const contextWindow = Number.isSafeInteger(parsedContextWindow) && parsedContextWindow > 0
109
+ ? parsedContextWindow
110
+ : undefined
111
+ return {
112
+ id,
113
+ ...(name.length > 0 ? { name } : {}),
114
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
115
+ }
116
+ }
117
+
118
+ function structuredCatalogModel(value: unknown): CatalogModel | undefined {
119
+ if (typeof value === 'string') return legacyCatalogModel(value)
120
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
121
+ const raw = value as Record<string, unknown>
122
+ const id = typeof raw.id === 'string' ? raw.id.trim() : ''
123
+ if (id.length === 0) return undefined
124
+ const name = typeof raw.name === 'string' ? raw.name.trim() : ''
125
+ const contextWindow = typeof raw.contextWindow === 'number' && Number.isSafeInteger(raw.contextWindow) && raw.contextWindow > 0
126
+ ? raw.contextWindow
127
+ : undefined
128
+ const maxTokens = typeof raw.maxTokens === 'number' && Number.isSafeInteger(raw.maxTokens) && raw.maxTokens > 0
129
+ ? raw.maxTokens
130
+ : undefined
131
+ const reasoningEfforts = Array.isArray(raw.reasoningEfforts)
132
+ ? (raw.reasoningEfforts as unknown[]).filter((effort): effort is string => typeof effort === 'string' && effort.length > 0)
133
+ : undefined
134
+ const input = Array.isArray(raw.input)
135
+ ? (raw.input as unknown[]).filter((modality): modality is 'text' | 'image' => modality === 'text' || modality === 'image')
136
+ : undefined
137
+ return {
138
+ id,
139
+ ...(name.length > 0 ? { name } : {}),
140
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
141
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
142
+ ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
143
+ ...(input !== undefined && input.length > 0 ? { input } : {}),
144
+ }
145
+ }
146
+
147
+ function readCatalogModels(value: unknown, fallback: CatalogModel[]): CatalogModel[] {
148
+ if (Array.isArray(value)) return value.map(structuredCatalogModel).filter((model) => model !== undefined)
149
+ if (typeof value === 'string') {
150
+ return value.split(/[\n,]/).map(legacyCatalogModel).filter((model) => model !== undefined)
151
+ }
152
+ return fallback
153
+ }
154
+
155
+ function providerCredentialRef(platform: string): CredentialRef {
156
+ return credentialRef(`SUB2API_${platform.toUpperCase()}_API_KEY`)
157
+ }
158
+
159
+ function isProviderKey(value: string): value is ProviderKey {
160
+ return PROVIDERS.some((def) => def.key === value)
161
+ }
162
+
163
+ function readToolModelRef(value: unknown): ImageToolModelRef | undefined {
164
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
165
+ const raw = value as Record<string, unknown>
166
+ const provider = typeof raw.provider === 'string' ? raw.provider.trim() : ''
167
+ const model = typeof raw.model === 'string' ? raw.model.trim() : ''
168
+ if (!isProviderKey(provider) || model.length === 0) return undefined
169
+ return { provider, model }
170
+ }
171
+
172
+ function readImageTools(value: unknown, fallback: ImageToolsConfig | undefined): ImageToolsConfig | undefined {
173
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return fallback
174
+ const raw = value as Record<string, unknown>
175
+ const generate = readToolModelRef(raw.generate)
176
+ if (generate === undefined) return undefined
177
+ return {
178
+ ...(generate !== undefined ? { generate } : {}),
179
+ }
180
+ }
181
+
182
+ interface RouteContext {
183
+ config: () => Config
184
+ setConfig: (config: Config) => void | Promise<void>
185
+ listRegisteredRoutes: () => string[]
186
+ /**
187
+ * Resolve the stored credential for one provider route. Used by discovery
188
+ * and usage probes when the settings form does not carry a freshly typed
189
+ * key (keys are write-only and stay in the credential store).
190
+ */
191
+ resolveApiKey: (route: string, profile: ProviderProfile) => Promise<string>
192
+ }
193
+
194
+ /**
195
+ * One-shot probe key for discovery/usage: a freshly typed key wins (it is the
196
+ * one under test); otherwise fall back to the credential already stored for
197
+ * that provider, so the settings page does not force the user to re-type the
198
+ * key every time.
199
+ */
200
+ async function resolveProbeKey(
201
+ ctx: Context,
202
+ routes: RouteContext,
203
+ provider: string,
204
+ typedKey: string,
205
+ ): Promise<string> {
206
+ if (typedKey.length > 0) return typedKey
207
+ if (!isProviderKey(provider)) throw new Error('provider 无效,应为 openai / claude / grok')
208
+ const def = PROVIDERS.find((entry) => entry.key === provider)
209
+ const profile = routes.config().providers[provider]
210
+ if (profile?.apiKeyEnv === undefined) {
211
+ throw new Error(`${def?.label ?? provider} 未配置 API key:请先填写 key 并保存配置,再获取模型/查看用量`)
212
+ }
213
+ try {
214
+ return await routes.resolveApiKey(`sub2api-${provider}`, profile)
215
+ } catch (error) {
216
+ throw new Error(`无法使用已保存的 ${def?.label ?? provider} key:${safeMessage(error)}`)
217
+ }
218
+ }
219
+
220
+ export function registerRoutes(ctx: Context, routes: RouteContext): void {
221
+ ctx.inject(['webServer'], (webCtx) => {
222
+ const register = (path: string, handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>) => {
223
+ webCtx.effect(() => webCtx.webServer.register({ kind: 'exact', path, handler }))
224
+ }
225
+
226
+ // GET/POST config share one pathname. The webserver routes by path only and
227
+ // rejects duplicate paths, so a single handler dispatches on the method.
228
+ register(ROUTES.get, async (req, res) => {
229
+ if (req.method !== 'GET' && req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
230
+ if (!trustedRequest(req)) return json(res, 403, { error: 'forbidden' })
231
+
232
+ // GET config: redacted view for the settings page.
233
+ if (req.method === 'GET') {
234
+ json(res, 200, readProviderConfig(routes.config()))
235
+ return
236
+ }
237
+
238
+ // POST config: persist baseURL + per-platform models; keys go to credentials.
239
+ try {
240
+ const body = await readJson(req)
241
+ const baseURL = typeof body.baseURL === 'string' ? body.baseURL.trim().replace(/\/+$/, '') : ''
242
+ if (baseURL.length === 0) return json(res, 400, { error: 'baseURL is required' })
243
+ if (!/^https?:\/\//.test(baseURL)) return json(res, 400, { error: 'baseURL must start with http(s)://' })
244
+
245
+ // Build a fresh config instead of mutating: the settings snapshot is
246
+ // frozen (handed out immutably by the settings service).
247
+ const current = routes.config()
248
+ const next: Config = {
249
+ baseURL,
250
+ providers: {
251
+ openai: { ...current.providers.openai },
252
+ claude: { ...current.providers.claude },
253
+ grok: { ...current.providers.grok },
254
+ },
255
+ ...(current.tools !== undefined ? { tools: { ...current.tools } } : {}),
256
+ }
257
+
258
+ const rawProviders = typeof body.providers === 'object' && body.providers !== null
259
+ ? body.providers as Record<string, unknown>
260
+ : {}
261
+ const credentials = ctx.get('credentials')
262
+
263
+ for (const def of PROVIDERS) {
264
+ const raw = rawProviders[def.key] as Record<string, unknown> | undefined
265
+ const profile = next.providers[def.key]
266
+ const apiKey = typeof raw?.apiKey === 'string' ? raw.apiKey.trim() : ''
267
+ if (apiKey.length > 0 && credentials !== undefined) {
268
+ await credentials.set(providerCredentialRef(def.key), apiKey)
269
+ profile.apiKeyEnv = providerCredentialRef(def.key)
270
+ } else if (apiKey.length > 0 && credentials === undefined) {
271
+ profile.apiKeyEnv = providerCredentialRef(def.key)
272
+ }
273
+ // Wire protocol: empty string clears an explicit override (the
274
+ // group's native protocol applies); a valid name sets one.
275
+ const api = typeof raw?.api === 'string' ? raw.api.trim() : undefined
276
+ if (api !== undefined) {
277
+ if (api.length === 0) {
278
+ delete profile.api
279
+ } else if ((API_PROTOCOLS as readonly string[]).includes(api)) {
280
+ profile.api = api as ApiProtocol
281
+ } else {
282
+ return json(res, 400, { error: `${def.label} 的网关协议 "${api}" 无效,应为 ${API_PROTOCOLS.join(' / ')}` })
283
+ }
284
+ }
285
+ profile.models = readCatalogModels(raw?.models, profile.models ?? [])
286
+ }
287
+
288
+ const tools = readImageTools(body.tools, current.tools)
289
+ if (tools !== undefined) next.tools = tools
290
+ else delete next.tools
291
+
292
+ await routes.setConfig(next)
293
+ json(res, 200, { ok: true, ...readProviderConfig(next), routes: routes.listRegisteredRoutes() })
294
+ } catch (error) {
295
+ json(res, 500, { error: safeMessage(error) })
296
+ }
297
+ })
298
+
299
+ // POST discover: GET {baseURL}/models. A freshly typed key wins; without
300
+ // one the stored credential for the provider is used.
301
+ register(ROUTES.discover, async (req, res) => {
302
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
303
+ if (!trustedRequest(req)) return json(res, 403, { error: 'forbidden' })
304
+ try {
305
+ const body = await readJson(req)
306
+ const baseURL = typeof body.baseURL === 'string' ? body.baseURL.trim().replace(/\/+$/, '') : ''
307
+ const provider = typeof body.provider === 'string' ? body.provider.trim() : ''
308
+ if (baseURL.length === 0) return json(res, 400, { error: 'baseURL is required' })
309
+ let apiKey: string
310
+ try {
311
+ apiKey = await resolveProbeKey(ctx, routes, provider, typeof body.apiKey === 'string' ? body.apiKey.trim() : '')
312
+ } catch (error) {
313
+ return json(res, 400, { error: safeMessage(error) })
314
+ }
315
+ const response = await fetch(`${gatewayApiRoot(baseURL)}/models`, {
316
+ method: 'GET',
317
+ headers: { authorization: `Bearer ${apiKey}` },
318
+ signal: AbortSignal.timeout(30000),
319
+ })
320
+ if (!response.ok) {
321
+ const text = await response.text().catch(() => '')
322
+ return json(res, response.status, { error: `HTTP ${response.status}: ${text.slice(0, 300)}` })
323
+ }
324
+ const payload = await response.json() as { data?: Array<{ id?: unknown; display_name?: unknown; name?: unknown }> }
325
+ const models = Array.isArray(payload.data)
326
+ ? payload.data
327
+ .map((m) => ({
328
+ id: typeof m.id === 'string' ? m.id : '',
329
+ name: typeof m.display_name === 'string' ? m.display_name : typeof m.name === 'string' ? m.name : undefined,
330
+ }))
331
+ .filter((m) => m.id.length > 0)
332
+ : []
333
+ json(res, 200, { ok: true, models })
334
+ } catch (error) {
335
+ json(res, 500, { error: safeMessage(error) })
336
+ }
337
+ })
338
+
339
+ // POST usage: GET {baseURL}/usage. Same key fallback as discovery.
340
+ register(ROUTES.usage, async (req, res) => {
341
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
342
+ if (!trustedRequest(req)) return json(res, 403, { error: 'forbidden' })
343
+ try {
344
+ const body = await readJson(req)
345
+ const baseURL = typeof body.baseURL === 'string' ? body.baseURL.trim().replace(/\/+$/, '') : ''
346
+ const provider = typeof body.provider === 'string' ? body.provider.trim() : ''
347
+ if (baseURL.length === 0) return json(res, 400, { error: 'baseURL is required' })
348
+ let apiKey: string
349
+ try {
350
+ apiKey = await resolveProbeKey(ctx, routes, provider, typeof body.apiKey === 'string' ? body.apiKey.trim() : '')
351
+ } catch (error) {
352
+ return json(res, 400, { error: safeMessage(error) })
353
+ }
354
+ const response = await fetch(`${gatewayApiRoot(baseURL)}/usage`, {
355
+ method: 'GET',
356
+ headers: { authorization: `Bearer ${apiKey}` },
357
+ signal: AbortSignal.timeout(30000),
358
+ })
359
+ if (!response.ok) {
360
+ const text = await response.text().catch(() => '')
361
+ return json(res, response.status, { error: `HTTP ${response.status}: ${text.slice(0, 300)}` })
362
+ }
363
+ const payload = await response.json() as Record<string, unknown>
364
+ const parts: string[] = []
365
+ const quota = payload.quota as Record<string, unknown> | undefined
366
+ if (quota !== undefined && typeof quota.limit === 'number') {
367
+ parts.push(`配额 ${String(quota.used ?? 0)}/${quota.limit}${typeof quota.unit === 'string' ? ` ${quota.unit}` : ''}(剩余 ${String(quota.remaining ?? 0)})`)
368
+ } else if (typeof payload.balance === 'number') {
369
+ parts.push(`余额 $${payload.balance}`)
370
+ } else if (typeof payload.remaining === 'number') {
371
+ parts.push(`剩余 ${payload.remaining}${typeof payload.unit === 'string' ? ` ${payload.unit}` : ' USD'}`)
372
+ }
373
+ if (typeof payload.planName === 'string') parts.push(`分组: ${payload.planName}`)
374
+ if (typeof payload.mode === 'string') parts.push(`模式: ${payload.mode}`)
375
+ if (typeof payload.status === 'string') parts.push(`状态: ${payload.status}`)
376
+ if (Array.isArray(payload.rate_limits)) {
377
+ parts.push(`限流: ${(payload.rate_limits as Array<{ window?: unknown; used?: unknown; limit?: unknown }>)
378
+ .map((r) => `${String(r.window ?? '')} ${String(r.used ?? 0)}/${String(r.limit ?? 0)}`).join(', ')}`)
379
+ }
380
+ const sub = payload.subscription as Record<string, unknown> | undefined
381
+ if (sub !== undefined) {
382
+ parts.push(`订阅日/周/月: ${[sub.daily_usage_usd, sub.weekly_usage_usd, sub.monthly_usage_usd]
383
+ .map((v) => typeof v === 'number' ? `$${v}` : '-').join(' / ')}`)
384
+ }
385
+ json(res, 200, { ok: true, summary: parts.length > 0 ? parts.join(';') : '该 key 无配额/余额信息(unrestricted 模式)' })
386
+ } catch (error) {
387
+ json(res, 500, { error: safeMessage(error) })
388
+ }
389
+ })
390
+
391
+ // GET status: registered routes + models per route.
392
+ register(ROUTES.status, async (req, res) => {
393
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
394
+ if (!trustedRequest(req)) return json(res, 403, { error: 'forbidden' })
395
+ const config = routes.config()
396
+ const models: Record<string, string[]> = {}
397
+ for (const def of PROVIDERS) {
398
+ if (config.providers[def.key].apiKeyEnv !== undefined) {
399
+ models[def.route] = config.providers[def.key].models?.map((m) => m.id) ?? []
400
+ }
401
+ }
402
+ json(res, 200, { routes: routes.listRegisteredRoutes(), models })
403
+ })
404
+
405
+ // GET attachment: serve one durable image attachment as raw bytes so the
406
+ // generate_image tool card can render it inline (<img src>). The request
407
+ // carries the full ImageAttachmentRef (base64url JSON in `ref`); the
408
+ // attachment store re-verifies the digest against the stored object, so a
409
+ // forged ref cannot read anything — the id must match the bytes exactly.
410
+ // Bound to trusted local origins like every other plugin route.
411
+ webCtx.effect(() => webCtx.webServer.register({ kind: 'prefix', path: ROUTES.attachment, handler: async (req, res) => {
412
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
413
+ if (!trustedRequest(req)) return json(res, 403, { error: 'forbidden' })
414
+ let ref: ImageAttachmentRef
415
+ try {
416
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`)
417
+ const raw = url.searchParams.get('ref') ?? ''
418
+ if (raw.length === 0) return json(res, 400, { error: 'ref is required' })
419
+ const parsed: unknown = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'))
420
+ if (typeof parsed !== 'object' || parsed === null) throw new Error('ref must be an object')
421
+ const candidate = parsed as Record<string, unknown>
422
+ if (typeof candidate.attachmentId !== 'string' || typeof candidate.mediaType !== 'string') throw new Error('ref is incomplete')
423
+ ref = candidate as unknown as ImageAttachmentRef
424
+ } catch {
425
+ return json(res, 400, { error: 'invalid ref' })
426
+ }
427
+ const attachments = ctx.get('attachments')
428
+ if (attachments === undefined) return json(res, 503, { error: 'attachment service unavailable' })
429
+ try {
430
+ const stored = await attachments.readImage(ref)
431
+ res.writeHead(200, {
432
+ 'content-type': stored.ref.mediaType,
433
+ 'cache-control': 'private, max-age=86400',
434
+ 'x-content-type-options': 'nosniff',
435
+ })
436
+ res.end(Buffer.from(stored.data))
437
+ } catch {
438
+ json(res, 404, { error: 'attachment not found' })
439
+ }
440
+ } }))
441
+ })
442
+ }