mslxdff 0.1.100 → 0.1.102
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/package.json +1 -1
- package/src/chat-pipeline/index.js +5 -1
- package/src/cli/commands/sync.js +3 -2
- package/src/providers/workbuddy/chat.js +3 -2
- package/src/providers/workbuddy/reshape.js +142 -0
- package/src/routes/hedge.js +2 -0
- package/src/routes/stream.js +2 -1
- package/src/sync-opencode.js +43 -2
- package/src/upstream-responses.js +118 -1
- package/src/upstream.js +24 -11
package/package.json
CHANGED
|
@@ -30,10 +30,14 @@ export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, t
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// order 推导 + plugin model:select 可改
|
|
33
|
+
// 语义:指定模型 = 死锁单模型(本机→组员同款,挂了就报挂,不兜其他 picks);只有 auto 才轮 picks
|
|
33
34
|
let order;
|
|
34
35
|
if (lockModel) order = [requested];
|
|
35
36
|
else if (useAuto) order = auto ? await auto.candidates() : [""];
|
|
36
|
-
else
|
|
37
|
+
else {
|
|
38
|
+
if (auto && requested) { try { await auto.candidatesFor(requested); } catch {} }
|
|
39
|
+
order = [requested];
|
|
40
|
+
}
|
|
37
41
|
if (!order.length) order = [""];
|
|
38
42
|
const canFallback = order.length > 1;
|
|
39
43
|
const canForwardPeers = Boolean(peers) && hops < (maxHops ?? 3);
|
package/src/cli/commands/sync.js
CHANGED
|
@@ -84,7 +84,7 @@ export async function handleSetto(args) {
|
|
|
84
84
|
if (!norm || norm === "auto") continue;
|
|
85
85
|
// 首次循环也同步 preferred(保持 daemon 热重载语义)
|
|
86
86
|
if (rawId === list[0]) savePreferredModel(norm);
|
|
87
|
-
const r = await syncToOpencode({ id: norm, token, port, file, keep: pruneKeep() });
|
|
87
|
+
const r = await syncToOpencode({ id: norm, token, port, file, keep: pruneKeep(), ensureAll: pruneKeep() });
|
|
88
88
|
if (r.action === "inserted") inserted++; else updated++;
|
|
89
89
|
prunedTotal += r.pruned || 0;
|
|
90
90
|
console.log(` ${r.action} "${r.id}" -> ${r.internal} @ ${file}`);
|
|
@@ -156,8 +156,9 @@ export async function handleSetto(args) {
|
|
|
156
156
|
const envPort = Number(process.env.MSLXDFF_PORT);
|
|
157
157
|
const port = persisted !== null ? persisted : (Number.isInteger(envPort) && envPort > 0 ? envPort : 8989);
|
|
158
158
|
const file = opencodeConfigPath();
|
|
159
|
-
const r = await syncToOpencode({ id, token, port, file, keep: pruneKeep() });
|
|
159
|
+
const r = await syncToOpencode({ id, token, port, file, keep: pruneKeep(), ensureAll: pruneKeep() });
|
|
160
160
|
console.log(`synced to opencode: ${r.action} "${r.id}" @ ${file}`);
|
|
161
|
+
if (r.backfilled) console.log(` backfilled ${r.backfilled} 个 picks 模型(此前 pick 了但未同步过,现已补齐)`);
|
|
161
162
|
if (r.pruned) console.log(` pruned ${r.pruned} 个失效模型(未在 picks,不再于 opencode 显示)`);
|
|
162
163
|
console.log(` url: http://127.0.0.1:${port}/v1`);
|
|
163
164
|
console.log(` opencode 选 mslxdff/${r.id} 直达本地 ${r.internal}${r.storageKey !== r.internal ? ` (dash→${r.internal} 自动映射)` : ""}`);
|
|
@@ -2,6 +2,7 @@ import { joinUrl } from "../base.js";
|
|
|
2
2
|
import { isAuthError, isInsufficientStatus } from "./auth.js";
|
|
3
3
|
import { appendRotationLog as defaultAppend } from "./rotation-log.js";
|
|
4
4
|
import { createTransport } from "../../transport/index.js";
|
|
5
|
+
import { reshapeWorkbuddySse } from "./reshape.js";
|
|
5
6
|
|
|
6
7
|
function buildAuthHeaders(key, auth) {
|
|
7
8
|
const h = {
|
|
@@ -86,7 +87,7 @@ export function createChatService({
|
|
|
86
87
|
let res;
|
|
87
88
|
try { res = await fetchOnce(url, body, key, auth); }
|
|
88
89
|
catch (e) { throw e; }
|
|
89
|
-
if (res.status < 400) return res;
|
|
90
|
+
if (res.status < 400) return reshapeWorkbuddySse(res);
|
|
90
91
|
let txt = "";
|
|
91
92
|
try { txt = await res.text(); } catch {}
|
|
92
93
|
if (!isAuthError(res.status, txt)) return res;
|
|
@@ -105,7 +106,7 @@ export function createChatService({
|
|
|
105
106
|
if (stillAuth || res2.status === 429 || res2.status >= 500) try { ring.onError(newKey); } catch {}
|
|
106
107
|
doLog({ uid: uid2, model: _modelForLog(), totalMs: Math.round(nowMs(clock) - _t0()), balanceHit: false, error: stillAuth ? `still auth ${res2.status}` : undefined });
|
|
107
108
|
if (stillAuth) throw failErr(`workbuddy auth still failing after refresh for ${uid2}: ${stillTxt.slice(0, 120)}`, _t0(), clock);
|
|
108
|
-
return res2;
|
|
109
|
+
return reshapeWorkbuddySse(res2);
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
let _t0Val = 0;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// workbuddy 流式 reasoning_content 整形:上游思考模型(如 glm-5.3-flash)把思考过程一词一 chunk
|
|
2
|
+
// 地放进 delta.reasoning_content,客户端会把每个增量渲染成独立 "- Thought" 条目。
|
|
3
|
+
// 旧版:连续 reasoning 增量缓冲到首个 content/finish 才一次性 flush → 思考期 10~30s 界面完全无变化(用户看到 workbuddy 后台流量走但 opencode 界面卡住)
|
|
4
|
+
// 新版:增量聚合 + 阈值分片:缓冲 reasoning,达到 REASONING_CHUNK_SIZE(默认 100 字符)即分片 flush,
|
|
5
|
+
// 期间仅首个 role 帧透传保持 TTFB,其余纯 reasoning 空帧吞掉(之前会透传 800+ 空 role 帧);
|
|
6
|
+
// 尾段不足阈值的在 content/finish/流结束时兜底 flush。恒空 reasoning(deepseek-v4-flash)零改写。
|
|
7
|
+
// 仅包 workbuddy 出口的 SSE 响应,不影响其他供应商与全局聚合器。
|
|
8
|
+
|
|
9
|
+
const REASONING_CHUNK_SIZE = 150;
|
|
10
|
+
|
|
11
|
+
export function reshapeWorkbuddySse(res) {
|
|
12
|
+
try {
|
|
13
|
+
const ct = res.headers?.get?.("content-type") || "";
|
|
14
|
+
if (res.status !== 200 || !ct.includes("text/event-stream") || !res.body) return res;
|
|
15
|
+
} catch { return res; }
|
|
16
|
+
|
|
17
|
+
const reader = res.body.getReader();
|
|
18
|
+
const decoder = new TextDecoder();
|
|
19
|
+
const encoder = new TextEncoder();
|
|
20
|
+
let buf = "";
|
|
21
|
+
let reasoningBuf = "";
|
|
22
|
+
let lastMeta = null;
|
|
23
|
+
let closed = false;
|
|
24
|
+
let hasSentRole = false;
|
|
25
|
+
let lastFlushAt = Date.now();
|
|
26
|
+
|
|
27
|
+
function flushReasoningInto(out) {
|
|
28
|
+
if (!reasoningBuf) return;
|
|
29
|
+
const meta = lastMeta || {};
|
|
30
|
+
out.push(`data: ${JSON.stringify({
|
|
31
|
+
id: meta.id || "chatcmpl-workbuddy-reshape",
|
|
32
|
+
object: meta.object || "chat.completion.chunk",
|
|
33
|
+
created: meta.created || Math.floor(Date.now() / 1000),
|
|
34
|
+
model: meta.model || "",
|
|
35
|
+
choices: [{ index: 0, delta: { reasoning_content: reasoningBuf }, finish_reason: null }],
|
|
36
|
+
})}\n\n`);
|
|
37
|
+
reasoningBuf = "";
|
|
38
|
+
lastFlushAt = Date.now();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let blankStreak = false;
|
|
42
|
+
// 处理一段完整行文本(以 \n 结尾),产出的行/帧推进 out(连续空行合并为单个事件分隔)
|
|
43
|
+
function processInto(text, out) {
|
|
44
|
+
for (const line of text.split("\n")) {
|
|
45
|
+
if (!line) { if (!blankStreak) out.push("\n"); blankStreak = true; continue; }
|
|
46
|
+
blankStreak = false;
|
|
47
|
+
if (!line.startsWith("data:")) { out.push(line + "\n"); continue; }
|
|
48
|
+
const payload = line.slice(5).trim();
|
|
49
|
+
if (!payload || payload === "[DONE]") { out.push(line + "\n"); continue; }
|
|
50
|
+
let obj;
|
|
51
|
+
try { obj = JSON.parse(payload); } catch { out.push(line + "\n"); continue; }
|
|
52
|
+
const ch = obj.choices?.[0];
|
|
53
|
+
if (ch && typeof ch === "object") {
|
|
54
|
+
if (obj.id) lastMeta = { id: obj.id, model: obj.model, object: obj.object, created: obj.created };
|
|
55
|
+
const d = ch.delta && typeof ch.delta === "object" ? ch.delta : null;
|
|
56
|
+
const rc = d ? d.reasoning_content : undefined;
|
|
57
|
+
if (typeof rc === "string" && rc) {
|
|
58
|
+
reasoningBuf += rc;
|
|
59
|
+
d.reasoning_content = "";
|
|
60
|
+
const isBoundary = (typeof d.content === "string" && d.content) || !!ch.finish_reason;
|
|
61
|
+
// 首个 reasoning 帧的 role 透传一次,保 TTFB;后续纯 reasoning 的 role 重复帧直接吞掉
|
|
62
|
+
if (!hasSentRole && d.role !== undefined) {
|
|
63
|
+
hasSentRole = true;
|
|
64
|
+
const rm = lastMeta || obj;
|
|
65
|
+
out.push(`data: ${JSON.stringify({
|
|
66
|
+
id: rm.id || obj.id || "chatcmpl-workbuddy-reshape",
|
|
67
|
+
object: rm.object || obj.object || "chat.completion.chunk",
|
|
68
|
+
created: rm.created || obj.created || Math.floor(Date.now() / 1000),
|
|
69
|
+
model: rm.model || obj.model || "",
|
|
70
|
+
choices: [{ index: 0, delta: { role: d.role }, finish_reason: null }],
|
|
71
|
+
})}\n\n`);
|
|
72
|
+
} else if (d.role !== undefined) {
|
|
73
|
+
// 后续重复 role 帧不再透传,避免 800+ 空帧洪泛
|
|
74
|
+
// 标记已发送过,避免后续非 reasoning 帧再误判为首次
|
|
75
|
+
hasSentRole = true;
|
|
76
|
+
}
|
|
77
|
+
// 阈值分片:攒够 100 字符即向前吐一块,期间界面可见增量而非全程静默
|
|
78
|
+
// 时间阈值:若距上次 flush 已超 700ms 且已攒 20+ 字符,也吐一块(避免小尾巴长时间不更新)
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const shouldChunk = reasoningBuf.length >= REASONING_CHUNK_SIZE
|
|
81
|
+
|| (reasoningBuf.length >= 50 && now - lastFlushAt > 1500);
|
|
82
|
+
if (shouldChunk) flushReasoningInto(out);
|
|
83
|
+
if (isBoundary) {
|
|
84
|
+
// 兜底:content/finish 前把剩余思考一次性吐出,再透传本帧
|
|
85
|
+
flushReasoningInto(out);
|
|
86
|
+
const hasPassthrough = isBoundary || (Array.isArray(d.tool_calls) && d.tool_calls.length);
|
|
87
|
+
if (hasPassthrough) out.push(`data: ${JSON.stringify(obj)}\n\n`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
// 纯 reasoning 碎片帧已缓冲/或已分片吐出,原空帧吞掉
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// 非 reasoning 帧:若有 content/finish 先兜底 flush
|
|
94
|
+
if ((typeof d?.content === "string" && d.content) || ch.finish_reason) flushReasoningInto(out);
|
|
95
|
+
if (!hasSentRole && d?.role !== undefined) {
|
|
96
|
+
hasSentRole = true;
|
|
97
|
+
} else if (hasSentRole && d?.role !== undefined) {
|
|
98
|
+
// 去重:首个 role 后续重复 role 去掉,避免下游每帧都带 role
|
|
99
|
+
try { const clone = JSON.parse(payload); delete clone.choices[0].delta.role; out.push(`data: ${JSON.stringify(clone)}\n\n`); continue; } catch { /* fallback透传 */ }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
out.push(line + "\n");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const body = new ReadableStream({
|
|
107
|
+
async pull(controller) {
|
|
108
|
+
if (closed) { try { controller.close(); } catch {} return; }
|
|
109
|
+
try {
|
|
110
|
+
const { done, value } = await reader.read();
|
|
111
|
+
if (done) {
|
|
112
|
+
closed = true;
|
|
113
|
+
const out = [];
|
|
114
|
+
if (buf) { processInto(buf + "\n", out); buf = ""; }
|
|
115
|
+
flushReasoningInto(out);
|
|
116
|
+
if (out.length) controller.enqueue(encoder.encode(out.join("")));
|
|
117
|
+
controller.close();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
buf += decoder.decode(value, { stream: true });
|
|
121
|
+
const idx = buf.lastIndexOf("\n");
|
|
122
|
+
if (idx < 0) return;
|
|
123
|
+
const complete = buf.slice(0, idx + 1);
|
|
124
|
+
buf = buf.slice(idx + 1);
|
|
125
|
+
const out = [];
|
|
126
|
+
processInto(complete, out);
|
|
127
|
+
if (out.length) controller.enqueue(encoder.encode(out.join("")));
|
|
128
|
+
} catch {
|
|
129
|
+
closed = true;
|
|
130
|
+
try { controller.close(); } catch {}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
cancel() {
|
|
134
|
+
closed = true;
|
|
135
|
+
try { reader.cancel(); } catch {}
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const out = new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
140
|
+
try { out._t = res._t; } catch {}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
package/src/routes/hedge.js
CHANGED
|
@@ -20,6 +20,8 @@ function shouldHedge({ isStream, canForwardPeers, hedgeDelayMs: d, hasPeers, mod
|
|
|
20
20
|
if (!d || d <= 0) return false;
|
|
21
21
|
// deepseek = 本机私有凭据供应商(组员节点没有凭据),对冲必败且 both-fail 会误杀本地慢首块流(reasoner 思考 3-30s),不走组员
|
|
22
22
|
if (String(model || "").startsWith("deepseek/")) return false;
|
|
23
|
+
// muse-spark 走 /responses 流式(event: 包装 + 加密 reasoning),组员旧版无此整形且聚合 JSON 带错 header,必抢赢本地慢首块,需本地直出
|
|
24
|
+
if (String(model || "").toLowerCase().startsWith("muse-spark")) return false;
|
|
23
25
|
return true;
|
|
24
26
|
}
|
|
25
27
|
|
package/src/routes/stream.js
CHANGED
|
@@ -31,7 +31,8 @@ export const MAX_STREAM_MS = (() => {
|
|
|
31
31
|
export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, streamTimeoutMs = STREAM_TIMEOUT_MS, fallback } = {}) {
|
|
32
32
|
const t0 = performance.now();
|
|
33
33
|
const contentType = upRes.headers.get("content-type") || "";
|
|
34
|
-
|
|
34
|
+
// 需同时满足:客户端要流 + 上游真的是 SSE;避免 muse-spark 聚合 JSON 被误判为流式,或 workbuddy SSE 被聚合
|
|
35
|
+
const isStream = Boolean(body?.stream) && contentType.includes("text/event-stream");
|
|
35
36
|
res.statusCode = upRes.status;
|
|
36
37
|
// propagate workbuddy uid / allowlist headers
|
|
37
38
|
try {
|
package/src/sync-opencode.js
CHANGED
|
@@ -78,7 +78,43 @@ export function pruneOpencodeModels(models, keep, currentKey) {
|
|
|
78
78
|
return pruned;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
// 补齐:把 ensureAll(picks 口径)里缺失的键写入 models(slash → dash + alias 注册)。
|
|
82
|
+
// 返回 { nextModels, backfilled };ensureAll 非数组或为空时原样返回(backfilled=0)。
|
|
83
|
+
// 注意:需在剪枝之前调用——先补 picks 缺失,再剪 picks 外旧键,结果集恰为 picks ∪ currentKey。
|
|
84
|
+
export async function ensureAllOpencodeModels(models, ensureAll) {
|
|
85
|
+
if (!Array.isArray(ensureAll) || !ensureAll.length || !models || typeof models !== "object") {
|
|
86
|
+
return { nextModels: models, backfilled: 0 };
|
|
87
|
+
}
|
|
88
|
+
const existing = new Set(Object.keys(models).map(normalizeOpencodeKey));
|
|
89
|
+
let backfilled = 0;
|
|
90
|
+
let aliasDirty = false;
|
|
91
|
+
for (const raw of ensureAll) {
|
|
92
|
+
const internal = toInternalId(String(raw || "").trim());
|
|
93
|
+
if (!internal || internal === "auto") continue;
|
|
94
|
+
const storageKey = internal.includes("/") ? internal.replace(/\//g, "-") : internal;
|
|
95
|
+
if (!storageKey || existing.has(storageKey)) continue;
|
|
96
|
+
models[storageKey] = { name: storageKey };
|
|
97
|
+
existing.add(storageKey);
|
|
98
|
+
backfilled++;
|
|
99
|
+
if (internal.includes("/") && storageKey !== internal) {
|
|
100
|
+
try {
|
|
101
|
+
const { loadModelAliases, registerModelAlias } = await import("./providers/model-id.js");
|
|
102
|
+
loadModelAliases();
|
|
103
|
+
registerModelAlias(storageKey, internal);
|
|
104
|
+
aliasDirty = true;
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (aliasDirty) {
|
|
109
|
+
try {
|
|
110
|
+
const { persistModelAliases } = await import("./providers/model-id.js");
|
|
111
|
+
persistModelAliases();
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
return { nextModels: models, backfilled };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function syncToOpencode({ id, token, port, file, keep, ensureAll } = {}) {
|
|
82
118
|
const targetFile = file || opencodeConfigPath();
|
|
83
119
|
const normalizedRaw = String(id || "").trim();
|
|
84
120
|
if (!normalizedRaw) throw new Error("model id required");
|
|
@@ -119,6 +155,7 @@ export async function syncToOpencode({ id, token, port, file, keep } = {}) {
|
|
|
119
155
|
let action;
|
|
120
156
|
let effectiveId = storageKey;
|
|
121
157
|
let pruned = 0;
|
|
158
|
+
let backfilled = 0;
|
|
122
159
|
if (oldProvider) {
|
|
123
160
|
const oldModels = oldProvider.models && typeof oldProvider.models === "object" && !Array.isArray(oldProvider.models)
|
|
124
161
|
? oldProvider.models
|
|
@@ -158,6 +195,8 @@ export async function syncToOpencode({ id, token, port, file, keep } = {}) {
|
|
|
158
195
|
effectiveId = storageKey;
|
|
159
196
|
action = "inserted";
|
|
160
197
|
}
|
|
198
|
+
const ensured = await ensureAllOpencodeModels(nextModels, ensureAll);
|
|
199
|
+
backfilled = ensured.backfilled;
|
|
161
200
|
pruned = pruneOpencodeModels(nextModels, keep, storageKey);
|
|
162
201
|
const nextProvider = {
|
|
163
202
|
...oldProvider,
|
|
@@ -177,6 +216,8 @@ export async function syncToOpencode({ id, token, port, file, keep } = {}) {
|
|
|
177
216
|
if (!data.provider.mslxdff.models[storageKey]) {
|
|
178
217
|
data.provider.mslxdff.models = { [storageKey]: { name: storageKey } };
|
|
179
218
|
}
|
|
219
|
+
const ensured = await ensureAllOpencodeModels(data.provider.mslxdff.models, ensureAll);
|
|
220
|
+
backfilled = ensured.backfilled;
|
|
180
221
|
action = "inserted";
|
|
181
222
|
}
|
|
182
223
|
|
|
@@ -208,5 +249,5 @@ export async function syncToOpencode({ id, token, port, file, keep } = {}) {
|
|
|
208
249
|
}
|
|
209
250
|
} catch {}
|
|
210
251
|
|
|
211
|
-
return { action, file: targetFile, id: effectiveId, alias: storageKey, internal, corrupted, storageKey, pruned };
|
|
252
|
+
return { action, file: targetFile, id: effectiveId, alias: storageKey, internal, corrupted, storageKey, pruned, backfilled };
|
|
212
253
|
}
|
|
@@ -17,7 +17,7 @@ export function chatToResponsesBody(chatBody) {
|
|
|
17
17
|
return `${m.role}: ${String(c || "")}`;
|
|
18
18
|
});
|
|
19
19
|
const input = inputParts.join("\n\n") || "hi";
|
|
20
|
-
const out = { model: chatBody.model, input, stream: false };
|
|
20
|
+
const out = { model: chatBody.model, input, stream: chatBody.stream !== false };
|
|
21
21
|
if (system) out.instructions = system;
|
|
22
22
|
if (chatBody.tools) out.tools = chatBody.tools;
|
|
23
23
|
if (chatBody.tool_choice) out.tool_choice = chatBody.tool_choice;
|
|
@@ -62,3 +62,120 @@ export function toChatResponse(res, respJson) {
|
|
|
62
62
|
headers.set("content-type", "application/json");
|
|
63
63
|
return new Response(JSON.stringify(chatJson), { status: res.status, headers });
|
|
64
64
|
}
|
|
65
|
+
|
|
66
|
+
export function reshapeResponsesSse(res, fallbackModel) {
|
|
67
|
+
try {
|
|
68
|
+
const ct = res.headers?.get?.("content-type") || "";
|
|
69
|
+
if (res.status !== 200 || !ct.includes("text/event-stream") || !res.body) return res;
|
|
70
|
+
} catch { return res; }
|
|
71
|
+
const reader = res.body.getReader();
|
|
72
|
+
const decoder = new TextDecoder();
|
|
73
|
+
const encoder = new TextEncoder();
|
|
74
|
+
let buf = "";
|
|
75
|
+
let evtType = "";
|
|
76
|
+
let respId = "";
|
|
77
|
+
let respModel = fallbackModel || "";
|
|
78
|
+
let created = Math.floor(Date.now() / 1000);
|
|
79
|
+
let hasSentRole = false;
|
|
80
|
+
|
|
81
|
+
function chatChunk(delta, finish) {
|
|
82
|
+
const id = respId || `resp_${Date.now()}`;
|
|
83
|
+
const payload = {
|
|
84
|
+
id,
|
|
85
|
+
object: "chat.completion.chunk",
|
|
86
|
+
created,
|
|
87
|
+
model: respModel,
|
|
88
|
+
choices: [{ index: 0, delta: delta || {}, finish_reason: finish || null }],
|
|
89
|
+
};
|
|
90
|
+
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let closed = false;
|
|
94
|
+
const body = new ReadableStream({
|
|
95
|
+
async pull(controller) {
|
|
96
|
+
if (closed) { try { controller.close(); } catch {} return; }
|
|
97
|
+
try {
|
|
98
|
+
const { done, value } = await reader.read();
|
|
99
|
+
if (done) {
|
|
100
|
+
closed = true;
|
|
101
|
+
if (buf.trim()) {
|
|
102
|
+
// 残余缓冲尝试处理
|
|
103
|
+
}
|
|
104
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
105
|
+
controller.close();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
buf += decoder.decode(value, { stream: true });
|
|
109
|
+
let out = "";
|
|
110
|
+
// 按 \n\n 分事件
|
|
111
|
+
while (true) {
|
|
112
|
+
const sep = buf.indexOf("\n\n");
|
|
113
|
+
if (sep < 0) break;
|
|
114
|
+
const raw = buf.slice(0, sep);
|
|
115
|
+
buf = buf.slice(sep + 2);
|
|
116
|
+
const lines = raw.split("\n");
|
|
117
|
+
let curEvent = evtType;
|
|
118
|
+
let dataStr = "";
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
if (line.startsWith("event:")) curEvent = line.slice(6).trim();
|
|
121
|
+
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
|
|
122
|
+
}
|
|
123
|
+
if (!dataStr) { evtType = ""; continue; }
|
|
124
|
+
evtType = "";
|
|
125
|
+
let data;
|
|
126
|
+
try { data = JSON.parse(dataStr); } catch { continue; }
|
|
127
|
+
// 记录 id/model/created
|
|
128
|
+
if (data.response?.id) respId = data.response.id;
|
|
129
|
+
if (data.response?.model) respModel = data.response.model;
|
|
130
|
+
if (data.response?.created_at) created = Math.floor(data.response.created_at);
|
|
131
|
+
if (data.response?.id && !respId) respId = data.response.id;
|
|
132
|
+
// 关注 output_text.delta
|
|
133
|
+
if (curEvent === "response.output_text.delta" || data.type === "response.output_text.delta") {
|
|
134
|
+
const deltaText = data.delta || "";
|
|
135
|
+
if (deltaText) {
|
|
136
|
+
if (!hasSentRole) {
|
|
137
|
+
hasSentRole = true;
|
|
138
|
+
out += chatChunk({ role: "assistant" }, null);
|
|
139
|
+
}
|
|
140
|
+
out += chatChunk({ content: deltaText }, null);
|
|
141
|
+
}
|
|
142
|
+
} else if (curEvent === "response.completed" || data.type === "response.completed") {
|
|
143
|
+
const usage = data.response?.usage || null;
|
|
144
|
+
const finish = data.response?.status === "completed" ? "stop" : null;
|
|
145
|
+
// 末帧带 usage
|
|
146
|
+
const id = respId || `resp_${Date.now()}`;
|
|
147
|
+
const payload = {
|
|
148
|
+
id,
|
|
149
|
+
object: "chat.completion.chunk",
|
|
150
|
+
created,
|
|
151
|
+
model: respModel,
|
|
152
|
+
choices: [{ index: 0, delta: {}, finish_reason: finish }],
|
|
153
|
+
usage: usage || undefined,
|
|
154
|
+
};
|
|
155
|
+
out += `data: ${JSON.stringify(payload)}\n\n`;
|
|
156
|
+
} else if (data.type === "response.output_item.added" && data.item?.type === "message") {
|
|
157
|
+
// message 开始,可发送 role
|
|
158
|
+
if (!hasSentRole) {
|
|
159
|
+
hasSentRole = true;
|
|
160
|
+
out += chatChunk({ role: "assistant" }, null);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// reasoning 加密块忽略
|
|
164
|
+
}
|
|
165
|
+
if (out) controller.enqueue(encoder.encode(out));
|
|
166
|
+
} catch {
|
|
167
|
+
closed = true;
|
|
168
|
+
try { controller.close(); } catch {}
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
cancel() {
|
|
172
|
+
closed = true;
|
|
173
|
+
try { reader.cancel(); } catch {}
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
const headers = new Headers(res.headers);
|
|
177
|
+
headers.set("content-type", "text/event-stream");
|
|
178
|
+
const out = new Response(body, { status: res.status, statusText: res.statusText, headers });
|
|
179
|
+
try { out._t = res._t; } catch {}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
package/src/upstream.js
CHANGED
|
@@ -6,7 +6,7 @@ import { performance } from "node:perf_hooks";
|
|
|
6
6
|
import { isFreeModel } from "./models.js";
|
|
7
7
|
import { fmtShanghaiYMDHMS } from "./time.js";
|
|
8
8
|
import { createTransport } from "./transport/index.js";
|
|
9
|
-
import { isResponsesModel, chatToResponsesBody, toChatResponse } from "./upstream-responses.js";
|
|
9
|
+
import { isResponsesModel, chatToResponsesBody, toChatResponse, reshapeResponsesSse } from "./upstream-responses.js";
|
|
10
10
|
import { uuid } from "./compat.js";
|
|
11
11
|
|
|
12
12
|
function genId(prefix) {
|
|
@@ -110,7 +110,7 @@ export function createUpstreamClient({
|
|
|
110
110
|
const reqBody = isResp ? chatToResponsesBody(body) : body;
|
|
111
111
|
const t0 = performance.now();
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
// 首发请求(transport 已处理 network/429 等重试)
|
|
114
114
|
let res;
|
|
115
115
|
try {
|
|
116
116
|
res = await transport.request({
|
|
@@ -151,15 +151,21 @@ export function createUpstreamClient({
|
|
|
151
151
|
// responses 模型需转回 chat 形状(复用 upstream-responses)
|
|
152
152
|
let outAnon = anonRes;
|
|
153
153
|
if (isResp && anonRes.ok) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
154
|
+
const ctAnon = anonRes.headers.get("content-type") || "";
|
|
155
|
+
const isStreamAnon = body?.stream !== false && ctAnon.includes("text/event-stream");
|
|
156
|
+
if (isStreamAnon) {
|
|
157
|
+
outAnon = reshapeResponsesSse(anonRes, body.model);
|
|
158
|
+
} else {
|
|
159
|
+
try {
|
|
160
|
+
const txt = await anonRes.text();
|
|
161
|
+
const j = JSON.parse(txt);
|
|
162
|
+
if (j && Array.isArray(j.output)) {
|
|
163
|
+
outAnon = toChatResponse(anonRes, j);
|
|
164
|
+
} else {
|
|
165
|
+
outAnon = new Response(txt, { status: anonRes.status, headers: anonRes.headers });
|
|
166
|
+
}
|
|
167
|
+
} catch { outAnon = anonRes; }
|
|
168
|
+
}
|
|
163
169
|
}
|
|
164
170
|
outAnon._t = { ...(outAnon._t || {}), anonTried: true, anonAttempts: i + 1, totalMs: Math.round(performance.now() - t0) };
|
|
165
171
|
consecutiveHits += 1;
|
|
@@ -180,6 +186,13 @@ export function createUpstreamClient({
|
|
|
180
186
|
|
|
181
187
|
// responses 模型成功态转 chat(复用 upstream-responses)
|
|
182
188
|
if (isResp && res.ok) {
|
|
189
|
+
const ct = res.headers.get("content-type") || "";
|
|
190
|
+
const isStream = body?.stream !== false && ct.includes("text/event-stream");
|
|
191
|
+
if (isStream) {
|
|
192
|
+
const transformed = reshapeResponsesSse(res, body.model);
|
|
193
|
+
transformed._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
|
|
194
|
+
return transformed;
|
|
195
|
+
}
|
|
183
196
|
try {
|
|
184
197
|
const txt = await res.text();
|
|
185
198
|
const j = JSON.parse(txt);
|