mslxdff 0.1.186 → 0.1.188
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/bin/mslxdff.js +3 -3
- package/docs/cli_help_mini.md +145 -0
- package/package.json +1 -1
- package/src/auto.js +254 -254
- package/src/autostart.js +5 -2
- package/src/bench/cline-bench.js +42 -42
- package/src/bench/probe.js +70 -70
- package/src/bench/report.js +162 -162
- package/src/bench/runner.js +77 -77
- package/src/bench/via-probe.js +124 -124
- package/src/bench/via-routes.js +87 -87
- package/src/bench/workbuddy-bench.js +54 -54
- package/src/chat/engine.js +160 -160
- package/src/chat/gateway.js +163 -163
- package/src/chat/orchestrator.js +234 -234
- package/src/chat/prompt.js +70 -70
- package/src/chat/repl.js +88 -88
- package/src/chat/terminal.js +135 -135
- package/src/chat/tools.js +306 -306
- package/src/chat-pipeline/index.js +173 -131
- package/src/chat-pipeline/policy.js +84 -84
- package/src/chat-pipeline/serial-trial.js +6 -3
- package/src/chat-pipeline/terminal-verdict.js +29 -0
- package/src/cli/commands/daemon.js +55 -18
- package/src/cli/commands/group.js +249 -249
- package/src/cli/commands/model/picks.js +50 -50
- package/src/cli/commands/provider/bench-via.js +247 -247
- package/src/cli/commands/provider/bench.js +141 -141
- package/src/cli/commands/provider/index.js +136 -136
- package/src/cli/commands/provider/models.js +145 -145
- package/src/cli/commands/provider/qwenwork-login.js +119 -119
- package/src/cli/commands/sync.js +310 -310
- package/src/cli/commands/system.js +23 -7
- package/src/cli/join-wizard.js +8 -4
- package/src/cli/policy.js +14 -2
- package/src/cli/status.js +279 -279
- package/src/daemon.js +232 -195
- package/src/logs.js +27 -11
- package/src/model-capabilities/enrich.js +86 -86
- package/src/model-capabilities/index.js +183 -183
- package/src/model-capabilities/parse.js +70 -70
- package/src/model-trace.js +35 -4
- package/src/models.js +225 -225
- package/src/peers.js +20 -1
- package/src/providers/AGENTS.md +49 -0
- package/src/providers/cline/auth.js +228 -228
- package/src/providers/cline/chat.js +307 -307
- package/src/providers/cline.js +2 -2
- package/src/providers/codearts/chat.js +182 -182
- package/src/providers/globalqwenwork/cosy.js +123 -123
- package/src/providers/globalqwenwork/crypto.js +218 -218
- package/src/providers/globalqwenwork/http.js +20 -20
- package/src/providers/globalqwenwork/rsa.js +54 -54
- package/src/providers/globalqwenwork/sse.js +268 -268
- package/src/providers/globalqwenwork/stream.js +130 -130
- package/src/providers/globalqwenwork/upstream.js +122 -122
- package/src/providers/keyring.js +60 -60
- package/src/providers/qoder/chat.js +203 -203
- package/src/providers/qoder/index.js +249 -249
- package/src/providers/qoder/sse.js +145 -145
- package/src/providers/qwenwork/account-store.js +133 -133
- package/src/providers/qwenwork/constants.js +67 -67
- package/src/providers/qwenwork/cosy.js +120 -120
- package/src/providers/qwenwork/crypto.js +218 -218
- package/src/providers/qwenwork/http.js +20 -20
- package/src/providers/qwenwork/index.js +337 -327
- package/src/providers/qwenwork/payload.js +144 -144
- package/src/providers/qwenwork/rsa.js +54 -54
- package/src/providers/qwenwork/sse.js +268 -268
- package/src/providers/qwenwork/stream.js +130 -130
- package/src/providers/qwenwork/upstream.js +120 -120
- package/src/providers/qwenwork.js +1 -1
- package/src/providers/registry.js +79 -79
- package/src/providers/traework/chat.js +7 -1
- package/src/providers/workbuddy/chat.js +248 -248
- package/src/providers/workbuddy/reshape.js +152 -152
- package/src/providers/workbuddy.js +2 -2
- package/src/reasoning.js +32 -32
- package/src/routes/AGENTS.md +37 -0
- package/src/routes/chat/empty-turn-report.js +31 -0
- package/src/routes/chat/gateway.js +46 -46
- package/src/routes/chat/peer-handler.js +3 -2
- package/src/routes/chat/relay-pipeline.js +282 -294
- package/src/routes/chat/via-route-handler.js +146 -146
- package/src/routes/hedge.js +253 -255
- package/src/routes/index.js +14 -6
- package/src/routes/models-route.js +167 -167
- package/src/routes/peers.js +335 -273
- package/src/routes/responses-route.js +44 -2
- package/src/routes/stream-body.js +84 -0
- package/src/routes/stream-hold.js +152 -4
- package/src/routes/stream-scan.js +27 -2
- package/src/routes/stream.js +343 -385
- package/src/runtime/bootstrap.js +67 -67
- package/src/runtime/group-sync.js +70 -17
- package/src/runtime/provider-gate.js +36 -36
- package/src/runtime/providers-setup.js +168 -168
- package/src/runtime/server-lifecycle.js +1 -1
- package/src/server.js +78 -78
- package/src/state/schemas/allowlist.js +92 -92
- package/src/sync-opencode.js +280 -280
- package/src/transport/index.js +244 -244
- package/src/transport/pool.js +56 -56
- package/src/transport/retry.js +24 -24
- package/src/transport/sse.js +93 -93
- package/src/upstream-engine/sdk/attempt.js +9 -1
- package/src/upstream-probe/display.js +52 -52
- package/src/upstream-probe/probe.js +49 -49
- package/src/upstream-probe/rotate.js +110 -110
- package/src/upstream-probe/start.js +45 -45
- package/src/upstream.js +293 -293
- package/src/v6pool/claim.js +157 -0
- package/src/v6pool/config.js +65 -0
- package/src/v6pool/detect.js +130 -0
- package/src/v6pool/index.js +152 -0
- package/src/v6pool/prefix.js +140 -0
- package/src/v6pool/probe.js +40 -0
- package/src/v6pool/report.js +104 -0
- package/src/v6pool/rotate.js +239 -0
|
@@ -50,6 +50,34 @@ export function createCollector() {
|
|
|
50
50
|
return { res, get: () => ({ status, text }) };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* 纯错误帧直通判定(垫片 write 的前置拦截):整段只含 `data:` 行、且每帧都是
|
|
55
|
+
* `{error:{…}}` 形状(无 `choices`)时,返回原样 SSE 帧数组;否则返回 null(交翻译器)。
|
|
56
|
+
* 为什么必须拦:翻译器只认 chat 帧(choices.delta),错误帧会被它静默 `continue` 掉。
|
|
57
|
+
*/
|
|
58
|
+
function passthroughErrorFrames(text) {
|
|
59
|
+
const src = String(text ?? "");
|
|
60
|
+
if (!src.includes("data:")) return null;
|
|
61
|
+
const out = [];
|
|
62
|
+
let sawError = false;
|
|
63
|
+
for (const rawLine of src.split("\n")) {
|
|
64
|
+
const line = rawLine.trim();
|
|
65
|
+
if (!line) continue;
|
|
66
|
+
if (line.startsWith(":")) { out.push(`${line}\n\n`); continue; } // 注释帧照原样(翻译器也是跳过)
|
|
67
|
+
if (!line.startsWith("data:")) return null;
|
|
68
|
+
const payload = line.slice(5).trim();
|
|
69
|
+
if (!payload) continue;
|
|
70
|
+
if (payload === "[DONE]") { out.push("data: [DONE]\n\n"); continue; }
|
|
71
|
+
let j;
|
|
72
|
+
try { j = JSON.parse(payload); } catch { return null; } // 解析失败 = 不是我们的错误帧,交翻译器(它自会记 jsonFails)
|
|
73
|
+
if (!j || typeof j !== "object" || Array.isArray(j) || j.choices) return null; // 有 choices = chat 帧,绝不拦
|
|
74
|
+
if (!j.error) return null; // 无 choices 又无 error:形状未知,交翻译器
|
|
75
|
+
sawError = true;
|
|
76
|
+
out.push(`data: ${JSON.stringify(j)}\n\n`);
|
|
77
|
+
}
|
|
78
|
+
return sawError ? out : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
53
81
|
// 流式:逐 write 实时翻译并转发(head 延迟到首块,避免错误状态码已发送)
|
|
54
82
|
export function createLiveForwarder(realRes, translator) {
|
|
55
83
|
let status = 200;
|
|
@@ -61,16 +89,30 @@ export function createLiveForwarder(realRes, translator) {
|
|
|
61
89
|
realRes.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
62
90
|
for (const e of translator.begin()) realRes.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
63
91
|
};
|
|
92
|
+
// headersSent 必须是**计算值**:垫片的 write() 会调 sendHead() 真发头,若 headersSent 仍是普通属性
|
|
93
|
+
// (只在 end() 里置真),则「头已发但流未结束」这段窗口里它为假 ⇒ helpers.json() 会走 `!headersSent`
|
|
94
|
+
// 分支去 setHeader+end(而本垫片 setHeader 是 no-op、且真头已发)⇒ 错误体静默丢失、只剩裸关流。
|
|
95
|
+
// messages-route 的 createAnthropicForwarder 早已用 getter 实现并点名了同一个坑;本处是同契约的遗漏。
|
|
96
|
+
// 口径与 messages-route 的 createAnthropicForwarder 逐字对齐(哪里算「头已发」只此一处判)。
|
|
97
|
+
const sent = () => headSent || realRes.headersSent === true;
|
|
64
98
|
const res = {
|
|
65
99
|
...createEmitter(),
|
|
66
100
|
set statusCode(v) { status = v; },
|
|
67
101
|
get statusCode() { return status; },
|
|
68
|
-
headersSent
|
|
102
|
+
get headersSent() { return sent(); },
|
|
103
|
+
set headersSent(_v) { /* 兼容旧赋值点:真实状态由 headSent/ended 推导,不接受外部覆写 */ },
|
|
104
|
+
getHeader(k) { return String(k).toLowerCase() === "content-type" && sent() ? "text/event-stream" : undefined; },
|
|
69
105
|
setHeader() {},
|
|
70
106
|
write(c) {
|
|
71
107
|
if (ended) return true;
|
|
72
108
|
sendHead();
|
|
73
|
-
|
|
109
|
+
const text = chunkToString(c);
|
|
110
|
+
// 纯错误帧直写:helpers.json() 的 in-band 收场写的是 OpenAI 形状 `{error:{…}}`(无 choices),
|
|
111
|
+
// 若交给 chat→responses 翻译器,会被当作「非 chat 帧」静默忽略(translate.js 只认 choices.delta)——
|
|
112
|
+
// 错误在翻译层被吞掉,客户端只看到流被关。错误帧与 chat 帧形状互斥,故这里拦下直通。
|
|
113
|
+
const errOut = passthroughErrorFrames(text);
|
|
114
|
+
if (errOut != null) { for (const chunk of errOut) realRes.write(chunk); return true; }
|
|
115
|
+
for (const e of translator.push(text)) realRes.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
74
116
|
return true;
|
|
75
117
|
},
|
|
76
118
|
end(c) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// relay 的读取层工具(从 stream.js 拆出:HEAD 版已 19.8KB / 20KB 硬门,先拆后写)。
|
|
2
|
+
// 全部是「不碰闸门、不碰 res、不碰计时」的纯 IO 适配:字节→文本、取消上游、注释帧判定、读取迭代。
|
|
3
|
+
// 语义与拆出前逐字一致 —— 这里只搬家,不改行为。
|
|
4
|
+
import { enrichSseChunkText, enrichNonStreamJson as enrichNonStreamJsonFn } from "./fallback.js";
|
|
5
|
+
import { extractUsageFromSseText as extractUsageFn } from "../metrics.js";
|
|
6
|
+
import { scanNonStreamBody as scanNonStreamBodyFn } from "./stream-scan.js";
|
|
7
|
+
|
|
8
|
+
// SDK 通道(TextEncoder)产出 Uint8Array,legacy 通道为 Buffer;
|
|
9
|
+
// 统一转文本,避免 [DONE]/finish_reason/usage/chars 统计在 SDK 路径下静默失效。
|
|
10
|
+
export function chunkText(chunk) {
|
|
11
|
+
if (typeof chunk === "string") return chunk;
|
|
12
|
+
if (Buffer.isBuffer(chunk)) return chunk.toString("utf8");
|
|
13
|
+
if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString("utf8");
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// body.cancel() 可能返回非 Promise(自定义/AI SDK 流)——同步异常与 rejection 双路径都要吞掉
|
|
18
|
+
export function cancelBody(body) {
|
|
19
|
+
try {
|
|
20
|
+
const p = typeof body?.cancel === "function" ? body.cancel() : null;
|
|
21
|
+
if (p && typeof p.catch === "function") p.catch(() => {});
|
|
22
|
+
} catch { /* ignore */ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 注释帧(": keepalive" 等)不是模型输出:不算首块、不解除闸门、不触发超时救回,但照常透传
|
|
26
|
+
export function hasPayload(text) {
|
|
27
|
+
for (const line of String(text).split("\n")) {
|
|
28
|
+
const t = line.trim();
|
|
29
|
+
if (!t || t.startsWith(":")) continue;
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 非流式的「先判后写」:在 headers 还没 flush 之前解析上游体,决定能不能富化 / 是不是空轮。
|
|
37
|
+
* 返回 { enriched, emptyTurn }:enriched=null 表示上游给的不是可解析 JSON(调用方走原文透传);
|
|
38
|
+
* emptyTurn=true 表示这一发是空轮、且留口是开的(调用方沿用留口语义,不写任何响应)。
|
|
39
|
+
* 纯搬家:行为与拆出前逐字一致。
|
|
40
|
+
*/
|
|
41
|
+
export function scanNonStreamReply(detail, text, { fallback, holdEnd, isEmptyTurn, captureFallback }) {
|
|
42
|
+
let enriched = null;
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(text);
|
|
45
|
+
scanNonStreamBodyFn(detail, parsed);
|
|
46
|
+
enriched = enrichNonStreamJsonFn(parsed, fallback);
|
|
47
|
+
} catch { /* 上游给的不是可解析 JSON:调用方走原文透传 */ }
|
|
48
|
+
if (enriched === null) {
|
|
49
|
+
// 纯文本时按长度估 chars;若文本其实是 SSE(client 未要流但上游给流)仍尝试提 usage
|
|
50
|
+
try { detail.chars = text.length; } catch {}
|
|
51
|
+
try { const u = extractUsageFn(text); if (u) detail.usage = u; } catch {}
|
|
52
|
+
try { if (detail.talk) captureFallback(detail.talk, text); } catch {}
|
|
53
|
+
return { enriched: null, emptyTurn: false };
|
|
54
|
+
}
|
|
55
|
+
const emptyTurn = Boolean(holdEnd() && !detail.downstreamClosed && isEmptyTurn(detail));
|
|
56
|
+
return { enriched, emptyTurn };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 上游回的是普通 reply(无流式 delta)时,客户端仍要看到增量:把整块按 fallback 富化成 SSE 帧。
|
|
60
|
+
// 只在首块前、且本轮尚未写出 payload 时调用一次;解析不出就原样返回(绝不改字节)。
|
|
61
|
+
export function enrichFirstReply(chunk, fallback) {
|
|
62
|
+
if (!fallback?.fallback) return chunk;
|
|
63
|
+
try {
|
|
64
|
+
const txt = chunkText(chunk);
|
|
65
|
+
if (!txt.includes("data:")) return chunk;
|
|
66
|
+
const enriched = enrichSseChunkText(txt, fallback);
|
|
67
|
+
return enriched !== txt ? Buffer.from(enriched, "utf8") : chunk;
|
|
68
|
+
} catch {
|
|
69
|
+
return chunk;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 自持 reader 优先:for-await 会锁定 ReadableStream,使 body.cancel() 必 reject(真流上等于空操作),
|
|
74
|
+
// 超时/断下游要真能掐上游必须走 reader.cancel()
|
|
75
|
+
export async function* bodyChunks(body, reader) {
|
|
76
|
+
if (reader) {
|
|
77
|
+
for (;;) {
|
|
78
|
+
const { done, value } = await reader.read();
|
|
79
|
+
if (done) return;
|
|
80
|
+
yield value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for await (const c of body) yield c;
|
|
84
|
+
}
|
|
@@ -14,6 +14,63 @@ import { json } from "./helpers.js";
|
|
|
14
14
|
export const HOLD_MAX_FRAMES = 64;
|
|
15
15
|
export const HOLD_MAX_BYTES = 32 * 1024;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* 把一个 chunk 里夹带的 `[DONE]` 终止帧切下来:返回 { head, rest },head = 该帧之前的全部字节,
|
|
19
|
+
* rest = 从 `data: [DONE]` 那一行的行首到 chunk 末尾。
|
|
20
|
+
*
|
|
21
|
+
* 为什么必须在 submit 之前切:网络 chunk 边界与 SSE 帧边界**无关**(TCP 分段事实),上游常把末个
|
|
22
|
+
* 内容帧与 `data: [DONE]` 一次 flush 送进同一个 chunk。这种帧若整体直写,`[DONE]` 就抢在错误帧
|
|
23
|
+
* 之前到达客户端 —— 客户端一见 `[DONE]` 即收尾,截断错误帧永远看不见(BUG-17 的修复在这类形态下
|
|
24
|
+
* 完全失效),且收场还会因为 tail 槽空着而补出第二个 `[DONE]`。故 `[DONE]` 必须扣进 tail 槽,
|
|
25
|
+
* 由 flushTail 决定它的最终时机(错误帧之后)。
|
|
26
|
+
*
|
|
27
|
+
* 必须**行锚定**:正文里出现字面量 `[DONE]`(本仓域内很常见——讨论 SSE 的对话)绝不能误判成终止帧,
|
|
28
|
+
* 故只认「整行 trims 后等于 `data: [DONE]`」。扫描走 latin1(1 字节↔1 字符),索引与字节一一对应,
|
|
29
|
+
* 不受多字节字符切分影响。
|
|
30
|
+
*/
|
|
31
|
+
function toBuf(buf) {
|
|
32
|
+
if (typeof buf === "string") return Buffer.from(buf, "utf8");
|
|
33
|
+
if (Buffer.isBuffer(buf)) return buf;
|
|
34
|
+
if (buf instanceof Uint8Array) return Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const DONE_LINE_RE = /(^|\n)[ \t]*data:[ \t]*\[DONE\][ \t]*\r?(?=\n|$)/g;
|
|
38
|
+
function splitTrailingDone(buf) {
|
|
39
|
+
const b = toBuf(buf);
|
|
40
|
+
if (!b) return null;
|
|
41
|
+
const s = b.toString("latin1");
|
|
42
|
+
DONE_LINE_RE.lastIndex = 0;
|
|
43
|
+
let last = null;
|
|
44
|
+
for (let m; (m = DONE_LINE_RE.exec(s)); ) last = m; // 取最后一个终止帧行
|
|
45
|
+
if (!last) return null;
|
|
46
|
+
const head = last.index + (last[1] === "\n" ? 1 : 0);
|
|
47
|
+
return { head: b.subarray(0, head), rest: b.subarray(head) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 这个 chunk 的**最后一个未结束行**像不像 `data: [DONE]` 的半截?
|
|
52
|
+
*
|
|
53
|
+
* 上面的行锚定只解决「完整终止帧与正文同 chunk」,解决不了**终止帧自己被 TCP 切开**
|
|
54
|
+
* (`…data: [DO` | `NE]\n\n` 两个 chunk)。这种形态下行锚定永远匹配不到 ⇒ 终止符照样
|
|
55
|
+
* 抢先于错误帧、收场照样补出第二个 `[DONE]`(实测复现)。故凡尾部是终止行前缀的,
|
|
56
|
+
* 先扣住等下一块拼上再判;下一块到齐后发现不是终止帧,立刻原样放行。
|
|
57
|
+
*
|
|
58
|
+
* 只认「去掉行首空白后是 `data: [DONE]` 的字面前缀」,且长度有上限(半个帧不会很长),
|
|
59
|
+
* 避免把普通正文尾巴扣住。
|
|
60
|
+
*/
|
|
61
|
+
const MAX_DONE_LINE_LEN = 32;
|
|
62
|
+
function doneLinePrefix(buf) {
|
|
63
|
+
const s = buf.toString("latin1");
|
|
64
|
+
const nl = s.lastIndexOf("\n");
|
|
65
|
+
const tail = s.slice(nl + 1); // 最后一个 \n 之后 = 本 chunk 未结束的那一行
|
|
66
|
+
if (!tail || tail.length > MAX_DONE_LINE_LEN) return 0;
|
|
67
|
+
const t = tail.replace(/^[ \t]+/, "").toLowerCase().replace(/[ \t]+/g, " ");
|
|
68
|
+
if (!t) return 0;
|
|
69
|
+
// `data: [DONE]` 与 `data:[DONE]` 两种空白写法都可能是上游形式
|
|
70
|
+
if ("data: [done]".startsWith(t) || "data:[done]".startsWith(t)) return Buffer.byteLength(tail, "latin1");
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
17
74
|
// 空轮「延后封口 + 前缀暂扣」总开关(默认开)。0/off/false = 关:连缓冲都不做,流一结束立即 end(),
|
|
18
75
|
// 逐字节复现改前行为(代价:之后的空轮重拉/换候选写的是一次已终结的响应,只省额度、救不回正文)。
|
|
19
76
|
export function holdEndEnabled() {
|
|
@@ -32,14 +89,35 @@ export function createRevocablePrefix(res, detail, { maxFrames = HOLD_MAX_FRAMES
|
|
|
32
89
|
let pending = [];
|
|
33
90
|
let pendingBytes = 0;
|
|
34
91
|
let tail = null; // [DONE] 槽:必须最后写,且要让位给错误帧
|
|
92
|
+
let carry = null; // 半截 `data: [DONE]` 行:等下一块拼上再判(见 doneLinePrefix)
|
|
35
93
|
let committed = false;
|
|
36
94
|
let draining = false;
|
|
95
|
+
let inCarryFlush = false;
|
|
37
96
|
const writeNow = (buf, size) => {
|
|
97
|
+
// carry 是**先到**的字节:任何直写都必须等它先出,否则下游看到乱序帧。
|
|
98
|
+
// 之前只有 submit() 自己消化 carry,hold() 的两个分支与注释帧直写都绕过了它(实测确认)。
|
|
99
|
+
if (!inCarryFlush && carry) flushCarry();
|
|
38
100
|
if (!draining && tail) flushTail(); // 保持帧序:先补已暂扣的 [DONE],再写新到的
|
|
39
101
|
detail.wroteChunks += 1;
|
|
40
102
|
detail.wroteBytes += size;
|
|
41
103
|
try { res.write(buf); } catch { /* 下游已断开:onClose 已掐上游 */ }
|
|
42
104
|
};
|
|
105
|
+
/**
|
|
106
|
+
* 放行扣着的半截行。它若没以换行结尾**必须补一个** —— 否则随后的错误帧 / `[DONE]` 会粘在同一行上
|
|
107
|
+
* (`data: [DOdata: [DONE]`):既不是合法 SSE 行、客户端也认不出终止符(实测终止符数=0,比多一个更糟)。
|
|
108
|
+
*/
|
|
109
|
+
const flushCarry = () => {
|
|
110
|
+
if (!carry) return false;
|
|
111
|
+
const c = carry;
|
|
112
|
+
carry = null;
|
|
113
|
+
const endsWithNl = c.size > 0 && c.buf[c.size - 1] === 0x0a;
|
|
114
|
+
const b = endsWithNl
|
|
115
|
+
? (toBuf(c.buf) ?? Buffer.alloc(0))
|
|
116
|
+
: Buffer.concat([toBuf(c.buf) ?? Buffer.alloc(0), Buffer.from("\n", "utf8")]);
|
|
117
|
+
inCarryFlush = true;
|
|
118
|
+
try { writeNow(b, b.length); } finally { inCarryFlush = false; }
|
|
119
|
+
return true;
|
|
120
|
+
};
|
|
43
121
|
const drainPending = () => {
|
|
44
122
|
draining = true;
|
|
45
123
|
try {
|
|
@@ -68,6 +146,9 @@ export function createRevocablePrefix(res, detail, { maxFrames = HOLD_MAX_FRAMES
|
|
|
68
146
|
*/
|
|
69
147
|
hold(buf, size, isDone = false) {
|
|
70
148
|
if (isDone) {
|
|
149
|
+
// 半截终止行若还扣着(上一块尾部像终止行前缀、本块却是独立完整终止帧),必须先放出去:
|
|
150
|
+
// 直接塞进 tail 会让它和 [DONE] 粘成 `data: [DOdata: [DONE]` —— 非法行,客户端认不出终止符(实测数=0)。
|
|
151
|
+
if (carry) flushCarry();
|
|
71
152
|
if (tail) flushTail();
|
|
72
153
|
tail = { buf, size };
|
|
73
154
|
return true;
|
|
@@ -78,12 +159,45 @@ export function createRevocablePrefix(res, detail, { maxFrames = HOLD_MAX_FRAMES
|
|
|
78
159
|
pendingBytes += size;
|
|
79
160
|
return true;
|
|
80
161
|
},
|
|
81
|
-
/**
|
|
162
|
+
/**
|
|
163
|
+
* 提交:按序补写 pending(不含 tail),再写本帧,此后一律直写。
|
|
164
|
+
* 夹带的 `[DONE]` 先切进 tail 槽:chunk 边界≠帧边界,末个内容帧常与 `[DONE]` 同 chunk 到达,
|
|
165
|
+
* 整体直写会让终止符抢先于截断错误帧(见 splitTrailingDone)。
|
|
166
|
+
*/
|
|
82
167
|
submit(buf, size) {
|
|
83
168
|
committed = true;
|
|
84
169
|
drainPending();
|
|
85
|
-
|
|
170
|
+
// 上一块末尾扣着半个终止帧行:先拼上,整帧判过再决定去留
|
|
171
|
+
let b = buf;
|
|
172
|
+
if (carry) {
|
|
173
|
+
const joined = Buffer.concat([carry.buf, toBuf(buf) ?? Buffer.alloc(0)]);
|
|
174
|
+
b = joined;
|
|
175
|
+
carry = null;
|
|
176
|
+
}
|
|
177
|
+
const sp = splitTrailingDone(b);
|
|
178
|
+
if (sp) {
|
|
179
|
+
if (sp.head.length) writeNow(sp.head, sp.head.length);
|
|
180
|
+
if (tail) flushTail(); // 极端:本帧前已有暂扣的 [DONE],保序先补
|
|
181
|
+
tail = { buf: sp.rest, size: sp.rest.length };
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// 尾部是终止行前缀(终止帧被 TCP 切开):扣住等下一块,别让它抢先入下游
|
|
185
|
+
const pl = doneLinePrefix(b);
|
|
186
|
+
if (pl > 0 && pl < b.length) {
|
|
187
|
+
const keep = b.subarray(b.length - pl);
|
|
188
|
+
const head = b.subarray(0, b.length - pl);
|
|
189
|
+
if (head.length) writeNow(head, head.length);
|
|
190
|
+
carry = { buf: keep, size: keep.length };
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (pl > 0) { carry = { buf: b, size: b.length }; return; } // 整块就是半截行
|
|
194
|
+
writeNow(b, b.length);
|
|
86
195
|
},
|
|
196
|
+
/**
|
|
197
|
+
* 收场兜底:扣着的半截行(若上游就此断了,它就是最后一帧)必须原样放出去,不能吞字节。
|
|
198
|
+
* 补换行的理由见模块内 flushCarry。
|
|
199
|
+
*/
|
|
200
|
+
flushCarry,
|
|
87
201
|
/** 收尾前补写 pending;tail 留给 flushTail 决定时机(错误帧要先站上去)。 */
|
|
88
202
|
commit() {
|
|
89
203
|
committed = true;
|
|
@@ -94,6 +208,16 @@ export function createRevocablePrefix(res, detail, { maxFrames = HOLD_MAX_FRAMES
|
|
|
94
208
|
};
|
|
95
209
|
}
|
|
96
210
|
|
|
211
|
+
/**
|
|
212
|
+
* 收场时按到达顺序放行暂扣内容:先放半截终止行(carry),再放 `[DONE]`(tail)。
|
|
213
|
+
* 两段都在 prefix 里,故调用方只需要一个函数——顺序不能反:carry 是先到的字节。
|
|
214
|
+
* @returns {boolean} true = `[DONE]` 确实由这次调用写出去(收场方别再补第二个)
|
|
215
|
+
*/
|
|
216
|
+
export function flushPrefixTail(prefix) {
|
|
217
|
+
try { prefix.flushCarry(); } catch { /* ignore */ }
|
|
218
|
+
try { return !!prefix.flushTail(); } catch { return false; }
|
|
219
|
+
}
|
|
220
|
+
|
|
97
221
|
/**
|
|
98
222
|
* 终局收场:把响应「恰好一次」关掉,形状必须与已经发出去的东西相容。
|
|
99
223
|
* - headers 未 flush → 502 JSON(干净的 OpenAI 形错误);
|
|
@@ -122,13 +246,37 @@ export function endEmptyTurnStream(res, message, flushTail) {
|
|
|
122
246
|
return closeSseWithError(res, String(message || "EMPTY_MODEL_RESPONSE: empty turn"), flushTail);
|
|
123
247
|
}
|
|
124
248
|
|
|
249
|
+
/**
|
|
250
|
+
* 截断收场(判据 + 收场一起,供 relay 的断流分支一行调用)。
|
|
251
|
+
*
|
|
252
|
+
* 触发形态:流已写出真实内容、却**从未收到上游的终止标记**(`sawFinishReason=null`)就被中断
|
|
253
|
+
* —— 超时腰斩、上游连接断、`upstream-error` 都走这里。旧行为只补一个 `[DONE]` 再按 200 成功返回,
|
|
254
|
+
* 于是客户端既无错误帧也无 finish_reason,**分不出「答完了」与「被砍断」**,会把半截回答当完整结果
|
|
255
|
+
* 继续用(比直接报错更危险);calls.log 也把这次事故记成一次成功的慢请求。
|
|
256
|
+
* 现网取证:300s 腰斩 2012 块 / 416KB 输出,errors.log 零记录、calls.log 记 200。
|
|
257
|
+
*
|
|
258
|
+
* 收场形状与空轮一致(错误帧 → 原 [DONE] → end),type 另标以便客户端区分两种失败。
|
|
259
|
+
* @returns {boolean} true = 已按「截断」收场;false = 流本来就有终止标记,调用方照旧补 [DONE]
|
|
260
|
+
*/
|
|
261
|
+
export function endTruncatedStream(res, detail, flushTail) {
|
|
262
|
+
if (!res || res.writableEnded) return false;
|
|
263
|
+
if (detail?.sawFinishReason) return false; // 上游交代过结局,不算截断
|
|
264
|
+
const d = detail || {};
|
|
265
|
+
const why = d.upstreamError || d.exitReason || "stream ended";
|
|
266
|
+
const msg = `STREAM_TRUNCATED: 上游在收到 ${d.receivedChunks || 0} 块 / ${d.receivedBytes || 0} 字节后中断,`
|
|
267
|
+
+ `未发出 finish_reason(${why})— 回答可能在半途被截断`;
|
|
268
|
+
if (detail) { detail.truncated = true; detail.exitReason = "truncated"; detail.upstreamErrorText = d.upstreamErrorText || msg; }
|
|
269
|
+
closeSseWithError(res, msg, flushTail, "mslxdff_truncated");
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
125
273
|
/**
|
|
126
274
|
* SSE 收场:错误帧 → 上游原 [DONE](若被暂扣过)→ end。
|
|
127
275
|
* 铁律:终止符只能有一个,且必须排在错误帧之后 —— 否则客户端在错误帧到达前就已收尾。
|
|
128
276
|
*/
|
|
129
|
-
function closeSseWithError(res, msg, flushTail) {
|
|
277
|
+
function closeSseWithError(res, msg, flushTail, type = "mslxdff_empty_turn") {
|
|
130
278
|
try {
|
|
131
|
-
res.write(`data: ${JSON.stringify({ error: { message: msg, type
|
|
279
|
+
res.write(`data: ${JSON.stringify({ error: { message: msg, type } })}\n\n`);
|
|
132
280
|
} catch { /* 下游已断:收不到是它的权利 */ }
|
|
133
281
|
let emittedDone = false;
|
|
134
282
|
try { emittedDone = typeof flushTail === "function" ? !!flushTail() : false; } catch { /* ignore */ }
|
|
@@ -76,6 +76,29 @@ function talkConsumeLine(talk, raw, glued = false) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// 已见工具调用 index:按 detail 对象存进 WeakMap,既不污染 detail(它会被序列化进 events/模型日志,
|
|
80
|
+
// 多一个属性就是日志噪音),也不随 detail 的展开/复制丢失统计口径。
|
|
81
|
+
const tcIndexes = new WeakMap();
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 工具调用去重计数:同一 `index` 的多个分片只算一次。
|
|
85
|
+
* 分片缺 `index` 字段时退回 0(OpenAI 规范不要求每个 arguments 分片都重复 index)。
|
|
86
|
+
*/
|
|
87
|
+
function countToolCalls(detail, txt) {
|
|
88
|
+
if (!detail || typeof detail !== "object") return;
|
|
89
|
+
let seen = tcIndexes.get(detail);
|
|
90
|
+
if (!seen) { seen = new Set(); tcIndexes.set(detail, seen); }
|
|
91
|
+
const re = /"tool_calls"\s*:\s*\[\s*\{/g;
|
|
92
|
+
let m;
|
|
93
|
+
while ((m = re.exec(txt))) {
|
|
94
|
+
// 从 tool_calls 数组起点往后取第一个 index(choices[].index 在此位置之前,不会误取)
|
|
95
|
+
const after = txt.slice(m.index + m[0].length - 1);
|
|
96
|
+
const im = after.match(/"index"\s*:\s*(\d+)/);
|
|
97
|
+
seen.add(im ? Number(im[1]) : 0);
|
|
98
|
+
}
|
|
99
|
+
if (seen.size) detail.toolCalls = seen.size;
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
/** 逐 chunk 累积一轮对话的思考/正文/工具调用。talk 为空 = 未开启,直接返回。跨 chunk 行缓冲见文件头。 */
|
|
80
103
|
export function captureTalkSse(talk, txt) {
|
|
81
104
|
if (!talk) return;
|
|
@@ -124,8 +147,10 @@ export function scanSseChunk(detail, txt) {
|
|
|
124
147
|
if (!detail.chatShaped && (txt.includes('"choices"') || txt.includes('"delta"') || txt.includes('"finish_reason"') || txt.includes('"usage"') || txt.includes('"prompt_tokens"') || txt.includes("[DONE]"))) detail.chatShaped = true;
|
|
125
148
|
// 工具调用计数(空数组不算):tool_calls 无正文是合法 agent 轮,
|
|
126
149
|
// 空转闸门必须豁免它,否则所有工具轮都会被误判为空轮——见 relay-pipeline 4b。
|
|
127
|
-
|
|
128
|
-
|
|
150
|
+
// 口径按 `index` 去重:流式 tool_calls 的 arguments 是**分片**到达的,每个分片都含
|
|
151
|
+
// `"tool_calls":[{"index":N,...` ⇒ 按命中次数累加会把一次调用计成 N 次(实测 4894/5999 帧命中,
|
|
152
|
+
// 虚高数千倍,且与 scanNonStreamBody 的 `.length` 口径不一致)。去重后与非流式同义。
|
|
153
|
+
countToolCalls(detail, txt);
|
|
129
154
|
// 尝试提取 usage(流式末帧):口径收口到 metrics.js,与未流式分支共用
|
|
130
155
|
if (txt.includes("\"usage\"") || txt.includes("\"prompt_tokens\"")) {
|
|
131
156
|
try {
|