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/LICENSE +21 -0
- package/README.md +275 -2
- package/cordis.patch.yml +10 -0
- package/lib/index.js +1766 -0
- package/package.json +63 -4
- package/src/cn.ts +41 -0
- package/src/contract.ts +169 -0
- package/src/core.test.ts +552 -0
- package/src/core.ts +425 -0
- package/src/cosy.ts +270 -0
- package/src/global.ts +38 -0
- package/src/icon.ts +8 -0
- package/src/index.ts +64 -0
- package/src/messages.ts +337 -0
- package/src/sse.ts +176 -0
- package/src/types.ts +42 -0
- package/src/upstream.ts +992 -0
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
|
+
}
|
package/src/messages.ts
ADDED
|
@@ -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
|
+
}
|