dsh-router-core 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/README.md +151 -0
- package/cordis.patch.yml +9 -0
- package/lib/client-registry.js +3006 -0
- package/lib/client.js +3006 -0
- package/lib/index.js +1756 -0
- package/lib/types/credential-store.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/keys.d.ts +39 -0
- package/lib/types/llm/adapter.d.ts +31 -0
- package/lib/types/router/index.d.ts +73 -0
- package/lib/types/router/types.d.ts +80 -0
- package/lib/types/shared.d.ts +138 -0
- package/lib/types/supplier-config.d.ts +28 -0
- package/lib/types/suppliers/contract.d.ts +98 -0
- package/lib/types/suppliers/loader.d.ts +41 -0
- package/lib/types/suppliers/registry.d.ts +33 -0
- package/package.json +86 -0
- package/src/client/CombosTab.tsx +458 -0
- package/src/client/EndpointTab.tsx +360 -0
- package/src/client/Modal.tsx +46 -0
- package/src/client/RouterView.tsx +186 -0
- package/src/client/SupplierDetail.tsx +943 -0
- package/src/client/index.tsx +18 -0
- package/src/client/router.css +2096 -0
- package/src/client/sidebar-entry.ts +167 -0
- package/src/client/workspace-controller.ts +26 -0
- package/src/client/workspace-mount.tsx +117 -0
- package/src/credential-store.ts +64 -0
- package/src/index.ts +468 -0
- package/src/keys.ts +143 -0
- package/src/llm/adapter.ts +273 -0
- package/src/router/index.ts +273 -0
- package/src/router/types.ts +85 -0
- package/src/shared.ts +152 -0
- package/src/supplier-config.ts +144 -0
- package/src/suppliers/contract.ts +73 -0
- package/src/suppliers/loader.ts +191 -0
- package/src/suppliers/nvidia/plugin.ts +268 -0
- package/src/suppliers/opencode/plugin.ts +200 -0
- package/src/suppliers/openrouter/plugin.ts +282 -0
- package/src/suppliers/registry.ts +365 -0
- package/src/types.d.ts +45 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openrouter 供应商插件 —— 参考 9Router(open-sse) 的 openrouter 实现。
|
|
3
|
+
*
|
|
4
|
+
* 上游:https://openrouter.ai/api/v1(OpenAI 兼容)
|
|
5
|
+
* - chat: POST /api/v1/chat/completions(SSE 流式 / 非流式)
|
|
6
|
+
* - models: GET /api/v1/models(过滤免费模型:pricing 全 0 + context ≥ 200k)
|
|
7
|
+
*
|
|
8
|
+
* API key 账号:走「添加链接 + 连接池」(账号池模型),弹窗填名字+key,
|
|
9
|
+
* 一个供应商可有多个命名 key,按池顺序/策略尝试。凭证存通用 CredentialStore
|
|
10
|
+
* (SQLite:auths/credentials.sqlite,{ name, apiKey })。
|
|
11
|
+
*/
|
|
12
|
+
import type { ServerResponse } from 'node:http'
|
|
13
|
+
import type { ChatRequest, ModelInfo, SupplierStatus } from '../../router/types.ts'
|
|
14
|
+
import type { SupplierEnv, SupplierModule } from '../contract.ts'
|
|
15
|
+
|
|
16
|
+
export const id = 'openrouter'
|
|
17
|
+
export const name = 'OpenRouter'
|
|
18
|
+
export const priority = 10 // 免费直连(opencode=0)之后
|
|
19
|
+
/** 面板图标。 */
|
|
20
|
+
export const icon = 'data:image/png;base64,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'
|
|
21
|
+
|
|
22
|
+
const BASE = 'https://openrouter.ai/api/v1'
|
|
23
|
+
const MODELS_URL = `${BASE}/models`
|
|
24
|
+
const CHAT_URL = `${BASE}/chat/completions`
|
|
25
|
+
const COOL_DOWN_MS = 60 * 1000 // 失败冷却 60s
|
|
26
|
+
|
|
27
|
+
/** 免费模型过滤(同 9Router openrouter-free):pricing 全 0 且 context ≥ 200k。 */
|
|
28
|
+
function isFreeModelMeta(m: { pricing?: { prompt?: string; completion?: string }; context_length?: number }): boolean {
|
|
29
|
+
return (
|
|
30
|
+
m.pricing?.prompt === '0' &&
|
|
31
|
+
m.pricing?.completion === '0' &&
|
|
32
|
+
(m.context_length ?? 0) >= 200000
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 剥 alias 前缀(or/xxx → xxx)。 */
|
|
37
|
+
function stripAlias(model: string): string {
|
|
38
|
+
const slash = model.lastIndexOf('/')
|
|
39
|
+
return slash >= 0 ? model.slice(slash + 1) : model
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ApiKeyAccount {
|
|
43
|
+
name: string
|
|
44
|
+
apiKey: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function factory(env: SupplierEnv): SupplierModule {
|
|
48
|
+
// 模型缓存由 dsh-router 核心统一管;插件每次拉取,失败回退上次成功结果
|
|
49
|
+
let modelsCache: ModelInfo[] | undefined
|
|
50
|
+
let freeIds = new Set<string>()
|
|
51
|
+
/** 失败冷却:uid → until(ms)。 */
|
|
52
|
+
const cooling = new Map<string, number>()
|
|
53
|
+
|
|
54
|
+
function listKeys(): string[] {
|
|
55
|
+
return env.credentials.list(id)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getKey(uid: string): ApiKeyAccount | undefined {
|
|
59
|
+
return env.credentials.get<ApiKeyAccount>(id, uid)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 账号顺序:池顺序优先,未配置按凭证原始顺序。 */
|
|
63
|
+
function orderedKeys(): Array<{ uid: string; acct: ApiKeyAccount }> {
|
|
64
|
+
const byUid = new Map(listKeys().map((uid) => [uid, uid] as const))
|
|
65
|
+
const order = env.store.get(id).poolOrder
|
|
66
|
+
const uids = [...order.filter((u) => byUid.has(u)), ...listKeys().filter((u) => !order.includes(u))]
|
|
67
|
+
return uids.map((uid) => ({ uid, acct: getKey(uid)! })).filter((x) => x.acct !== undefined)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isCooling(uid: string, now = Date.now()): boolean {
|
|
71
|
+
return (cooling.get(uid) ?? 0) > now
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function refreshModels(): Promise<ModelInfo[]> {
|
|
75
|
+
try {
|
|
76
|
+
const resp = await fetch(MODELS_URL, {
|
|
77
|
+
headers: { 'User-Agent': 'dsh-router', 'HTTP-Referer': 'https://endpoint-proxy.local', 'X-Title': 'dsh-router' },
|
|
78
|
+
signal: AbortSignal.timeout(15000),
|
|
79
|
+
})
|
|
80
|
+
if (!resp.ok) return []
|
|
81
|
+
const json = (await resp.json()) as { data?: Array<{ id: string; name?: string; pricing?: { prompt?: string; completion?: string }; context_length?: number }> }
|
|
82
|
+
const raw = json.data ?? []
|
|
83
|
+
const models: ModelInfo[] = []
|
|
84
|
+
const ids = new Set<string>()
|
|
85
|
+
for (const m of raw) {
|
|
86
|
+
if (m.id === '' || !isFreeModelMeta(m)) continue
|
|
87
|
+
if (ids.has(m.id)) continue
|
|
88
|
+
ids.add(m.id)
|
|
89
|
+
const entry: ModelInfo = { id: m.id }
|
|
90
|
+
if ((m.context_length ?? 0) > 0) entry.context_length = Math.round((m.context_length ?? 0) / 1000)
|
|
91
|
+
models.push(entry)
|
|
92
|
+
}
|
|
93
|
+
if (models.length > 0) {
|
|
94
|
+
modelsCache = models
|
|
95
|
+
freeIds = ids
|
|
96
|
+
}
|
|
97
|
+
return models
|
|
98
|
+
} catch {
|
|
99
|
+
return modelsCache ?? []
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function allModels(force: boolean): Promise<ModelInfo[]> {
|
|
104
|
+
return refreshModels()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
id,
|
|
109
|
+
name,
|
|
110
|
+
priority,
|
|
111
|
+
icon,
|
|
112
|
+
status: (): SupplierStatus => {
|
|
113
|
+
const now = Date.now()
|
|
114
|
+
const accounts = orderedKeys().map(({ uid, acct }) => ({
|
|
115
|
+
uid,
|
|
116
|
+
nickname: acct.name || 'API Key',
|
|
117
|
+
credits: 0,
|
|
118
|
+
cooling: isCooling(uid, now),
|
|
119
|
+
disabled: false,
|
|
120
|
+
err_count: 0,
|
|
121
|
+
}))
|
|
122
|
+
return { id, name, accounts }
|
|
123
|
+
},
|
|
124
|
+
listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
|
|
125
|
+
getAlias: (): string => 'or',
|
|
126
|
+
async addApiKey(input: { name: string; apiKey: string }): Promise<{ ok: boolean; error?: string; account?: { uid: string; nickname: string } }> {
|
|
127
|
+
const key = input.apiKey.trim()
|
|
128
|
+
if (key === '') return { ok: false, error: 'API key 不能为空' }
|
|
129
|
+
// 验证有效性:GET /api/v1/key 需要鉴权,无效 key 返回 401
|
|
130
|
+
try {
|
|
131
|
+
const probe = await fetch(`${BASE}/key`, {
|
|
132
|
+
headers: { Authorization: `Bearer ${key}`, 'User-Agent': 'dsh-router' },
|
|
133
|
+
signal: AbortSignal.timeout(15000),
|
|
134
|
+
})
|
|
135
|
+
if (!probe.ok) {
|
|
136
|
+
const text = await probe.text().catch(() => '')
|
|
137
|
+
return { ok: false, error: `key 无效: ${probe.status} ${text.slice(0, 200)}` }
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return { ok: false, error: `验证失败: ${(err as Error).message}` }
|
|
141
|
+
}
|
|
142
|
+
// uid:key-<序号>,避免重复
|
|
143
|
+
let n = listKeys().length + 1
|
|
144
|
+
let uid = `key-${n}`
|
|
145
|
+
while (getKey(uid) !== undefined) uid = `key-${++n}`
|
|
146
|
+
env.credentials.save(id, uid, { name: input.name.trim() || `Key ${n}`, apiKey: key })
|
|
147
|
+
env.log(`openrouter add api key ${uid}`)
|
|
148
|
+
return { ok: true, account: { uid, nickname: input.name.trim() || `Key ${n}` } }
|
|
149
|
+
},
|
|
150
|
+
async removeLink(uid: string): Promise<boolean> {
|
|
151
|
+
if (getKey(uid) === undefined) return false
|
|
152
|
+
env.credentials.remove(id, uid)
|
|
153
|
+
cooling.delete(uid)
|
|
154
|
+
return true
|
|
155
|
+
},
|
|
156
|
+
async testModel(mid: string): Promise<{ ok: boolean; error?: string }> {
|
|
157
|
+
const base = stripAlias(mid)
|
|
158
|
+
const keys = orderedKeys()
|
|
159
|
+
if (keys.length === 0) return { ok: false, error: '未添加 API key(点「添加链接」配置)' }
|
|
160
|
+
if (!freeIds.has(base) && !(await allModels(false)).some((m) => m.id === base)) {
|
|
161
|
+
return { ok: false, error: `unknown free model ${JSON.stringify(mid)}` }
|
|
162
|
+
}
|
|
163
|
+
for (const { acct } of keys) {
|
|
164
|
+
try {
|
|
165
|
+
const resp = await fetch(CHAT_URL, {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
headers: {
|
|
168
|
+
'Content-Type': 'application/json',
|
|
169
|
+
Authorization: `Bearer ${acct.apiKey}`,
|
|
170
|
+
'HTTP-Referer': 'https://endpoint-proxy.local',
|
|
171
|
+
'X-Title': 'dsh-router',
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify({ model: base, messages: [{ role: 'user', content: 'ping' }], stream: false, max_tokens: 1 }),
|
|
174
|
+
signal: AbortSignal.timeout(30000),
|
|
175
|
+
})
|
|
176
|
+
if (resp.ok) return { ok: true }
|
|
177
|
+
const body = await resp.text().catch(() => '')
|
|
178
|
+
return { ok: false, error: `upstream ${resp.status}: ${body.slice(0, 200)}` }
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return { ok: false, error: (err as Error).message }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { ok: false, error: 'no api key' }
|
|
184
|
+
},
|
|
185
|
+
async chatCompletions(req: ChatRequest, res: ServerResponse): Promise<boolean> {
|
|
186
|
+
const base = stripAlias(req.model)
|
|
187
|
+
if (!freeIds.has(base) && !(await allModels(false)).some((m) => m.id === base)) return false
|
|
188
|
+
const keys = orderedKeys().filter(({ uid }) => !isCooling(uid))
|
|
189
|
+
if (keys.length === 0) return false // 无健康 key → 路由器 fallback
|
|
190
|
+
|
|
191
|
+
let body = req.rawBody
|
|
192
|
+
try {
|
|
193
|
+
const obj = JSON.parse(body) as Record<string, unknown>
|
|
194
|
+
obj.model = base
|
|
195
|
+
body = JSON.stringify(obj)
|
|
196
|
+
} catch {
|
|
197
|
+
// 保持原样
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let lastErr = ''
|
|
201
|
+
for (const { uid, acct } of keys) {
|
|
202
|
+
let upstream: Response
|
|
203
|
+
try {
|
|
204
|
+
upstream = await fetch(CHAT_URL, {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: {
|
|
207
|
+
'Content-Type': 'application/json',
|
|
208
|
+
Authorization: `Bearer ${acct.apiKey}`,
|
|
209
|
+
'HTTP-Referer': 'https://endpoint-proxy.local',
|
|
210
|
+
'X-Title': 'dsh-router',
|
|
211
|
+
},
|
|
212
|
+
body,
|
|
213
|
+
signal: AbortSignal.timeout(120000),
|
|
214
|
+
})
|
|
215
|
+
} catch (err) {
|
|
216
|
+
cooling.set(uid, Date.now() + COOL_DOWN_MS)
|
|
217
|
+
lastErr = (err as Error).message
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
// 非 2xx:冷却该 key,试下一个
|
|
221
|
+
if (upstream.status < 200 || upstream.status >= 300) {
|
|
222
|
+
cooling.set(uid, Date.now() + COOL_DOWN_MS)
|
|
223
|
+
const text = await upstream.text().catch(() => '')
|
|
224
|
+
lastErr = `upstream ${upstream.status}: ${text.slice(0, 120)}`
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
// 成功:透传(流式 SSE 或 JSON)
|
|
228
|
+
if (req.stream) {
|
|
229
|
+
res.writeHead(upstream.status, {
|
|
230
|
+
'Content-Type': 'text/event-stream',
|
|
231
|
+
'Cache-Control': 'no-cache',
|
|
232
|
+
Connection: 'keep-alive',
|
|
233
|
+
'X-Accel-Buffering': 'no',
|
|
234
|
+
})
|
|
235
|
+
if (!upstream.body) {
|
|
236
|
+
res.end()
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
239
|
+
const reader = upstream.body.getReader()
|
|
240
|
+
try {
|
|
241
|
+
for (;;) {
|
|
242
|
+
const { done, value } = await reader.read()
|
|
243
|
+
if (done) break
|
|
244
|
+
res.write(Buffer.from(value))
|
|
245
|
+
if (typeof (res as { flushHeaders?: () => void }).flushHeaders === 'function') {
|
|
246
|
+
;(res as { flushHeaders: () => void }).flushHeaders()
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} finally {
|
|
250
|
+
reader.releaseLock()
|
|
251
|
+
}
|
|
252
|
+
res.end()
|
|
253
|
+
return true
|
|
254
|
+
}
|
|
255
|
+
const text = await upstream.text().catch(() => '')
|
|
256
|
+
let status = upstream.status
|
|
257
|
+
if (status === 200 && text === '') status = 502
|
|
258
|
+
try {
|
|
259
|
+
writeJson(res, status, JSON.parse(text))
|
|
260
|
+
} catch {
|
|
261
|
+
writeJson(res, status, { error: { message: text.slice(0, 500), type: 'api_error', code: 'upstream_error' } })
|
|
262
|
+
}
|
|
263
|
+
return true
|
|
264
|
+
}
|
|
265
|
+
// 所有 key 都失败:记录日志,返回 false 让路由器 fallback
|
|
266
|
+
env.log(`openrouter chat failed: ${lastErr}`)
|
|
267
|
+
return false
|
|
268
|
+
},
|
|
269
|
+
dispose: (): void => {
|
|
270
|
+
modelsCache = undefined
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
276
|
+
const payload = typeof body === 'string' ? body : JSON.stringify(body)
|
|
277
|
+
res.writeHead(status, {
|
|
278
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
279
|
+
'Cache-Control': 'no-store',
|
|
280
|
+
})
|
|
281
|
+
res.end(payload)
|
|
282
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 供应商注册表 —— 持有加载的供应商 + 按能力暴露的通用端点。
|
|
3
|
+
*
|
|
4
|
+
* 通用能力(dsh-router 核心,所有供应商自动可用,js 无需实现):
|
|
5
|
+
* GET /suppliers/:id/models models + alias(listModels 合并启用状态)
|
|
6
|
+
* POST /suppliers/:id/models/toggle {id, enabled}
|
|
7
|
+
* POST /suppliers/:id/models/add {id}
|
|
8
|
+
* POST /suppliers/:id/models/remove {id}
|
|
9
|
+
* POST /suppliers/:id/models/bulk {enabled}
|
|
10
|
+
* POST /suppliers/:id/alias {alias}
|
|
11
|
+
* GET /suppliers/:id/pool/order + POST {uids}
|
|
12
|
+
* GET /suppliers/:id/pool/strategy + POST {strategy}
|
|
13
|
+
*
|
|
14
|
+
* 差异化能力(供应商 js 实现,按存在性注册;未实现返回 404):
|
|
15
|
+
* POST /suppliers/:id/models/fetch 拉取上游模型
|
|
16
|
+
* POST /suppliers/:id/models/test {id}
|
|
17
|
+
* POST /suppliers/:id/login 生成登录链接
|
|
18
|
+
* POST /suppliers/:id/login/callback {callbackUrl}
|
|
19
|
+
* POST /suppliers/:id/links/remove {uid}
|
|
20
|
+
* GET /suppliers/:id/checkin/rule + POST {rule}
|
|
21
|
+
* POST /suppliers/:id/checkin 触发签到
|
|
22
|
+
*/
|
|
23
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
24
|
+
import type { ModelWithEnabled } from '../router/types.ts'
|
|
25
|
+
import type { SupplierConfigStore } from '../supplier-config.ts'
|
|
26
|
+
import type { LoadedSupplier } from './loader.ts'
|
|
27
|
+
|
|
28
|
+
/** webServer 路由形状(与 index.ts 的 WebServerRoute 一致)。 */
|
|
29
|
+
export interface WebServerRoute {
|
|
30
|
+
kind: 'prefix' | 'exact'
|
|
31
|
+
path: string
|
|
32
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
36
|
+
const payload = JSON.stringify(body)
|
|
37
|
+
res.writeHead(status, {
|
|
38
|
+
'content-type': 'application/json; charset=utf-8',
|
|
39
|
+
'content-length': Buffer.byteLength(payload),
|
|
40
|
+
})
|
|
41
|
+
res.end(payload)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readBody(req: IncomingMessage, limit = 64 << 10): Promise<string> {
|
|
45
|
+
const chunks: Buffer[] = []
|
|
46
|
+
let size = 0
|
|
47
|
+
return await new Promise((resolve, reject) => {
|
|
48
|
+
req.on('data', (chunk: Buffer) => {
|
|
49
|
+
size += chunk.length
|
|
50
|
+
if (size > limit) {
|
|
51
|
+
reject(new Error('body_too_large'))
|
|
52
|
+
req.destroy()
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
chunks.push(chunk)
|
|
56
|
+
})
|
|
57
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
58
|
+
req.on('error', reject)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cap(set: Set<string>, key: string): boolean {
|
|
63
|
+
return set.has(key)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 模型列表统一缓存(dsh-router 核心管;插件只管拉取,不缓存)。 */
|
|
67
|
+
const modelsCache = new Map<string, { models: ModelWithEnabled[]; fetchedAt: number }>()
|
|
68
|
+
const MODELS_TTL_MS = 60 * 1000
|
|
69
|
+
|
|
70
|
+
/** 拉取并缓存某供应商模型(force 时强制刷新)。custom 模型并入显示。 */
|
|
71
|
+
async function cachedModels(s: LoadedSupplier['supplier'], cfg: ReturnType<SupplierConfigStore['get']>, force = false): Promise<ModelWithEnabled[]> {
|
|
72
|
+
const hit = modelsCache.get(s.id)
|
|
73
|
+
if (!force && hit && Date.now() - hit.fetchedAt < MODELS_TTL_MS) return hit.models
|
|
74
|
+
const list = await Promise.resolve(s.listModels())
|
|
75
|
+
const custom = new Set(cfg.custom)
|
|
76
|
+
const disabled = new Set(cfg.disabled)
|
|
77
|
+
const models: ModelWithEnabled[] = list.map((mm) => ({
|
|
78
|
+
...mm,
|
|
79
|
+
enabled: !disabled.has(mm.id),
|
|
80
|
+
custom: custom.has(mm.id) ? true : undefined,
|
|
81
|
+
}))
|
|
82
|
+
// 自定义模型(listModels 之外的)并入显示
|
|
83
|
+
const seen = new Set(models.map((mm) => mm.id))
|
|
84
|
+
for (const id of custom) {
|
|
85
|
+
if (!seen.has(id)) {
|
|
86
|
+
seen.add(id)
|
|
87
|
+
models.push({ id, enabled: !disabled.has(id), custom: true })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
modelsCache.set(s.id, { models, fetchedAt: Date.now() })
|
|
91
|
+
return models
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mod(s: LoadedSupplier): Record<string, unknown> {
|
|
95
|
+
return (s.supplier as unknown as { __module?: Record<string, unknown> }).__module ?? {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 为一个供应商生成端点(通用 + 差异化,返回 route 列表)。 */
|
|
99
|
+
export function supplierRoutes(base: string, loaded: LoadedSupplier, store: SupplierConfigStore): WebServerRoute[] {
|
|
100
|
+
const s = loaded.supplier
|
|
101
|
+
const m = mod(loaded)
|
|
102
|
+
const c = loaded.capabilities
|
|
103
|
+
const p = `${base}/suppliers/${s.id}`
|
|
104
|
+
const routes: WebServerRoute[] = []
|
|
105
|
+
|
|
106
|
+
// ---- 通用: models(listModels 差异化 + 启用状态通用;核心统一缓存) ----
|
|
107
|
+
routes.push({
|
|
108
|
+
kind: 'exact',
|
|
109
|
+
path: `${p}/models`,
|
|
110
|
+
handler: async (_req, res) => {
|
|
111
|
+
const models = await cachedModels(s, store.get(s.id))
|
|
112
|
+
writeJson(res, 200, { ok: true, alias: s.getAlias(), models })
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ---- 通用: alias ----
|
|
117
|
+
routes.push({
|
|
118
|
+
kind: 'exact',
|
|
119
|
+
path: `${p}/alias`,
|
|
120
|
+
handler: async (req, res) => {
|
|
121
|
+
const body = JSON.parse(await readBody(req)) as { alias?: string }
|
|
122
|
+
const alias = body.alias ?? ''
|
|
123
|
+
const clean = alias.trim()
|
|
124
|
+
if (clean === '' || !/^[A-Za-z0-9_-]+$/.test(clean)) {
|
|
125
|
+
writeJson(res, 400, { ok: false, error: '前缀只能包含字母、数字、- 和 _' })
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
store.setAlias(s.id, clean)
|
|
129
|
+
writeJson(res, 200, { ok: true })
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// ---- 通用: models/toggle ----
|
|
134
|
+
routes.push({
|
|
135
|
+
kind: 'exact',
|
|
136
|
+
path: `${p}/models/toggle`,
|
|
137
|
+
handler: async (req, res) => {
|
|
138
|
+
const body = JSON.parse(await readBody(req)) as { id?: string; enabled?: boolean }
|
|
139
|
+
const cfg = store.get(s.id)
|
|
140
|
+
const models = await cachedModels(s, cfg)
|
|
141
|
+
if (!body.id || !models.some((mm) => mm.id === body.id)) {
|
|
142
|
+
writeJson(res, 400, { ok: false, error: '模型不存在' })
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
store.setModelEnabled(s.id, body.id, !!body.enabled)
|
|
146
|
+
modelsCache.delete(s.id) // 启用状态变化后失效缓存
|
|
147
|
+
writeJson(res, 200, { ok: true })
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// ---- 通用: models/add(自定义模型) ----
|
|
152
|
+
routes.push({
|
|
153
|
+
kind: 'exact',
|
|
154
|
+
path: `${p}/models/add`,
|
|
155
|
+
handler: async (req, res) => {
|
|
156
|
+
const body = JSON.parse(await readBody(req)) as { id?: string }
|
|
157
|
+
const id = (body.id ?? '').trim()
|
|
158
|
+
if (id === '') {
|
|
159
|
+
writeJson(res, 400, { ok: false, error: '模型 id 不能为空' })
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
const models = await cachedModels(s, store.get(s.id))
|
|
163
|
+
if (models.some((mm) => mm.id === id)) {
|
|
164
|
+
writeJson(res, 400, { ok: false, error: `模型 ${id} 已存在` })
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
store.addCustomModel(s.id, id)
|
|
168
|
+
modelsCache.delete(s.id)
|
|
169
|
+
writeJson(res, 200, { ok: true })
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// ---- 通用: models/remove(自定义模型) ----
|
|
174
|
+
routes.push({
|
|
175
|
+
kind: 'exact',
|
|
176
|
+
path: `${p}/models/remove`,
|
|
177
|
+
handler: async (req, res) => {
|
|
178
|
+
const body = JSON.parse(await readBody(req)) as { id?: string }
|
|
179
|
+
const cfg = store.get(s.id)
|
|
180
|
+
if (!body.id || !cfg.custom.includes(body.id)) {
|
|
181
|
+
writeJson(res, 400, { ok: false, error: `模型 ${body.id} 不是自定义模型` })
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
store.removeCustomModel(s.id, body.id)
|
|
185
|
+
modelsCache.delete(s.id)
|
|
186
|
+
writeJson(res, 200, { ok: true })
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// ---- 通用: models/bulk ----
|
|
191
|
+
routes.push({
|
|
192
|
+
kind: 'exact',
|
|
193
|
+
path: `${p}/models/bulk`,
|
|
194
|
+
handler: async (req, res) => {
|
|
195
|
+
const body = JSON.parse(await readBody(req)) as { enabled?: boolean }
|
|
196
|
+
const models = await cachedModels(s, store.get(s.id))
|
|
197
|
+
store.setAllModelsEnabled(s.id, !!body.enabled, models.map((mm) => mm.id))
|
|
198
|
+
modelsCache.delete(s.id)
|
|
199
|
+
writeJson(res, 200, { ok: true })
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// ---- 通用: pool/order + pool/strategy ----
|
|
204
|
+
routes.push({
|
|
205
|
+
kind: 'exact',
|
|
206
|
+
path: `${p}/pool/order`,
|
|
207
|
+
handler: async (req, res) => {
|
|
208
|
+
if (req.method === 'GET') {
|
|
209
|
+
writeJson(res, 200, { ok: true, order: store.get(s.id).poolOrder })
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
const body = JSON.parse(await readBody(req)) as { uids?: string[] }
|
|
213
|
+
if (!Array.isArray(body.uids) || body.uids.some((u) => typeof u !== 'string')) {
|
|
214
|
+
writeJson(res, 400, { ok: false, error: '顺序必须是 uid 数组' })
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
store.setPoolOrder(s.id, body.uids)
|
|
218
|
+
writeJson(res, 200, { ok: true })
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
routes.push({
|
|
223
|
+
kind: 'exact',
|
|
224
|
+
path: `${p}/pool/strategy`,
|
|
225
|
+
handler: async (req, res) => {
|
|
226
|
+
if (req.method === 'GET') {
|
|
227
|
+
writeJson(res, 200, { ok: true, strategy: store.get(s.id).poolStrategy })
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
const body = JSON.parse(await readBody(req)) as { strategy?: string }
|
|
231
|
+
if (body.strategy !== 'fallback' && body.strategy !== 'round-robin') {
|
|
232
|
+
writeJson(res, 400, { ok: false, error: '策略无效' })
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
store.setPoolStrategy(s.id, body.strategy)
|
|
236
|
+
writeJson(res, 200, { ok: true })
|
|
237
|
+
},
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// ---- 通用 UI: 获取模型(= 强制刷新 + 核心缓存更新) ----
|
|
241
|
+
routes.push({
|
|
242
|
+
kind: 'exact',
|
|
243
|
+
path: `${p}/models/fetch`,
|
|
244
|
+
handler: async (_req, res) => {
|
|
245
|
+
const models = await cachedModels(s, store.get(s.id), true)
|
|
246
|
+
writeJson(res, 200, { ok: true, models })
|
|
247
|
+
},
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// ---- 必要差异化: testModel(所有供应商必须实现) ----
|
|
251
|
+
routes.push({
|
|
252
|
+
kind: 'exact',
|
|
253
|
+
path: `${p}/models/test`,
|
|
254
|
+
handler: async (req, res) => {
|
|
255
|
+
const body = JSON.parse(await readBody(req)) as { id?: string }
|
|
256
|
+
const result = await s.testModel(body.id ?? '')
|
|
257
|
+
writeJson(res, result.ok ? 200 : 400, result)
|
|
258
|
+
},
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// ---- 通用 UI: 添加链接(差异化实现 generateLoginUrl/completeLogin) ----
|
|
262
|
+
routes.push({
|
|
263
|
+
kind: 'exact',
|
|
264
|
+
path: `${p}/login`,
|
|
265
|
+
handler: async (_req, res) => {
|
|
266
|
+
if (!cap(c, 'generateLoginUrl')) {
|
|
267
|
+
writeJson(res, 400, { ok: false, error: '该供应商不支持添加链接' })
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
const r = await (m.generateLoginUrl as () => string | { ok: boolean; error?: string; loginUrl?: string } | Promise<string | { ok: boolean; error?: string; loginUrl?: string }>).call(loaded.supplier)
|
|
271
|
+
if (typeof r === 'string') writeJson(res, 200, { ok: true, loginUrl: r })
|
|
272
|
+
else writeJson(res, r.ok === false ? 400 : 200, r)
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
routes.push({
|
|
276
|
+
kind: 'exact',
|
|
277
|
+
path: `${p}/login/callback`,
|
|
278
|
+
handler: async (req, res) => {
|
|
279
|
+
if (!cap(c, 'completeLogin')) {
|
|
280
|
+
writeJson(res, 400, { ok: false, error: '该供应商不支持添加链接' })
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
const body = JSON.parse(await readBody(req)) as { callbackUrl?: string }
|
|
284
|
+
try {
|
|
285
|
+
const acct = await (m.completeLogin as (url: string) => Promise<{ uid: string; nickname: string }>).call(loaded.supplier, body.callbackUrl ?? '')
|
|
286
|
+
writeJson(res, 200, { ok: true, account: acct })
|
|
287
|
+
} catch (err) {
|
|
288
|
+
writeJson(res, 400, { ok: false, error: (err as Error).message })
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// ---- 通用 UI: 添加 API key 账号(差异化实现 addApiKey,弹窗填名字+key) ----
|
|
294
|
+
routes.push({
|
|
295
|
+
kind: 'exact',
|
|
296
|
+
path: `${p}/links/add`,
|
|
297
|
+
handler: async (req, res) => {
|
|
298
|
+
if (!cap(c, 'addApiKey')) {
|
|
299
|
+
writeJson(res, 400, { ok: false, error: '该供应商不支持添加 API key' })
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
const body = JSON.parse(await readBody(req)) as { name?: string; apiKey?: string }
|
|
303
|
+
try {
|
|
304
|
+
const r = await (m.addApiKey as (input: { name: string; apiKey: string }) => Promise<{ ok: boolean; error?: string; account?: { uid: string; nickname: string } }>).call(loaded.supplier, {
|
|
305
|
+
name: (body.name ?? '').trim(),
|
|
306
|
+
apiKey: (body.apiKey ?? '').trim(),
|
|
307
|
+
})
|
|
308
|
+
writeJson(res, r.ok === false ? 400 : 200, r)
|
|
309
|
+
} catch (err) {
|
|
310
|
+
writeJson(res, 400, { ok: false, error: (err as Error).message })
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// ---- 通用 UI: 删除链接(数据删除,核心统一;凭证清理由供应商内部 removeLink) ----
|
|
316
|
+
routes.push({
|
|
317
|
+
kind: 'exact',
|
|
318
|
+
path: `${p}/links/remove`,
|
|
319
|
+
handler: async (req, res) => {
|
|
320
|
+
if (typeof s.removeLink !== 'function') {
|
|
321
|
+
writeJson(res, 400, { ok: false, error: '该供应商不支持删除链接' })
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
const body = JSON.parse(await readBody(req)) as { uid?: string }
|
|
325
|
+
const ok = await s.removeLink(body.uid ?? '')
|
|
326
|
+
writeJson(res, ok ? 200 : 404, ok ? { ok: true } : { ok: false, error: 'link not found' })
|
|
327
|
+
},
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
// ---- 通用: 签到规则(dsh-router 策略,存 SupplierConfigStore) ----
|
|
331
|
+
routes.push({
|
|
332
|
+
kind: 'exact',
|
|
333
|
+
path: `${p}/checkin/rule`,
|
|
334
|
+
handler: async (req, res) => {
|
|
335
|
+
if (req.method === 'GET') {
|
|
336
|
+
writeJson(res, 200, { ok: true, rule: store.get(s.id).checkinRule })
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
const body = JSON.parse(await readBody(req)) as { rule?: string }
|
|
340
|
+
if (body.rule !== 'all' && body.rule !== 'first') {
|
|
341
|
+
writeJson(res, 400, { ok: false, error: 'rule 必须是 all 或 first' })
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
store.setCheckinRule(s.id, body.rule)
|
|
345
|
+
writeJson(res, 200, { ok: true })
|
|
346
|
+
},
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
if (cap(c, 'checkinNow')) {
|
|
350
|
+
routes.push({
|
|
351
|
+
kind: 'exact',
|
|
352
|
+
path: `${p}/checkin`,
|
|
353
|
+
handler: async (_req, res) => {
|
|
354
|
+
try {
|
|
355
|
+
const result = await (m.checkinNow as () => Promise<{ ok: boolean; error?: string }>).call(loaded.supplier)
|
|
356
|
+
writeJson(res, result.ok === false ? 400 : 200, result)
|
|
357
|
+
} catch (err) {
|
|
358
|
+
writeJson(res, 500, { ok: false, error: (err as Error).message })
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return routes
|
|
365
|
+
}
|