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,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH llm adapter for dsh-router(provider 固定卡片,名字 Router)。
|
|
3
|
+
*
|
|
4
|
+
* 模型目录 = 组合(自动带出,不可改)。对话转发到本插件 /v1/chat/completions,
|
|
5
|
+
* 由现有路由按组合策略命中供应商模型。
|
|
6
|
+
*
|
|
7
|
+
* 纯文本流:组合为文本模型,本 adapter 不处理图片附件。
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
CallId,
|
|
11
|
+
EMPTY_RESPONSE_CODE,
|
|
12
|
+
LlmAdapter,
|
|
13
|
+
LlmError,
|
|
14
|
+
type GenerateOptions,
|
|
15
|
+
type LlmModelInfo,
|
|
16
|
+
type StreamChunk,
|
|
17
|
+
} from '@deepseek-ai/dsh-llm'
|
|
18
|
+
|
|
19
|
+
/** 模型目录来源:组合。 */
|
|
20
|
+
export interface RouterAdapterSource {
|
|
21
|
+
comboModels: () => Promise<Array<{ id: string; name?: string }>>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 解析 SSE 文本,产出 `data:` 载荷(含结尾 [DONE])。 */
|
|
25
|
+
function* parseSseText(text: string): Generator<string> {
|
|
26
|
+
let data = ''
|
|
27
|
+
for (const line of text.split('\n')) {
|
|
28
|
+
if (line.startsWith('data:')) {
|
|
29
|
+
const value = line.slice(5).trimStart()
|
|
30
|
+
if (data.length > 0) data += '\n'
|
|
31
|
+
data += value
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
if (line === '' && data.length > 0) {
|
|
35
|
+
yield data
|
|
36
|
+
data = ''
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (data.length > 0) yield data
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。 */
|
|
43
|
+
function wireMessages(options: GenerateOptions): Array<Record<string, unknown>> {
|
|
44
|
+
const out: Array<Record<string, unknown>> = []
|
|
45
|
+
for (const message of options.messages) {
|
|
46
|
+
if (message.role === 'system') {
|
|
47
|
+
out.push({ role: 'system', content: flattenText(message.content) })
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (message.role === 'assistant') {
|
|
51
|
+
const text = flattenText(message.content)
|
|
52
|
+
const toolCalls = message.content
|
|
53
|
+
.filter((b) => b.type === 'tool-call')
|
|
54
|
+
.map((b) => ({ id: b.id, type: 'function', function: { name: b.name, arguments: b.arguments } }))
|
|
55
|
+
const reasoning = message.content
|
|
56
|
+
.filter((b) => b.type === 'reasoning')
|
|
57
|
+
.map((b) => b.text)
|
|
58
|
+
.join('')
|
|
59
|
+
out.push({
|
|
60
|
+
role: 'assistant',
|
|
61
|
+
content: text,
|
|
62
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
|
63
|
+
...(reasoning.length > 0 ? { reasoning_content: reasoning } : {}),
|
|
64
|
+
})
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
// user / tool-result
|
|
68
|
+
const text = flattenText(message.content)
|
|
69
|
+
const toolResults = message.content.filter((b) => b.type === 'tool-result')
|
|
70
|
+
if (text.length > 0 || toolResults.length === 0) out.push({ role: 'user', content: text })
|
|
71
|
+
for (const result of toolResults) {
|
|
72
|
+
out.push({ role: 'tool', tool_call_id: result.toolCallId, content: flattenText(result.content) || '(no output)' })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 组装 wire 请求体。 */
|
|
79
|
+
function wireRequest(options: GenerateOptions): Record<string, unknown> {
|
|
80
|
+
const messages = wireMessages(options)
|
|
81
|
+
const body: Record<string, unknown> = {
|
|
82
|
+
model: options.model,
|
|
83
|
+
messages,
|
|
84
|
+
stream: true,
|
|
85
|
+
stream_options: { include_usage: true },
|
|
86
|
+
}
|
|
87
|
+
const tools = options.tools?.map((tool) => ({
|
|
88
|
+
type: 'function',
|
|
89
|
+
function: { name: tool.name, description: tool.description, parameters: tool.parameters },
|
|
90
|
+
}))
|
|
91
|
+
if (tools !== undefined && tools.length > 0) body.tools = tools
|
|
92
|
+
if (options.temperature !== undefined) body.temperature = options.temperature
|
|
93
|
+
if (options.maxTokens !== undefined) body.max_tokens = options.maxTokens
|
|
94
|
+
if (options.stop !== undefined) body.stop = options.stop
|
|
95
|
+
return body
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function flattenText(blocks: readonly { type: string; text?: string }[]): string {
|
|
99
|
+
return blocks.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface OpenBlock {
|
|
103
|
+
index: number
|
|
104
|
+
kind: 'text' | 'reasoning' | 'tool-call'
|
|
105
|
+
text: string
|
|
106
|
+
callId?: string
|
|
107
|
+
name?: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** openai SSE payload → DSH StreamChunk 流。 */
|
|
111
|
+
async function* translate(payloads: Iterable<string>): AsyncGenerator<StreamChunk> {
|
|
112
|
+
let nextIndex = 0
|
|
113
|
+
let textBlock: OpenBlock | undefined
|
|
114
|
+
let reasoningBlock: OpenBlock | undefined
|
|
115
|
+
const toolBlocks = new Map<number, OpenBlock>()
|
|
116
|
+
const order: OpenBlock[] = []
|
|
117
|
+
let pendingFinish: { kind: string; failure?: unknown } | undefined
|
|
118
|
+
let pendingUsage: unknown
|
|
119
|
+
|
|
120
|
+
const open = (kind: OpenBlock['kind']): OpenBlock => {
|
|
121
|
+
const block: OpenBlock = { index: nextIndex++, kind, text: '' }
|
|
122
|
+
order.push(block)
|
|
123
|
+
return block
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const payload of payloads) {
|
|
127
|
+
if (payload === '[DONE]') {
|
|
128
|
+
for (const block of order) {
|
|
129
|
+
yield { type: 'block-end', index: block.index, block: closeBlock(block) }
|
|
130
|
+
}
|
|
131
|
+
if (pendingUsage !== undefined) yield { type: 'usage', usage: pendingUsage as never }
|
|
132
|
+
const reason = pendingFinish ?? { kind: 'stop' }
|
|
133
|
+
yield {
|
|
134
|
+
type: 'finish',
|
|
135
|
+
reason:
|
|
136
|
+
reason.kind === 'stop' && order.length === 0
|
|
137
|
+
? { kind: 'error', failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE } }
|
|
138
|
+
: (reason as never),
|
|
139
|
+
}
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
let chunk: { choices?: Array<{ delta?: Record<string, unknown>; finish_reason?: string }>; usage?: unknown }
|
|
143
|
+
try {
|
|
144
|
+
chunk = JSON.parse(payload) as typeof chunk
|
|
145
|
+
} catch {
|
|
146
|
+
throw new LlmError(`malformed SSE payload: ${payload.slice(0, 120)}`, 'MALFORMED_RESPONSE')
|
|
147
|
+
}
|
|
148
|
+
for (const choice of chunk.choices ?? []) {
|
|
149
|
+
const delta = choice.delta ?? {}
|
|
150
|
+
const reasoning = delta.reasoning_content
|
|
151
|
+
if (typeof reasoning === 'string' && reasoning.length > 0) {
|
|
152
|
+
if (!reasoningBlock) {
|
|
153
|
+
reasoningBlock = open('reasoning')
|
|
154
|
+
yield { type: 'block-start', index: reasoningBlock.index, blockType: 'reasoning' }
|
|
155
|
+
}
|
|
156
|
+
reasoningBlock.text += reasoning
|
|
157
|
+
yield { type: 'reasoning-delta', index: reasoningBlock.index, text: reasoning }
|
|
158
|
+
}
|
|
159
|
+
const content = delta.content
|
|
160
|
+
if (typeof content === 'string' && content.length > 0) {
|
|
161
|
+
if (!textBlock) {
|
|
162
|
+
textBlock = open('text')
|
|
163
|
+
yield { type: 'block-start', index: textBlock.index, blockType: 'text' }
|
|
164
|
+
}
|
|
165
|
+
textBlock.text += content
|
|
166
|
+
yield { type: 'text-delta', index: textBlock.index, text: content }
|
|
167
|
+
}
|
|
168
|
+
const toolCalls = delta.tool_calls as Array<{ index?: number; id?: string; function?: { name?: string; arguments?: string } }> | undefined
|
|
169
|
+
for (const call of toolCalls ?? []) {
|
|
170
|
+
const key = call.index ?? 0
|
|
171
|
+
let block = toolBlocks.get(key)
|
|
172
|
+
if (!block) {
|
|
173
|
+
block = open('tool-call')
|
|
174
|
+
toolBlocks.set(key, block)
|
|
175
|
+
yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
|
|
176
|
+
}
|
|
177
|
+
if (call.id !== undefined) block.callId = call.id
|
|
178
|
+
if (call.function?.name !== undefined) block.name = call.function.name
|
|
179
|
+
const fragment = call.function?.arguments ?? ''
|
|
180
|
+
block.text += fragment
|
|
181
|
+
yield {
|
|
182
|
+
type: 'tool-call-delta',
|
|
183
|
+
index: block.index,
|
|
184
|
+
id: CallId(block.callId ?? ''),
|
|
185
|
+
...(block.name !== undefined ? { name: block.name } : {}),
|
|
186
|
+
argumentsDelta: fragment,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (typeof choice.finish_reason === 'string') pendingFinish = mapFinishReason(choice.finish_reason)
|
|
190
|
+
}
|
|
191
|
+
if (chunk.usage !== undefined) pendingUsage = chunk.usage
|
|
192
|
+
}
|
|
193
|
+
throw new LlmError('SSE payload stream ended without [DONE]', 'STREAM_CLOSED')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function mapFinishReason(reason: string): { kind: string; failure?: unknown } {
|
|
197
|
+
switch (reason) {
|
|
198
|
+
case 'stop': return { kind: 'stop' }
|
|
199
|
+
case 'tool_calls': return { kind: 'tool-calls' }
|
|
200
|
+
case 'length': return { kind: 'max-tokens' }
|
|
201
|
+
default: return { kind: 'error', failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() } }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function closeBlock(block: OpenBlock): never {
|
|
206
|
+
switch (block.kind) {
|
|
207
|
+
case 'text': return { type: 'text', text: block.text } as never
|
|
208
|
+
case 'reasoning': return { type: 'reasoning', text: block.text } as never
|
|
209
|
+
case 'tool-call': return {
|
|
210
|
+
type: 'tool-call',
|
|
211
|
+
id: CallId(block.callId ?? ''),
|
|
212
|
+
name: block.name ?? '',
|
|
213
|
+
arguments: block.text,
|
|
214
|
+
} as never
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* DSH adapter:provider `router`。模型目录 = 组合;stream 转发到本插件
|
|
220
|
+
* /v1/chat/completions(组合路由在 /v1 内完成)。
|
|
221
|
+
*/
|
|
222
|
+
export class RouterAdapter extends LlmAdapter {
|
|
223
|
+
private readonly baseURL: string
|
|
224
|
+
private readonly source: RouterAdapterSource
|
|
225
|
+
|
|
226
|
+
constructor(baseURL: string, source: RouterAdapterSource) {
|
|
227
|
+
super()
|
|
228
|
+
this.baseURL = baseURL
|
|
229
|
+
this.source = source
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
providerInfo(provider: string): { id: string; name: string } {
|
|
233
|
+
return { id: provider, name: 'Router' }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async listModels(_provider: string): Promise<readonly LlmModelInfo[]> {
|
|
237
|
+
const combos = await this.source.comboModels()
|
|
238
|
+
const seen = new Set<string>()
|
|
239
|
+
const out: LlmModelInfo[] = []
|
|
240
|
+
for (const m of combos) {
|
|
241
|
+
if (seen.has(m.id)) continue
|
|
242
|
+
seen.add(m.id)
|
|
243
|
+
out.push({ provider: 'router', id: m.id, name: m.name ?? m.id })
|
|
244
|
+
}
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
249
|
+
const body = wireRequest(options)
|
|
250
|
+
const controller = new AbortController()
|
|
251
|
+
const onAbort = (): void => controller.abort()
|
|
252
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
253
|
+
let resp: Response
|
|
254
|
+
try {
|
|
255
|
+
resp = await fetch(`${this.baseURL}/chat/completions`, {
|
|
256
|
+
method: 'POST',
|
|
257
|
+
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
258
|
+
body: JSON.stringify(body),
|
|
259
|
+
signal: controller.signal,
|
|
260
|
+
})
|
|
261
|
+
} catch (error) {
|
|
262
|
+
throw new LlmError(`dsh-router upstream call failed: ${(error as Error).message}`, 'TRANSPORT', { cause: error })
|
|
263
|
+
} finally {
|
|
264
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
265
|
+
}
|
|
266
|
+
if (!resp.ok) {
|
|
267
|
+
const detail = await resp.text().catch(() => '')
|
|
268
|
+
throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, 'TRANSPORT')
|
|
269
|
+
}
|
|
270
|
+
const text = resp.body === null ? '' : await resp.text()
|
|
271
|
+
yield* translate(parseSseText(text))
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-router 路由器本体:供应商注册表 + OpenAI 兼容 /v1/* 处理。
|
|
3
|
+
* 仿 9router:组合 = 一组模型,请求 model 命中组合名时,按策略
|
|
4
|
+
* (fallback 顺序尝试 / round-robin 轮转)选中一个模型路由。
|
|
5
|
+
*/
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
import type { ServerResponse } from 'node:http'
|
|
9
|
+
import type { Supplier, ChatRequest, ModelInfo, ModelWithEnabled, SupplierStatus, Combo } from './types.ts'
|
|
10
|
+
|
|
11
|
+
function openAIError(code: string, msg: string): Record<string, unknown> {
|
|
12
|
+
return { error: { message: msg, type: 'api_error', code } }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
16
|
+
res.writeHead(status, {
|
|
17
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
18
|
+
'Cache-Control': 'no-store',
|
|
19
|
+
})
|
|
20
|
+
res.end(JSON.stringify(body))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 路由器。 */
|
|
24
|
+
export class Router {
|
|
25
|
+
private suppliers: Supplier[] = []
|
|
26
|
+
private combosFp = ''
|
|
27
|
+
private customCombos: Combo[] = []
|
|
28
|
+
/** round-robin 轮转游标(按组合 id 记忆)。 */
|
|
29
|
+
private rrCursors = new Map<string, number>()
|
|
30
|
+
|
|
31
|
+
constructor(stateFile = '') {
|
|
32
|
+
this.combosFp = stateFile ? join(dirname(stateFile), 'combos.json') : ''
|
|
33
|
+
this.loadCombos()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private loadCombos(): void {
|
|
37
|
+
if (this.combosFp === '') return
|
|
38
|
+
try {
|
|
39
|
+
const f = JSON.parse(readFileSync(this.combosFp, 'utf8')) as { combos?: Combo[] }
|
|
40
|
+
if (Array.isArray(f.combos)) {
|
|
41
|
+
// 兼容旧格式:steps(供应商) → models(模型);无 strategy 默认 fallback。
|
|
42
|
+
this.customCombos = f.combos
|
|
43
|
+
.filter((c) => typeof c.id === 'string' && c.id !== '')
|
|
44
|
+
.map((c) => {
|
|
45
|
+
const old = c as unknown as { steps?: Array<{ supplier: string }> }
|
|
46
|
+
const raw = Array.isArray(c.models)
|
|
47
|
+
? c.models.filter((m) => typeof m === 'string' && m !== '')
|
|
48
|
+
: Array.isArray(old.steps)
|
|
49
|
+
? old.steps
|
|
50
|
+
.map((s) => s.supplier)
|
|
51
|
+
.filter((m) => typeof m === 'string' && m !== '')
|
|
52
|
+
: []
|
|
53
|
+
// 兼容旧版存的全名 alias/id:剥掉前缀存裸 id(前缀随供应商动态变)
|
|
54
|
+
const models = raw.map((m) => {
|
|
55
|
+
const slash = m.lastIndexOf('/')
|
|
56
|
+
return slash >= 0 ? m.slice(slash + 1) : m
|
|
57
|
+
})
|
|
58
|
+
return {
|
|
59
|
+
id: c.id,
|
|
60
|
+
name: typeof c.name === 'string' ? c.name : c.id,
|
|
61
|
+
strategy: c.strategy === 'round-robin' ? 'round-robin' as const : 'fallback' as const,
|
|
62
|
+
models,
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
.filter((c) => c.models.length > 0)
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// 无文件或损坏 → 空
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private saveCombos(): void {
|
|
73
|
+
if (this.combosFp === '') return
|
|
74
|
+
try {
|
|
75
|
+
const dir = dirname(this.combosFp)
|
|
76
|
+
if (dir !== '' && dir !== '.') mkdirSync(dir, { recursive: true })
|
|
77
|
+
const raw = JSON.stringify({ combos: this.customCombos }, null, 2)
|
|
78
|
+
const tmp = this.combosFp + '.tmp'
|
|
79
|
+
writeFileSync(tmp, raw, { mode: 0o600 })
|
|
80
|
+
renameSync(tmp, this.combosFp)
|
|
81
|
+
} catch {
|
|
82
|
+
// 持久化失败不阻断
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
add(supplier: Supplier): void {
|
|
87
|
+
this.suppliers.push(supplier)
|
|
88
|
+
this.suppliers.sort((a, b) => a.priority - b.priority)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 移除供应商(外部插件卸载时注销)。 */
|
|
92
|
+
removeSupplier(id: string): boolean {
|
|
93
|
+
const i = this.suppliers.findIndex((s) => s.id === id)
|
|
94
|
+
if (i < 0) return false
|
|
95
|
+
const [s] = this.suppliers.splice(i, 1)
|
|
96
|
+
s?.dispose()
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 返回全部供应商状态(面板用)。 */
|
|
101
|
+
status(): { suppliers: SupplierStatus[] } {
|
|
102
|
+
return { suppliers: this.suppliers.map((s) => s.status()) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。 */
|
|
106
|
+
aliases(): Array<{ id: string; name: string; alias: string }> {
|
|
107
|
+
return this.suppliers.map((s) => ({ id: s.id, name: s.name, alias: s.getAlias() }))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* OpenAI 兼容模型列表(/v1/models):组合(自动带出,不可改)+ 手动添加的模型。
|
|
112
|
+
* pi-ai 等 DSH provider 通过它发现 Router 的模型目录。
|
|
113
|
+
*/
|
|
114
|
+
async listModels(): Promise<ModelInfo[]> {
|
|
115
|
+
const seen = new Set<string>()
|
|
116
|
+
const out: ModelInfo[] = []
|
|
117
|
+
const combos = await this.combos()
|
|
118
|
+
for (const c of combos) {
|
|
119
|
+
if (!seen.has(c.name)) {
|
|
120
|
+
seen.add(c.name)
|
|
121
|
+
out.push({ id: c.name })
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const s of this.suppliers) {
|
|
125
|
+
try {
|
|
126
|
+
const ids = s.customModelIds?.()
|
|
127
|
+
for (const id of ids ?? []) {
|
|
128
|
+
if (!seen.has(id)) {
|
|
129
|
+
seen.add(id)
|
|
130
|
+
out.push({ id })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// 单供应商失败不影响其它
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 组合列表(面板用):用户自定义组合。 */
|
|
141
|
+
async combos(): Promise<Combo[]> {
|
|
142
|
+
return [...this.customCombos]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 可用模型(按供应商分组,仅启用),面板加模型用。 */
|
|
146
|
+
async supplierModels(): Promise<Array<{ supplier: { id: string; name: string; alias: string }; models: ModelWithEnabled[] }>> {
|
|
147
|
+
const out: Array<{ supplier: { id: string; name: string; alias: string }; models: ModelWithEnabled[] }> = []
|
|
148
|
+
for (const s of this.suppliers) {
|
|
149
|
+
try {
|
|
150
|
+
const models = (await s.modelsWithEnabled()).filter((m) => m.enabled)
|
|
151
|
+
out.push({ supplier: { id: s.id, name: s.name, alias: s.getAlias() }, models })
|
|
152
|
+
} catch {
|
|
153
|
+
// 单供应商失败不影响其它
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private validModels(models: string[]): boolean {
|
|
160
|
+
return Array.isArray(models) && models.length > 0 && models.every((m) =>
|
|
161
|
+
typeof m === 'string' && m !== '')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private validStrategy(strategy: string | undefined): strategy is 'fallback' | 'round-robin' {
|
|
165
|
+
return strategy === 'fallback' || strategy === 'round-robin'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。 */
|
|
169
|
+
private normalizeModelIds(models: string[]): string[] {
|
|
170
|
+
return models.map((m) => {
|
|
171
|
+
const slash = m.lastIndexOf('/')
|
|
172
|
+
return slash >= 0 ? m.slice(slash + 1) : m
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 创建组合(name 唯一,非 default)。 */
|
|
177
|
+
createCombo(name: string, strategy: string, models: string[]): { ok: boolean; error?: string; combo?: Combo } {
|
|
178
|
+
const clean = name.trim()
|
|
179
|
+
if (clean === '' || clean === 'default') return { ok: false, error: '组合名无效' }
|
|
180
|
+
if (!/^[A-Za-z0-9._-]+$/.test(clean)) return { ok: false, error: '组合名只能含字母、数字、-、_ 和 .' }
|
|
181
|
+
if (this.customCombos.some((c) => c.name === clean)) return { ok: false, error: `组合 ${clean} 已存在` }
|
|
182
|
+
if (!this.validModels(models)) return { ok: false, error: '至少需要一个模型' }
|
|
183
|
+
if (!this.validStrategy(strategy)) return { ok: false, error: '策略无效' }
|
|
184
|
+
const combo: Combo = { id: clean, name: clean, strategy, models: this.normalizeModelIds(models) }
|
|
185
|
+
this.customCombos.push(combo)
|
|
186
|
+
this.saveCombos()
|
|
187
|
+
return { ok: true, combo }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 更新组合(按 id)。 */
|
|
191
|
+
updateCombo(id: string, name: string, strategy: string, models: string[]): { ok: boolean; error?: string } {
|
|
192
|
+
const target = this.customCombos.find((c) => c.id === id)
|
|
193
|
+
if (!target) return { ok: false, error: '组合不存在' }
|
|
194
|
+
const clean = name.trim()
|
|
195
|
+
if (clean === '' || clean === 'default') return { ok: false, error: '组合名无效' }
|
|
196
|
+
if (!/^[A-Za-z0-9._-]+$/.test(clean)) return { ok: false, error: '组合名只能含字母、数字、-、_ 和 .' }
|
|
197
|
+
if (this.customCombos.some((c) => c.id !== id && c.name === clean)) return { ok: false, error: `组合 ${clean} 已存在` }
|
|
198
|
+
if (!this.validModels(models)) return { ok: false, error: '至少需要一个模型' }
|
|
199
|
+
if (!this.validStrategy(strategy)) return { ok: false, error: '策略无效' }
|
|
200
|
+
target.name = clean
|
|
201
|
+
target.strategy = strategy
|
|
202
|
+
target.models = this.normalizeModelIds(models)
|
|
203
|
+
this.saveCombos()
|
|
204
|
+
return { ok: true }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 删除组合(按 id,default 不可删)。 */
|
|
208
|
+
removeCombo(id: string): { ok: boolean; error?: string } {
|
|
209
|
+
if (id === 'default') return { ok: false, error: '默认组合不可删除' }
|
|
210
|
+
const idx = this.customCombos.findIndex((c) => c.id === id)
|
|
211
|
+
if (idx === -1) return { ok: false, error: '组合不存在' }
|
|
212
|
+
this.customCombos.splice(idx, 1)
|
|
213
|
+
this.saveCombos()
|
|
214
|
+
return { ok: true }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 按组合名查组合(含 `/` 的模型名不匹配)。 */
|
|
218
|
+
comboByName(name: string): Combo | undefined {
|
|
219
|
+
if (name.includes('/')) return undefined
|
|
220
|
+
return this.customCombos.find((c) => c.name === name)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 处理 /v1/chat/completions。
|
|
225
|
+
* - model 命中组合名 → 在组合模型里按策略选一个,交给供应商;失败按回退顺序尝试剩余模型。
|
|
226
|
+
* - 否则 → 依次尝试供应商,直到某个返回 true(已写响应)或全部返回 false。
|
|
227
|
+
*/
|
|
228
|
+
async chatCompletions(req: ChatRequest, res: ServerResponse): Promise<void> {
|
|
229
|
+
const combo = this.comboByName(req.model)
|
|
230
|
+
if (combo) {
|
|
231
|
+
// 组合:按策略选起点,然后按组合模型顺序回退
|
|
232
|
+
const start = combo.strategy === 'round-robin'
|
|
233
|
+
? (this.rrCursors.get(combo.id) ?? 0) % combo.models.length
|
|
234
|
+
: 0
|
|
235
|
+
if (combo.strategy === 'round-robin') this.rrCursors.set(combo.id, (this.rrCursors.get(combo.id) ?? 0) + 1)
|
|
236
|
+
for (let i = 0; i < combo.models.length; i++) {
|
|
237
|
+
const model = combo.models[(start + i) % combo.models.length]
|
|
238
|
+
if (model === undefined) continue
|
|
239
|
+
const served = await this.chatWithModel(req, res, model)
|
|
240
|
+
if (served) return
|
|
241
|
+
}
|
|
242
|
+
writeJson(res, 503, openAIError('no_healthy_supplier', `combo ${JSON.stringify(req.model)}: all models unavailable`))
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
for (const s of this.suppliers) {
|
|
246
|
+
const served = await s.chatCompletions(req, res)
|
|
247
|
+
if (served) return
|
|
248
|
+
}
|
|
249
|
+
writeJson(res, 503, openAIError('no_healthy_supplier', 'all suppliers unavailable'))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 把 model 改写为组合选中的模型名后尝试供应商。 */
|
|
253
|
+
private async chatWithModel(req: ChatRequest, res: ServerResponse, model: string): Promise<boolean> {
|
|
254
|
+
const clone: ChatRequest = { ...req, model }
|
|
255
|
+
try {
|
|
256
|
+
const obj = JSON.parse(req.rawBody) as Record<string, unknown>
|
|
257
|
+
obj.model = model
|
|
258
|
+
clone.rawBody = JSON.stringify(obj)
|
|
259
|
+
} catch {
|
|
260
|
+
clone.rawBody = req.rawBody
|
|
261
|
+
}
|
|
262
|
+
for (const s of this.suppliers) {
|
|
263
|
+
const served = await s.chatCompletions(clone, res)
|
|
264
|
+
if (served) return true
|
|
265
|
+
}
|
|
266
|
+
return false
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
dispose(): void {
|
|
270
|
+
for (const s of this.suppliers) s.dispose()
|
|
271
|
+
this.suppliers = []
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 供应商(supplier)抽象 —— dsh-router 路由器本体对接的外部通道。
|
|
3
|
+
* 每个供应商提供 OpenAI 兼容的 chat/models,并暴露面板状态。
|
|
4
|
+
* 仿 9router:供应商 = 一种"免费/付费"通道;dsh-router 在其上做路由。
|
|
5
|
+
*/
|
|
6
|
+
import type { ServerResponse } from 'node:http'
|
|
7
|
+
|
|
8
|
+
/** 面板展示的账号状态(脱敏)。 */
|
|
9
|
+
export interface SupplierAccount {
|
|
10
|
+
uid: string
|
|
11
|
+
nickname?: string
|
|
12
|
+
credits: number
|
|
13
|
+
cooling: boolean
|
|
14
|
+
until?: string
|
|
15
|
+
reason?: string
|
|
16
|
+
disabled: boolean
|
|
17
|
+
err_count?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 供应商面板状态。 */
|
|
21
|
+
export interface SupplierStatus {
|
|
22
|
+
id: string
|
|
23
|
+
name: string
|
|
24
|
+
accounts: SupplierAccount[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** OpenAI 模型条目。 */
|
|
28
|
+
export interface ModelInfo {
|
|
29
|
+
id: string
|
|
30
|
+
context_length?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 面板展示的模型(含启用状态)。 */
|
|
34
|
+
export interface ModelWithEnabled extends ModelInfo {
|
|
35
|
+
enabled: boolean
|
|
36
|
+
/** 是否为自定义模型(可删除)。 */
|
|
37
|
+
custom?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 组合策略:fallback = 按顺序尝试;round-robin = 轮转。 */
|
|
41
|
+
export type ComboStrategy = 'fallback' | 'round-robin'
|
|
42
|
+
|
|
43
|
+
/** 组合:命名的一组模型,路由时按策略(回退/轮询)命中其中一个。 */
|
|
44
|
+
export interface Combo {
|
|
45
|
+
id: string
|
|
46
|
+
name: string
|
|
47
|
+
strategy: ComboStrategy
|
|
48
|
+
models: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 一次 /v1/chat/completions 请求。 */
|
|
52
|
+
export interface ChatRequest {
|
|
53
|
+
/** 原始请求体 JSON 字符串。 */
|
|
54
|
+
rawBody: string
|
|
55
|
+
stream: boolean
|
|
56
|
+
model: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 供应商接口。chatCompletions 直接向 res 写响应(流式 SSE 或 JSON),
|
|
61
|
+
* 成功返回 true;返回 false 表示"该供应商无健康账号可服务"(触发路由器
|
|
62
|
+
* 轮换到下一个供应商)。写响应后不得再改 res。
|
|
63
|
+
*/
|
|
64
|
+
export interface Supplier {
|
|
65
|
+
readonly id: string
|
|
66
|
+
readonly name: string
|
|
67
|
+
/** 优先级:数字越小越优先(免费通道优先)。 */
|
|
68
|
+
readonly priority: number
|
|
69
|
+
/** 图标(URL 或 SVG data URI),面板供应商卡片显示。可选。 */
|
|
70
|
+
readonly icon?: string
|
|
71
|
+
status(): SupplierStatus
|
|
72
|
+
listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[]
|
|
73
|
+
/** 面板展示的模型(含启用/自定义标记)。 */
|
|
74
|
+
modelsWithEnabled(): Promise<ModelWithEnabled[]> | ModelWithEnabled[]
|
|
75
|
+
/** 供应商前缀(模型完整名 = alias/id)。 */
|
|
76
|
+
getAlias(): string
|
|
77
|
+
/** 用户手动添加的模型 id(可选,/v1/models 用它聚合自定义模型)。 */
|
|
78
|
+
customModelIds?(): string[]
|
|
79
|
+
chatCompletions(req: ChatRequest, res: ServerResponse): Promise<boolean>
|
|
80
|
+
/** 测试模型(验证上游真实可用,必要差异化能力)。 */
|
|
81
|
+
testModel(id: string): Promise<{ ok: boolean; error?: string }>
|
|
82
|
+
/** 删除链接(清理凭证;内部能力,js 契约不要求实现)。 */
|
|
83
|
+
removeLink?(uid: string): Promise<boolean>
|
|
84
|
+
dispose(): void
|
|
85
|
+
}
|