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.
Files changed (42) hide show
  1. package/README.md +151 -0
  2. package/cordis.patch.yml +9 -0
  3. package/lib/client-registry.js +3006 -0
  4. package/lib/client.js +3006 -0
  5. package/lib/index.js +1756 -0
  6. package/lib/types/credential-store.d.ts +15 -0
  7. package/lib/types/index.d.ts +6 -0
  8. package/lib/types/keys.d.ts +39 -0
  9. package/lib/types/llm/adapter.d.ts +31 -0
  10. package/lib/types/router/index.d.ts +73 -0
  11. package/lib/types/router/types.d.ts +80 -0
  12. package/lib/types/shared.d.ts +138 -0
  13. package/lib/types/supplier-config.d.ts +28 -0
  14. package/lib/types/suppliers/contract.d.ts +98 -0
  15. package/lib/types/suppliers/loader.d.ts +41 -0
  16. package/lib/types/suppliers/registry.d.ts +33 -0
  17. package/package.json +86 -0
  18. package/src/client/CombosTab.tsx +458 -0
  19. package/src/client/EndpointTab.tsx +360 -0
  20. package/src/client/Modal.tsx +46 -0
  21. package/src/client/RouterView.tsx +186 -0
  22. package/src/client/SupplierDetail.tsx +943 -0
  23. package/src/client/index.tsx +18 -0
  24. package/src/client/router.css +2096 -0
  25. package/src/client/sidebar-entry.ts +167 -0
  26. package/src/client/workspace-controller.ts +26 -0
  27. package/src/client/workspace-mount.tsx +117 -0
  28. package/src/credential-store.ts +64 -0
  29. package/src/index.ts +468 -0
  30. package/src/keys.ts +143 -0
  31. package/src/llm/adapter.ts +273 -0
  32. package/src/router/index.ts +273 -0
  33. package/src/router/types.ts +85 -0
  34. package/src/shared.ts +152 -0
  35. package/src/supplier-config.ts +144 -0
  36. package/src/suppliers/contract.ts +73 -0
  37. package/src/suppliers/loader.ts +191 -0
  38. package/src/suppliers/nvidia/plugin.ts +268 -0
  39. package/src/suppliers/opencode/plugin.ts +200 -0
  40. package/src/suppliers/openrouter/plugin.ts +282 -0
  41. package/src/suppliers/registry.ts +365 -0
  42. 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
+ }