dsh-router-qoder 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/global.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * qoder-global(Qoder 国际版)供应商 profile —— 上游 https://api1.qoder.sh。
3
+ *
4
+ * 与国内版**同协议同契约**(cosy 签名、jobToken、同一套模型/聊天端点),差异只有
5
+ * 主机与一件实测事实:**sash 面(积分 / 签到)需要设备风控头**,那三件套来自本机
6
+ * Qoder 客户端的 UMID 组件。本插件不伪造它 —— 拿不到时积分与签到会如实报
7
+ * 「该区域需要本机 Qoder 客户端的设备组件」,而不是显示「今天没有活动」。
8
+ *
9
+ * chat 与模型列表不受这条限制(它们走 cosy 面,不吃设备风控头)。
10
+ *
11
+ * ⚠️ `id = 'qoder-global'` **同时是凭证与配置的存储键**(`credentials.sqlite` 的
12
+ * `supplier` 列、`supplier-config.json` 的键),改它 = 账号全部消失。
13
+ */
14
+ import type { ModelInfo } from './types.ts'
15
+ import type { QoderProfile } from './core.ts'
16
+ import { QODER_ICON } from './icon.ts'
17
+
18
+ /** 兜底列表故意留空(理由见 cn.ts)。 */
19
+ const FALLBACK_MODELS: ModelInfo[] = []
20
+
21
+ export const profile: QoderProfile = {
22
+ id: 'qoder-global',
23
+ name: 'Qoder Global',
24
+ region: 'global',
25
+ icon: QODER_ICON,
26
+ priority: -2,
27
+ uidPrefix: 'qdg',
28
+ defaultNickname: 'Qoder Global',
29
+ errPrefix: 'qoder-global',
30
+ // 国际版控制台域名是 qoder.com(`qoder.sh` 只有网关/开放 API 主机名,没有网页);
31
+ // 写成 qoder.sh 会让用户去访问一个解析不出来域名。
32
+ apiKeyHint: '粘贴 Qoder 国际版的个人访问令牌(PAT,形如 pt-…)。在哪拿:登录 qoder.com '
33
+ + '控制台 → Settings → Personal Access Tokens → 创建(复制只显示一次的 pt-…)。'
34
+ + '令牌按区域签发,国际版的 pt-… 在国内版卡片上无效(反之亦然)。'
35
+ + '国际版的积分与每日签到还需要本机安装 Qoder 客户端(设备风控组件),'
36
+ + '只有 PAT 时聊天与模型可用,积分/签到会如实报错。',
37
+ fallbackModels: FALLBACK_MODELS,
38
+ }
package/src/icon.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * qoder 族供应商共用的面板图标 —— 内联 SVG data URI。
3
+ *
4
+ * 为什么内联:图标不该依赖另一个服务活着,更不该依赖特定端口。国内版与国际版
5
+ * 同族,共用同一张图(只有 name 不同)。
6
+ */
7
+ export const QODER_ICON =
8
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCIgd2lkdGg9IjEyOCIgaGVpZ2h0PSIxMjgiPjxyZWN0IHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgcng9IjE0IiBmaWxsPSIjMmI2YmZmIi8+PGNpcmNsZSBjeD0iMzIiIGN5PSIzMSIgcj0iMTUiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSI2Ii8+PHJlY3QgeD0iMzgiIHk9IjM3IiB3aWR0aD0iMTIiIGhlaWdodD0iNiIgcng9IjMiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDM4IDM3KSIgZmlsbD0iI2ZmZmZmZiIvPjwvc3ZnPg=='
package/src/index.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * dsh-router-qoder —— DSH 插件 host half,一次注册**两个** Qoder 供应商。
3
+ *
4
+ * 通过 cordis service `router.suppliers`(聚合表 `{ [supplierId]: (env) => SupplierModule }`)
5
+ * 把 qoder 族供应商工厂暴露给 dsh-router:
6
+ * - `qoder` Qoder 国内版(gateway.qoder.com.cn)
7
+ * - `qoder-global` Qoder 国际版(api1.qoder.sh)
8
+ * 两者共用 core.ts 的同一份实现,差异只在 cn.ts / global.ts 的 profile。
9
+ *
10
+ * cordis 的 `ctx.provide` 每个 service name 只允许一个插件注册,多个供应商插件不能
11
+ * 各自 provide `router.suppliers`(会抛 "service has been registered")。本插件用
12
+ * 共享表模式:`inject` 等待该 service(由核心 dsh-router 持有空表),把工厂追加进
13
+ * 共享表后广播一次 `internal/service`,让 dsh-router 重新扫描。该模式与插件加载
14
+ * 顺序无关。
15
+ */
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+ import { createSupplier } from './core.ts'
18
+ import { profile as cnProfile } from './cn.ts'
19
+ import { profile as globalProfile } from './global.ts'
20
+ import type { SupplierEnv, SupplierModule } from './contract.ts'
21
+
22
+ export const name = 'dsh-router-qoder'
23
+
24
+ /** 暴露给 dsh-router 的供应商工厂表。 */
25
+ export interface RouterSuppliersService {
26
+ [supplierId: string]: (env: SupplierEnv) => SupplierModule
27
+ }
28
+
29
+ /** 本插件注册的全部供应商(profile → 工厂)。 */
30
+ const SUPPLIERS: ReadonlyArray<readonly [string, (env: SupplierEnv) => SupplierModule]> = [
31
+ [cnProfile.id, createSupplier(cnProfile)],
32
+ [globalProfile.id, createSupplier(globalProfile)],
33
+ ]
34
+
35
+ /** 读取当前 router.suppliers 聚合表(同一 live 对象,可追加)。 */
36
+ function currentSuppliers(ctx: Context): RouterSuppliersService | undefined {
37
+ const c = ctx as unknown as {
38
+ get?: (key: string) => unknown
39
+ router?: { suppliers?: RouterSuppliersService }
40
+ }
41
+ return (c.get?.('router.suppliers') ?? c.router?.suppliers) as RouterSuppliersService | undefined
42
+ }
43
+
44
+ export function apply(ctx: Context): void {
45
+ // 等 router.suppliers 可用后,把两个供应商追加进共享聚合表并通知 dsh-router 重扫。
46
+ ctx.inject(['router.suppliers'], (sctx) => {
47
+ const suppliers = currentSuppliers(sctx)
48
+ if (!suppliers) return undefined
49
+ const added: string[] = []
50
+ for (const [id, factory] of SUPPLIERS) {
51
+ if (suppliers[id]) continue
52
+ suppliers[id] = factory
53
+ added.push(id)
54
+ }
55
+ if (added.length > 0) {
56
+ // 复用 dsh-router 的 internal/service 监听(读 live 表、幂等),增量加载。
57
+ ctx.emit('internal/service', 'router.suppliers', suppliers)
58
+ ctx.logger?.info?.(`[dsh-router-qoder] registered router.suppliers: ${added.join(', ')}`)
59
+ }
60
+ return () => {
61
+ for (const [id] of SUPPLIERS) delete suppliers[id]
62
+ }
63
+ })
64
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * OpenAI 请求 → Qoder `agent_chat_generation` 请求体。
3
+ *
4
+ * Qoder 网关说「半个 OpenAI」:**出站**(网关 → 我们)的 SSE delta 就是 OpenAI 形状
5
+ * (`choices[].delta.{role,content,reasoning_content,tool_calls}`),但**入站**的
6
+ * 请求体是它自己的一套(`chat_context` / `model_config` / `business` / `session_id` …),
7
+ * 消息也不是 `{role, content}` 而是带 `contents` parts 的结构。
8
+ *
9
+ * 模板取自 dingminhua/dsh-connect-qoder(MIT)的 `src/messages.ts`,其来源是
10
+ * fengyinxia/qoder2api 的 `baseprompt.json`(MINIMAL 变体,实测可用:不带系统
11
+ * 提示词,messages 完全由调用方替换)。
12
+ *
13
+ * @module dsh-router-qoder/messages
14
+ */
15
+
16
+ /** 请求体模板(字段名与上游逐字一致,别重命名)。 */
17
+ export interface QoderChatTemplate {
18
+ request_id: string
19
+ request_set_id: string
20
+ chat_record_id: string
21
+ stream: boolean
22
+ chat_task: string
23
+ chat_context: {
24
+ extra: {
25
+ modelConfig: { is_reasoning: boolean; key: string }
26
+ originalContent: { type: string; text: string }
27
+ }
28
+ text: { type: string; text: string }
29
+ }
30
+ session_id: string
31
+ source: number
32
+ version: string
33
+ aliyun_user_type: string
34
+ session_type: string
35
+ agent_id: string
36
+ task_id: string
37
+ model_config: { key: string; is_vl: boolean; is_reasoning: boolean; source: string }
38
+ /**
39
+ * 思考强度档位(`low` / `high` / `max` …)。
40
+ *
41
+ * 实测自官方客户端:档位在这个**顶层**字段上,同时还会写一份到
42
+ * `parameters.reasoning_effort`。官方两个都写,所以我们也两个都写 ——
43
+ * 只写一个可能被网关忽略(各区域行为不完全一致)。
44
+ */
45
+ reasoningEffort?: string
46
+ parameters?: Record<string, unknown>
47
+ messages: unknown[]
48
+ business: { id: string; name: string; begin_at: number }
49
+ [key: string]: unknown
50
+ }
51
+
52
+ /**
53
+ * 一份全新的请求体。**每个请求都要新建**(ID 字段必须各自唯一,复用会被上游
54
+ * 当成重复请求)。
55
+ */
56
+ export function createChatBodyTemplate(): QoderChatTemplate {
57
+ return {
58
+ request_id: '',
59
+ request_set_id: '',
60
+ chat_record_id: '',
61
+ stream: true,
62
+ chat_task: 'FREE_INPUT',
63
+ chat_context: {
64
+ extra: {
65
+ modelConfig: { is_reasoning: false, key: 'lite' },
66
+ originalContent: { type: 'text', text: '' },
67
+ },
68
+ text: { type: 'text', text: '' },
69
+ },
70
+ session_id: '',
71
+ source: 1,
72
+ version: '3',
73
+ aliyun_user_type: 'personal_standard',
74
+ session_type: 'qodercli',
75
+ agent_id: 'agent_common',
76
+ task_id: 'common',
77
+ model_config: { key: 'lite', is_vl: false, is_reasoning: false, source: 'system' },
78
+ messages: [],
79
+ business: { id: '', name: 'hi', begin_at: 0 },
80
+ }
81
+ }
82
+
83
+ /** 模型 key / 图像能力 / 用户类型三处一起改(漏一处就会被网关当成别的模型)。 */
84
+ export function applyModelKey(body: QoderChatTemplate, key: string, userType: string, isVl: boolean): void {
85
+ body.model_config.key = key
86
+ body.model_config.is_vl = isVl
87
+ body.chat_context.extra.modelConfig.key = key
88
+ body.aliyun_user_type = userType
89
+ }
90
+
91
+ /** 一条转换后的入站消息。 */
92
+ interface QoderMessage {
93
+ role: string
94
+ content?: string
95
+ contents?: Array<{ type?: string; text?: string; image_url?: { url?: string } }>
96
+ tool_calls?: unknown[]
97
+ name?: string
98
+ tool_call_id?: string
99
+ reasoning_content_signature?: string
100
+ response_meta?: Record<string, unknown>
101
+ }
102
+
103
+ /**
104
+ * 每条消息都要带的「假回执」。
105
+ *
106
+ * 上游把消息当成**历史回执**读:缺 `response_meta` / `reasoning_content_signature`
107
+ * 的 assistant 消息会被当成格式异常的记录。这里给一份全零的合法骨架,与官方
108
+ * 客户端的形状一致。
109
+ */
110
+ function blankResponseMeta(): Record<string, unknown> {
111
+ return {
112
+ id: '',
113
+ usage: {
114
+ prompt_tokens: 0,
115
+ completion_tokens: 0,
116
+ total_tokens: 0,
117
+ completion_tokens_details: { reasoning_tokens: 0 },
118
+ prompt_tokens_details: { cached_tokens: 0 },
119
+ },
120
+ }
121
+ }
122
+
123
+ function structuredMessage(role: string, text: string): QoderMessage {
124
+ return { role, content: text, response_meta: blankResponseMeta(), reasoning_content_signature: '' }
125
+ }
126
+
127
+ function userMessage(text: string, images: string[] = []): QoderMessage {
128
+ const parts: Array<{ type: string; text?: string; image_url?: { url: string } }> = []
129
+ for (const url of images) parts.push({ type: 'image_url', image_url: { url } })
130
+ if (text.trim()) parts.push({ type: 'text', text })
131
+ if (parts.length === 0) parts.push({ type: 'text', text: '' })
132
+ return {
133
+ role: 'user',
134
+ content: '',
135
+ contents: parts,
136
+ response_meta: blankResponseMeta(),
137
+ reasoning_content_signature: '',
138
+ }
139
+ }
140
+
141
+ function toolMessage(message: Record<string, unknown>, text: string): QoderMessage {
142
+ const out = structuredMessage('tool', text)
143
+ if (typeof message.name === 'string') out.name = message.name
144
+ if (typeof message.tool_call_id === 'string') out.tool_call_id = message.tool_call_id
145
+ return out
146
+ }
147
+
148
+ /** 取一条 OpenAI 消息的纯文本(content 字符串 / parts 数组 / value 字段)。 */
149
+ export function normalizeMessageText(message: Record<string, unknown>): string {
150
+ const content = message.content
151
+ if (typeof content === 'string') return content
152
+ if (Array.isArray(content)) {
153
+ const parts: string[] = []
154
+ for (const part of content as unknown[]) {
155
+ if (typeof part === 'string') {
156
+ parts.push(part)
157
+ continue
158
+ }
159
+ if (part !== null && typeof part === 'object') {
160
+ const p = part as Record<string, unknown>
161
+ if (typeof p.text === 'string') parts.push(p.text)
162
+ else if (typeof p.value === 'string') parts.push(p.value)
163
+ }
164
+ }
165
+ return parts.join('')
166
+ }
167
+ return ''
168
+ }
169
+
170
+ /** user 消息里的图片 URL(只认 `image_url.url` part)。 */
171
+ export function extractMessageImages(message: Record<string, unknown>): string[] {
172
+ if (message.role !== 'user') return []
173
+ const content = message.content
174
+ if (!Array.isArray(content)) return []
175
+ const urls: string[] = []
176
+ for (const part of content as unknown[]) {
177
+ if (part === null || typeof part !== 'object') continue
178
+ const iu = (part as Record<string, unknown>).image_url
179
+ if (iu !== null && typeof iu === 'object') {
180
+ const url = (iu as Record<string, unknown>).url
181
+ if (typeof url === 'string') urls.push(url)
182
+ }
183
+ }
184
+ return urls
185
+ }
186
+
187
+ const TOOL_CALLS_TEXT_RE = /Tool calls:\s*(\[[\s\S]*?\])/
188
+
189
+ /** 从文本里认回 `Tool calls: [...]`(有的宿主把 tool_calls 渲染成了文本)。 */
190
+ export function parseToolCallsText(text: string | null | undefined): unknown[] | undefined {
191
+ if (!text) return undefined
192
+ const m = TOOL_CALLS_TEXT_RE.exec(text)
193
+ if (!m?.[1]) return undefined
194
+ try {
195
+ const parsed = JSON.parse(m[1]) as unknown
196
+ if (Array.isArray(parsed) && parsed.every((item) => item !== null && typeof item === 'object')) {
197
+ return normalizeToolCalls(parsed)
198
+ }
199
+ } catch {
200
+ return undefined
201
+ }
202
+ return undefined
203
+ }
204
+
205
+ /** `{id, name, input}` 形状 → OpenAI 的 `{id, type, function:{name, arguments}}`。 */
206
+ function normalizeToolCalls(raw: unknown[]): unknown[] {
207
+ return raw.map((tc) => {
208
+ if (tc !== null && typeof tc === 'object' && !('function' in tc)) {
209
+ const r = tc as Record<string, unknown>
210
+ if (r.id && r.name && r.input) {
211
+ return { id: r.id, type: 'function', function: { name: r.name, arguments: JSON.stringify(r.input) } }
212
+ }
213
+ }
214
+ return tc
215
+ })
216
+ }
217
+
218
+ /**
219
+ * 这条 assistant 消息后面有没有**真的** tool 结果。
220
+ *
221
+ * 只有「有回执」的 tool_calls 才能结构化下发:网关要求 assistant 的 tool_calls
222
+ * 与 tool 消息配对出现,一条没有回执的 tool_calls 会被判成断链(与 OpenAI 的
223
+ * 422 同类)。没有回执时改渲染成文本,模型仍然读得到。
224
+ */
225
+ function hasResolvedToolResponse(messages: Array<Record<string, unknown>>, assistantIndex: number): boolean {
226
+ const message = messages[assistantIndex]
227
+ if (message === undefined || message.role !== 'assistant') return false
228
+ const tc = message.tool_calls
229
+ if (!(Array.isArray(tc) && tc.length > 0) && !parseToolCallsText(normalizeMessageText(message))) return false
230
+ for (let i = assistantIndex + 1; i < messages.length; i += 1) {
231
+ const next = messages[i]
232
+ if (next?.role === 'tool') return true
233
+ if (next !== undefined && (next.role === 'assistant' || next.role === 'user' || next.role === 'system')) return false
234
+ }
235
+ return false
236
+ }
237
+
238
+ function renderToolCalls(toolCalls: unknown[]): string {
239
+ return `Tool calls:\n${JSON.stringify(toolCalls)}`
240
+ }
241
+
242
+ function renderToolResult(message: Record<string, unknown>, text: string): string {
243
+ const name = typeof message.name === 'string' ? message.name : ''
244
+ const toolCallId = typeof message.tool_call_id === 'string' ? message.tool_call_id : ''
245
+ let out = 'Tool result'
246
+ if (name) out += ` (${name})`
247
+ if (toolCallId) out += ` [${toolCallId}]`
248
+ if (text.trim()) out += `:\n${text}`
249
+ return out
250
+ }
251
+
252
+ /**
253
+ * 一条 OpenAI 消息 → Qoder 消息。`null` = 这条不携带任何内容(跳过)。
254
+ *
255
+ * `toolsEnabled` 表示这个请求带了 tools;`allowStructuredToolCalls` 表示这条
256
+ * assistant 的 tool_calls 有配对回执、可以结构化下发(见
257
+ * {@link hasResolvedToolResponse})。
258
+ */
259
+ export function convertIncomingMessage(
260
+ message: Record<string, unknown>,
261
+ toolsEnabled: boolean,
262
+ allowStructuredToolCalls: boolean,
263
+ ): QoderMessage | null {
264
+ const role = typeof message.role === 'string' ? message.role : 'user'
265
+ const text = normalizeMessageText(message)
266
+
267
+ let structuredToolCalls: unknown[] | undefined
268
+ if (toolsEnabled && allowStructuredToolCalls) {
269
+ const tc = Array.isArray(message.tool_calls) && message.tool_calls.length > 0
270
+ ? message.tool_calls
271
+ : parseToolCallsText(text)
272
+ if (tc) structuredToolCalls = tc
273
+ }
274
+
275
+ if (role === 'assistant' && structuredToolCalls) {
276
+ const out = structuredMessage('assistant', text)
277
+ out.tool_calls = structuredToolCalls
278
+ return out
279
+ }
280
+
281
+ if (role === 'tool') {
282
+ if (toolsEnabled) return toolMessage(message, text)
283
+ // 没有 tools 通道时,把结果渲染成 user 话术:不渲染就是**丢**了工具结果
284
+ return structuredMessage('user', renderToolResult(message, text))
285
+ }
286
+
287
+ let finalText = text
288
+ if (role === 'assistant' && !toolsEnabled && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
289
+ finalText = [text, renderToolCalls(message.tool_calls)].filter(Boolean).join('\n\n')
290
+ }
291
+
292
+ const images = role === 'user' ? extractMessageImages(message) : []
293
+ if (!finalText.trim() && images.length === 0) return null
294
+
295
+ if (role === 'user') {
296
+ // 图片走真实 parts 时,去掉文本里的 `[image] <url>` 兜底占位(否则同一张图两遍)
297
+ const cleaned = images.length > 0
298
+ ? finalText.split('\n\n').filter((s) => !s.trim().startsWith('[image] ')).join('\n\n')
299
+ : finalText
300
+ return userMessage(cleaned, images)
301
+ }
302
+
303
+ return structuredMessage(role, finalText)
304
+ }
305
+
306
+ /** OpenAI messages → Qoder messages。 */
307
+ export function buildQoderMessages(
308
+ incoming: Array<Record<string, unknown>>,
309
+ prompt: string,
310
+ toolsEnabled: boolean,
311
+ ): QoderMessage[] {
312
+ const rebuilt: QoderMessage[] = []
313
+ for (let i = 0; i < incoming.length; i += 1) {
314
+ const allowStructured = toolsEnabled && hasResolvedToolResponse(incoming, i)
315
+ const converted = convertIncomingMessage(incoming[i]!, toolsEnabled, allowStructured)
316
+ if (converted !== null) rebuilt.push(converted)
317
+ }
318
+ if (rebuilt.length === 0 && prompt.trim()) rebuilt.push(userMessage(prompt))
319
+ return rebuilt
320
+ }
321
+
322
+ /** 最后一条 user 消息的纯文本(`business.name` 用它,官方客户端也这么填)。 */
323
+ export function extractLatestUserPrompt(messages: Array<Record<string, unknown>>): string {
324
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
325
+ const m = messages[i]
326
+ if (m?.role === 'user') {
327
+ const text = normalizeMessageText(m)
328
+ if (text.trim()) return text
329
+ }
330
+ }
331
+ return ''
332
+ }
333
+
334
+ /** 深拷贝(模板必须逐请求独立,否则并发请求会互相改对方的 ID)。 */
335
+ export function deepClone<T>(value: T): T {
336
+ return structuredClone(value)
337
+ }
package/src/sse.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Qoder SSE → OpenAI SSE。
3
+ *
4
+ * 上游的帧是**一个信封套一层 OpenAI 形状**:
5
+ *
6
+ * ```text
7
+ * data: {"headers":{…},"body":"{\"choices\":[{\"delta\":{\"content\":\"你\"}}]}","statusCodeValue":200}
8
+ * ```
9
+ *
10
+ * 而 dsh-router 只接受 OpenAI SSE(它会把这个流 pipe 给客户端,也会在客户端要
11
+ * 非流式时自己聚合)。所以这一层把信封拆开、把 delta 重新包成 OpenAI chunk,
12
+ * 并在收尾补上 usage 与 `[DONE]`。
13
+ *
14
+ * 为什么要自己拼满这些字段:宿主聚合非流式响应时读的是
15
+ * `choices[0].delta.tool_calls[].function.name` 与 `usage`,缺 `id`/`model`/
16
+ * `created` 会让它拼出一个没有名字的工具调用(下游报 `unknown tool ""`)。
17
+ */
18
+ import { isQoderError, parseStreamDelta, type QoderDelta, type QoderError } from './upstream.ts'
19
+
20
+ export interface SseOptions {
21
+ /** 响应里的 `model` 字段(客户端会拿它做展示,用请求的模型 id)。 */
22
+ model: string
23
+ /**
24
+ * 流已经写出若干字节之后上游才报错(Qoder 会把 provider 故障包在 200 的
25
+ * 信封里)时回调。HTTP 状态已经发不出去了,但「这个号今天额度用尽」对**后续**
26
+ * 请求仍然有用 —— 核心的 `onLateFailure` 正是为这种时刻准备的。
27
+ */
28
+ onLateError?: (error: QoderError) => void
29
+ }
30
+
31
+ /** 把一条 delta 转成 OpenAI chunk。 */
32
+ function chunkOf(
33
+ id: string,
34
+ created: number,
35
+ model: string,
36
+ delta: Record<string, unknown>,
37
+ finishReason: string | null = null,
38
+ ): string {
39
+ return `data: ${JSON.stringify({
40
+ id,
41
+ object: 'chat.completion.chunk',
42
+ created,
43
+ model,
44
+ choices: [{ index: 0, delta, finish_reason: finishReason }],
45
+ })}\n\n`
46
+ }
47
+
48
+ /** delta → OpenAI `delta` 对象(只带上游真的给了的字段)。 */
49
+ function deltaPayload(delta: QoderDelta, first: boolean): Record<string, unknown> | undefined {
50
+ const out: Record<string, unknown> = {}
51
+ if (first && delta.role !== '') out.role = delta.role
52
+ if (delta.content !== '') out.content = delta.content
53
+ if (delta.reasoningContent !== '') out.reasoning_content = delta.reasoningContent
54
+ if (delta.toolCalls !== undefined) out.tool_calls = delta.toolCalls
55
+ return Object.keys(out).length === 0 ? undefined : out
56
+ }
57
+
58
+ /** usage 帧:OpenAI 的最后一帧 `choices: []` + `usage`。 */
59
+ function usageFrame(
60
+ id: string,
61
+ created: number,
62
+ model: string,
63
+ delta: QoderDelta,
64
+ ): string {
65
+ const usage: Record<string, unknown> = {
66
+ prompt_tokens: delta.inputTokens,
67
+ completion_tokens: delta.outputTokens,
68
+ total_tokens: delta.inputTokens + delta.outputTokens,
69
+ }
70
+ // cached_tokens 是 prompt_tokens 的子集,不是额外增量;上游没报就不写,
71
+ // 别用 0 顶替「不知道」(那会让宿主的缓存命中率变成不可信的 0%)。
72
+ if (delta.cachedTokens !== undefined) usage.prompt_tokens_details = { cached_tokens: delta.cachedTokens }
73
+ return `data: ${JSON.stringify({
74
+ id,
75
+ object: 'chat.completion.chunk',
76
+ created,
77
+ model,
78
+ choices: [],
79
+ usage,
80
+ })}\n\n`
81
+ }
82
+
83
+ /**
84
+ * 包装上游响应体。返回的流**始终**以 `data: [DONE]\n\n` 收尾(正常与被拒都一样),
85
+ * 这样客户端不会挂在那儿等一个永远不来的结束帧。
86
+ */
87
+ export function toOpenAiSse(body: ReadableStream<Uint8Array>, options: SseOptions): ReadableStream<Uint8Array> {
88
+ const encoder = new TextEncoder()
89
+ const decoder = new TextDecoder()
90
+ const id = `chatcmpl-${globalThis.crypto.randomUUID()}`
91
+ const created = Math.floor(Date.now() / 1000)
92
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
93
+
94
+ return new ReadableStream<Uint8Array>({
95
+ start: async (ctrl) => {
96
+ const emit = (text: string): void => {
97
+ try {
98
+ ctrl.enqueue(encoder.encode(text))
99
+ } catch {
100
+ // 客户端已断开
101
+ }
102
+ }
103
+ // 第一帧先声明 role:有的客户端要求 assistant 角色先到,否则不认后续 delta
104
+ emit(chunkOf(id, created, options.model, { role: 'assistant' }))
105
+ reader = body.getReader()
106
+ let buffer = ''
107
+ let sawUsage = false
108
+ let finished = false
109
+ try {
110
+ for (;;) {
111
+ const { done, value } = await reader.read()
112
+ if (done) break
113
+ buffer += decoder.decode(value, { stream: true })
114
+ const lines = buffer.split('\n')
115
+ buffer = lines.pop() ?? ''
116
+ for (const line of lines) {
117
+ const trimmed = line.trim()
118
+ if (trimmed === '' || !trimmed.startsWith('data:')) continue
119
+ const payload = trimmed.slice('data:'.length).trim()
120
+ if (payload === '' || payload === '[DONE]') continue
121
+ let delta: QoderDelta | undefined
122
+ try {
123
+ delta = parseStreamDelta(payload)
124
+ } catch (error: unknown) {
125
+ // 信封里的业务错误:把 OpenAI 形状的 error 帧发出去再收尾
126
+ const qoderError = isQoderError(error) ? error : undefined
127
+ emit(`data: ${JSON.stringify({
128
+ error: {
129
+ message: error instanceof Error ? error.message : String(error),
130
+ type: qoderError?.kind ?? 'upstream_error',
131
+ code: qoderError?.code ?? null,
132
+ },
133
+ })}\n\n`)
134
+ if (qoderError !== undefined) options.onLateError?.(qoderError)
135
+ finished = true
136
+ break
137
+ }
138
+ if (delta === undefined) continue
139
+ if (delta.inputTokens > 0 || delta.outputTokens > 0) sawUsage = true
140
+ const payloadDelta = deltaPayload(delta, true)
141
+ if (payloadDelta !== undefined) emit(chunkOf(id, created, options.model, payloadDelta))
142
+ if (delta.inputTokens > 0 || delta.outputTokens > 0) {
143
+ emit(usageFrame(id, created, options.model, delta))
144
+ }
145
+ }
146
+ if (finished) break
147
+ }
148
+ if (!finished) {
149
+ // 正常收尾:补一帧 finish_reason(上游不保证给),再补 [DONE]
150
+ emit(chunkOf(id, created, options.model, {}, 'stop'))
151
+ if (!sawUsage) void sawUsage
152
+ }
153
+ } catch (error: unknown) {
154
+ const qoderError = isQoderError(error) ? error : undefined
155
+ emit(`data: ${JSON.stringify({
156
+ error: { message: error instanceof Error ? error.message : String(error), type: qoderError?.kind ?? 'stream_error' },
157
+ })}\n\n`)
158
+ if (qoderError !== undefined) options.onLateError?.(qoderError)
159
+ } finally {
160
+ try {
161
+ reader.releaseLock()
162
+ } catch {
163
+ // 有挂起 read 时会抛,忽略
164
+ }
165
+ emit('data: [DONE]\n\n')
166
+ try {
167
+ ctrl.close()
168
+ } catch {
169
+ // 已取消
170
+ }
171
+ }
172
+ },
173
+ /** 取消必须往下传:不实现它,客户端断开后上游 TCP 连接会一直挂着。 */
174
+ cancel: (reason) => reader?.cancel(reason),
175
+ })
176
+ }
package/src/types.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 供应商通用类型 —— 自含(不依赖 dsh-router 包)。
3
+ * 与 dsh-router 的 router/types.ts 保持同步。
4
+ */
5
+ import type { ServerResponse } from 'node:http'
6
+
7
+ /** 面板展示的账号状态(脱敏)。 */
8
+ export interface SupplierAccount {
9
+ uid: string
10
+ nickname?: string
11
+ credits: number
12
+ cooling: boolean
13
+ until?: string
14
+ reason?: string
15
+ err_count?: number
16
+ }
17
+
18
+ /** 供应商面板状态。 */
19
+ export interface SupplierStatus {
20
+ id: string
21
+ name: string
22
+ accounts: SupplierAccount[]
23
+ }
24
+
25
+ /** OpenAI 模型条目。 */
26
+ export interface ModelInfo {
27
+ /** 上游模型 key(`qmodel_38max`)——组合、路由、请求都用它。 */
28
+ id: string
29
+ /**
30
+ * 可读名(上游目录的 `display_name`,如 `Qwen3.8-Max`),**只用于显示**。
31
+ * 核心把它画在主标签上;请求仍然只认 `id`。
32
+ */
33
+ name?: string
34
+ context_length?: number
35
+ }
36
+
37
+ /** 一次 /v1/chat/completions 请求。 */
38
+ export interface ChatRequest {
39
+ rawBody: string
40
+ stream: boolean
41
+ model: string
42
+ }