@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/LICENSE +21 -0
- package/README.md +158 -0
- package/README.zh.md +160 -0
- package/assets/icons/claude.svg +1 -0
- package/assets/icons/grok.svg +1 -0
- package/assets/icons/openai.svg +1 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.cjs +1098 -0
- package/lib/client.js +1107 -0
- package/lib/image-tools.d.ts +18 -0
- package/lib/index.d.ts +148 -0
- package/lib/index.js +1220 -0
- package/lib/invariant.d.ts +13 -0
- package/lib/invariant.js +9 -0
- package/lib/pi-ai-patch.d.ts +45 -0
- package/lib/pi-ai.d.ts +50 -0
- package/lib/routes.d.ts +43 -0
- package/package.json +160 -0
- package/src/client/icons.tsx +38 -0
- package/src/client/index.tsx +31 -0
- package/src/client/settings.tsx +847 -0
- package/src/client/toolview.tsx +76 -0
- package/src/image-tools.ts +590 -0
- package/src/index.ts +339 -0
- package/src/invariant.ts +17 -0
- package/src/pi-ai-patch.ts +94 -0
- package/src/pi-ai.ts +185 -0
- package/src/routes.ts +442 -0
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
|
+
}
|