@wwkit/llmproxy 1.0.3 → 1.0.5

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.
@@ -0,0 +1,258 @@
1
+ // providers/qianwen-web/auth.js — 千问 Web 浏览器登录(CDP 捕获)
2
+ //
3
+ // 登录流程(手动,需手机验证码):
4
+ // 1. 用 Chrome 带 --remote-debugging-port 打开 www.qianwen.com
5
+ // 2. 用户在浏览器里完成登录
6
+ // 3. 通过 CDP Network 拦截,捕获 Cookie + 签名 token
7
+ // 4. 凭证存入 ~/.config/llmproxy/.creds.json
8
+
9
+ import { spawn } from "node:child_process";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { getXdgConfigDir } from "@wwkit/shared";
13
+ import { status as cftStatus } from "@wwkit/cft";
14
+ import { AuthProvider } from "../../core/auth.js";
15
+ import { log, sleep } from "../../util.js";
16
+
17
+ const CREDS_FILE = path.join(getXdgConfigDir("llmproxy"), ".creds.json");
18
+ const LOGIN_URL = "https://www.qianwen.com";
19
+ const CAPTURE_REGEX = /qianwen\.com\/api\/v2\//;
20
+ const CAPTURE_TIMEOUT_MS = 5 * 60 * 1000;
21
+
22
+ function getChromePath() {
23
+ try {
24
+ const info = cftStatus();
25
+ if (info?.chrome_path && fs.existsSync(info.chrome_path)) return info.chrome_path;
26
+ } catch {}
27
+ return null;
28
+ }
29
+
30
+ export default class QianwenAuth extends AuthProvider {
31
+ constructor(opts) {
32
+ super(opts);
33
+ this._profileDir = path.join(
34
+ getXdgConfigDir("llmproxy"),
35
+ "chrome-profiles",
36
+ this.id.replace(/\//g, "-"),
37
+ );
38
+ }
39
+
40
+ async getCredentials(opts = {}) {
41
+ const autoLogin = opts.autoLogin !== false;
42
+ if (this._cred && this._cred.expiresAt > Date.now()) {
43
+ return this._cred;
44
+ }
45
+ if (!this._cred) await this._loadFromFile();
46
+ if (this._cred) return this._cred;
47
+ if (!autoLogin) {
48
+ throw new Error(`auth:${this.id} 无凭证,请先执行 llmproxy login --provider ${this.id}`);
49
+ }
50
+ await this.login();
51
+ return this._cred;
52
+ }
53
+
54
+ async login() {
55
+ log(`[auth:${this.id}] 启动浏览器手动登录千问...`);
56
+ const chromePath = getChromePath();
57
+ if (!chromePath) {
58
+ throw new Error("未找到 Chrome(可先执行 pnpm --filter cft install)");
59
+ }
60
+ const cdp = await this._launchChrome(chromePath);
61
+ try {
62
+ const cred = await this._captureCredentials(cdp);
63
+ this._cred = { ...cred, expiresAt: Date.now() + 7 * 24 * 3600 * 1000 };
64
+ await this._saveToFile();
65
+ log(`[auth:${this.id}] 登录成功,凭证已保存`);
66
+ } finally {
67
+ try { cdp.close(); } catch {}
68
+ }
69
+ }
70
+
71
+ async _launchChrome(chromePath) {
72
+ fs.mkdirSync(this._profileDir, { recursive: true });
73
+ const port = 9400 + Math.floor(Math.random() * 200);
74
+ const child = spawn(
75
+ chromePath,
76
+ [
77
+ `--remote-debugging-port=${port}`,
78
+ `--user-data-dir=${this._profileDir}`,
79
+ "--no-first-run",
80
+ "--no-default-browser-check",
81
+ "--disable-background-networking",
82
+ "about:blank",
83
+ ],
84
+ { stdio: "ignore", detached: true },
85
+ );
86
+ child.unref();
87
+
88
+ const versionUrl = `http://127.0.0.1:${port}/json/version`;
89
+ for (let i = 0; i < 60; i++) {
90
+ try {
91
+ const res = await fetch(versionUrl);
92
+ if (res.ok) break;
93
+ } catch {}
94
+ await sleep(200);
95
+ if (i === 59) throw new Error("Chrome CDP 启动超时");
96
+ }
97
+ this._chromePort = port;
98
+ log(`[auth:${this.id}] Chrome 已启动 (cdp=:${port}),请在浏览器中登录`);
99
+
100
+ const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
101
+ const page = targets.find((t) => t.type === "page");
102
+ if (!page) throw new Error("未找到 Chrome page target");
103
+
104
+ const ws = new WebSocket(page.webSocketDebuggerUrl);
105
+ await new Promise((resolve, reject) => {
106
+ ws.onopen = resolve;
107
+ ws.onerror = reject;
108
+ });
109
+ return new CdpClient(ws, port);
110
+ }
111
+
112
+ async _captureCredentials(cdp) {
113
+ await cdp.send("Network.enable");
114
+ await cdp.send("Page.enable");
115
+
116
+ // 捕获 Cookie + 签名 token
117
+ const captured = {};
118
+ const capturedPromise = new Promise((resolve) => {
119
+ const tryResolve = () => {
120
+ if (captured.cookie && captured.dvidn && captured.actkn) {
121
+ resolve(captured);
122
+ }
123
+ };
124
+
125
+ // 捕获请求头中的签名信息
126
+ cdp.on("Network.requestWillBeSent", (p) => {
127
+ const url = p?.request?.url;
128
+ if (!url || !CAPTURE_REGEX.test(url)) return;
129
+ const h = p?.request?.headers || {};
130
+ if (h["eo-clt-dvidn"]) captured.dvidn = h["eo-clt-dvidn"];
131
+ if (h["eo-clt-actkn"]) captured.actkn = h["eo-clt-actkn"];
132
+ if (h["eo-clt-snver"]) captured.snver = h["eo-clt-snver"];
133
+ if (h["eo-clt-sacsft"]) {
134
+ if (!captured.sacsftList) captured.sacsftList = [];
135
+ captured.sacsftList.push(h["eo-clt-sacsft"]);
136
+ }
137
+ if (h["eo-clt-acs-kp"]) captured.kp = h["eo-clt-acs-kp"];
138
+ tryResolve();
139
+ });
140
+
141
+ // 捕获 Cookie
142
+ cdp.on("Network.requestWillBeSentExtraInfo", (p) => {
143
+ const h = p?.headers || {};
144
+ if (h.Cookie || h.cookie) {
145
+ captured.cookie = h.Cookie || h.cookie;
146
+ tryResolve();
147
+ }
148
+ });
149
+ });
150
+
151
+ log(`[auth:${this.id}] 打开 ${LOGIN_URL},请完成登录...`);
152
+ await cdp.send("Page.navigate", { url: LOGIN_URL });
153
+
154
+ const cred = await Promise.race([
155
+ capturedPromise,
156
+ new Promise((_, reject) =>
157
+ setTimeout(() => reject(new Error("捕获超时,请确认已完成登录")), CAPTURE_TIMEOUT_MS)
158
+ ),
159
+ ]);
160
+
161
+ log(`[auth:${this.id}] 已捕获凭证 dvidn=${cred.dvidn?.slice(0, 8)}... sacsft count=${cred.sacsftList?.length || 0}`);
162
+ return {
163
+ cookie: cred.cookie,
164
+ dvidn: cred.dvidn,
165
+ actkn: cred.actkn,
166
+ snver: cred.snver,
167
+ sacsftList: cred.sacsftList || [],
168
+ kp: cred.kp || "",
169
+ };
170
+ }
171
+
172
+ async _saveToFile() {
173
+ let all = {};
174
+ if (fs.existsSync(CREDS_FILE)) {
175
+ try { all = JSON.parse(fs.readFileSync(CREDS_FILE, "utf8")); } catch {}
176
+ }
177
+ if (!all.providers) all.providers = {};
178
+ all.providers[this.id] = {
179
+ cookie: this._cred.cookie,
180
+ dvidn: this._cred.dvidn,
181
+ actkn: this._cred.actkn,
182
+ snver: this._cred.snver,
183
+ sacsftList: this._cred.sacsftList,
184
+ kp: this._cred.kp,
185
+ expiresAt: this._cred.expiresAt,
186
+ loginAt: Date.now(),
187
+ };
188
+ fs.writeFileSync(CREDS_FILE, JSON.stringify(all, null, 2), { mode: 0o600 });
189
+ }
190
+
191
+ async _loadFromFile() {
192
+ if (!fs.existsSync(CREDS_FILE)) return;
193
+ try {
194
+ const all = JSON.parse(fs.readFileSync(CREDS_FILE, "utf8"));
195
+ const data = all.providers?.[this.id];
196
+ if (!data) return;
197
+ this._cred = {
198
+ cookie: data.cookie,
199
+ dvidn: data.dvidn,
200
+ actkn: data.actkn,
201
+ snver: data.snver,
202
+ sacsftList: data.sacsftList || [],
203
+ kp: data.kp || "",
204
+ expiresAt: data.expiresAt || 0,
205
+ };
206
+ if (!this._cred.cookie || !this._cred.dvidn || !this._cred.actkn) {
207
+ log(`[auth:${this.id}] 凭证文件缺少关键字段,请重新登录`);
208
+ this._cred = null;
209
+ return;
210
+ }
211
+ log(`[auth:${this.id}] 从 .creds.json 加载凭证 dvidn=${this._cred.dvidn?.slice(0, 8)}...`);
212
+ } catch (e) {
213
+ log(`[auth:${this.id}] 加载 .creds.json 失败: ${e.message}`);
214
+ }
215
+ }
216
+ }
217
+
218
+ // ---- 最小 CDP 客户端 ----
219
+ class CdpClient {
220
+ constructor(ws, port) {
221
+ this.ws = ws;
222
+ this.port = port;
223
+ this.seq = 0;
224
+ this.pending = new Map();
225
+ this.eventHandlers = new Map();
226
+ ws.onmessage = (ev) => {
227
+ const msg = JSON.parse(ev.data);
228
+ if (msg.id) {
229
+ const p = this.pending.get(msg.id);
230
+ if (!p) return;
231
+ this.pending.delete(msg.id);
232
+ if (msg.error) p.reject(new Error(msg.error.message));
233
+ else p.resolve(msg.result || {});
234
+ } else if (msg.method) {
235
+ const cbs = this.eventHandlers.get(msg.method);
236
+ if (cbs) for (const cb of cbs) cb(msg.params || {});
237
+ }
238
+ };
239
+ }
240
+
241
+ send(method, params = {}) {
242
+ return new Promise((resolve, reject) => {
243
+ const id = ++this.seq;
244
+ this.pending.set(id, { resolve, reject });
245
+ this.ws.send(JSON.stringify({ id, method, params }));
246
+ });
247
+ }
248
+
249
+ on(method, cb) {
250
+ const arr = this.eventHandlers.get(method) || [];
251
+ arr.push(cb);
252
+ this.eventHandlers.set(method, arr);
253
+ }
254
+
255
+ close() {
256
+ try { this.ws.close(); } catch {}
257
+ }
258
+ }
@@ -0,0 +1,159 @@
1
+ // providers/qianwen-web/client.js — 千问 Web 推理客户端
2
+ //
3
+ // 完整管线:
4
+ // ① auth.getCredentials() 拿 cookie + 签名 token
5
+ // ② 构建请求体(OpenAI messages → 千问 messages 格式)
6
+ // ③ 用签名算法计算 clt-acs-sign 等头
7
+ // ④ POST /api/v2/chat?biz_id=ai_qwen&...
8
+ // ⑤ 千问 SSE → OpenAI SSE(createSseTransformStream)
9
+ //
10
+ // 会话策略:无状态 —— 每次请求新建 session_id。
11
+
12
+ import crypto from "node:crypto";
13
+ import { fetch } from "undici";
14
+ import { log } from "../../util.js";
15
+ import { SIGN_CONFIG, MODEL_MAP } from "./config.js";
16
+ import { QianwenSigner } from "./sign.js";
17
+ import { createSseTransformStream } from "./sse.js";
18
+ import { buildMessages } from "./tools.js";
19
+
20
+ const CHAT_PATH = "/chat";
21
+ const CHAT_PARAMS = "biz_id=ai_qwen&fe_version=1.0.0&chat_client=h5&device=pc&fr=pc&pr=qwen&la=zh-CN&tz=Asia%2FShanghai&wv=4.6.2&ve=4.6.2";
22
+ const USER_AGENT =
23
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15";
24
+
25
+ export default class QianwenClient {
26
+ constructor({ id, signProvider, authProvider, host, basePath }) {
27
+ this.id = id;
28
+ this.authProvider = authProvider;
29
+ this.host = host || SIGN_CONFIG.host;
30
+ this.basePath = basePath || SIGN_CONFIG.basePath;
31
+ }
32
+
33
+ /**
34
+ * POST /chat(返回 OpenAI 风格 Response)
35
+ */
36
+ async chatCompletions(body, opts = {}) {
37
+ const cred = await this.authProvider.getCredentials({ autoLogin: false });
38
+
39
+ // 构建签名器
40
+ const signer = new QianwenSigner({
41
+ dvidn: cred.dvidn,
42
+ actkn: cred.actkn,
43
+ snver: cred.snver,
44
+ sacsftList: cred.sacsftList,
45
+ kp: cred.kp,
46
+ });
47
+
48
+ // 生成随机 session_id 和 req_id
49
+ const sessionId = crypto.randomUUID().replace(/-/g, "").slice(0, 32);
50
+ const reqId = crypto.randomUUID().replace(/-/g, "").slice(0, 32);
51
+
52
+ // ① 构建请求体
53
+ const dsBody = this._convertRequest(body, sessionId, reqId);
54
+ const bodyStr = JSON.stringify(dsBody);
55
+
56
+ // ② 获取服务器时间戳(简化:用本地时间 + 偏移)
57
+ const timestamp = Date.now();
58
+
59
+ // ③ 签名
60
+ const paramNames = Object.keys(dsBody).filter((k) => dsBody[k] !== undefined && dsBody[k] !== null);
61
+ const signHeaders = signer.buildHeaders(bodyStr, timestamp, paramNames);
62
+
63
+ // ④ 发请求
64
+ const nonce = Math.random().toString(36).slice(2, 14);
65
+ // 从 cookie 提取 cna 值作为 ut 参数(千问 API 必需)
66
+ const cna = cred.cookie.split(";").map((s) => s.trim()).find((s) => s.startsWith("cna="))?.split("=")?.[1] || "";
67
+ const url = `https://${this.host}${this.basePath}${CHAT_PATH}?${CHAT_PARAMS}&ut=${cna}&nonce=${nonce}&timestamp=${timestamp}`;
68
+ log(`[qw:${this.id}] POST ${url.slice(0, 80)}... session=${sessionId.slice(0, 8)} model=${body.model} stream=${body.stream === true}`);
69
+
70
+ const headers = {
71
+ "Content-Type": "application/json",
72
+ "Accept": "application/json, text/event-stream, text/plain, */*",
73
+ "Accept-Language": "en-US,en;q=0.9",
74
+ "Origin": "https://www.qianwen.com",
75
+ "Referer": "https://www.qianwen.com/",
76
+ "User-Agent": USER_AGENT,
77
+ "Cookie": cred.cookie,
78
+ "x-chat-biz": JSON.stringify({ chatId: sessionId, agentId: "", enableWebp: "", runtimeEnabled: true, debugEnabled: false }),
79
+ "x-chat-id": sessionId,
80
+ "x-device-id": cred.dvidn,
81
+ "x-platform": "pc_tongyi",
82
+ "x-user-id": cred.dvidn,
83
+ "prod_id": "tongyi",
84
+ ...signHeaders,
85
+ };
86
+
87
+ const upstream = await fetch(url, {
88
+ method: "POST",
89
+ headers,
90
+ body: bodyStr,
91
+ signal: opts.signal || undefined,
92
+ });
93
+
94
+ if (!upstream.ok) {
95
+ const text = await upstream.text();
96
+ log(`[qw:${this.id}] 上游错误 ${upstream.status}: ${text.slice(0, 300)}`);
97
+ return new Response(
98
+ JSON.stringify({ error: { message: text.slice(0, 500), type: "upstream_error", code: upstream.status } }),
99
+ { status: upstream.status, headers: { "Content-Type": "application/json" } },
100
+ );
101
+ }
102
+
103
+ // ⑤ 响应处理:千问 API 始终返回 SSE 格式(即使 stream=false)
104
+ const contentType = upstream.headers.get("content-type") || "";
105
+ if (contentType.includes("text/event-stream")) {
106
+ // SSE 格式:转换为 OpenAI SSE
107
+ const converted = createSseTransformStream(upstream.body, body.model);
108
+ return new Response(converted, {
109
+ status: 200,
110
+ headers: { "Content-Type": "text/event-stream; charset=utf-8" },
111
+ });
112
+ } else {
113
+ // JSON 格式:直接返回
114
+ return new Response(upstream.body, {
115
+ status: 200,
116
+ headers: { "Content-Type": "application/json" },
117
+ });
118
+ }
119
+ }
120
+
121
+ /**
122
+ * OpenAI 请求体 → 千问 web 请求体
123
+ */
124
+ _convertRequest(body, sessionId, reqId) {
125
+ const model = body.model || "qwen";
126
+ const mapping = MODEL_MAP[model] || MODEL_MAP["qwen"];
127
+
128
+ const messages = buildMessages(body.messages || []);
129
+ const lastMsg = messages[messages.length - 1];
130
+ const query = lastMsg?.content || "";
131
+
132
+ log(`[qw:${this.id}] msgs=${messages.length} query=${JSON.stringify(query.slice(0, 40))}`);
133
+
134
+ return {
135
+ req_id: reqId,
136
+ parent_req_id: "",
137
+ messages,
138
+ scene: "chat",
139
+ sub_scene: "",
140
+ scene_param: "continue_chat",
141
+ session_id: sessionId,
142
+ biz_id: "ai_qwen",
143
+ model: mapping.model,
144
+ from: "default",
145
+ protocol_version: "v2",
146
+ messages_merge: false,
147
+ chat_client: "h5",
148
+ deep_search: null,
149
+ temporary: false,
150
+ chat_mode: "quick",
151
+ cms_test_data_ids: "",
152
+ bucket: {},
153
+ };
154
+ }
155
+
156
+ models() {
157
+ return Object.keys(MODEL_MAP);
158
+ }
159
+ }
@@ -0,0 +1,22 @@
1
+ // providers/qianwen-web/config.js — 千问 Web Provider 固定配置
2
+
3
+ export const SIGN_CONFIG = {
4
+ host: "chat2.qianwen.com",
5
+ basePath: "/api/v2",
6
+ };
7
+
8
+ // OpenAI 模型名 → 千问 web 请求参数映射
9
+ export const MODEL_MAP = {
10
+ "qwen": { model: "Qwen" },
11
+ "qwen3.8-max": { model: "Qwen3.8-Max" },
12
+ "qwen3.7-max": { model: "Qwen3.7-Max" },
13
+ };
14
+
15
+ export const MODELS = ["qwen", "qwen3.8-max", "qwen3.7-max"];
16
+
17
+ // 签名相关端点(sec.qianwen.com)
18
+ export const SEC_CONFIG = {
19
+ host: "sec.qianwen.com",
20
+ registerPath: "/security/external/access/register",
21
+ refreshPath: "/security/external/access/refresh",
22
+ };
@@ -0,0 +1,10 @@
1
+ // providers/qianwen-web/error.js — 千问 Web 错误模式
2
+
3
+ import { ErrorPatterns } from "../../core/error.js";
4
+
5
+ export default class QianwenErrorPatterns extends ErrorPatterns {
6
+ isSessionLimit(status, bodyText) {
7
+ // 千问无明显并发限制标识,暂不实现
8
+ return false;
9
+ }
10
+ }
@@ -0,0 +1,86 @@
1
+ // providers/qianwen-web/sign.js — 千问 Web 请求签名
2
+ //
3
+ // 签名算法(从千问前端 JS 逆向):
4
+ // 1. 获取服务器时间戳 timestamp
5
+ // 2. salt = sacsft + ":" + timestamp
6
+ // 3. bodySign = HMAC-SHA256(body, salt) → Base64
7
+ // 4. signInput = dvidn + ve + kp + sacsft + bodySign
8
+ // 5. sign = HMAC-SHA256(signInput, salt) → Base64
9
+ //
10
+ // 必须字段:
11
+ // eo-clt-dvidn — 设备 ID(注册时获取)
12
+ // eo-clt-actkn — Access Token(注册时获取)
13
+ // eo-clt-sacsft — Baxia 安全令牌(每次请求从列表弹出)
14
+ // eo-clt-snver — 签名版本号
15
+
16
+ import { createHmac } from "node:crypto";
17
+
18
+ export { SIGN_CONFIG } from "./config.js";
19
+
20
+ // NoopSigner:千问的签名在 client 层完成,sign provider 不需要做任何事
21
+ export default class NoopSigner {
22
+ constructor() {}
23
+ sign() { return {}; }
24
+ }
25
+
26
+ export class QianwenSigner {
27
+ constructor({ dvidn, actkn, snver, sacsftList = [], ve = "1.0.0", kp = "" }) {
28
+ this.dvidn = dvidn;
29
+ this.actkn = actkn;
30
+ this.snver = snver;
31
+ this.sacsftList = [...sacsftList];
32
+ this.ve = ve;
33
+ this.kp = kp;
34
+ }
35
+
36
+ /**
37
+ * 弹出一个 sacsft(Baxia 安全令牌),用后即弃
38
+ */
39
+ popSacsft() {
40
+ return this.sacsftList.pop() || "";
41
+ }
42
+
43
+ /**
44
+ * 计算签名
45
+ * @param {string} body 请求体 JSON 字符串
46
+ * @param {number} timestamp 服务器时间戳(毫秒)
47
+ * @param {string} sacsft 当前使用的 Baxia 令牌
48
+ * @returns {{ sign: string, bodySign: string }}
49
+ */
50
+ calculateSignature(body, timestamp, sacsft) {
51
+ const salt = `${sacsft}:${timestamp}`;
52
+ const bodySign = this._hmacSha256Base64(body, salt);
53
+ const signInput = `${this.dvidn}${this.ve}${this.kp}${sacsft}${bodySign}`;
54
+ const sign = this._hmacSha256Base64(signInput, salt);
55
+ return { sign, bodySign };
56
+ }
57
+
58
+ /**
59
+ * 构建请求头
60
+ * @param {string} body 请求体 JSON
61
+ * @param {number} timestamp 服务器时间戳
62
+ * @param {string[]} paramNames 请求参数名列表
63
+ * @returns {object} 需要注入的 headers
64
+ */
65
+ buildHeaders(body, timestamp, paramNames = []) {
66
+ const sacsft = this.popSacsft();
67
+ const { sign } = this.calculateSignature(body, timestamp, sacsft);
68
+
69
+ return {
70
+ "clt-acs-sign": sign,
71
+ "clt-acs-reqt": String(timestamp),
72
+ "clt-acs-request-params": paramNames.join(","),
73
+ "clt-acs-caer": "vrad",
74
+ "eo-clt-dvidn": this.dvidn,
75
+ "eo-clt-acs-ve": this.ve,
76
+ "eo-clt-sacsft": sacsft,
77
+ "eo-clt-snver": this.snver,
78
+ "eo-clt-actkn": this.actkn,
79
+ "eo-clt-acs-kp": this.kp,
80
+ };
81
+ }
82
+
83
+ _hmacSha256Base64(data, key) {
84
+ return createHmac("sha256", key).update(data).digest("base64");
85
+ }
86
+ }
@@ -0,0 +1,157 @@
1
+ // providers/qianwen-web/sse.js — 千问 Web SSE → OpenAI SSE 转换
2
+ //
3
+ // 千问 SSE 格式:
4
+ // data:{"error_msg":"","data":{"messages":[{"mime_type":"multi_load/iframe","content":"文本","status":"processing"}]}}
5
+ // data:{"error_msg":"","data":{"messages":[{"mime_type":"multi_load/iframe","content":"完整文本","status":"complete"}]}}
6
+ // event:complete
7
+ // data:{"error_msg":"","data":{...},"error_code":0}
8
+ //
9
+ // 转换为 OpenAI SSE:
10
+ // content 增量 → choices[0].delta.content
11
+ // status=complete + event:complete → finish_reason:"stop"
12
+ // data: [DONE]
13
+
14
+ import crypto from "node:crypto";
15
+
16
+ class QianwenSseTransformer {
17
+ constructor(model) {
18
+ this.model = model;
19
+ this.sentStart = false;
20
+ this.sentStop = false;
21
+ this.sentDone = false;
22
+ this.buffer = "";
23
+ this.lastContent = "";
24
+ }
25
+
26
+ transform(byteChunk) {
27
+ this.buffer += new TextDecoder().decode(byteChunk, { stream: true });
28
+ const outputs = [];
29
+ let idx;
30
+ while ((idx = this.buffer.indexOf("\n\n")) >= 0) {
31
+ const raw = this.buffer.slice(0, idx);
32
+ this.buffer = this.buffer.slice(idx + 2);
33
+ const out = this.handleEvent(raw);
34
+ if (out) outputs.push(out);
35
+ }
36
+ return outputs;
37
+ }
38
+
39
+ handleEvent(raw) {
40
+ let event = "message";
41
+ const dataLines = [];
42
+ for (const line of raw.split("\n")) {
43
+ if (line.startsWith("event:")) event = line.slice(6).trim();
44
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
45
+ }
46
+ if (dataLines.length === 0) return null;
47
+
48
+ if (event === "complete") {
49
+ if (this.sentDone) return null;
50
+ this.sentDone = true;
51
+ return this.emitData("[DONE]");
52
+ }
53
+
54
+ for (const line of dataLines) {
55
+ let obj;
56
+ try { obj = JSON.parse(line); } catch { continue; }
57
+ const out = this.handleData(obj);
58
+ if (out) return out;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ handleData(obj) {
64
+ if (!obj || typeof obj !== "object") return null;
65
+ const messages = obj.data?.messages;
66
+ if (!Array.isArray(messages)) return null;
67
+
68
+ const outputs = [];
69
+ let done = false;
70
+
71
+ for (const msg of messages) {
72
+ const mime = msg.mime_type || "";
73
+ const content = msg.content || "";
74
+ const status = msg.status || "";
75
+
76
+ // 跳过非内容消息
77
+ if (!content && mime !== "signal/post") continue;
78
+
79
+ // 内容增量:只发送新增部分
80
+ if (content && content.length > this.lastContent.length) {
81
+ const delta = content.slice(this.lastContent.length);
82
+ this.lastContent = content;
83
+ outputs.push(this.emitDelta(delta));
84
+ }
85
+
86
+ // 完成信号
87
+ if (status === "complete" && !this.sentStop) {
88
+ this.sentStop = true;
89
+ outputs.push(this.emitChunk({}, "stop"));
90
+ }
91
+ }
92
+
93
+ // 把所有输出合并成一个 chunk
94
+ if (outputs.length === 0) return null;
95
+ return new Uint8Array(
96
+ outputs.reduce((acc, chunk) => {
97
+ const arr = chunk instanceof Uint8Array ? chunk : new TextEncoder().encode(chunk);
98
+ return [...acc, ...arr];
99
+ }, []),
100
+ );
101
+ }
102
+
103
+ emitDelta(text) {
104
+ if (!this.sentStart) this.sentStart = true;
105
+ return this.emitChunk({ role: "assistant", content: text });
106
+ }
107
+
108
+ emitChunk(delta, finishReason) {
109
+ const chunk = {
110
+ id: `chatcmpl-${crypto.randomUUID().slice(0, 8)}`,
111
+ object: "chat.completion.chunk",
112
+ created: Math.floor(Date.now() / 1000),
113
+ model: this.model,
114
+ choices: [{ index: 0, delta, finish_reason: finishReason ?? null }],
115
+ };
116
+ return this.emitData(JSON.stringify(chunk));
117
+ }
118
+
119
+ emitData(data) {
120
+ return new TextEncoder().encode(`data: ${data}\n\n`);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * 把千问 upstream.body 包装成 OpenAI SSE ReadableStream
126
+ */
127
+ export function createSseTransformStream(upstreamBody, model) {
128
+ const tr = new QianwenSseTransformer(model);
129
+ const reader = upstreamBody.getReader();
130
+ return new ReadableStream({
131
+ async pull(controller) {
132
+ try {
133
+ while (true) {
134
+ const { done, value } = await reader.read();
135
+ if (done) {
136
+ if (!tr.sentDone) controller.enqueue(tr.emitData("[DONE]"));
137
+ controller.close();
138
+ return;
139
+ }
140
+ const outputs = tr.transform(value);
141
+ if (outputs.length > 0) {
142
+ for (const c of outputs) controller.enqueue(c);
143
+ return;
144
+ }
145
+ }
146
+ } catch (e) {
147
+ controller.error(e);
148
+ }
149
+ },
150
+ cancel() {
151
+ reader.releaseLock();
152
+ upstreamBody.cancel().catch(() => {});
153
+ },
154
+ });
155
+ }
156
+
157
+ export { QianwenSseTransformer };